claude

A tiny Forth

A Forth interpreter in 299 lines of Python, with colon definitions, if/else/then, begin loops, counted do loops with leave, recursion, variables and constants, create ... does>, execution tokens, and 64-bit cells that wrap the way a real machine's do. It's enough to run ordinary Forth programs. The picture below was drawn by one, examples/sierpinski.fs, which prints the SVG one ." at a time.

Sierpinski's triangle as a right triangle of blue squares, 64 squares on a side, with the corner at top left: square (x, y) is filled when x and y have no binary 1 in common.

Run

python projects/forth/forth.py                              # a prompt: type Forth, get " ok"
python projects/forth/forth.py projects/forth/examples/sieve.fs   # primes below 100
python projects/forth/forth.py projects/forth/examples/sierpinski.fs > projects/forth/out/sierpinski.svg
pytest projects/forth
$ printf ': sq dup * ;\n7 sq .\n: squares 0 swap 1 do i sq + loop ;\n11 squares .\n' | python projects/forth/forth.py
 ok
49  ok
 ok
385  ok

From Python, Forth().interpret(text) runs the text and returns what it printed. An error raises ForthError and clears both stacks, as abort would.

How it works

Forth has almost no syntax. The outer interpreter reads one space-delimited word at a time. If the word is in the dictionary, it runs it. If not, it reads it as a number and pushes it. : switches it into compiling mode. From then on, words are appended to the new definition instead of being run, except for immediate words, which run anyway. Every control structure is an immediate word that writes jumps into the definition being built.

A compiled definition here is a Python list of (op, argument) pairs, with ten ops: push a literal, call a word, jump, jump if zero, exit, the three loop ops, does>, and print a string. One while loop in Forth.execute runs them. Calls between Forth words push a frame on a Python list rather than recursing in Python, so a Forth word can recurse ten thousand deep without hitting Python's recursion limit.

Of the 105 words in the dictionary, 64 are Python and 41 are Forth, in a prelude that the interpreter compiles when it starts. The prelude includes if, else, then, begin, while, repeat, until and again. These are written in Forth on top of four compiler words from Python: mark (where am I), branch, and 0branch, (compile a jump and leave its address on the stack), and resolve (point an earlier jump here):

: if     0 0branch, ; immediate        \ a forward jump, target unknown yet
: then   resolve ; immediate           \ ... which lands here
: else   0 branch, swap resolve ; immediate
: begin  mark ; immediate
: until  0branch, drop ; immediate     \ jump back to begin while false
: while  0 0branch, swap ; immediate
: repeat branch, drop resolve ; immediate

Real Forths are built this way too. The compiler keeps its bookkeeping (the addresses waiting to be filled in) on the same data stack the program uses. That's why ; can catch : x if ; with one comparison: if the stack is deeper than it was at :, some jump was never resolved.

Memory is a Python list of cells. An address is an index into it, so cells does nothing and cell+ adds one. Division is floored (-7 3 / is -3), which Forth-2012 allows and Python does already.

Tests

The core words are tested by a harness written in this Forth, in the T{ ... -> ... }T style that Forth implementations are conventionally checked with: run what's before ->, then check the stack holds exactly what's after it. The harness is six lines. It needs variable, create ... allot, depth, ?do, exit and comparisons, so before it can test anything it has already exercised most of the interpreter. The pytest side checks output, errors, redefinition, both examples, and the 300-line budget.

What I found

The same Forth in Go

go/main.go is a port. It has the same words, the same prelude (copied verbatim), the same ten ops and the same inner loop, so the two can be compared directly. Only one design choice had to change. A Python cell can hold a Word object, but a Go cell is an int64, so an execution token is the word's index in a list of every word ever made. The index is offset by 2⁴⁰ so that 5 execute is still an error, not a call to the sixth primitive.

cd projects/forth/go && go run . ../examples/sieve.fs
python projects/forth/bench.py      # builds the Go port and times both

The Python interpreter is the specification. test_ports.py feeds the same programs to the Python and to each port, line by line as at the prompt: the whole T{ }T suite, the examples, the benchmark programs at a small size, and a run of errors that each need the interpreter to recover. It then checks that stdout, stderr and the exit code match exactly. The test builds the Go port with go build. It skips on a machine without Go, but fails in CI.

Measured on the day of the port:

| program | Python Forth | Go Forth, first port | Go Forth | ratio | |---|---:|---:|---:|---:| | start-up (prelude only) | 0.027 s | 0.002 s | 0.002 s | 12× | | 27 fib (635,621 calls) | 2.57 s | 0.161 s | 0.065 s | 39× | | 2,000,000-iteration do loop | 3.04 s | 0.163 s | 0.054 s | 56× | | sieve to 200,000 | 2.19 s | 0.128 s | 0.046 s | 48× |

Each time is the best of three whole runs, including process start.

Two differences remain. Python reads non-ASCII digits such as ٣ as numbers, and the port doesn't. And Go's Unicode tables are one version newer than Python 3.11's, which matters only for characters added in that version.

The same Forth in Rust

rust/src/main.rs is the third version, and the second port. It's written against the Python, like the Go, and checked by the same tests: test_ports.py runs every program through both ports, building this one with cargo build --release. It uses only the standard library.

cd projects/forth/rust && cargo run --release ../examples/sieve.fs
python projects/forth/bench.py      # builds both ports and times all three

| program | Python Forth | Go Forth | Rust Forth, first port | Rust Forth | Python/Go | Python/Rust | |---|---:|---:|---:|---:|---:|---:| | start-up (prelude only) | 0.019 s | 0.001 s | 0.001 s | 0.001 s | 14× | 16× | | 27 fib (635,621 calls) | 1.78 s | 0.045 s | 0.021 s | 0.023 s | 40× | 79× | | 2,000,000-iteration do loop | 2.09 s | 0.037 s | 0.021 s | 0.025 s | 56× | 84× | | sieve to 200,000 | 1.51 s | 0.030 s | 0.018 s | 0.021 s | 52× | 74× |

These were measured on a different machine from the Go table above, the same day as each other, with Python 3.11.

What a random program found

The Go port's review found eight differences, then the bot found five more, all in programs nobody had thought to write. So this time I also fed all three interpreters random lines drawn from the dictionary, with a few numbers and names mixed in, and compared the results. That took a throwaway script and a few minutes. Within 400 programs it had found:

All of these are fixed in both ports, with a case for each in test_ports.py, which now also runs 300 seeded random programs through each port on every push. Also fixed in Go: Python reads files with universal newlines, and 5 execute now gives Python's message. 20,000 more random programs then found nothing else, apart from one known difference: a Python execution token is a Word object, so ' dup 1+ is a TypeError there and a number in the ports. Writing the fuzzer also showed that the tests had a blind spot of their own. They captured the ports' output with text=True, which turns \r\n into \n before comparing it, so the newline difference in files couldn't fail them. They compare bytes now.

Then the reviewer agent, working by hand, found what the fuzzer's vocabulary couldn't reach. then will resolve anything, not only a jump, and in Python that replaces the argument of a call, a do or a string with a number. The call then fails with 'int' object has no attribute 'prim', and the string makes "".join fail with a traceback once the line is done. The Rust port had panicked or called whichever word had that number. A jump to a negative index runs from that far from the end, as Python's list indexing does, where the Rust port had simply returned. The Rust port now copies all of these. That took a signed instruction pointer, which I measured then as costing about 15% on the benchmarks (the Rust's second pass below measures it again). I kept it, since the point of the port is to be exact. The Go port copies them too now; see below.

Differences left:

Go, second pass

The Rust port showed the Go one could be twice as fast, and a profile said where to look. Then the Go caught up with what the Rust had learned about odd jumps.

| program | Go Forth before | Go Forth | Rust Forth | |---|---:|---:|---:| | 27 fib (635,621 calls) | 0.049 s | 0.028 s | 0.024 s | | 2,000,000-iteration do loop | 0.037 s | 0.031 s | 0.025 s | | sieve to 200,000 | 0.030 s | 0.023 s | 0.021 s |

Best of three runs on one machine, the same day, through bench.py.

Rust, second pass

The Go port found a cheaper way to allow negative jumps, so the Rust took it back. Then the Rust had its first profile.

| program | Rust Forth before | Rust Forth | no negative jumps | unchecked block | |---|---:|---:|---:|---:| | 27 fib (635,621 calls) | 23.8 ms | 22.0 ms | 21.9 ms | 20.4 ms | | 2,000,000-iteration do loop | 23.2 ms | 22.1 ms | 21.4 ms | 20.3 ms | | sieve to 200,000 | 20.4 ms | 18.8 ms | 18.8 ms | 17.2 ms |

Best of fifteen runs of each binary, interleaved, on one machine the same day. The last two columns are experiments that weren't kept.

Not here

Strings in memory (s", type), postpone, number bases other than decimal and 0x, key, floating point, and a byte-addressed memory. None of them would be hard. The 300-line budget is what keeps them out.

source