Compare commits
18 Commits
v0.2.3
...
b4a28f342d
| Author | SHA1 | Date | |
|---|---|---|---|
| b4a28f342d | |||
| 0a0f1e9e95 | |||
| 94a0566ce3 | |||
| 35da69cf7b | |||
| 392f2d0136 | |||
| b1cc93edc6 | |||
| 4f96f8860a | |||
| e963e636d3 | |||
| 645c2dadd7 | |||
| 3bb613ece0 | |||
| b8dcc021a2 | |||
| fc34bd9b24 | |||
| e6c10a6fa1 | |||
| e110ca9516 | |||
| 8e2fd0d7d4 | |||
| 69309006a2 | |||
| 9b10723a95 | |||
| 4769987b20 |
+313
@@ -5,6 +5,316 @@ All notable changes to WAFER are documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Search-order conveniences from common practice** (none are Forth 2012;
|
||||||
|
all three exist in gforth and friends, and the semantics were checked
|
||||||
|
against gforth 0.7.3):
|
||||||
|
- `>ORDER ( wid -- )` pushes a wordlist on top of the search order --
|
||||||
|
the word the standard forgot when ANS replaced named vocabularies
|
||||||
|
with anonymous wid handles and left `ALSO` with nothing to name.
|
||||||
|
- `-ORDER ( wid -- )` removes a wordlist from the search order wherever
|
||||||
|
it sits (VFX/MPE extension, the inverse of `>ORDER`).
|
||||||
|
- `VOCABULARY <name>` creates a named wordlist; executing the name
|
||||||
|
replaces the top of the search order, the same semantics the standard
|
||||||
|
gives `FORTH`. `ORDER` and `WORDS ALL` now print vocabulary names
|
||||||
|
instead of `wid#N`, and `MARKER` rollback forgets them along with
|
||||||
|
the words.
|
||||||
|
|
||||||
|
## [0.2.9] - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A word that never recurses no longer gets a typed entry it cannot use.**
|
||||||
|
Every word with a statically known stack effect was given the typed
|
||||||
|
wrapper + fast-entry pair. In the JIT path the function-table slot holds
|
||||||
|
the wrapper and the only caller that can reach the fast entry is
|
||||||
|
`RECURSE`, so for any other word a cross-word call went
|
||||||
|
`call_indirect` -> wrapper -> fast entry: one hop more for exactly the
|
||||||
|
same memory traffic. On a 300k-iteration loop over a callee too big to
|
||||||
|
inline that cost **1569 µs against 1067 with the convention off** -- an
|
||||||
|
optimisation making things worse. It is now emitted only when the body
|
||||||
|
calls itself, which is where it is worth 4x (Fibonacci 242 µs typed
|
||||||
|
against 636 untyped). `CONSOLIDATE` and the AOT export are unaffected;
|
||||||
|
they solve their effects separately. Present in 0.2.7 and 0.2.8.
|
||||||
|
|
||||||
|
- **The inliner's loop guard has never actually fired.** 0.2.7 added a rule
|
||||||
|
that a loop-bearing callee must not be inlined into a caller that can
|
||||||
|
never be promoted, since the loop then loses its registers -- a 7x
|
||||||
|
pessimisation applied by an optimisation pass. The check ran _before_
|
||||||
|
inlining, where the caller is nothing but calls: `DROP` is
|
||||||
|
`Call(WordId(2))`, `CR` is `Call(WordId(38))`. Since the check looks
|
||||||
|
through calls by design, it called nearly every caller promotable and
|
||||||
|
the guard did nothing. Inlining now happens in two passes -- loop-free
|
||||||
|
callees first, then the question, then the rest.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A sixth benchmark, `CrossCalls(300K)`, that measures what `CONSOLIDATE`
|
||||||
|
does.** The other five have no cross-word call left in their hot loop:
|
||||||
|
four have their callee inlined away and Fibonacci is self-recursive. So
|
||||||
|
the `CONSOL` column measured nothing, which is how both bugs above stayed
|
||||||
|
hidden. With a real call in the loop, consolidation is worth 2.8-4x.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The benchmark harness stops reporting noise.** It took the median of three
|
||||||
|
timed repetitions inside one process, and a `samples` field that was never
|
||||||
|
read. Each measurement is now the mean of the three fastest of seven
|
||||||
|
repetitions, and that whole process runs three times with the fastest kept.
|
||||||
|
Benchmark noise is one-sided -- a scheduling hiccup or a busy SMT sibling can
|
||||||
|
only make a run slower -- so the fastest runs are the honest ones, and only a
|
||||||
|
fresh process resamples core placement and code layout. On a shared 16-vCPU
|
||||||
|
box the run-to-run spread went from 20-79% to 1-6%, and Fibonacci after
|
||||||
|
`CONSOLIDATE` stopped being bimodal (413-419 µs on three reports and 712-770
|
||||||
|
on two, with nothing in between; now 412-426 across four).
|
||||||
|
|
||||||
|
- **Every benchmark is now sized to run about 10 ms**, from the 0.2-2 ms most
|
||||||
|
of them took. Not for the usual reason -- the timing wrapper already excludes
|
||||||
|
start-up and compilation, and in the measurements shorter benchmarks were if
|
||||||
|
anything the _steadier_ ones -- but it buys a comfortable margin over timer
|
||||||
|
resolution and first-iteration effects for nothing: the report still finishes
|
||||||
|
in under a minute, and gforth, 3-20x slower than WAFER, is what sets that
|
||||||
|
clock. Fibonacci went from 25 to 33 rather than into a loop, so it stays pure
|
||||||
|
recursion; Collatz repeats its 2000-value round 50 times instead of counting
|
||||||
|
higher, because past ~100000 the sequence peaks near 1.5 billion and `3 * 1+`
|
||||||
|
overflows WAFER's 32-bit cells while sf64's 64-bit cells carry on -- the two
|
||||||
|
engines would stop doing the same work. All three engines agree on the results
|
||||||
|
at the new sizes.
|
||||||
|
|
||||||
|
### Explained
|
||||||
|
|
||||||
|
- **Why `CONSOLIDATE` makes some promoted loops slower** (NestedLoops
|
||||||
|
1.7x on x86-64, 1.1x on arm64): not worse code -- the WASM is
|
||||||
|
byte-identical and the machine code instruction-identical modulo
|
||||||
|
registers -- but worse placement. A tight loop pays for straddling an
|
||||||
|
instruction-fetch window (16 bytes on the M1 at ~9%; 32 bytes on
|
||||||
|
Skylake at up to ~65%, where a fused `cmp+jcc` crossing the boundary
|
||||||
|
drops the loop out of the uop cache every iteration -- the JCC
|
||||||
|
erratum). Cranelift never aligns loop headers, and the per-word JIT
|
||||||
|
module's dead dsp-prologue bytes happen to shift its loops onto
|
||||||
|
luckier offsets. Verified by a padding sweep that reproduces the full
|
||||||
|
penalty range on both hosts, including placements where consolidated
|
||||||
|
code beats the JIT. Details in docs/OPTIMIZATIONS.md; native x86-64
|
||||||
|
reference numbers in the README re-taken at the new workload sizes.
|
||||||
|
|
||||||
|
## [0.2.8] - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A recursive word tests its base case at the call site.** A recursive Forth
|
||||||
|
word almost always opens with a guard that returns early --
|
||||||
|
`: FIB DUP 2 < IF EXIT THEN ... RECURSE ... ;` -- so every leaf of the
|
||||||
|
recursion costs a call whose entire body is that test. `Call(self)` now
|
||||||
|
compiles as `<guard> IF <what the guard returns> ELSE Call(self) THEN`,
|
||||||
|
which computes the same thing: the callee would have run the guard, taken
|
||||||
|
the branch and returned. In fib's tree the leaves are half of all nodes.
|
||||||
|
|
||||||
|
Fibonacci(25) 356 -> 237 µs on the arm64 development machine, where that
|
||||||
|
reads 1.24x -> 0.83x of SwiftForth `sf64`. Measured again with **both
|
||||||
|
engines native on x86-64** -- the macOS `sf64` build runs under Rosetta 2,
|
||||||
|
which flatters WAFER -- Fibonacci is 1.16x, so it remains the one benchmark
|
||||||
|
of the five that `sf64` wins. See the two tables in the README.
|
||||||
|
|
||||||
|
The guard runs twice along the recursive path, so it has to be small (at
|
||||||
|
most six operations) and free of effects -- no calls, no memory, no
|
||||||
|
branches. Words with more than four self-call sites are left alone to bound
|
||||||
|
the code growth, and a `TailCall` is never expanded.
|
||||||
|
|
||||||
|
## [0.2.7] - 2026-08-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A typed calling convention for words with a known stack effect.** Such a
|
||||||
|
word now compiles to two entry points: a fast one whose signature is
|
||||||
|
`(i32 x p) -> (i32 x q)`, carrying its stack items as WASM values, and the
|
||||||
|
usual `( -- )` wrapper that moves those items on and off the memory data
|
||||||
|
stack. The wrapper keeps the function-table slot, so `EXECUTE`, the outer
|
||||||
|
interpreter, host words and `CATCH` see exactly the ABI they saw before;
|
||||||
|
only direct calls inside a module take the fast entry.
|
||||||
|
|
||||||
|
This is what the SwiftForth gap was made of. sf64 keeps TOS in `RBX` and
|
||||||
|
the stack pointer in `RBP`, and both survive a `CALL` untouched, so its
|
||||||
|
`FIB` is 16 instructions and ~7 memory touches per node. WAFER kept the
|
||||||
|
whole stack in linear memory and flushed its cached `$dsp` to an imported
|
||||||
|
global before every call: ~36 memory touches per node. The stack simulator
|
||||||
|
that already promoted loop and `IF` bodies into WASM locals refused any
|
||||||
|
body containing a call or an `EXIT` -- exactly the words where the
|
||||||
|
convention cost the most. It now handles both.
|
||||||
|
|
||||||
|
Fibonacci(25) goes from 1035 to 366 µs, 4.3x slower than `sf64` to 1.2x.
|
||||||
|
Loop-heavy benchmarks are unchanged by this entry — see the region
|
||||||
|
promotion below for those. Words that keep the memory convention: anything
|
||||||
|
using `SP@`, `DEPTH`, `EXECUTE`, `>R`/`R>`, floats or locals; anything
|
||||||
|
calling a word that is itself untyped, which in the JIT path means every
|
||||||
|
call except `RECURSE`; mutually recursive words; and words whose effect is
|
||||||
|
not static -- branches that disagree on depth, `EXIT` at the wrong depth,
|
||||||
|
a non-neutral loop body, or a recursion that grows the stack per level.
|
||||||
|
|
||||||
|
`CONSOLIDATE` extends this across words, since it puts them all in one
|
||||||
|
module: the effects are solved to a fixpoint from the leaves outward, and
|
||||||
|
105 of 187 words in a booted dictionary end up typed.
|
||||||
|
|
||||||
|
Stack guards get cheap as a side effect -- they hang off the memory-stack
|
||||||
|
push/pop choke points, and a typed word barely has any. The default
|
||||||
|
guards-on configuration that the REPL and the web build use went from 1631
|
||||||
|
to 365 µs on the same benchmark.
|
||||||
|
|
||||||
|
`WAFER_TYPED_CALLS=0` falls back to the memory-stack convention.
|
||||||
|
|
||||||
|
- **Promotion is now per region, not per word.** Stack-to-local promotion
|
||||||
|
used to be all-or-nothing: a single `.`, `CR`, `>R` or host call
|
||||||
|
anywhere in a definition put the _entire_ body on the memory data
|
||||||
|
stack, hot loops included. The stack simulator now runs over each
|
||||||
|
stretch of a word that can live in WASM locals, loading what the
|
||||||
|
region reads and writing back what it leaves, with the rest of the
|
||||||
|
word unchanged around it.
|
||||||
|
|
||||||
|
The cliff this removes was steep. The same loop, same build:
|
||||||
|
|
||||||
|
| `: L1 0 5000000 0 DO 1+ LOOP DROP ;` reached as | µs | ns/iter |
|
||||||
|
| ----------------------------------------------- | ----- | ------- |
|
||||||
|
| its own word | 1571 | 0.31 |
|
||||||
|
| inlined into a caller with a `.` in it (before) | 11100 | 2.22 |
|
||||||
|
| the same, after this change | 1572 | 0.31 |
|
||||||
|
|
||||||
|
7x, for one `i32.add`: on the memory path the accumulator is stored to
|
||||||
|
linear memory and reloaded next iteration, so the loop-carried
|
||||||
|
dependency runs through store-to-load forwarding instead of a
|
||||||
|
register.
|
||||||
|
|
||||||
|
A region may only use `I` / `J` when the DO loops naming them are
|
||||||
|
inside the region, since the simulator resolves them against its own
|
||||||
|
loop stack. Straight-line regions have to be at least three operations
|
||||||
|
to be worth the load and store either side; a loop always is.
|
||||||
|
|
||||||
|
- **The inliner no longer drags a loop onto the memory stack.** It
|
||||||
|
inlined any callee of eight IR operations or fewer, so a small
|
||||||
|
loop-bearing word inlined into a caller that can never be promoted
|
||||||
|
lost its registers -- an optimisation pass applying the 7x
|
||||||
|
pessimisation above. Loop-bearing callees now stay put in that case:
|
||||||
|
one call is far cheaper than a loop's worth of memory traffic.
|
||||||
|
Straight-line words still inline everywhere.
|
||||||
|
|
||||||
|
- **`BEGIN` loops promote as well.** `BEGIN..UNTIL`, `BEGIN..AGAIN` and
|
||||||
|
`BEGIN..WHILE..REPEAT` were rejected outright by the eligibility check,
|
||||||
|
so any word built on the idiomatic Forth loop kept the memory data
|
||||||
|
stack no matter how hot it was. They are promoted now when the
|
||||||
|
construct is stack-neutral: `UNTIL` consumes exactly the flag its body
|
||||||
|
leaves, `AGAIN`'s body is neutral, and for `WHILE..REPEAT` the test and
|
||||||
|
the body balance separately -- `WHILE` leaves the loop between the two,
|
||||||
|
so a net that only added up over the pair would give the two exits
|
||||||
|
different stack shapes. Bodies containing an `EXIT` stay out, the same
|
||||||
|
rule `DO`/`LOOP` follows. `BEGIN..WHILE..WHILE..REPEAT` is still
|
||||||
|
excluded.
|
||||||
|
|
||||||
|
GCD 994 -> 540 µs, Collatz 428 -> 185.
|
||||||
|
|
||||||
|
Together these four entries put four of the five cross-engine
|
||||||
|
benchmarks past SwiftForth `sf64`: Factorial 0.29x, Collatz 0.30x,
|
||||||
|
NestedLoops 0.27x, GCD 0.67x. Fibonacci stays at 1.24x, being pure
|
||||||
|
call overhead with no loop to promote.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A promoted loop or `IF` whose branch permutes the stack lost a value.**
|
||||||
|
At the bottom of a promoted loop the body's results are copied back into
|
||||||
|
the loop-top locals, and the join after a promoted `IF` copies one
|
||||||
|
branch's locals into the other's. Both did it one slot at a time in index
|
||||||
|
order, which is wrong as soon as a destination is also a later source:
|
||||||
|
`: C 3 4 2 0 DO SWAP LOOP . . ;` printed `4 4` where gforth and
|
||||||
|
SwiftForth print `4 3`, and `2 0 DO ROT LOOP` over three cells printed
|
||||||
|
`3 2 3` instead of `2 1 3`. The copies are now ordered so every source is
|
||||||
|
read before it is overwritten, with one scratch local to break a cycle.
|
||||||
|
Present since stack-to-local promotion was introduced; reachable from
|
||||||
|
any `DO` loop or `IF` whose body reorders cells it did not create.
|
||||||
|
|
||||||
|
- The Forth 2012 Core suite now also runs against consolidated code
|
||||||
|
(`compliance_core_after_consolidate`). `CONSOLIDATE` had no correctness
|
||||||
|
test at all before -- only benchmarks.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Three cross-engine benchmarks were too small to be measured.** GCD ran
|
||||||
|
in 14 µs, Factorial in 49 and NestedLoops in 51, where per-run scatter is
|
||||||
|
a good fraction of the total and fixed per-invocation costs in the other
|
||||||
|
engines dominate. Scaled to Factorial x100K, GCD-bench(20K) and
|
||||||
|
NestedLoops(50)x1K, all now around 0.5-1 ms.
|
||||||
|
|
||||||
|
This changed a result rather than just steadying it: GCD looked like a
|
||||||
|
win at 0.42x of `sf64` and was in fact a loss at 1.17x. That is what
|
||||||
|
pointed at `BEGIN` loops as the remaining gap -- GCD is the one benchmark
|
||||||
|
whose loop is a `BEGIN ... WHILE ... REPEAT` -- and with those promoted it
|
||||||
|
now reads 0.67x. The regression limits, which had drifted to 3-6x looser
|
||||||
|
than the measurements they guard, were retightened to ~45% above the
|
||||||
|
current ratios.
|
||||||
|
|
||||||
|
## [0.2.6] - 2026-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An uncaught `ABORT` no longer prints anything.** It used to report
|
||||||
|
`ABORT (throw -1)`, but the standard defines `ABORT` as "empty the data
|
||||||
|
stack and perform the function of `QUIT`", and `QUIT` displays no
|
||||||
|
message. gforth and SwiftForth are both silent here. `CATCH` still
|
||||||
|
reports -1 as before, and `ABORT"` still prints its text — that is a
|
||||||
|
different word with a different code (-2).
|
||||||
|
- **Compile-only words used in interpretation state name the condition.**
|
||||||
|
`ABORT"`, `IF`, `THEN`, `LOOP`, `LITERAL`, `RECURSE` and the rest of
|
||||||
|
the compile-time constructs claimed to be an `unknown word`, which is
|
||||||
|
actively misleading for a word the system obviously knows. They now
|
||||||
|
report `interpreting a compile-only word: <name> (throw -14)`, the
|
||||||
|
standard condition both reference engines give. A genuine typo still
|
||||||
|
reports `unknown word`.
|
||||||
|
|
||||||
|
## [0.2.5] - 2026-08-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`QUIT`** ( -- ) ( R: i\*x -- ), the CORE word that was missing: empty
|
||||||
|
the return stack, enter interpretation state, hand the input source
|
||||||
|
back to the user input device and return to the interpreter without a
|
||||||
|
message. The data stack is deliberately left alone — that is the whole
|
||||||
|
difference to `ABORT`, which the standard defines as "empty the data
|
||||||
|
stack, then `QUIT`". It unwinds through nested `EVALUATE` and
|
||||||
|
`INCLUDE`, abandoning them, and `SOURCE-ID` is restored to 0.
|
||||||
|
|
||||||
|
`CATCH` does **not** report it: `QUIT` rides throw code -56, which the
|
||||||
|
interpreter treats as a return to the prompt rather than an exception.
|
||||||
|
Both behaviours were checked against gforth 0.7.3 and SwiftForth
|
||||||
|
`sf64`, which agree — `1 2 ' QUIT CATCH .` prints nothing and leaves
|
||||||
|
`1 2` on the stack in all three engines.
|
||||||
|
|
||||||
|
The gap had gone unnoticed because the Forth 2012 test suite skips it
|
||||||
|
by its own admission ("I HAVEN'T FIGURED OUT HOW TO TEST KEY, QUIT,
|
||||||
|
ABORT, OR ABORT\""), and because `HELP`'s coverage lint compares the
|
||||||
|
dictionary against the docs — a word absent from both looks complete.
|
||||||
|
`docs/wafer-anki.txt` had been documenting `QUIT` as if it existed.
|
||||||
|
|
||||||
|
Note that `ABORT` was already correct: executing it while a definition
|
||||||
|
is open does clear both stacks and return to interpretation state.
|
||||||
|
Typing `ABORT` (or `QUIT`) into an unfinished definition compiles it
|
||||||
|
rather than running it, exactly as in every other Forth; `[` is the
|
||||||
|
word that gets you out.
|
||||||
|
|
||||||
|
## [0.2.4] - 2026-08-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Errors from host words in the browser build read like Forth errors
|
||||||
|
again.** A host word signals failure by throwing across the JS
|
||||||
|
boundary, and the browser runtime reported the exception with its
|
||||||
|
`Debug` form, so an empty-stack `RESIZE` came back as
|
||||||
|
`call_func(134) failed: JsValue(Error: Stack underflow ...)` trailed by
|
||||||
|
an engine stack trace. The thrown message is the Forth message, so it
|
||||||
|
is now surfaced verbatim — `Stack underflow`, exactly what the native
|
||||||
|
CLI prints. Exceptions that carry no message keep the call context,
|
||||||
|
since those are genuine runtime faults rather than Forth throws.
|
||||||
|
`CATCH` was never affected: it reads the throw code from its own
|
||||||
|
channel, not from the message.
|
||||||
|
|
||||||
## [0.2.3] - 2026-08-06
|
## [0.2.3] - 2026-08-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -148,6 +458,9 @@ compliance suite, `CONSOLIDATE` whole-program recompilation, `wafer build`
|
|||||||
AOT export (WASM / native / JS loader), browser REPL, SHA-1/256/512 words,
|
AOT export (WASM / native / JS loader), browser REPL, SHA-1/256/512 words,
|
||||||
and cross-engine benchmark lanes against gforth and SwiftForth.
|
and cross-engine benchmark lanes against gforth and SwiftForth.
|
||||||
|
|
||||||
|
[0.2.9]: https://github.com/ok2/wafer/compare/v0.2.8...v0.2.9
|
||||||
|
[0.2.8]: https://github.com/ok2/wafer/compare/v0.2.7...v0.2.8
|
||||||
|
[0.2.7]: https://github.com/ok2/wafer/compare/v0.2.6...v0.2.7
|
||||||
[0.2.1]: https://github.com/ok2/wafer/compare/v0.2.0...v0.2.1
|
[0.2.1]: https://github.com/ok2/wafer/compare/v0.2.0...v0.2.1
|
||||||
[0.2.0]: https://github.com/ok2/wafer/compare/v0.1.0...v0.2.0
|
[0.2.0]: https://github.com/ok2/wafer/compare/v0.1.0...v0.2.0
|
||||||
[0.1.0]: https://github.com/ok2/wafer/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/ok2/wafer/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What is WAFER?
|
## What is WAFER?
|
||||||
|
|
||||||
WAFER (WebAssembly Forth Engine in Rust) is an optimizing Forth 2012 compiler targeting WebAssembly. Currently a working Forth system with 200+ words, JIT compilation, 12 word sets at 100% compliance, and a full optimization pipeline (peephole, constant folding, inlining, strength reduction, DCE, tail calls, stack-to-local promotion with loop/IF support, self-recursive direct calls, consolidation). Beats gforth on all benchmarks in release mode. Includes a browser-based REPL via wasm-pack.
|
WAFER (WebAssembly Forth Engine in Rust) is an optimizing Forth 2012 compiler targeting WebAssembly. Currently a working Forth system with 200+ words, JIT compilation, 12 word sets at 100% compliance, and a full optimization pipeline (peephole, constant folding, inlining, strength reduction, DCE, tail calls, per-region stack-to-local promotion with DO/BEGIN loop and IF support, self-recursive direct calls, a typed calling convention for words with a known stack effect, self-guard expansion for recursive words, consolidation). Beats gforth on every benchmark, and SwiftForth `sf64` on five of six (measured native-vs-native on x86-64; the macOS sf64 build is x86-64 under Rosetta and flatters WAFER). Includes a browser-based REPL via wasm-pack.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ Handle in `interpret_token_immediate()` or `compile_token()` as a special case.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Run `cargo test --workspace` before committing (currently 562 unit + 1 benchmark + 11 compliance + 9 comparison + 5 crypto)
|
- Run `cargo test --workspace` before committing (currently 611 unit + 1 benchmark + 12 compliance + 9 comparison + 5 crypto)
|
||||||
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
|
- Forth 2012 compliance: `cargo test -p wafer-core --test compliance`
|
||||||
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
|
- Cross-engine comparison (vs gforth): `cargo test -p wafer-core --test comparison`
|
||||||
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
|
- Performance benchmarks (release mode): `cargo test -p wafer-core --test comparison -- --nocapture --ignored`
|
||||||
|
|||||||
Generated
+3
-3
@@ -1589,7 +1589,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer"
|
name = "wafer"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1600,7 +1600,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer-core"
|
name = "wafer-core"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"insta",
|
"insta",
|
||||||
@@ -1615,7 +1615,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wafer-web"
|
name = "wafer-web"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/ok2/wafer"
|
repository = "https://github.com/ok2/wafer"
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ An optimizing Forth 2012 compiler targeting WebAssembly. WAFER JIT-compiles each
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **200+ words** across 12 Forth 2012 word sets, all at **100% compliance**
|
- **200+ words** across 12 Forth 2012 word sets, all at **100% compliance**
|
||||||
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (loops + IF) + consolidation
|
- **Optimizing compiler** with 6 IR passes + stack-to-local promotion (per region, so a hot loop keeps its registers even inside a word that does I/O; `DO` and `BEGIN` loops alike) + consolidation
|
||||||
- **Faster than gforth** on all benchmarks in release mode (2-10x faster)
|
- **Faster than gforth** on every benchmark, and past SwiftForth `sf64` -- a native-code compiler -- on five of six
|
||||||
- **JIT compilation** — each `:` definition compiles to its own WASM module
|
- **JIT compilation** — each `:` definition compiles to its own WASM module
|
||||||
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect`
|
- **Self-recursive direct calls** — RECURSE compiles to native `call` instead of `call_indirect`
|
||||||
|
- **Typed calling convention** — a word with a statically known stack effect passes its stack items as WASM values, so a call keeps them in registers instead of round-tripping through memory
|
||||||
- **Consolidation mode** — recompile all words into a single optimized WASM module
|
- **Consolidation mode** — recompile all words into a single optimized WASM module
|
||||||
- **Interactive REPL** with line editing (rustyline)
|
- **Interactive REPL** with line editing (rustyline)
|
||||||
- **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys
|
- **Browser REPL** — runs entirely in the browser via wasm-pack + js-sys
|
||||||
@@ -79,38 +80,106 @@ git submodule update --init
|
|||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
WAFER beats gforth (the GNU Forth reference implementation) on all benchmarks in release mode:
|
WAFER beats gforth (the GNU Forth reference implementation) on every benchmark by 3-20x, and
|
||||||
|
SwiftForth `sf64` -- which compiles to native code -- on five of the six. Fibonacci is the one it
|
||||||
|
loses: one call per node, no loop to promote, and `sf64` keeps its stack in registers across a call
|
||||||
|
the way only a native code generator can.
|
||||||
|
|
||||||
|
Measured on the development machine (M1 Ultra, arm64), median of three reports:
|
||||||
|
|
||||||
```
|
```
|
||||||
Benchmark WAFER CONSOL gforth WAFER/gf
|
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
|
||||||
Fibonacci(25) 1629 1535 3422 0.45x
|
Fibonacci(33) 11307 11407 157001 13053 0.07x 0.87x
|
||||||
Factorial(12)x10K 340 339 638 0.53x
|
Factorial(12)x2M 9639 9599 123950 32091 0.08x 0.30x
|
||||||
GCD-bench(500) 18 15 30 0.50x
|
GCD-bench(400K) 11662 11580 38580 17001 0.30x 0.68x
|
||||||
NestedLoops(50) 84 73 720 0.10x
|
NestedLoops(50)x20K 8920 9852 140518 36828 0.06x 0.24x
|
||||||
Collatz(2K) 1212 1202 3914 0.31x
|
CrossCalls(3M) 10883 3769 87691 8240 0.04x 0.46x
|
||||||
|
Collatz(2K)x50 8838 8715 189903 28657 0.05x 0.30x
|
||||||
```
|
```
|
||||||
|
|
||||||
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster. CONSOL = after `CONSOLIDATE`.
|
Times in microseconds; the ratios use the better of `WAFER` and `CONSOL`. Below 1.0 means WAFER is
|
||||||
|
faster.
|
||||||
|
|
||||||
|
**The `sf64` column here flatters WAFER, and by enough to change an answer.** The only SwiftForth
|
||||||
|
build for macOS is x86-64 running under Rosetta 2, while WAFER and gforth are native arm64 -- so
|
||||||
|
that column compares native code against emulated code, and the penalty falls hardest on the
|
||||||
|
call-heavy benchmark. Measured with all three engines native on x86-64 (Xeon Platinum 8124M,
|
||||||
|
Ubuntu 22.04; two reports agreed within 1%), Fibonacci reads **1.21x** where the table above says
|
||||||
|
0.87x; the other five keep their wins. That native comparison is what the "five of six" above
|
||||||
|
rests on:
|
||||||
|
|
||||||
|
```
|
||||||
|
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
|
||||||
|
Fibonacci(33) 19512 19511 129784 16076 0.15x 1.21x
|
||||||
|
Factorial(12)x2M 22532 16601 137168 57986 0.12x 0.29x
|
||||||
|
GCD-bench(400K) 34216 34089 66595 51680 0.51x 0.66x
|
||||||
|
NestedLoops(50)x20K 10729 17827 126687 40469 0.08x 0.27x
|
||||||
|
CrossCalls(3M) 20457 7412 81303 29264 0.09x 0.25x
|
||||||
|
Collatz(2K)x50 18686 17328 188592 80857 0.09x 0.21x
|
||||||
|
```
|
||||||
|
|
||||||
|
A second caveat holds on any host: `sf64` uses 64-bit cells to WAFER's 32-bit, so WAFER does less
|
||||||
|
work per operation.
|
||||||
|
|
||||||
|
`CrossCalls` is the only benchmark with a cross-word call left in its hot loop -- the other five
|
||||||
|
have their callee inlined away or are self-recursive -- so it is the only one that measures what
|
||||||
|
`CONSOLIDATE` does, and there it is worth 2.9x. `NestedLoops` goes the other way: `CONSOLIDATE`
|
||||||
|
makes it 1.1x _slower_ on the M1 and 1.7x on x86-64 -- not worse code but worse luck. Both paths
|
||||||
|
emit identical WASM for the hot word; the delta is where the machine code lands. A tight loop
|
||||||
|
pays for straddling an instruction-fetch window (16 bytes on the M1, 32 on Skylake, where a fused
|
||||||
|
branch crossing the boundary drops the loop out of the uop cache -- the JCC erratum), Cranelift
|
||||||
|
does not align loop headers, and dead prologue bytes in the per-word JIT module happen to shift
|
||||||
|
its loops into luckier spots. Details in
|
||||||
|
[docs/OPTIMIZATIONS.md](docs/OPTIMIZATIONS.md#8-consolidation).
|
||||||
|
|
||||||
|
Every benchmark is sized to run about 10 ms. Not for the usual reason -- the timing wrapper already
|
||||||
|
excludes start-up and compilation -- but to keep a comfortable margin over timer resolution and
|
||||||
|
first-iteration effects without pushing the report past a minute. gforth is 3-20x slower than
|
||||||
|
WAFER, so it sets the wall clock.
|
||||||
|
|
||||||
|
A word whose stack effect is statically known gets a **typed entry point**: its stack items travel in and out
|
||||||
|
as WASM values instead of through the memory data stack, so cranelift keeps them in registers across a call
|
||||||
|
the way a native Forth keeps TOS in one. The word also keeps a `( -- )` wrapper, which is what the function
|
||||||
|
table, `EXECUTE` and the outer interpreter reach, so nothing about the memory ABI changes from the outside.
|
||||||
|
Only a caller inside the same module can use the fast entry -- `RECURSE` in the JIT path, every resolvable
|
||||||
|
call after `CONSOLIDATE` -- so that is exactly when it is emitted. Set `WAFER_TYPED_CALLS=0` to fall back.
|
||||||
|
|
||||||
|
Recursive words then get one more thing: their base-case guard is tested at the **call site**, so a
|
||||||
|
leaf of the recursion costs a comparison instead of a call. `: FIB DUP 2 < IF EXIT THEN ... RECURSE`
|
||||||
|
compiles its `RECURSE` as `DUP 2 < IF ELSE RECURSE THEN`, which is what the callee would have done
|
||||||
|
on entry anyway. Half of fib's nodes are leaves, and that is worth 1.4x.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
Everything below has a `just` target; the raw command is given where it is worth
|
||||||
|
knowing what the target does.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All tests (~570 currently passing)
|
just test # all tests (~638 currently passing)
|
||||||
cargo test --workspace
|
just compliance # Forth 2012 compliance suite
|
||||||
|
just clippy # lints
|
||||||
# Forth 2012 compliance suite
|
just fmt # formatting check (Rust + Markdown)
|
||||||
cargo test -p wafer-core --test compliance
|
just ci # everything CI runs
|
||||||
|
|
||||||
# Cross-engine comparison (WAFER vs gforth, requires gforth)
|
|
||||||
cargo test -p wafer-core --test comparison -- --nocapture --ignored
|
|
||||||
|
|
||||||
# Optimization benchmark report (WAFER-internal)
|
|
||||||
cargo test -p wafer-core --test benchmark_report -- --nocapture --ignored
|
|
||||||
|
|
||||||
# Lints
|
|
||||||
cargo clippy --workspace
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Benchmarks are separate, because they are `#[ignore]`d -- they take minutes, and
|
||||||
|
a debug build would measure nothing useful:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just bench-compare # WAFER vs gforth vs SwiftForth, the table in Performance
|
||||||
|
just bench-opts # WAFER against its own optimization settings
|
||||||
|
just bench # criterion micro-benchmarks
|
||||||
|
just compare-correctness # same three engines, compared on output instead of time
|
||||||
|
```
|
||||||
|
|
||||||
|
`bench-compare` needs `gforth` and `sf64` on `PATH` -- a missing engine drops its
|
||||||
|
column rather than failing. Each number in it is the best of three processes, and
|
||||||
|
each process reports the mean of its three fastest of seven timed repetitions:
|
||||||
|
benchmark noise is one-sided, so the fastest runs are the honest ones, and only a
|
||||||
|
fresh process resamples core placement and code layout. Run it on an idle
|
||||||
|
machine; a busy one produced 20-79% run-to-run spread where an idle one gives
|
||||||
|
1-6%.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -128,7 +197,7 @@ Forth Source -> Outer Interpreter -> IR -> [Optimize] -> WASM Codegen (wasm-enco
|
|||||||
- `WebRuntime` — browser WebAssembly API via js-sys, for the browser REPL
|
- `WebRuntime` — browser WebAssembly API via js-sys, for the browser REPL
|
||||||
- **Subroutine threading** via WASM function tables (`call_indirect` for cross-word, direct `call` for self-recursion)
|
- **Subroutine threading** via WASM function tables (`call_indirect` for cross-word, direct `call` for self-recursion)
|
||||||
- **JIT mode**: each new word compiles to a separate WASM module linked to shared memory/globals/table
|
- **JIT mode**: each new word compiles to a separate WASM module linked to shared memory/globals/table
|
||||||
- **IR-based pipeline** with 6 optimization passes (peephole, constant folding, strength reduction, DCE, tail call detection, inlining) plus stack-to-local promotion (with loop and IF/ELSE support), DO/LOOP index locals, and consolidation
|
- **IR-based pipeline** with 6 optimization passes (peephole, constant folding, strength reduction, DCE, tail call detection, inlining) plus per-region stack-to-local promotion (DO and BEGIN loops, IF/ELSE), DO/LOOP index locals, typed entry points for words with a known stack effect, self-guard expansion, and consolidation
|
||||||
- **Dictionary**: linked-list word headers in simulated linear memory
|
- **Dictionary**: linked-list word headers in simulated linear memory
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ license.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wafer-core = { path = "../core", version = "0.2.3" }
|
wafer-core = { path = "../core", version = "0.3.0" }
|
||||||
wasmtime = { workspace = true }
|
wasmtime = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
|
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
|
||||||
/// the per-command default (REPL/file execution on, build off).
|
/// the per-command default (REPL/file execution on, build off);
|
||||||
|
/// `WAFER_TYPED_CALLS=0` falls back to the memory-stack calling convention.
|
||||||
fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
|
fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
|
||||||
let mut cfg = wafer_core::config::WaferConfig::all();
|
let mut cfg = wafer_core::config::WaferConfig::all();
|
||||||
cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() {
|
cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() {
|
||||||
@@ -271,6 +272,7 @@ fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
|
|||||||
Some(_) => true,
|
Some(_) => true,
|
||||||
None => default_guards,
|
None => default_guards,
|
||||||
};
|
};
|
||||||
|
cfg.codegen.typed_calls = std::env::var("WAFER_TYPED_CALLS").ok().as_deref() != Some("0");
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1283
-107
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,11 @@ pub struct CodegenOpts {
|
|||||||
/// corrupting stack pointers. On by default; benchmarks and
|
/// corrupting stack pointers. On by default; benchmarks and
|
||||||
/// exported production modules turn it off.
|
/// exported production modules turn it off.
|
||||||
pub stack_guards: bool,
|
pub stack_guards: bool,
|
||||||
|
/// Compile words with a statically known stack effect to a typed entry
|
||||||
|
/// point that carries stack items in WASM values, so a call keeps them
|
||||||
|
/// in registers instead of round-tripping through the memory stack.
|
||||||
|
/// On by default; `WAFER_TYPED_CALLS=0` turns it off.
|
||||||
|
pub typed_calls: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Master configuration for all WAFER optimizations.
|
/// Master configuration for all WAFER optimizations.
|
||||||
@@ -34,10 +39,12 @@ impl WaferConfig {
|
|||||||
strength_reduce: true,
|
strength_reduce: true,
|
||||||
dce: true,
|
dce: true,
|
||||||
inline: true,
|
inline: true,
|
||||||
|
self_guard: true,
|
||||||
},
|
},
|
||||||
codegen: CodegenOpts {
|
codegen: CodegenOpts {
|
||||||
stack_to_local_promotion: true,
|
stack_to_local_promotion: true,
|
||||||
stack_guards: true,
|
stack_guards: true,
|
||||||
|
typed_calls: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,10 +59,12 @@ impl WaferConfig {
|
|||||||
strength_reduce: false,
|
strength_reduce: false,
|
||||||
dce: false,
|
dce: false,
|
||||||
inline: false,
|
inline: false,
|
||||||
|
self_guard: false,
|
||||||
},
|
},
|
||||||
codegen: CodegenOpts {
|
codegen: CodegenOpts {
|
||||||
stack_to_local_promotion: false,
|
stack_to_local_promotion: false,
|
||||||
stack_guards: false,
|
stack_guards: false,
|
||||||
|
typed_calls: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ mod tests {
|
|||||||
// Empty word list should produce nothing (but we guard against this at call site)
|
// Empty word list should produce nothing (but we guard against this at call site)
|
||||||
let words = vec![];
|
let words = vec![];
|
||||||
let map = HashMap::new();
|
let map = HashMap::new();
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
// Empty is valid -- should produce a valid module with no functions
|
// Empty is valid -- should produce a valid module with no functions
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ mod tests {
|
|||||||
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
|
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32); // function index 1 (after emit import)
|
map.insert(WordId(1), 1u32); // function index 1 (after emit import)
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ mod tests {
|
|||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
map.insert(WordId(3), 3u32);
|
map.insert(WordId(3), 3u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ mod tests {
|
|||||||
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
|
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
|
||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(3), 1u32);
|
map.insert(WordId(3), 1u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 256, None);
|
let result = compile_consolidated_module(&words, &map, 256, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ mod tests {
|
|||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ mod tests {
|
|||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ mod tests {
|
|||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ mod tests {
|
|||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ mod tests {
|
|||||||
let mut map = HashMap::new();
|
let mut map = HashMap::new();
|
||||||
map.insert(WordId(1), 1u32);
|
map.insert(WordId(1), 1u32);
|
||||||
map.insert(WordId(2), 2u32);
|
map.insert(WordId(2), 2u32);
|
||||||
let result = compile_consolidated_module(&words, &map, 16, None);
|
let result = compile_consolidated_module(&words, &map, 16, None, true);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ pub fn export_module(
|
|||||||
table_size,
|
table_size,
|
||||||
&export_sections,
|
&export_sections,
|
||||||
vm.stack_guard_param(),
|
vm.stack_guard_param(),
|
||||||
|
vm.typed_calls(),
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ pub struct OptConfig {
|
|||||||
pub dce: bool,
|
pub dce: bool,
|
||||||
/// Enable inlining of small word bodies.
|
/// Enable inlining of small word bodies.
|
||||||
pub inline: bool,
|
pub inline: bool,
|
||||||
|
/// Expand a recursive word's base-case guard into its own call sites, so
|
||||||
|
/// the leaves of the recursion cost a test instead of a call.
|
||||||
|
pub self_guard: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all enabled optimization passes.
|
/// Run all enabled optimization passes.
|
||||||
@@ -34,6 +37,7 @@ pub fn optimize(
|
|||||||
ops: Vec<IrOp>,
|
ops: Vec<IrOp>,
|
||||||
config: &OptConfig,
|
config: &OptConfig,
|
||||||
bodies: &HashMap<WordId, Vec<IrOp>>,
|
bodies: &HashMap<WordId, Vec<IrOp>>,
|
||||||
|
self_id: Option<WordId>,
|
||||||
) -> Vec<IrOp> {
|
) -> Vec<IrOp> {
|
||||||
let mut ir = ops;
|
let mut ir = ops;
|
||||||
|
|
||||||
@@ -53,7 +57,25 @@ pub fn optimize(
|
|||||||
|
|
||||||
// Phase 2: inline then simplify again
|
// Phase 2: inline then simplify again
|
||||||
if config.inline {
|
if config.inline {
|
||||||
ir = inline(ir, bodies, 8);
|
// A caller that can never leave the memory data stack would drag an
|
||||||
|
// inlined loop down with it, so leave those callees where they are:
|
||||||
|
// as their own word the loop keeps its registers, and one call is far
|
||||||
|
// cheaper than a loop's worth of memory traffic.
|
||||||
|
//
|
||||||
|
// This takes two passes, because before the primitives are substituted
|
||||||
|
// the caller is nothing but `Call`s -- `DROP` and `CR` included -- and
|
||||||
|
// the promotability check deliberately looks through calls. Asked too
|
||||||
|
// early it says "promotable" about almost anything, which is how this
|
||||||
|
// guard managed to be a no-op. Inline the loop-free callees first, then
|
||||||
|
// ask, then let the loop-bearing ones in if the answer was yes.
|
||||||
|
ir = inline(ir, bodies, 8, true);
|
||||||
|
let keep_loops_out = !crate::codegen::promotable_modulo_calls(&ir);
|
||||||
|
ir = inline(ir, bodies, 8, keep_loops_out);
|
||||||
|
}
|
||||||
|
if config.self_guard
|
||||||
|
&& let Some(id) = self_id
|
||||||
|
{
|
||||||
|
ir = expand_self_guard(ir, id);
|
||||||
}
|
}
|
||||||
if config.peephole {
|
if config.peephole {
|
||||||
ir = peephole(ir);
|
ir = peephole(ir);
|
||||||
@@ -496,7 +518,12 @@ fn dce(ops: Vec<IrOp>) -> Vec<IrOp> {
|
|||||||
|
|
||||||
/// Inline small word bodies: replaces `Call(id)` with the word's IR body
|
/// Inline small word bodies: replaces `Call(id)` with the word's IR body
|
||||||
/// if the body is small enough and not recursive.
|
/// if the body is small enough and not recursive.
|
||||||
fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize) -> Vec<IrOp> {
|
fn inline(
|
||||||
|
ops: Vec<IrOp>,
|
||||||
|
bodies: &HashMap<WordId, Vec<IrOp>>,
|
||||||
|
max_size: usize,
|
||||||
|
keep_loops_out: bool,
|
||||||
|
) -> Vec<IrOp> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for op in ops {
|
for op in ops {
|
||||||
match &op {
|
match &op {
|
||||||
@@ -505,6 +532,7 @@ fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize)
|
|||||||
&& body.len() <= max_size
|
&& body.len() <= max_size
|
||||||
&& !contains_call_to(body, *id)
|
&& !contains_call_to(body, *id)
|
||||||
&& !contains_exit(body)
|
&& !contains_exit(body)
|
||||||
|
&& !(keep_loops_out && crate::codegen::contains_loop(body))
|
||||||
{
|
{
|
||||||
// Inline the body, recursively converting TailCall back to Call
|
// Inline the body, recursively converting TailCall back to Call
|
||||||
// (tail position in the callee is not tail position in the caller).
|
// (tail position in the callee is not tail position in the caller).
|
||||||
@@ -517,7 +545,7 @@ fn inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>, max_size: usize)
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
out.push(apply_to_bodies(op, &|inner| {
|
out.push(apply_to_bodies(op, &|inner| {
|
||||||
inline(inner, bodies, max_size)
|
inline(inner, bodies, max_size, keep_loops_out)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,6 +602,142 @@ fn detailcall(op: IrOp) -> IrOp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an IR body contains a direct call to the given word (recursion guard).
|
/// Check if an IR body contains a direct call to the given word (recursion guard).
|
||||||
|
/// Largest guard the expander is willing to run twice, in IR operations.
|
||||||
|
const MAX_GUARD_OPS: usize = 6;
|
||||||
|
/// Most self-call sites worth expanding, to bound the code growth.
|
||||||
|
const MAX_GUARD_SITES: usize = 4;
|
||||||
|
|
||||||
|
/// Expand a recursive word's base-case guard into its own call sites.
|
||||||
|
///
|
||||||
|
/// A recursive Forth word almost always opens with a guard that returns early
|
||||||
|
/// -- `: FIB DUP 2 < IF EXIT THEN ... RECURSE ... ;` -- so every leaf of the
|
||||||
|
/// recursion costs a call whose whole body is that test. Testing at the call
|
||||||
|
/// site instead removes the call for the leaves, which in fib's tree is half
|
||||||
|
/// of all nodes.
|
||||||
|
///
|
||||||
|
/// `Call(self)` becomes `<guard> IF <what the guard returns> ELSE Call(self)
|
||||||
|
/// THEN`, which computes the same thing: the callee would have run the guard,
|
||||||
|
/// taken the branch and returned. The price is that the guard runs twice along
|
||||||
|
/// the recursive path, which is why it has to be small and free of effects.
|
||||||
|
fn expand_self_guard(ops: Vec<IrOp>, self_id: WordId) -> Vec<IrOp> {
|
||||||
|
let Some((cond, base)) = split_guard(&ops) else {
|
||||||
|
return ops;
|
||||||
|
};
|
||||||
|
if count_self_calls(&ops, self_id) > MAX_GUARD_SITES {
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
let (cond, base) = (cond.to_vec(), base.to_vec());
|
||||||
|
replace_self_calls(ops, self_id, &cond, &base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a body into the condition of its leading base-case guard and what
|
||||||
|
/// that guard leaves behind, or `None` if it does not open with one.
|
||||||
|
fn split_guard(ops: &[IrOp]) -> Option<(&[IrOp], &[IrOp])> {
|
||||||
|
let at = ops.iter().position(|op| matches!(op, IrOp::If { .. }))?;
|
||||||
|
let cond = &ops[..at];
|
||||||
|
if at > MAX_GUARD_OPS || !cond.iter().all(is_duplicable) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let IrOp::If {
|
||||||
|
then_body,
|
||||||
|
else_body: None,
|
||||||
|
} = &ops[at]
|
||||||
|
else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
// The guard is only a guard if it returns; what precedes the `EXIT` is
|
||||||
|
// the value it returns, and has to be as harmless as the condition.
|
||||||
|
let (IrOp::Exit, base) = then_body.split_last()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if base.len() > MAX_GUARD_OPS || !base.iter().all(is_duplicable) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((cond, base))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Can this operation be duplicated at every call site -- cheap, effect-free,
|
||||||
|
/// and not itself a call or a branch?
|
||||||
|
fn is_duplicable(op: &IrOp) -> bool {
|
||||||
|
matches!(
|
||||||
|
op,
|
||||||
|
IrOp::PushI32(_)
|
||||||
|
| IrOp::Drop
|
||||||
|
| IrOp::Dup
|
||||||
|
| IrOp::Swap
|
||||||
|
| IrOp::Over
|
||||||
|
| IrOp::Rot
|
||||||
|
| IrOp::Nip
|
||||||
|
| IrOp::Tuck
|
||||||
|
| IrOp::TwoDup
|
||||||
|
| IrOp::TwoDrop
|
||||||
|
| IrOp::Add
|
||||||
|
| IrOp::Sub
|
||||||
|
| IrOp::Mul
|
||||||
|
| IrOp::Negate
|
||||||
|
| IrOp::Abs
|
||||||
|
| IrOp::Eq
|
||||||
|
| IrOp::NotEq
|
||||||
|
| IrOp::Lt
|
||||||
|
| IrOp::Gt
|
||||||
|
| IrOp::LtUnsigned
|
||||||
|
| IrOp::ZeroEq
|
||||||
|
| IrOp::ZeroLt
|
||||||
|
| IrOp::And
|
||||||
|
| IrOp::Or
|
||||||
|
| IrOp::Xor
|
||||||
|
| IrOp::Invert
|
||||||
|
| IrOp::Lshift
|
||||||
|
| IrOp::Rshift
|
||||||
|
| IrOp::ArithRshift
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_self_calls(ops: &[IrOp], self_id: WordId) -> usize {
|
||||||
|
ops.iter()
|
||||||
|
.map(|op| match op {
|
||||||
|
IrOp::Call(id) if *id == self_id => 1,
|
||||||
|
IrOp::If {
|
||||||
|
then_body,
|
||||||
|
else_body,
|
||||||
|
} => {
|
||||||
|
count_self_calls(then_body, self_id)
|
||||||
|
+ else_body
|
||||||
|
.as_deref()
|
||||||
|
.map_or(0, |eb| count_self_calls(eb, self_id))
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap every `Call(self_id)` in the guard. Only plain calls: a `TailCall` is
|
||||||
|
/// followed by a return, and leaving those alone keeps tail-call detection and
|
||||||
|
/// this pass from having to agree about what tail position means.
|
||||||
|
fn replace_self_calls(ops: Vec<IrOp>, self_id: WordId, cond: &[IrOp], base: &[IrOp]) -> Vec<IrOp> {
|
||||||
|
let mut out = Vec::with_capacity(ops.len());
|
||||||
|
for op in ops {
|
||||||
|
match op {
|
||||||
|
IrOp::Call(id) if id == self_id => {
|
||||||
|
out.extend_from_slice(cond);
|
||||||
|
out.push(IrOp::If {
|
||||||
|
then_body: base.to_vec(),
|
||||||
|
else_body: Some(vec![IrOp::Call(id)]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
IrOp::If {
|
||||||
|
then_body,
|
||||||
|
else_body,
|
||||||
|
} => out.push(IrOp::If {
|
||||||
|
then_body: replace_self_calls(then_body, self_id, cond, base),
|
||||||
|
else_body: else_body.map(|eb| replace_self_calls(eb, self_id, cond, base)),
|
||||||
|
}),
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn contains_call_to(ops: &[IrOp], target: WordId) -> bool {
|
fn contains_call_to(ops: &[IrOp], target: WordId) -> bool {
|
||||||
for op in ops {
|
for op in ops {
|
||||||
match op {
|
match op {
|
||||||
@@ -735,8 +899,173 @@ mod tests {
|
|||||||
strength_reduce: true,
|
strength_reduce: true,
|
||||||
dce: true,
|
dce: true,
|
||||||
inline: false,
|
inline: false,
|
||||||
|
self_guard: false,
|
||||||
};
|
};
|
||||||
optimize(ops, &config, &HashMap::new())
|
optimize(ops, &config, &HashMap::new(), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body shaped like a recursive Forth word: a base-case guard, then the
|
||||||
|
/// recursive step. `SELF` is the word being compiled.
|
||||||
|
const SELF: WordId = WordId(9);
|
||||||
|
|
||||||
|
fn guarded_body(step: Vec<IrOp>) -> Vec<IrOp> {
|
||||||
|
let mut ops = vec![
|
||||||
|
IrOp::Dup,
|
||||||
|
IrOp::PushI32(2),
|
||||||
|
IrOp::Lt,
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Exit],
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
ops.extend(step);
|
||||||
|
ops
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_moves_the_base_case_to_the_call_site() {
|
||||||
|
let out = expand_self_guard(guarded_body(vec![IrOp::Call(SELF)]), SELF);
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
guarded_body(vec![
|
||||||
|
IrOp::Dup,
|
||||||
|
IrOp::PushI32(2),
|
||||||
|
IrOp::Lt,
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![],
|
||||||
|
else_body: Some(vec![IrOp::Call(SELF)]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_carries_the_value_the_guard_returns() {
|
||||||
|
// `: F DUP 2 < IF DROP 0 EXIT THEN RECURSE ;` -- the base case is not
|
||||||
|
// "leave the argument", it is "replace it with 0".
|
||||||
|
let body = vec![
|
||||||
|
IrOp::Dup,
|
||||||
|
IrOp::PushI32(2),
|
||||||
|
IrOp::Lt,
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Drop, IrOp::PushI32(0), IrOp::Exit],
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
IrOp::Call(SELF),
|
||||||
|
];
|
||||||
|
let out = expand_self_guard(body, SELF);
|
||||||
|
let IrOp::If { then_body, .. } = &out[7] else {
|
||||||
|
panic!("expected the expanded guard at index 7, got {out:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(then_body, &vec![IrOp::Drop, IrOp::PushI32(0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_leaves_a_body_without_a_guard_alone() {
|
||||||
|
// An `IF` with an `ELSE` is a branch, not an early return.
|
||||||
|
let body = vec![
|
||||||
|
IrOp::Dup,
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Drop],
|
||||||
|
else_body: Some(vec![IrOp::Call(SELF)]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
|
||||||
|
// No `EXIT` in the then-branch: also not a guard.
|
||||||
|
let body = guarded_body(vec![IrOp::Call(SELF)])
|
||||||
|
.into_iter()
|
||||||
|
.map(|op| match op {
|
||||||
|
IrOp::If { .. } => IrOp::If {
|
||||||
|
then_body: vec![IrOp::Drop],
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_refuses_a_condition_it_cannot_run_twice() {
|
||||||
|
// A guard reached through a call or a memory write would be evaluated
|
||||||
|
// once at the call site and again inside the callee.
|
||||||
|
let body = vec![
|
||||||
|
IrOp::Call(WordId(3)),
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Exit],
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
IrOp::Call(SELF),
|
||||||
|
];
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
|
||||||
|
let body = vec![
|
||||||
|
IrOp::Dup,
|
||||||
|
IrOp::Fetch,
|
||||||
|
IrOp::If {
|
||||||
|
then_body: vec![IrOp::Exit],
|
||||||
|
else_body: None,
|
||||||
|
},
|
||||||
|
IrOp::Call(SELF),
|
||||||
|
];
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_stops_at_the_call_site_budget() {
|
||||||
|
let step = std::iter::repeat_n(IrOp::Call(SELF), MAX_GUARD_SITES + 1).collect();
|
||||||
|
let body = guarded_body(step);
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_guard_leaves_tail_calls_alone() {
|
||||||
|
let body = guarded_body(vec![IrOp::TailCall(SELF)]);
|
||||||
|
assert_eq!(expand_self_guard(body.clone(), SELF), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_loop_stays_out_of_a_caller_that_is_only_unpromotable_through_a_call() {
|
||||||
|
// The shape the benchmark harness uses, and the one that made this
|
||||||
|
// guard a no-op for its whole life: at the moment the guard runs, the
|
||||||
|
// caller's `CR` is still `Call(cr_word)`, not `IrOp::Cr`. A test built
|
||||||
|
// from `IrOp::Cr` directly passes even with the bug.
|
||||||
|
let cross = WordId(7);
|
||||||
|
let cr = WordId(9);
|
||||||
|
let mut bodies = HashMap::new();
|
||||||
|
bodies.insert(cr, vec![IrOp::Cr]);
|
||||||
|
bodies.insert(
|
||||||
|
cross,
|
||||||
|
vec![
|
||||||
|
IrOp::PushI32(0),
|
||||||
|
IrOp::Swap,
|
||||||
|
IrOp::PushI32(0),
|
||||||
|
IrOp::DoLoop {
|
||||||
|
body: vec![IrOp::RFetch, IrOp::Call(WordId(8)), IrOp::Xor],
|
||||||
|
is_plus_loop: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = opt_with_inline(
|
||||||
|
vec![
|
||||||
|
IrOp::PushI32(300000),
|
||||||
|
IrOp::Call(cross),
|
||||||
|
IrOp::Drop,
|
||||||
|
IrOp::Call(cr),
|
||||||
|
],
|
||||||
|
&bodies,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.iter()
|
||||||
|
.any(|op| matches!(op, IrOp::Call(id) if *id == cross)),
|
||||||
|
"a loop-bearing callee must not be inlined into a caller that cannot \
|
||||||
|
be promoted -- it would lose its registers: {out:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
out.iter().any(|op| matches!(op, IrOp::Cr)),
|
||||||
|
"the loop-free callee should still have been inlined: {out:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn opt_with_inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>) -> Vec<IrOp> {
|
fn opt_with_inline(ops: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>) -> Vec<IrOp> {
|
||||||
@@ -747,8 +1076,9 @@ mod tests {
|
|||||||
strength_reduce: true,
|
strength_reduce: true,
|
||||||
dce: true,
|
dce: true,
|
||||||
inline: true,
|
inline: true,
|
||||||
|
self_guard: false,
|
||||||
};
|
};
|
||||||
optimize(ops, &config, bodies)
|
optimize(ops, &config, bodies, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peephole tests
|
// Peephole tests
|
||||||
@@ -1008,8 +1338,59 @@ mod tests {
|
|||||||
strength_reduce: false,
|
strength_reduce: false,
|
||||||
dce: false,
|
dce: false,
|
||||||
inline: true,
|
inline: true,
|
||||||
|
self_guard: false,
|
||||||
};
|
};
|
||||||
let result = optimize(vec![IrOp::Call(WordId(5))], &config, &bodies);
|
let result = optimize(vec![IrOp::Call(WordId(5))], &config, &bodies, None);
|
||||||
assert_eq!(result, vec![IrOp::Call(WordId(5))]);
|
assert_eq!(result, vec![IrOp::Call(WordId(5))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_a_loop_out_of_a_caller_stuck_on_the_memory_stack() {
|
||||||
|
// The caller has a `.`, so it can never leave the memory data stack.
|
||||||
|
// Inlining the loop would drag it down too; as its own word the loop
|
||||||
|
// keeps its registers and the caller just pays one call.
|
||||||
|
let mut bodies = HashMap::new();
|
||||||
|
bodies.insert(
|
||||||
|
WordId(5),
|
||||||
|
vec![IrOp::DoLoop {
|
||||||
|
body: vec![IrOp::PushI32(1), IrOp::Add],
|
||||||
|
is_plus_loop: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dot], &bodies);
|
||||||
|
assert!(
|
||||||
|
matches!(result.first(), Some(IrOp::Call(WordId(5)))),
|
||||||
|
"loop should not have been inlined, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_inlines_a_loop_into_a_caller_that_can_be_promoted() {
|
||||||
|
let mut bodies = HashMap::new();
|
||||||
|
bodies.insert(
|
||||||
|
WordId(5),
|
||||||
|
vec![IrOp::DoLoop {
|
||||||
|
body: vec![IrOp::PushI32(1), IrOp::Add],
|
||||||
|
is_plus_loop: false,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dup], &bodies);
|
||||||
|
assert!(
|
||||||
|
!result.iter().any(|op| matches!(op, IrOp::Call(_))),
|
||||||
|
"loop should have been inlined, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn still_inlines_straight_line_words_anywhere() {
|
||||||
|
// Only loops are held back; a small straight-line word is still
|
||||||
|
// better off inlined even into an unpromotable caller.
|
||||||
|
let mut bodies = HashMap::new();
|
||||||
|
bodies.insert(WordId(5), vec![IrOp::Dup, IrOp::Mul]);
|
||||||
|
let result = opt_with_inline(vec![IrOp::Call(WordId(5)), IrOp::Dot], &bodies);
|
||||||
|
assert!(
|
||||||
|
!result.iter().any(|op| matches!(op, IrOp::Call(_))),
|
||||||
|
"straight-line word should still inline, got {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+514
-25
@@ -180,6 +180,15 @@ enum PendingAction {
|
|||||||
DeclareLocalEnd,
|
DeclareLocalEnd,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forth 2012 throw code for QUIT (table 9.1). Unlike every other code it is
|
||||||
|
/// not an exception: CATCH lets it through and the interpreter reports nothing.
|
||||||
|
const QUIT_THROW: i32 = -56;
|
||||||
|
|
||||||
|
/// Forth 2012 throw code for ABORT. CATCH sees it like any other exception,
|
||||||
|
/// but an uncaught one prints nothing: ABORT is specified as "empty the data
|
||||||
|
/// stack and perform the function of QUIT", and QUIT displays no message.
|
||||||
|
const ABORT_THROW: i32 = -1;
|
||||||
|
|
||||||
// Control-flow action codes for PendingAction::CompileControl
|
// Control-flow action codes for PendingAction::CompileControl
|
||||||
const CTRL_IF: i32 = 1;
|
const CTRL_IF: i32 = 1;
|
||||||
const CTRL_ELSE: i32 = 2;
|
const CTRL_ELSE: i32 = 2;
|
||||||
@@ -261,6 +270,7 @@ pub(crate) const INTERPRETER_TOKENS: &[&str] = &[
|
|||||||
"GILD",
|
"GILD",
|
||||||
"EMPTY",
|
"EMPTY",
|
||||||
"SYNONYM",
|
"SYNONYM",
|
||||||
|
"VOCABULARY",
|
||||||
"CONSOLIDATE",
|
"CONSOLIDATE",
|
||||||
// Parsing words
|
// Parsing words
|
||||||
"'",
|
"'",
|
||||||
@@ -339,6 +349,7 @@ struct MarkerState {
|
|||||||
// REPLACES table, ABORT" texts
|
// REPLACES table, ABORT" texts
|
||||||
search_order: Vec<u32>,
|
search_order: Vec<u32>,
|
||||||
next_wid: u32,
|
next_wid: u32,
|
||||||
|
wid_names: HashMap<u32, String>,
|
||||||
current_wid: u32,
|
current_wid: u32,
|
||||||
substitutions: HashMap<String, Vec<u8>>,
|
substitutions: HashMap<String, Vec<u8>>,
|
||||||
abort_messages_len: usize,
|
abort_messages_len: usize,
|
||||||
@@ -475,6 +486,8 @@ pub struct ForthVM<R: Runtime> {
|
|||||||
search_order: Arc<Mutex<Vec<u32>>>,
|
search_order: Arc<Mutex<Vec<u32>>>,
|
||||||
/// Next wordlist ID to allocate (shared).
|
/// Next wordlist ID to allocate (shared).
|
||||||
next_wid: Arc<Mutex<u32>>,
|
next_wid: Arc<Mutex<u32>>,
|
||||||
|
/// Names of wordlists created by VOCABULARY, for ORDER/WORDS display.
|
||||||
|
wid_names: HashMap<u32, String>,
|
||||||
/// xorshift64 PRNG state for RANDOM / RND-SEED.
|
/// xorshift64 PRNG state for RANDOM / RND-SEED.
|
||||||
rng_state: Arc<Mutex<u64>>,
|
rng_state: Arc<Mutex<u64>>,
|
||||||
/// Stacked compile state for nested definitions (quotations `[: ;]`).
|
/// Stacked compile state for nested definitions (quotations `[: ;]`).
|
||||||
@@ -703,6 +716,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
substitutions: Arc::new(Mutex::new(HashMap::new())),
|
substitutions: Arc::new(Mutex::new(HashMap::new())),
|
||||||
search_order: Arc::new(Mutex::new(vec![1])),
|
search_order: Arc::new(Mutex::new(vec![1])),
|
||||||
next_wid: Arc::new(Mutex::new(2)),
|
next_wid: Arc::new(Mutex::new(2)),
|
||||||
|
wid_names: HashMap::new(),
|
||||||
// SystemTime::now() PANICS on wasm32-unknown-unknown (no time
|
// SystemTime::now() PANICS on wasm32-unknown-unknown (no time
|
||||||
// source), which turned VM construction into an `unreachable`
|
// source), which turned VM construction into an `unreachable`
|
||||||
// trap in the browser. Seed from the wall clock only where one
|
// trap in the browser. Seed from the wall clock only where one
|
||||||
@@ -758,6 +772,22 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.compile_frames.clear();
|
self.compile_frames.clear();
|
||||||
self.compiling_source.clear();
|
self.compiling_source.clear();
|
||||||
self.source_capture_from = None;
|
self.source_capture_from = None;
|
||||||
|
// QUIT and ABORT are not errors: the wipe above IS their
|
||||||
|
// "enter interpretation state", and the standard asks for
|
||||||
|
// the user input device back and no message at all (ABORT
|
||||||
|
// is defined as emptying the data stack and then doing
|
||||||
|
// QUIT; only ABORT" prints, and that is code -2). The rest
|
||||||
|
// of this input -- and any EVALUATE / INCLUDE frame it
|
||||||
|
// unwound through -- is abandoned by returning here.
|
||||||
|
let mut tc = self.throw_code.lock().unwrap();
|
||||||
|
if matches!(*tc, Some(QUIT_THROW | ABORT_THROW)) {
|
||||||
|
*tc = None;
|
||||||
|
drop(tc);
|
||||||
|
self.rt.mem_write_i32(crate::memory::SYSVAR_SOURCE_ID, 0);
|
||||||
|
self.include_frames.clear();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
drop(tc);
|
||||||
return Err(self.describe_uncaught(e));
|
return Err(self.describe_uncaught(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1211,21 +1241,14 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
"FVALUE" => return self.define_fvalue(),
|
"FVALUE" => return self.define_fvalue(),
|
||||||
"CONSOLIDATE" => return self.consolidate(),
|
"CONSOLIDATE" => return self.consolidate(),
|
||||||
"SYNONYM" => return self.define_synonym(),
|
"SYNONYM" => return self.define_synonym(),
|
||||||
|
"VOCABULARY" => return self.define_vocabulary(),
|
||||||
"ORDER" => {
|
"ORDER" => {
|
||||||
// wid 1 is FORTH-WORDLIST; other wids are anonymous.
|
let order = self.search_order.lock().unwrap().clone();
|
||||||
let wid_name = |wid: u32| {
|
let names: Vec<String> = order.iter().map(|&w| self.wid_name(w)).collect();
|
||||||
if wid == 1 {
|
|
||||||
"FORTH".to_string()
|
|
||||||
} else {
|
|
||||||
format!("wid#{wid}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let so = self.search_order.lock().unwrap();
|
|
||||||
let names: Vec<String> = so.iter().map(|&w| wid_name(w)).collect();
|
|
||||||
let output = format!(
|
let output = format!(
|
||||||
"Search order: {} Compilation: {}\n",
|
"Search order: {} Compilation: {}\n",
|
||||||
names.join(" "),
|
names.join(" "),
|
||||||
wid_name(self.dictionary.current_wid())
|
self.wid_name(self.dictionary.current_wid())
|
||||||
);
|
);
|
||||||
self.output.lock().unwrap().push_str(&output);
|
self.output.lock().unwrap().push_str(&output);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1271,6 +1294,14 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constructs the outer interpreter only knows how to compile. Forth
|
||||||
|
// 2012 leaves their interpretation semantics undefined and both gforth
|
||||||
|
// and SwiftForth name the standard condition, so say what is wrong
|
||||||
|
// instead of claiming the word does not exist.
|
||||||
|
if INTERPRETER_TOKENS.contains(&token.to_uppercase().as_str()) {
|
||||||
|
anyhow::bail!("interpreting a compile-only word: {token} (throw -14)");
|
||||||
|
}
|
||||||
|
|
||||||
anyhow::bail!("unknown word: {token}");
|
anyhow::bail!("unknown word: {token}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2430,8 +2461,13 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run all enabled optimization passes on an IR sequence.
|
/// Run all enabled optimization passes on an IR sequence.
|
||||||
fn optimize_ir(&self, ir: Vec<IrOp>, bodies: &HashMap<WordId, Vec<IrOp>>) -> Vec<IrOp> {
|
fn optimize_ir(
|
||||||
optimize(ir, &self.config.opt, bodies)
|
&self,
|
||||||
|
ir: Vec<IrOp>,
|
||||||
|
bodies: &HashMap<WordId, Vec<IrOp>>,
|
||||||
|
self_id: Option<WordId>,
|
||||||
|
) -> Vec<IrOp> {
|
||||||
|
optimize(ir, &self.config.opt, bodies, self_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `{: args | locals -- comment :}` block and compile local
|
/// Parse a `{: args | locals -- comment :}` block and compile local
|
||||||
@@ -2543,7 +2579,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
|
|
||||||
let ir = std::mem::take(&mut self.compiling_ir);
|
let ir = std::mem::take(&mut self.compiling_ir);
|
||||||
let bodies = self.ir_bodies.clone();
|
let bodies = self.ir_bodies.clone();
|
||||||
let ir = self.optimize_ir(ir, &bodies);
|
let ir = self.optimize_ir(ir, &bodies, Some(word_id));
|
||||||
self.ir_bodies.insert(word_id, ir.clone());
|
self.ir_bodies.insert(word_id, ir.clone());
|
||||||
|
|
||||||
// Compile to WASM
|
// Compile to WASM
|
||||||
@@ -2611,6 +2647,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
&local_fn_map,
|
&local_fn_map,
|
||||||
table_size,
|
table_size,
|
||||||
self.stack_guard_param(),
|
self.stack_guard_param(),
|
||||||
|
self.config.codegen.typed_calls,
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
|
||||||
|
|
||||||
@@ -2641,6 +2678,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
&local_fn_map,
|
&local_fn_map,
|
||||||
table_size,
|
table_size,
|
||||||
self.stack_guard_param(),
|
self.stack_guard_param(),
|
||||||
|
self.config.codegen.typed_calls,
|
||||||
)
|
)
|
||||||
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
|
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
|
||||||
|
|
||||||
@@ -2933,6 +2971,11 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
.then_some(self.stack_fault_id)
|
.then_some(self.stack_fault_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether words with a known stack effect get a typed entry point.
|
||||||
|
pub(crate) fn typed_calls(&self) -> bool {
|
||||||
|
self.config.codegen.typed_calls
|
||||||
|
}
|
||||||
|
|
||||||
/// Codegen configuration for compiling one word.
|
/// Codegen configuration for compiling one word.
|
||||||
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
|
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
|
||||||
CodegenConfig {
|
CodegenConfig {
|
||||||
@@ -2940,6 +2983,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
table_size: self.table_size(),
|
table_size: self.table_size(),
|
||||||
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
|
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
|
||||||
stack_guards: self.stack_guard_param(),
|
stack_guards: self.stack_guard_param(),
|
||||||
|
typed_calls: self.config.codegen.typed_calls,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2951,7 +2995,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
ir_body: Vec<IrOp>,
|
ir_body: Vec<IrOp>,
|
||||||
) -> anyhow::Result<WordId> {
|
) -> anyhow::Result<WordId> {
|
||||||
let bodies = self.ir_bodies.clone();
|
let bodies = self.ir_bodies.clone();
|
||||||
let ir_body = self.optimize_ir(ir_body, &bodies);
|
let ir_body = self.optimize_ir(ir_body, &bodies, None);
|
||||||
let word_id = self
|
let word_id = self
|
||||||
.dictionary
|
.dictionary
|
||||||
.create(name, immediate)
|
.create(name, immediate)
|
||||||
@@ -3696,6 +3740,47 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// VOCABULARY <name> -- create a named wordlist (fig-Forth heritage;
|
||||||
|
/// gforth/SwiftForth extension, not Forth 2012). Executing the created
|
||||||
|
/// word replaces the top of the search order with its wordlist, the
|
||||||
|
/// same semantics the standard gives the word FORTH. The name is
|
||||||
|
/// remembered so ORDER and WORDS ALL display it instead of wid#N.
|
||||||
|
fn define_vocabulary(&mut self) -> anyhow::Result<()> {
|
||||||
|
let name = self
|
||||||
|
.next_token()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("VOCABULARY: expected name"))?;
|
||||||
|
|
||||||
|
let set_context_id = self
|
||||||
|
.dictionary
|
||||||
|
.find("_SET_CONTEXT_")
|
||||||
|
.map(|(_, id, _)| id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("_SET_CONTEXT_ not found"))?;
|
||||||
|
|
||||||
|
let wid = {
|
||||||
|
let mut nw = self.next_wid.lock().unwrap();
|
||||||
|
let wid = *nw;
|
||||||
|
*nw += 1;
|
||||||
|
wid
|
||||||
|
};
|
||||||
|
self.wid_names.insert(wid, name.to_uppercase());
|
||||||
|
|
||||||
|
let word_id = self
|
||||||
|
.dictionary
|
||||||
|
.create(&name, false)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let ir_body = vec![IrOp::PushI32(wid as i32), IrOp::Call(set_context_id)];
|
||||||
|
self.ir_bodies.insert(word_id, ir_body.clone());
|
||||||
|
self.word_sources
|
||||||
|
.insert(word_id, format!("VOCABULARY {name}"));
|
||||||
|
let config = self.codegen_config(word_id.0);
|
||||||
|
let compiled = compile_word(&name, &ir_body, &config)
|
||||||
|
.map_err(|e| anyhow::anyhow!("codegen error for VOCABULARY: {e}"))?;
|
||||||
|
self.instantiate_and_install(&compiled, word_id)?;
|
||||||
|
self.dictionary.reveal();
|
||||||
|
self.next_table_index = self.next_table_index.max(word_id.0 + 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// IMMEDIATE -- toggle the immediate flag on the most recently defined word.
|
/// IMMEDIATE -- toggle the immediate flag on the most recently defined word.
|
||||||
/// Called via `pending_define` when IMMEDIATE is executed from compiled code.
|
/// Called via `pending_define` when IMMEDIATE is executed from compiled code.
|
||||||
fn set_immediate(&mut self) -> anyhow::Result<()> {
|
fn set_immediate(&mut self) -> anyhow::Result<()> {
|
||||||
@@ -3765,6 +3850,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
fvalue_words: self.fvalue_words.clone(),
|
fvalue_words: self.fvalue_words.clone(),
|
||||||
search_order: self.search_order.lock().unwrap().clone(),
|
search_order: self.search_order.lock().unwrap().clone(),
|
||||||
next_wid: *self.next_wid.lock().unwrap(),
|
next_wid: *self.next_wid.lock().unwrap(),
|
||||||
|
wid_names: self.wid_names.clone(),
|
||||||
current_wid: self.dictionary.current_wid(),
|
current_wid: self.dictionary.current_wid(),
|
||||||
substitutions: self.substitutions.lock().unwrap().clone(),
|
substitutions: self.substitutions.lock().unwrap().clone(),
|
||||||
abort_messages_len: self.abort_messages.lock().unwrap().len(),
|
abort_messages_len: self.abort_messages.lock().unwrap().len(),
|
||||||
@@ -3788,6 +3874,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.fvalue_words = state.fvalue_words;
|
self.fvalue_words = state.fvalue_words;
|
||||||
*self.search_order.lock().unwrap() = state.search_order;
|
*self.search_order.lock().unwrap() = state.search_order;
|
||||||
*self.next_wid.lock().unwrap() = state.next_wid;
|
*self.next_wid.lock().unwrap() = state.next_wid;
|
||||||
|
self.wid_names = state.wid_names;
|
||||||
*self.substitutions.lock().unwrap() = state.substitutions;
|
*self.substitutions.lock().unwrap() = state.substitutions;
|
||||||
self.abort_messages
|
self.abort_messages
|
||||||
.lock()
|
.lock()
|
||||||
@@ -4442,6 +4529,20 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
});
|
});
|
||||||
self.register_host_primitive("_ABORT_Q_", false, func)?;
|
self.register_host_primitive("_ABORT_Q_", false, func)?;
|
||||||
|
|
||||||
|
// QUIT ( -- ) ( R: i*x -- ) empty the return stack and return to the
|
||||||
|
// interpreter. The data stack is deliberately untouched -- that is the
|
||||||
|
// whole difference to ABORT, which is specified as "empty the data
|
||||||
|
// stack, then QUIT". Unwinding rides the throw plumbing so nested
|
||||||
|
// EVALUATE / INCLUDE frames are abandoned on the way out; the standard
|
||||||
|
// code -56 tells `evaluate` and CATCH what this is.
|
||||||
|
let throw_code = Arc::clone(&self.throw_code);
|
||||||
|
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||||
|
ctx.set_rsp((RETURN_STACK_TOP as i32) as u32);
|
||||||
|
*throw_code.lock().unwrap() = Some(QUIT_THROW);
|
||||||
|
Err(anyhow::anyhow!("forth-throw"))
|
||||||
|
});
|
||||||
|
self.register_host_primitive("QUIT", false, func)?;
|
||||||
|
|
||||||
// BYE ( -- ) request REPL/driver exit.
|
// BYE ( -- ) request REPL/driver exit.
|
||||||
let bye = Arc::clone(&self.bye);
|
let bye = Arc::clone(&self.bye);
|
||||||
let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| {
|
let func: HostFn = Box::new(move |_ctx: &mut dyn HostAccess| {
|
||||||
@@ -4519,9 +4620,16 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
ctx.set_dsp((new_sp as i32) as u32);
|
ctx.set_dsp((new_sp as i32) as u32);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(e) => {
|
||||||
// Check if this was a THROW (vs some other trap)
|
// Check if this was a THROW (vs some other trap)
|
||||||
let mut tc = throw_code_for_catch.lock().unwrap();
|
let mut tc = throw_code_for_catch.lock().unwrap();
|
||||||
|
// QUIT is not an exception: it unwinds past CATCH straight
|
||||||
|
// to the interpreter, leaving both stacks as it found them
|
||||||
|
// (verified against gforth and SwiftForth).
|
||||||
|
if *tc == Some(QUIT_THROW) {
|
||||||
|
drop(tc);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
let code = tc.take().unwrap_or(-1);
|
let code = tc.take().unwrap_or(-1);
|
||||||
drop(tc);
|
drop(tc);
|
||||||
|
|
||||||
@@ -6455,6 +6563,17 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
|
|
||||||
/// `WORDS ALL` -- grouped full view: one section per wordlist (search
|
/// `WORDS ALL` -- grouped full view: one section per wordlist (search
|
||||||
/// order first, then any other populated wids), then internal words.
|
/// order first, then any other populated wids), then internal words.
|
||||||
|
/// Display name for a wordlist: FORTH, a VOCABULARY name, or wid#N.
|
||||||
|
fn wid_name(&self, wid: u32) -> String {
|
||||||
|
if wid == 1 {
|
||||||
|
return "FORTH".to_string();
|
||||||
|
}
|
||||||
|
self.wid_names
|
||||||
|
.get(&wid)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| format!("wid#{wid}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn do_words_all(&mut self) {
|
fn do_words_all(&mut self) {
|
||||||
let entries = self.dictionary.visible_entries();
|
let entries = self.dictionary.visible_entries();
|
||||||
let mut wids: Vec<u32> = self.search_order.lock().unwrap().clone();
|
let mut wids: Vec<u32> = self.search_order.lock().unwrap().clone();
|
||||||
@@ -6463,15 +6582,9 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
wids.push(*wid);
|
wids.push(*wid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let wid_name = |wid: u32| {
|
let wid_names: Vec<String> = wids.iter().map(|&w| self.wid_name(w)).collect();
|
||||||
if wid == 1 {
|
|
||||||
"FORTH".to_string()
|
|
||||||
} else {
|
|
||||||
format!("wid#{wid}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut out = self.output.lock().unwrap();
|
let mut out = self.output.lock().unwrap();
|
||||||
for wid in wids {
|
for (wid, wid_name) in wids.iter().copied().zip(wid_names) {
|
||||||
let names: Vec<&str> = entries
|
let names: Vec<&str> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, w, internal)| *w == wid && !internal)
|
.filter(|(_, w, internal)| *w == wid && !internal)
|
||||||
@@ -6480,7 +6593,7 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
if names.is_empty() {
|
if names.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
out.push_str(&format!("-- {} ({} words)\n", wid_name(wid), names.len()));
|
out.push_str(&format!("-- {} ({} words)\n", wid_name, names.len()));
|
||||||
push_wrapped(&mut out, &names);
|
push_wrapped(&mut out, &names);
|
||||||
}
|
}
|
||||||
let internals: Vec<&str> = entries
|
let internals: Vec<&str> = entries
|
||||||
@@ -6786,6 +6899,56 @@ impl<R: Runtime> ForthVM<R> {
|
|||||||
self.register_host_primitive("ALSO", false, func)?;
|
self.register_host_primitive("ALSO", false, func)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// >ORDER ( wid -- ) — push wid on top of the search order.
|
||||||
|
// Not in Forth 2012; a widely used gforth extension.
|
||||||
|
{
|
||||||
|
let so = Arc::clone(&self.search_order);
|
||||||
|
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||||
|
let sp = host_need(ctx, 1)?;
|
||||||
|
let wid = ctx.mem_read_i32(sp) as u32;
|
||||||
|
so.lock().unwrap().insert(0, wid);
|
||||||
|
ctx.set_dsp(sp + CELL_SIZE);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
self.register_host_primitive(">ORDER", false, func)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -ORDER ( wid -- ) — remove wid from the search order wherever it
|
||||||
|
// sits (no-op if absent). VFX/MPE extension, the inverse of >ORDER.
|
||||||
|
{
|
||||||
|
let so = Arc::clone(&self.search_order);
|
||||||
|
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||||
|
let sp = host_need(ctx, 1)?;
|
||||||
|
let wid = ctx.mem_read_i32(sp) as u32;
|
||||||
|
so.lock().unwrap().retain(|&w| w != wid);
|
||||||
|
ctx.set_dsp(sp + CELL_SIZE);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
self.register_host_primitive("-ORDER", false, func)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _SET_CONTEXT_ ( wid -- ) — replace the top of the search order.
|
||||||
|
// Internal carrier for words created by VOCABULARY (same pattern as
|
||||||
|
// _MARKER_RESTORE_): a vocabulary word compiles to
|
||||||
|
// `PushI32(wid) Call(_SET_CONTEXT_)`.
|
||||||
|
{
|
||||||
|
let so = Arc::clone(&self.search_order);
|
||||||
|
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
|
||||||
|
let sp = host_need(ctx, 1)?;
|
||||||
|
let wid = ctx.mem_read_i32(sp) as u32;
|
||||||
|
let mut order = so.lock().unwrap();
|
||||||
|
if order.is_empty() {
|
||||||
|
order.push(wid);
|
||||||
|
} else {
|
||||||
|
order[0] = wid;
|
||||||
|
}
|
||||||
|
drop(order);
|
||||||
|
ctx.set_dsp(sp + CELL_SIZE);
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
self.register_host_primitive("_SET_CONTEXT_", false, func)?;
|
||||||
|
}
|
||||||
|
|
||||||
// PREVIOUS ( -- ) remove top of search order
|
// PREVIOUS ( -- ) remove top of search order
|
||||||
{
|
{
|
||||||
let so = Arc::clone(&self.search_order);
|
let so = Arc::clone(&self.search_order);
|
||||||
@@ -8126,6 +8289,185 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::runtime_native::NativeRuntime;
|
use crate::runtime_native::NativeRuntime;
|
||||||
|
|
||||||
|
// -- Typed calling convention -------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_typed_word_recursion_matches_the_memory_convention() {
|
||||||
|
// FIB is the shape the typed entry exists for: self-recursive with
|
||||||
|
// an early EXIT, so it is compiled as WASM values in and out.
|
||||||
|
let (stack, _) = eval(
|
||||||
|
": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ; \
|
||||||
|
0 FIB 1 FIB 2 FIB 10 FIB 25 FIB",
|
||||||
|
);
|
||||||
|
assert_eq!(stack, vec![75025, 55, 1, 1, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_typed_word_keeps_the_items_below_its_arguments() {
|
||||||
|
// The wrapper may only move the cells the word declared; anything
|
||||||
|
// deeper has to still be there afterwards.
|
||||||
|
let (stack, _) = eval(": SQ DUP * ; 7 8 9 SQ");
|
||||||
|
assert_eq!(stack, vec![81, 8, 7]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_typed_word_is_reachable_through_execute() {
|
||||||
|
// EXECUTE goes through the table, which holds the `( -- )` wrapper.
|
||||||
|
let (stack, _) = eval(": SQ DUP * ; 6 ' SQ EXECUTE");
|
||||||
|
assert_eq!(stack, vec![36]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catch_sees_a_typed_word_underflow() {
|
||||||
|
// A typed word's stack guards live in its wrapper, where the
|
||||||
|
// arguments come off the memory stack. They still THROW -4 rather
|
||||||
|
// than corrupting the stack pointer, and CATCH still reports it.
|
||||||
|
let (stack, _) = eval(": SQ DUP * ; : CHK ['] SQ CATCH ; CHK");
|
||||||
|
assert_eq!(stack, vec![-4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_catch_restores_the_stack_around_a_caller_of_typed_code() {
|
||||||
|
// T contains THROW, a host word, so T itself keeps the memory
|
||||||
|
// convention and reaches SQ through its wrapper. CATCH restores the
|
||||||
|
// depth it saved across that boundary -- not the contents, so the 3
|
||||||
|
// that SQ squared stays squared. gforth agrees: `1 2 9 5 4`.
|
||||||
|
let (stack, _) = eval(": SQ DUP * ; : T SQ 5 THROW ; : CHK 1 2 3 ['] T CATCH DEPTH ; CHK");
|
||||||
|
assert_eq!(stack, vec![4, 5, 9, 2, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_typed_word_underflow_still_throws() {
|
||||||
|
// The stack guards for a typed word live in its wrapper, where the
|
||||||
|
// arguments are taken off the memory stack.
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate(": SQ DUP * ;").unwrap();
|
||||||
|
vm.take_output();
|
||||||
|
let err = vm.evaluate("SQ");
|
||||||
|
assert!(err.is_err(), "empty-stack SQ should throw, got {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deep_typed_recursion_unwinds_cleanly() {
|
||||||
|
// 10k levels of a typed self-call: results come back in WASM values,
|
||||||
|
// so nothing is left on the memory stack afterwards.
|
||||||
|
let (stack, _) =
|
||||||
|
eval(": COUNT-DOWN DUP 0= IF EXIT THEN 1- RECURSE ; 10000 COUNT-DOWN DEPTH");
|
||||||
|
assert_eq!(stack, vec![1, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Region promotion (a hot loop inside an unpromotable word) -----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_loop_in_an_unpromotable_word_still_computes() {
|
||||||
|
// `.` keeps MIXED off the register path as a whole, but the loop
|
||||||
|
// inside it is promoted as its own region. Values checked against
|
||||||
|
// gforth 0.7.3.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": MIXED 0 1000 0 DO 1+ LOOP . ; MIXED"),
|
||||||
|
"1000 "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_j_in_a_promoted_region_reads_the_right_loop() {
|
||||||
|
// A region may only use `I` / `J` when the DO loops they name are
|
||||||
|
// inside the region itself -- otherwise the simulator resolves them
|
||||||
|
// against its own empty loop stack. gforth prints 9.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": JT 0 3 0 DO 3 0 DO J + LOOP LOOP . ; JT"),
|
||||||
|
"9 "
|
||||||
|
);
|
||||||
|
assert_eq!(eval_output(": IT 0 5 0 DO I + LOOP . ; IT"), "10 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_promoted_loop_body_that_permutes_the_stack() {
|
||||||
|
// The values a loop body leaves have to reach the loop-top locals all
|
||||||
|
// at once. Copying them in index order writes the top into the second
|
||||||
|
// slot and then reads that slot back, so both come out equal -- this
|
||||||
|
// printed "4 4" and "3 2 3" before. gforth: "4 3" and "2 1 3".
|
||||||
|
assert_eq!(eval_output(": C 3 4 2 0 DO SWAP LOOP . . ; C"), "4 3 ");
|
||||||
|
assert_eq!(eval_output(": D 1 2 3 2 0 DO ROT LOOP . . . ; D"), "2 1 3 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_self_guard_expansion_keeps_the_answers() {
|
||||||
|
// The base-case guard is tested at the call site, so a leaf never
|
||||||
|
// costs a call. All four verified against gforth.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(
|
||||||
|
": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ; \
|
||||||
|
25 FIB . 0 FIB . 1 FIB . 2 FIB . 10 FIB ."
|
||||||
|
),
|
||||||
|
"75025 0 1 1 55 "
|
||||||
|
);
|
||||||
|
// A guard that replaces its argument rather than leaving it.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": G DUP 0= IF DROP 0 EXIT THEN DUP 1- RECURSE + ; 5 G . 0 G . 100 G ."),
|
||||||
|
"15 0 5050 "
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": H DUP 3 < IF DROP 7 EXIT THEN 1- RECURSE 2 * ; 5 H . 2 H . 8 H ."),
|
||||||
|
"56 7 448 "
|
||||||
|
);
|
||||||
|
// Two guards and three call sites, one of them behind the second guard.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(
|
||||||
|
": ACK OVER 0= IF SWAP DROP 1+ EXIT THEN DUP 0= IF DROP 1- 1 RECURSE EXIT THEN \
|
||||||
|
OVER SWAP 1- RECURSE SWAP 1- SWAP RECURSE ; 2 3 ACK . 1 2 ACK ."
|
||||||
|
),
|
||||||
|
"9 4 "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_promoted_begin_loops() {
|
||||||
|
// BEGIN loops promote too, so these run entirely in locals.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": GCD BEGIN DUP WHILE TUCK MOD REPEAT DROP . ; 1071 462 GCD"),
|
||||||
|
"21 "
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": CD BEGIN 1 - DUP 0= UNTIL DROP 42 . ; 5 CD"),
|
||||||
|
"42 "
|
||||||
|
);
|
||||||
|
// A WHILE test that permutes: the loop is left between test and body,
|
||||||
|
// so that exit needs the loop-top locals straightened out as well.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": W BEGIN SWAP DUP WHILE 1 - SWAP REPEAT . . ; 9 3 W"),
|
||||||
|
"0 3 "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_begin_loop_with_an_unbalanced_body_is_not_promoted() {
|
||||||
|
// `BEGIN DUP 1+ SWAP DUP 5 > UNTIL` leaves one extra cell per pass, so
|
||||||
|
// there is no fixed promoted stack shape. It has to keep working.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": U 0 BEGIN 1 + DUP DUP 3 > UNTIL DROP . . . . ; U"),
|
||||||
|
"4 3 2 1 "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_several_regions_in_one_word() {
|
||||||
|
// Two loops separated by a `.`: each is its own region, and the
|
||||||
|
// stack has to survive the hand-off through memory between them.
|
||||||
|
assert_eq!(
|
||||||
|
eval_output(": M2 0 10 0 DO I + LOOP DUP . 5 0 DO 1+ LOOP . ; M2"),
|
||||||
|
"45 50 "
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_region_hands_results_back_to_the_memory_stack() {
|
||||||
|
// The region computes in locals; what it leaves has to be visible to
|
||||||
|
// the interpreter afterwards.
|
||||||
|
let (stack, _) = eval(": R 7 4 0 DO 1+ LOOP ; 100 R");
|
||||||
|
assert_eq!(stack, vec![11, 100]);
|
||||||
|
}
|
||||||
|
|
||||||
fn eval(input: &str) -> (Vec<i32>, String) {
|
fn eval(input: &str) -> (Vec<i32>, String) {
|
||||||
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
vm.evaluate(input).unwrap();
|
vm.evaluate(input).unwrap();
|
||||||
@@ -9340,6 +9682,114 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// QUIT — Forth 2012 6.1.2050. Semantics checked against gforth 0.7.3
|
||||||
|
// and SwiftForth sf64: the data stack survives, nothing is printed,
|
||||||
|
// the rest of the input is abandoned, and CATCH does not see it.
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_keeps_data_stack_and_abandons_the_rest() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate("1 2 QUIT 99 .").unwrap(); // not an error, and 99 never runs
|
||||||
|
assert_eq!(vm.take_output(), "");
|
||||||
|
assert_eq!(vm.data_stack(), vec![2, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_from_inside_a_definition() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate(": T 7 QUIT 8 . ; 5 T 6 .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "");
|
||||||
|
assert_eq!(vm.data_stack(), vec![7, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_empties_the_return_stack() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate(": T 1 >R 2 >R QUIT ; T").unwrap();
|
||||||
|
vm.evaluate("RDEPTH .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "0 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_is_not_caught_by_catch() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate("1 2 ' QUIT CATCH .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "", "CATCH must not report QUIT");
|
||||||
|
assert_eq!(vm.data_stack(), vec![2, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_leaves_compile_mode_when_it_executes() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate(": BOOM QUIT ; IMMEDIATE").unwrap();
|
||||||
|
vm.evaluate("9 : FOO 1 2 BOOM").unwrap();
|
||||||
|
assert!(!vm.is_compiling(), "QUIT enters interpretation state");
|
||||||
|
assert_eq!(vm.data_stack(), vec![9], "the data stack is left alone");
|
||||||
|
vm.evaluate(": SQ DUP * ; 9 SQ .").unwrap(); // the VM is usable again
|
||||||
|
assert_eq!(vm.take_output(), "81 ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_quit_inside_evaluate_restores_user_input_source() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate("S\" 5 QUIT 6 .\" EVALUATE 7 .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "", "both the string and the line stop");
|
||||||
|
assert_eq!(vm.data_stack(), vec![5]);
|
||||||
|
vm.evaluate("SOURCE-ID .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "0 ", "back to the user input device");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ABORT reporting — gforth and sf64 both print nothing for an uncaught
|
||||||
|
// ABORT: it is specified as "empty the data stack and perform the
|
||||||
|
// function of QUIT", and QUIT displays no message. Only ABORT" prints.
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_abort_is_silent_and_abandons_the_rest() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate("1 2 ABORT 99 .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "");
|
||||||
|
assert!(vm.data_stack().is_empty(), "ABORT empties the data stack");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_abort_is_still_catchable() {
|
||||||
|
// Unlike QUIT: CATCH reports -1 and restores the stack depth.
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
vm.evaluate("1 2 ' ABORT CATCH .").unwrap();
|
||||||
|
assert_eq!(vm.take_output(), "-1 ");
|
||||||
|
assert_eq!(vm.data_stack(), vec![2, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_abort_quote_still_reports_its_text() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
let err = vm.evaluate(": T -1 ABORT\" oops\" ; T").unwrap_err();
|
||||||
|
assert_eq!(err.to_string(), "oops");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_only_words_say_so_in_interpret_mode() {
|
||||||
|
for word in ["ABORT\"", "IF", "THEN", "LOOP", "LITERAL", "RECURSE"] {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
let err = vm.evaluate(word).unwrap_err().to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("compile-only word"),
|
||||||
|
"{word}: expected the standard condition, got {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_only_check_does_not_swallow_typos() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
|
||||||
|
let err = vm.evaluate("NOSUCHWORD").unwrap_err().to_string();
|
||||||
|
assert!(err.contains("unknown word"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// New words: SOURCE
|
// New words: SOURCE
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -10373,6 +10823,45 @@ mod tests {
|
|||||||
assert!(!output.contains('['));
|
assert!(!output.contains('['));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_to_order_pushes_on_top() {
|
||||||
|
let output = eval_output("WORDLIST >ORDER ORDER");
|
||||||
|
assert!(output.contains("Search order: wid#2 FORTH"), "{output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_minus_order_removes_wid() {
|
||||||
|
let output = eval_output("WORDLIST DUP >ORDER -ORDER ORDER");
|
||||||
|
assert!(output.contains("Search order: FORTH "), "{output}");
|
||||||
|
assert!(!output.contains("wid#"), "{output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vocabulary_replaces_top_and_names_order() {
|
||||||
|
// Vocabulary execution has FORTH-word semantics: replace the top
|
||||||
|
// of the search order. ALSO first, so FORTH stays underneath.
|
||||||
|
let output = eval_output("VOCABULARY EDITOR ALSO EDITOR ORDER");
|
||||||
|
assert!(output.contains("Search order: EDITOR FORTH"), "{output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vocabulary_definitions_land_in_it_and_words_groups_by_name() {
|
||||||
|
let output = eval_output("VOCABULARY EDITOR ALSO EDITOR DEFINITIONS : E1 1 ; WORDS ALL");
|
||||||
|
assert!(output.contains("-- EDITOR (1 words)"), "{output}");
|
||||||
|
// and the word is findable through the search order
|
||||||
|
let output = eval_output("VOCABULARY EDITOR ALSO EDITOR DEFINITIONS : E1 42 ; E1 .");
|
||||||
|
assert!(output.contains("42"), "{output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_marker_rolls_back_vocabulary_name() {
|
||||||
|
// After rollback the vocabulary is gone; a freshly allocated wid
|
||||||
|
// with the same number must not inherit its stale name.
|
||||||
|
let output = eval_output("MARKER M VOCABULARY V0 M WORDLIST >ORDER ORDER");
|
||||||
|
assert!(!output.contains("V0"), "{output}");
|
||||||
|
assert!(output.contains("wid#2"), "{output}");
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// Double DOES>: Forth 2012 WEIRD: W1 test
|
// Double DOES>: Forth 2012 WEIRD: W1 test
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -716,6 +716,11 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
|
|||||||
"Read a line of input (unsupported here).",
|
"Read a line of input (unsupported here).",
|
||||||
),
|
),
|
||||||
("ABORT", "( i*x -- )", "Empty the stacks and abort."),
|
("ABORT", "( i*x -- )", "Empty the stacks and abort."),
|
||||||
|
(
|
||||||
|
"QUIT",
|
||||||
|
"( -- ) ( R: i*x -- )",
|
||||||
|
"Empty the return stack, return to the interpreter; data stack kept.",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"ABORT\"",
|
"ABORT\"",
|
||||||
"( flag -- )",
|
"( flag -- )",
|
||||||
@@ -999,6 +1004,21 @@ pub const WORD_DOCS: &[(&str, &str, &str)] = &[
|
|||||||
"New definitions go to the top wordlist.",
|
"New definitions go to the top wordlist.",
|
||||||
),
|
),
|
||||||
("ALSO", "( -- )", "Duplicate the top of the search order."),
|
("ALSO", "( -- )", "Duplicate the top of the search order."),
|
||||||
|
(
|
||||||
|
">ORDER",
|
||||||
|
"( wid -- )",
|
||||||
|
"Push wid on top of the search order (gforth extension).",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"-ORDER",
|
||||||
|
"( wid -- )",
|
||||||
|
"Remove wid from the search order (VFX extension).",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"VOCABULARY",
|
||||||
|
"( \"name\" -- )",
|
||||||
|
"Create a named wordlist; executing name replaces the top of the search order.",
|
||||||
|
),
|
||||||
("ONLY", "( -- )", "Reset the search order to the minimum."),
|
("ONLY", "( -- )", "Reset the search order to the minimum."),
|
||||||
("PREVIOUS", "( -- )", "Drop the top of the search order."),
|
("PREVIOUS", "( -- )", "Drop the top of the search order."),
|
||||||
(
|
(
|
||||||
|
|||||||
+101
-43
@@ -468,6 +468,11 @@ fn programs() -> Vec<Program> {
|
|||||||
expected: "-1 \n-1 \n42 \n-1 \n",
|
expected: "-1 \n-1 \n42 \n-1 \n",
|
||||||
category: Category::Definitions,
|
category: Category::Definitions,
|
||||||
},
|
},
|
||||||
|
// QUIT is deliberately absent from this corpus: what it abandons is
|
||||||
|
// "the input source", and each engine here is fed differently (wafer
|
||||||
|
// line by line, gforth from a file, sf64 from a prompting stdin), so
|
||||||
|
// a comparison would measure the harness. Its semantics are pinned by
|
||||||
|
// the QUIT tests in outer.rs, checked by hand against both engines.
|
||||||
// -- Strings --
|
// -- Strings --
|
||||||
Program {
|
Program {
|
||||||
name: "s-quote-type",
|
name: "s-quote-type",
|
||||||
@@ -726,7 +731,6 @@ struct PerfBenchmark {
|
|||||||
run_code: &'static str,
|
run_code: &'static str,
|
||||||
verify: &'static str,
|
verify: &'static str,
|
||||||
expected: i32,
|
expected: i32,
|
||||||
samples: u32, // Number of runs for WAFER median
|
|
||||||
/// Maximum acceptable WAFER/gforth ratio (< 1.0 = WAFER faster).
|
/// Maximum acceptable WAFER/gforth ratio (< 1.0 = WAFER faster).
|
||||||
/// Test fails if ratio exceeds this. Set ~40-50% above measured baseline.
|
/// Test fails if ratio exceeds this. Set ~40-50% above measured baseline.
|
||||||
max_ratio: f64,
|
max_ratio: f64,
|
||||||
@@ -735,55 +739,68 @@ struct PerfBenchmark {
|
|||||||
fn perf_benchmarks() -> Vec<PerfBenchmark> {
|
fn perf_benchmarks() -> Vec<PerfBenchmark> {
|
||||||
vec![
|
vec![
|
||||||
PerfBenchmark {
|
PerfBenchmark {
|
||||||
name: "Fibonacci(25)",
|
name: "Fibonacci(33)",
|
||||||
define: ": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ;",
|
define: ": FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ;",
|
||||||
run_code: "25 FIB DROP",
|
run_code: "33 FIB DROP",
|
||||||
verify: "25 FIB",
|
verify: "33 FIB",
|
||||||
expected: 75025,
|
expected: 3524578,
|
||||||
samples: 5,
|
max_ratio: 0.10,
|
||||||
max_ratio: 0.65,
|
|
||||||
},
|
},
|
||||||
PerfBenchmark {
|
PerfBenchmark {
|
||||||
name: "Factorial(12)x10K",
|
name: "Factorial(12)x2M",
|
||||||
define: ": FACT 1 SWAP 1+ 1 ?DO I * LOOP ; \
|
define: ": FACT 1 SWAP 1+ 1 ?DO I * LOOP ; \
|
||||||
: FACT-BENCH 10000 0 DO 12 FACT DROP LOOP ;",
|
: FACT-BENCH 2000000 0 DO 12 FACT DROP LOOP ;",
|
||||||
run_code: "FACT-BENCH",
|
run_code: "FACT-BENCH",
|
||||||
verify: "12 FACT",
|
verify: "12 FACT",
|
||||||
expected: 479001600,
|
expected: 479001600,
|
||||||
samples: 5,
|
max_ratio: 0.12,
|
||||||
max_ratio: 0.75,
|
|
||||||
},
|
},
|
||||||
PerfBenchmark {
|
PerfBenchmark {
|
||||||
name: "GCD-bench(500)",
|
name: "GCD-bench(400K)",
|
||||||
define: ": GCD BEGIN DUP WHILE TUCK MOD REPEAT DROP ; \
|
define: ": GCD BEGIN DUP WHILE TUCK MOD REPEAT DROP ; \
|
||||||
: GCD-BENCH 0 DO 10000 I 1+ GCD DROP LOOP ;",
|
: GCD-BENCH 0 DO 10000 I 1+ GCD DROP LOOP ;",
|
||||||
run_code: "500 GCD-BENCH",
|
run_code: "400000 GCD-BENCH",
|
||||||
verify: "48 36 GCD",
|
verify: "48 36 GCD",
|
||||||
expected: 12,
|
expected: 12,
|
||||||
samples: 5,
|
max_ratio: 0.45,
|
||||||
max_ratio: 0.70,
|
|
||||||
},
|
},
|
||||||
PerfBenchmark {
|
PerfBenchmark {
|
||||||
name: "NestedLoops(50)",
|
name: "NestedLoops(50)x20K",
|
||||||
define: ": NESTED 0 SWAP 0 DO I 0 ?DO I J + DROP LOOP LOOP ; \
|
define: ": NESTED 0 SWAP 0 DO I 0 ?DO I J + DROP LOOP LOOP ; \
|
||||||
: NESTED-BENCH 100 0 DO 50 NESTED DROP LOOP ;",
|
: NESTED-BENCH 20000 0 DO 50 NESTED DROP LOOP ;",
|
||||||
run_code: "NESTED-BENCH",
|
run_code: "NESTED-BENCH",
|
||||||
verify: "5 NESTED",
|
verify: "5 NESTED",
|
||||||
expected: 0,
|
expected: 0,
|
||||||
samples: 3,
|
max_ratio: 0.11,
|
||||||
max_ratio: 0.20,
|
|
||||||
},
|
},
|
||||||
PerfBenchmark {
|
PerfBenchmark {
|
||||||
name: "Collatz(2K)",
|
// The only benchmark with a cross-word call left in its hot loop:
|
||||||
|
// WORK is over the inliner's eight-operation budget, so it stays a
|
||||||
|
// real call. That is what CONSOLIDATE exists to turn into a direct
|
||||||
|
// one, and without this the CONSOL column measures nothing -- every
|
||||||
|
// other benchmark has its callee inlined away or self-recursive.
|
||||||
|
name: "CrossCalls(3M)",
|
||||||
|
define: ": WORK DUP 3 * OVER XOR SWAP 2 / XOR DUP 7 AND XOR DUP 1 AND XOR ; \
|
||||||
|
: CROSS-BENCH 0 SWAP 0 DO I WORK XOR LOOP ;",
|
||||||
|
run_code: "3000000 CROSS-BENCH DROP",
|
||||||
|
verify: "1000 CROSS-BENCH",
|
||||||
|
expected: 3176,
|
||||||
|
// Guards CONSOLIDATE as much as the engine: the ratio uses the
|
||||||
|
// better of the two columns, so a consolidation regression here
|
||||||
|
// pushes it from 0.04 to 0.12 and trips the limit.
|
||||||
|
max_ratio: 0.08,
|
||||||
|
},
|
||||||
|
PerfBenchmark {
|
||||||
|
name: "Collatz(2K)x50",
|
||||||
define: ": COLLATZ 0 SWAP BEGIN DUP 1 > WHILE \
|
define: ": COLLATZ 0 SWAP BEGIN DUP 1 > WHILE \
|
||||||
DUP 1 AND IF 3 * 1+ ELSE 2 / THEN \
|
DUP 1 AND IF 3 * 1+ ELSE 2 / THEN \
|
||||||
SWAP 1+ SWAP REPEAT DROP ; \
|
SWAP 1+ SWAP REPEAT DROP ; \
|
||||||
: COLLATZ-BENCH 0 DO I 1+ COLLATZ DROP LOOP ;",
|
: COLLATZ-BENCH 0 DO I 1+ COLLATZ DROP LOOP ; \
|
||||||
run_code: "2000 COLLATZ-BENCH",
|
: COLLATZ-REPEAT 50 0 DO 2000 COLLATZ-BENCH LOOP ;",
|
||||||
|
run_code: "COLLATZ-REPEAT",
|
||||||
verify: "27 COLLATZ",
|
verify: "27 COLLATZ",
|
||||||
expected: 111,
|
expected: 111,
|
||||||
samples: 3,
|
max_ratio: 0.08,
|
||||||
max_ratio: 0.45,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -828,15 +845,16 @@ fn measure_wafer_release(wafer: &str, bench: &PerfBenchmark) -> Option<u64> {
|
|||||||
let code = format!(
|
let code = format!(
|
||||||
"{define} {run} \
|
"{define} {run} \
|
||||||
: TIMED-BENCH UTIME {run} UTIME 2SWAP D- DROP . CR ; \
|
: TIMED-BENCH UTIME {run} UTIME 2SWAP D- DROP . CR ; \
|
||||||
TIMED-BENCH TIMED-BENCH TIMED-BENCH",
|
{reps}",
|
||||||
define = bench.define,
|
define = bench.define,
|
||||||
run = bench.run_code,
|
run = bench.run_code,
|
||||||
|
reps = repeat_timed(" "),
|
||||||
);
|
);
|
||||||
let output = run_via_stdin(wafer, &code)?;
|
let output = run_via_stdin(wafer, &code)?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
median_printed_time(&output.stdout)
|
best_of_printed_times(&output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure WAFER execution time after CONSOLIDATE (direct calls between all words).
|
/// Measure WAFER execution time after CONSOLIDATE (direct calls between all words).
|
||||||
@@ -844,31 +862,70 @@ fn measure_wafer_consolidated(wafer: &str, bench: &PerfBenchmark) -> Option<u64>
|
|||||||
let code = format!(
|
let code = format!(
|
||||||
"{define} CONSOLIDATE {run} \
|
"{define} CONSOLIDATE {run} \
|
||||||
: TIMED-BENCH UTIME {run} UTIME 2SWAP D- DROP . CR ; \
|
: TIMED-BENCH UTIME {run} UTIME 2SWAP D- DROP . CR ; \
|
||||||
TIMED-BENCH TIMED-BENCH TIMED-BENCH",
|
{reps}",
|
||||||
define = bench.define,
|
define = bench.define,
|
||||||
run = bench.run_code,
|
run = bench.run_code,
|
||||||
|
reps = repeat_timed(" "),
|
||||||
);
|
);
|
||||||
let output = run_via_stdin(wafer, &code)?;
|
let output = run_via_stdin(wafer, &code)?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
median_printed_time(&output.stdout)
|
best_of_printed_times(&output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the microsecond values printed by TIMED-BENCH (one per line) and
|
/// How many separate process invocations each measurement takes the best of.
|
||||||
/// return the median.
|
///
|
||||||
fn median_printed_time(stdout: &[u8]) -> Option<u64> {
|
/// `REPS`/`BEST_OF` deal with noise *inside* one process. They do not touch
|
||||||
|
/// the rest: whether a process lands on a core whose SMT sibling is busy, and
|
||||||
|
/// where its code ends up in memory, are fixed for its lifetime, and they make
|
||||||
|
/// some benchmarks frankly bimodal -- Fibonacci after CONSOLIDATE measured
|
||||||
|
/// 413-419 us in three runs of the report and 712-770 in the other two, with
|
||||||
|
/// nothing in between. Only a fresh process resamples that.
|
||||||
|
const PROCESS_RUNS: usize = 3;
|
||||||
|
/// How many timed repetitions each engine runs per benchmark.
|
||||||
|
const REPS: usize = 7;
|
||||||
|
/// How many of the fastest repetitions the reported time averages over.
|
||||||
|
const BEST_OF: usize = 3;
|
||||||
|
|
||||||
|
/// Run `measure` in `PROCESS_RUNS` fresh processes and keep the fastest.
|
||||||
|
///
|
||||||
|
/// The minimum, not a mean: process-level noise is one-sided too, so the
|
||||||
|
/// fastest process is the one that ran closest to undisturbed.
|
||||||
|
fn best_of_processes(mut measure: impl FnMut() -> Option<u64>) -> Option<u64> {
|
||||||
|
(0..PROCESS_RUNS).filter_map(|_| measure()).min()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `TIMED-BENCH` repeated `REPS` times, separated by `sep`.
|
||||||
|
///
|
||||||
|
/// sf64 needs one statement per line (it truncates input at ~256 characters);
|
||||||
|
/// the others do not care.
|
||||||
|
fn repeat_timed(sep: &str) -> String {
|
||||||
|
["TIMED-BENCH"; REPS].join(sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the microsecond values printed by `TIMED-BENCH` and reduce them to
|
||||||
|
/// one number: the mean of the fastest `BEST_OF`.
|
||||||
|
///
|
||||||
|
/// Not the median, and not the mean of all of them. Benchmark noise on a
|
||||||
|
/// shared machine is one-sided -- a scheduling hiccup, an SMT sibling or a
|
||||||
|
/// migration can only ever make a run slower, never faster -- so the fastest
|
||||||
|
/// repetitions are the ones closest to the cost we are trying to measure.
|
||||||
|
/// Averaging a few of them rather than taking the single minimum keeps one
|
||||||
|
/// lucky run from setting the result on its own.
|
||||||
|
fn best_of_printed_times(stdout: &[u8]) -> Option<u64> {
|
||||||
let stdout = String::from_utf8_lossy(stdout);
|
let stdout = String::from_utf8_lossy(stdout);
|
||||||
let mut times: Vec<u64> = stdout
|
let mut times: Vec<u64> = stdout
|
||||||
.trim()
|
.trim()
|
||||||
.lines()
|
.lines()
|
||||||
.filter_map(|l| l.trim().parse::<u64>().ok())
|
.filter_map(|l| l.trim().parse::<u64>().ok())
|
||||||
.collect();
|
.collect();
|
||||||
times.sort();
|
|
||||||
if times.is_empty() {
|
if times.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(times[times.len() / 2])
|
times.sort_unstable();
|
||||||
|
let n = times.len().min(BEST_OF);
|
||||||
|
Some(times[..n].iter().sum::<u64>() / n as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure gforth execution time using Forth-level `utime` (excludes startup).
|
/// Measure gforth execution time using Forth-level `utime` (excludes startup).
|
||||||
@@ -876,19 +933,19 @@ fn median_printed_time(stdout: &[u8]) -> Option<u64> {
|
|||||||
/// Returns microseconds, or None if gforth is unavailable.
|
/// Returns microseconds, or None if gforth is unavailable.
|
||||||
fn measure_gforth(gforth: &str, bench: &PerfBenchmark) -> Option<u64> {
|
fn measure_gforth(gforth: &str, bench: &PerfBenchmark) -> Option<u64> {
|
||||||
// The timing wrapper must be inside a word (DO/LOOP is compile-only in gforth).
|
// The timing wrapper must be inside a word (DO/LOOP is compile-only in gforth).
|
||||||
// We take the median of 3 runs.
|
|
||||||
let code = format!(
|
let code = format!(
|
||||||
"{define} {run} \
|
"{define} {run} \
|
||||||
: TIMED-BENCH utime {run} utime 2swap d- drop . CR ; \
|
: TIMED-BENCH utime {run} utime 2swap d- drop . CR ; \
|
||||||
TIMED-BENCH TIMED-BENCH TIMED-BENCH bye",
|
{reps} bye",
|
||||||
define = bench.define,
|
define = bench.define,
|
||||||
run = bench.run_code,
|
run = bench.run_code,
|
||||||
|
reps = repeat_timed(" "),
|
||||||
);
|
);
|
||||||
let output = Command::new(gforth).arg("-e").arg(&code).output().ok()?;
|
let output = Command::new(gforth).arg("-e").arg(&code).output().ok()?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
median_printed_time(&output.stdout)
|
best_of_printed_times(&output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Measure `SwiftForth` (`sf64`) execution time using Forth-level `ucounter`
|
/// Measure `SwiftForth` (`sf64`) execution time using Forth-level `ucounter`
|
||||||
@@ -901,15 +958,16 @@ fn measure_sf64(sf64: &str, bench: &PerfBenchmark) -> Option<u64> {
|
|||||||
let code = format!(
|
let code = format!(
|
||||||
"{define}\n{run}\n\
|
"{define}\n{run}\n\
|
||||||
: TIMED-BENCH ucounter {run} ucounter 2swap d- drop . cr ;\n\
|
: TIMED-BENCH ucounter {run} ucounter 2swap d- drop . cr ;\n\
|
||||||
TIMED-BENCH\nTIMED-BENCH\nTIMED-BENCH\nbye\n",
|
{reps}\nbye\n",
|
||||||
define = bench.define,
|
define = bench.define,
|
||||||
run = bench.run_code,
|
run = bench.run_code,
|
||||||
|
reps = repeat_timed("\n"),
|
||||||
);
|
);
|
||||||
let output = run_via_stdin(sf64, &code)?;
|
let output = run_via_stdin(sf64, &code)?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
median_printed_time(&output.stdout)
|
best_of_printed_times(&output.stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -982,14 +1040,14 @@ fn performance_report() {
|
|||||||
|
|
||||||
for bench in &benchmarks {
|
for bench in &benchmarks {
|
||||||
let wafer = wafer_release
|
let wafer = wafer_release
|
||||||
.and_then(|w| measure_wafer_release(w, bench))
|
.and_then(|w| best_of_processes(|| measure_wafer_release(w, bench)))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let consol = wafer_release
|
let consol = wafer_release
|
||||||
.and_then(|w| measure_wafer_consolidated(w, bench))
|
.and_then(|w| best_of_processes(|| measure_wafer_consolidated(w, bench)))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let gf = gforth.and_then(|g| measure_gforth(g, bench));
|
let gf = gforth.and_then(|g| best_of_processes(|| measure_gforth(g, bench)));
|
||||||
let gf_fast = gforth_fast.and_then(|g| measure_gforth(g, bench));
|
let gf_fast = gforth_fast.and_then(|g| best_of_processes(|| measure_gforth(g, bench)));
|
||||||
let sf = sf64.and_then(|s| measure_sf64(s, bench));
|
let sf = sf64.and_then(|s| best_of_processes(|| measure_sf64(s, bench)));
|
||||||
|
|
||||||
let gf_str = gf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
|
let gf_str = gf.map_or_else(|| "-".to_string(), |v| format!("{v}"));
|
||||||
let gf_fast_str = gf_fast.map_or_else(|| "-".to_string(), |v| format!("{v}"));
|
let gf_fast_str = gf_fast.map_or_else(|| "-".to_string(), |v| format!("{v}"));
|
||||||
|
|||||||
@@ -344,3 +344,32 @@ fn compliance_tools() {
|
|||||||
let errors = run_suite(&mut vm, "toolstest.fth");
|
let errors = run_suite(&mut vm, "toolstest.fth");
|
||||||
assert_eq!(errors, 0, "Programming-Tools: {errors} test failures");
|
assert_eq!(errors, 0, "Programming-Tools: {errors} test failures");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Forth 2012 Core suite against consolidated code.
|
||||||
|
///
|
||||||
|
/// `CONSOLIDATE` recompiles the whole dictionary into one WASM module, which
|
||||||
|
/// is where cross-word typed calls live: a word with a known stack effect
|
||||||
|
/// gets a fast entry taking and returning its stack items as WASM values,
|
||||||
|
/// and its `() -> ()` wrapper keeps the table slot. Nothing else covers that
|
||||||
|
/// path for correctness, so run the suite on top of it.
|
||||||
|
#[test]
|
||||||
|
fn compliance_core_after_consolidate() {
|
||||||
|
let mut vm = ForthVM::<NativeRuntime>::new().expect("Failed to create ForthVM");
|
||||||
|
let tester_path = format!("{SUITE_DIR}/tester.fr");
|
||||||
|
let f1 = load_file(&mut vm, &tester_path);
|
||||||
|
assert_load_fails_within_baseline(&tester_path, f1);
|
||||||
|
|
||||||
|
vm.evaluate("CONSOLIDATE").expect("CONSOLIDATE failed");
|
||||||
|
vm.take_output();
|
||||||
|
|
||||||
|
let core_path = format!("{SUITE_DIR}/core.fr");
|
||||||
|
let f2 = load_file(&mut vm, &core_path);
|
||||||
|
assert_load_fails_within_baseline(&core_path, f2);
|
||||||
|
|
||||||
|
let _ = vm.evaluate("DECIMAL #ERRORS @");
|
||||||
|
let errors = vm.data_stack().first().copied().unwrap_or(-1);
|
||||||
|
assert_eq!(
|
||||||
|
errors, 0,
|
||||||
|
"Core word set after CONSOLIDATE: {errors} failures"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ workspace = true
|
|||||||
crate-type = ["cdylib", "rlib"]
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wafer-core = { path = "../core", version = "0.2.3", default-features = false, features = ["crypto"] }
|
wafer-core = { path = "../core", version = "0.3.0", default-features = false, features = ["crypto"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
send_wrapper = { workspace = true }
|
send_wrapper = { workspace = true }
|
||||||
|
|||||||
@@ -38,6 +38,23 @@ impl WebHostAccess {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An exception on its way back out of compiled code. Host words rethrow the
|
||||||
|
/// Forth message (`Stack underflow`, an `ABORT"` text, a `THROW` description),
|
||||||
|
/// so surface exactly that and nothing else — the JS `Error` carries the whole
|
||||||
|
/// engine stack in its message, which is noise to a Forth programmer. Anything
|
||||||
|
/// without a message is a genuine runtime fault and keeps the call context.
|
||||||
|
fn call_error(fn_index: u32, e: &JsValue) -> anyhow::Error {
|
||||||
|
match Reflect::get(e, &"message".into())
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.as_string())
|
||||||
|
.and_then(|m| m.lines().next().map(str::trim).map(str::to_string))
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
|
Some(msg) => anyhow::anyhow!("{msg}"),
|
||||||
|
None => anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HostAccess for WebHostAccess {
|
impl HostAccess for WebHostAccess {
|
||||||
fn mem_read_i32(&mut self, addr: u32) -> i32 {
|
fn mem_read_i32(&mut self, addr: u32) -> i32 {
|
||||||
let view = js_sys::Int32Array::new(&self.buffer());
|
let view = js_sys::Int32Array::new(&self.buffer());
|
||||||
@@ -134,7 +151,7 @@ impl HostAccess for WebHostAccess {
|
|||||||
.dyn_into()
|
.dyn_into()
|
||||||
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?;
|
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not a function"))?;
|
||||||
func.call0(&JsValue::NULL)
|
func.call0(&JsValue::NULL)
|
||||||
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?;
|
.map_err(|e| call_error(fn_index, &e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,7 +423,7 @@ impl Runtime for WebRuntime {
|
|||||||
.dyn_into()
|
.dyn_into()
|
||||||
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?;
|
.map_err(|_| anyhow::anyhow!("table entry {fn_index} is not callable"))?;
|
||||||
func.call0(&JsValue::NULL)
|
func.call0(&JsValue::NULL)
|
||||||
.map_err(|e| anyhow::anyhow!("call_func({fn_index}) failed: {e:?}"))?;
|
.map_err(|e| call_error(fn_index, &e))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-13
@@ -14,7 +14,7 @@ This document describes every optimization that makes sense for WAFER, why it ma
|
|||||||
|
|
||||||
| # | Optimization | Level | Status | Impact |
|
| # | Optimization | Level | Status | Impact |
|
||||||
| -- | -------------------------- | ------------ | ----------- | ------- |
|
| -- | -------------------------- | ------------ | ----------- | ------- |
|
||||||
| 1 | Stack-to-Local Promotion | Codegen | Phase 2 | Highest |
|
| 1 | Stack-to-Local Promotion | Codegen | Phase 4 | Highest |
|
||||||
| 2 | Peephole Optimization | IR pass | Done | High |
|
| 2 | Peephole Optimization | IR pass | Done | High |
|
||||||
| 3 | Constant Folding | IR pass | Done | High |
|
| 3 | Constant Folding | IR pass | Done | High |
|
||||||
| 4 | Inlining | IR pass | Done | High |
|
| 4 | Inlining | IR pass | Done | High |
|
||||||
@@ -29,12 +29,19 @@ This document describes every optimization that makes sense for WAFER, why it ma
|
|||||||
| 13 | Startup Batching | Architecture | Done | Low |
|
| 13 | Startup Batching | Architecture | Done | Low |
|
||||||
| 14 | Self-Recursive Direct Call | Codegen | Done | High |
|
| 14 | Self-Recursive Direct Call | Codegen | Done | High |
|
||||||
| 15 | Float / Double-Cell | Codegen | Not started | Future |
|
| 15 | Float / Double-Cell | Codegen | Not started | Future |
|
||||||
|
| 16 | Typed Calling Convention | Codegen | Done | Highest |
|
||||||
|
| 17 | Self-Guard Expansion | IR pass | Done | Medium |
|
||||||
|
|
||||||
## 1. Stack-to-Local Promotion
|
## 1. Stack-to-Local Promotion
|
||||||
|
|
||||||
**Status: Phase 2 done.** Words with straight-line code, DO/LOOP, and IF/ELSE use WASM locals instead of memory stack. Stack manipulation ops (Swap, Rot, Nip, Tuck, Dup, Drop) emit zero WASM instructions. Loop index/limit kept in WASM locals (zero return stack traffic). Switchable via `WaferConfig::codegen.stack_to_local_promotion`.
|
**Status: Phase 4 done.** Straight-line code, DO/LOOP, IF/ELSE and the BEGIN loop family use WASM locals instead of the memory stack, per region rather than per word. Stack manipulation ops (Swap, Rot, Nip, Tuck, Dup, Drop) emit zero WASM instructions. Loop index/limit stay in WASM locals (zero return stack traffic). Switchable via `WaferConfig::codegen.stack_to_local_promotion`.
|
||||||
|
|
||||||
Phase 1 covered straight-line code only. Phase 2 extends to DO/LOOP (with stack-neutrality check) and IF/ELSE/THEN (with equal-branch-effect check). BEGIN loops and BeginDoubleWhileRepeat are not yet promoted.
|
- **Phase 1** — straight-line code.
|
||||||
|
- **Phase 2** — DO/LOOP (stack-neutrality check) and IF/ELSE/THEN (equal-branch-effect check).
|
||||||
|
- **Phase 3** — _per region instead of per word_. Promotion used to be all-or-nothing: one `.`, `CR`, `>R` or host call anywhere in a definition put the entire body on the memory stack, hot loops included, which costs 2.2 ns per loop-carried add instead of 0.31 — the accumulator round-trips through store-to-load forwarding rather than staying in a register. `emit_body` now partitions a body into maximal promotable stretches and runs the simulator over each, loading what a region reads and writing back what it leaves. A region may only use `I` / `J` when the DO loops naming them are inside it, and a straight-line region needs at least three operations to pay for its prologue and epilogue; a loop always does.
|
||||||
|
- **Phase 4** — `BEGIN..UNTIL`, `BEGIN..AGAIN` and `BEGIN..WHILE..REPEAT`, when the construct is provably stack-neutral: UNTIL's body nets +1 (the flag it consumes), AGAIN's nets 0, and for WHILE..REPEAT the test and the body must balance _separately_, because WHILE leaves the loop between them and a net that only added up over the pair would give the two exits different stack shapes. Bodies containing an `EXIT` stay out, the same rule DO/LOOP follows.
|
||||||
|
|
||||||
|
Still not promoted: `BeginDoubleWhileRepeat`, `>R`/`R>`, floats, `{: :}` locals, `SP@`/`DEPTH`/`EXECUTE`, and the flat forward-block IR ops.
|
||||||
|
|
||||||
### The Problem
|
### The Problem
|
||||||
|
|
||||||
@@ -301,6 +308,28 @@ After interactive development, `CONSOLIDATE` recompiles all defined words into a
|
|||||||
| JIT (current) | Interactive development | Per-word modules, `call_indirect`, fast redefine |
|
| JIT (current) | Interactive development | Per-word modules, `call_indirect`, fast redefine |
|
||||||
| Consolidated | After `CONSOLIDATE` | Single module, direct `call`, no redefine |
|
| Consolidated | After `CONSOLIDATE` | Single module, direct `call`, no redefine |
|
||||||
|
|
||||||
|
### Why the CONSOL column can lose to the JIT column
|
||||||
|
|
||||||
|
`NestedLoops` runs 1.1x slower after `CONSOLIDATE` on the M1 and 1.7x slower on a Skylake Xeon,
|
||||||
|
with **byte-identical WASM** for the hot word in both modes (verified via `WAFER_DUMP_WASM` +
|
||||||
|
`wasm-tools print`) and instruction-identical machine code modulo register names (verified via
|
||||||
|
`Engine::precompile_module` + objdump). The whole delta is code placement:
|
||||||
|
|
||||||
|
- A tight loop pays for straddling an instruction-fetch window: ~9% for a 16-byte window on the
|
||||||
|
M1, up to ~65% on Skylake when the fused `cmp+jcc` crosses a 32-byte boundary and the loop
|
||||||
|
falls out of the uop cache every iteration (the JCC erratum, post-microcode).
|
||||||
|
- Cranelift never aligns loop headers (`align_basic_block` is an identity default, no ISA
|
||||||
|
overrides it), so where a loop lands is whatever the code before it leaves behind.
|
||||||
|
- The per-word JIT module keeps a dead dsp load in its prologue (the store-back is DCE'd, the
|
||||||
|
load survives), which happens to shift its loops onto luckier offsets than the consolidated
|
||||||
|
module's cleaner function bodies. A padding experiment that moves the same loop across offsets
|
||||||
|
reproduces the full penalty range on both hosts, including placements where the consolidated
|
||||||
|
code **beats** the JIT code.
|
||||||
|
|
||||||
|
So the column difference on loop-only benchmarks is an alignment lottery, not an emitter defect;
|
||||||
|
divider-bound benchmarks (`GCD`) mask it entirely. Fixing it for real means loop-header alignment
|
||||||
|
upstream in Cranelift.
|
||||||
|
|
||||||
## 9. Compound IR Operations
|
## 9. Compound IR Operations
|
||||||
|
|
||||||
**Status: Done.** `TwoDup` and `TwoDrop` IrOp variants with optimized codegen. Peephole converts `Over, Over -> TwoDup` and `Drop, Drop -> TwoDrop`.
|
**Status: Done.** `TwoDup` and `TwoDrop` IrOp variants with optimized codegen. Peephole converts `Over, Over -> TwoDup` and `Drop, Drop -> TwoDrop`.
|
||||||
@@ -452,33 +481,97 @@ Fibonacci(25) with ~243K recursive calls:
|
|||||||
|
|
||||||
The optimization is implemented in `emit_op` for `IrOp::Call`: when `ctx.self_word_id == Some(word_id)`, emit `call WORD_FUNC` (function index 1 in the word's own module). The `self_word_id` is derived from `CodegenConfig::base_fn_index`.
|
The optimization is implemented in `emit_op` for `IrOp::Call`: when `ctx.self_word_id == Some(word_id)`, emit `call WORD_FUNC` (function index 1 in the word's own module). The `self_word_id` is derived from `CodegenConfig::base_fn_index`.
|
||||||
|
|
||||||
|
The numbers above are the state before section 16: they measure the call instruction, and what dominated turned out to be the calling _convention_ around it. A self-recursive word that is also typed now calls its own fast entry instead, and Fibonacci(25) is 356 microseconds rather than 1.6 ms.
|
||||||
|
|
||||||
## 15. Float and Double-Cell Stack
|
## 15. Float and Double-Cell Stack
|
||||||
|
|
||||||
**Status: Not started.** `PushI64` and `PushF64` exist as IR ops but are stubs in codegen. Float stack operations are currently all host functions.
|
**Status: Not started.** `PushI64` and `PushF64` exist as IR ops but are stubs in codegen. Float stack operations are currently all host functions.
|
||||||
|
|
||||||
The float stack lives in its own memory region (0x2540--0x2D40). Float operations will have the same memory-based overhead as integer operations, but worse: `f64` values are 8 bytes, doubling the memory traffic per push/pop. Stack-to-local promotion (section 1) is even more impactful for floats because WASM has native `f64` locals and operand stack support.
|
The float stack lives in its own memory region (0x2540--0x2D40). Float operations will have the same memory-based overhead as integer operations, but worse: `f64` values are 8 bytes, doubling the memory traffic per push/pop. Stack-to-local promotion (section 1) is even more impactful for floats because WASM has native `f64` locals and operand stack support.
|
||||||
|
|
||||||
|
## 16. Typed Calling Convention
|
||||||
|
|
||||||
|
**Status: Done.** A word that calls itself and whose stack effect is statically known compiles to two entry points: a fast one with signature `(i32 x p) -> (i32 x q)`, carrying its stack items as WASM values, and the usual `( -- )` wrapper that moves those items on and off the memory data stack. The wrapper keeps the function-table slot, so `EXECUTE`, the outer interpreter, host words and `CATCH` see exactly the ABI they saw before; only direct calls inside a module take the fast entry. `WAFER_TYPED_CALLS=0` falls back. The self-recursion condition matters: the table slot holds the wrapper, so in the JIT path nothing but `RECURSE` can reach the fast entry, and emitting it for any other word just puts a wrapper hop in front of every call through the table -- measured at +47% before that was fixed in 0.2.9.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
This is what the SwiftForth gap was made of. sf64 keeps TOS in `RBX` and the stack pointer in `RBP`, and both survive a `CALL` untouched, so its `FIB` is 16 instructions and about 7 memory touches per node. WAFER kept the whole stack in linear memory and flushed its cached `$dsp` to an imported global before every call: about 36 touches. Section 1's simulator, which already promoted loop and `IF` bodies into locals, refused any body containing a call or an `EXIT` -- exactly the words where the convention cost the most.
|
||||||
|
|
||||||
|
### The Effect Fixpoint
|
||||||
|
|
||||||
|
Self-recursion makes the stack-effect equation circular (`d = k + m*d`), so the effect is solved by iterating a guess until it reproduces itself: `FIB` settles on `(1,1)` in two rounds, while `: F 1 RECURSE ;` never settles and stays untyped. `CONSOLIDATE` extends this across words, since it puts them all in one module: the effects are solved from the leaves outward, and 105 of 187 words in a booted dictionary end up typed.
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
Fibonacci(25) went from 1035 to 366 microseconds, 4.3x slower than `sf64` to 1.2x. Stack guards became nearly free as a side effect -- they hang off the memory-stack push/pop choke points, and a typed word barely has any -- so the default guards-on configuration that the REPL and the web build use went from 1631 to 365 microseconds on the same benchmark.
|
||||||
|
|
||||||
|
Untyped by design: anything using `SP@`, `DEPTH`, `EXECUTE`, `>R`/`R>`, floats or locals; anything calling a word that is itself untyped, which in the JIT path means every call except `RECURSE`; mutually recursive words; and words whose effect is not static -- branches that disagree on depth, `EXIT` at the wrong depth, a non-neutral loop body, or a recursion that grows the stack per level.
|
||||||
|
|
||||||
|
## 17. Self-Guard Expansion
|
||||||
|
|
||||||
|
**Status: Done.** A recursive word's base-case guard is duplicated into its own call sites, so the leaves of the recursion cost a test instead of a call. Implemented in `optimizer.rs::expand_self_guard`, gated on `OptConfig::self_guard`, and applied after inlining so the later passes still run over the result.
|
||||||
|
|
||||||
|
### The Shape
|
||||||
|
|
||||||
|
A recursive Forth word almost always opens with a guard that returns early:
|
||||||
|
|
||||||
|
```forth
|
||||||
|
: FIB DUP 2 < IF EXIT THEN DUP 1- RECURSE SWAP 2 - RECURSE + ;
|
||||||
|
```
|
||||||
|
|
||||||
|
Every leaf of the recursion is then a call whose entire body is `DUP 2 <`. The pass rewrites each `Call(self)` as
|
||||||
|
|
||||||
|
```forth
|
||||||
|
DUP 2 < IF ( leave it ) ELSE RECURSE THEN
|
||||||
|
```
|
||||||
|
|
||||||
|
which computes the same thing -- the callee would have run the guard, taken the branch and returned. When the guard returns a value rather than its argument (`IF DROP 0 EXIT THEN`), that value moves into the then-branch with it.
|
||||||
|
|
||||||
|
### Why It Is Bounded
|
||||||
|
|
||||||
|
The guard runs twice along the recursive path: once at the call site, once inside the callee. So it must be small and free of effects -- `MAX_GUARD_OPS` is six, and the operations are restricted to stack shuffles, arithmetic and comparisons; a call, a memory access or a branch disqualifies it. `MAX_GUARD_SITES` caps the expansion at four call sites, since each one replicates the guard. A `TailCall` is never expanded, which keeps this pass and tail-call detection from having to agree about what tail position means.
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
|
||||||
|
Fibonacci(25): 356 to 237 microseconds on the arm64 development machine. In fib's tree half of all nodes are leaves, which is where the factor comes from. It does not take Fibonacci past `sf64`, though the arm64 table below says otherwise: with both engines native on x86-64, Fibonacci reads 1.16x and stays the one benchmark `sf64` wins.
|
||||||
|
|
||||||
## Current Performance vs Gforth
|
## Current Performance vs Gforth
|
||||||
|
|
||||||
All optimizations enabled, release mode, measured with UTIME:
|
All optimizations enabled, release mode, measured with UTIME:
|
||||||
|
|
||||||
|
Development machine (M1 Ultra, arm64), median of three reports, every
|
||||||
|
benchmark sized to about 10 ms:
|
||||||
|
|
||||||
```
|
```
|
||||||
Benchmark WAFER CONSOL gforth WAFER/gf
|
Benchmark WAFER CONSOL gforth sf64 WAFER/gf WAFER/sf
|
||||||
Fibonacci(25) 1629 1535 3422 0.45x
|
Fibonacci(33) 11307 11407 157001 13053 0.07x 0.87x
|
||||||
Factorial(12)x10K 340 339 638 0.53x
|
Factorial(12)x2M 9639 9599 123950 32091 0.08x 0.30x
|
||||||
GCD-bench(500) 18 15 30 0.50x
|
GCD-bench(400K) 11662 11580 38580 17001 0.30x 0.68x
|
||||||
NestedLoops(50) 84 73 720 0.10x
|
NestedLoops(50)x20K 8920 9852 140518 36828 0.06x 0.24x
|
||||||
Collatz(2K) 1212 1202 3914 0.31x
|
CrossCalls(3M) 10883 3769 87691 8240 0.04x 0.46x
|
||||||
|
Collatz(2K)x50 8838 8715 189903 28657 0.05x 0.30x
|
||||||
```
|
```
|
||||||
|
|
||||||
Times in microseconds. WAFER/gf < 1.0 means WAFER is faster.
|
Times in microseconds; ratios take the better of WAFER and CONSOL. The `sf64`
|
||||||
|
column flatters WAFER: the only SwiftForth build for macOS is x86-64 under
|
||||||
|
Rosetta 2 while WAFER and gforth are native arm64. Measured with all three
|
||||||
|
native on x86-64, Fibonacci reads 1.23x rather than 0.87x -- the emulation
|
||||||
|
penalty lands hardest on the call-heavy benchmark -- while the other five keep
|
||||||
|
their ratios. One caveat holds on both: sf64 uses 64-bit cells to WAFER's
|
||||||
|
32-bit. (The native table is being re-taken at these workload sizes.)
|
||||||
|
|
||||||
|
`CrossCalls` is the only benchmark with a cross-word call left in its hot loop,
|
||||||
|
so it is the only one that measures section 8 at all -- the other five have
|
||||||
|
their callee inlined away or are self-recursive. Note that `CONSOLIDATE` makes
|
||||||
|
NestedLoops and Collatz _slower_; see the open item below.
|
||||||
|
|
||||||
## Remaining Opportunities
|
## Remaining Opportunities
|
||||||
|
|
||||||
| Optimization | Status | Potential Impact |
|
| Optimization | Status | Potential Impact |
|
||||||
| -------------------------------- | ------------------- | ----------------------------------------------------- |
|
| --------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| BEGIN loop promotion | Not started | Would speed up GCD-style tight loops further |
|
| Explain CONSOLIDATE on pure loops | Open defect | Isolated on an idle box: NestedLoops 540 -> 905 us (1.68x) and Collatz 310 -> 360 (1.16x) on x86-64, against 1.07x and 1.05x for the same probes on arm64 -- so the magnitude is strongly architecture-dependent, which points at code size or branch density rather than a gross codegen error. Not the promotion logic (same code path), not inlining (no call left), not the harness (CONSOLIDATE is outside the timed window). Next step is to diff the emitted wat for NESTED-BENCH between the two paths |
|
||||||
| BeginDoubleWhileRepeat promotion | Not started | Rare pattern, low priority |
|
| Scoped exit for the inliner | Not started | The inliner still refuses any body containing an `EXIT`, because an inlined one would return from the caller. Compiling it as a branch to the end of a block would unlock inlining for every word with an early return, not just the guard shape section 17 handles |
|
||||||
|
| BeginDoubleWhileRepeat promotion | Not started | Rare pattern, low priority. Its promoted emitter exists but has no loop fixup and is unverified |
|
||||||
| LEAVE as IR primitive | Not started | Would enable fast-path for loops with LEAVE |
|
| LEAVE as IR primitive | Not started | Would enable fast-path for loops with LEAVE |
|
||||||
| Float stack-to-local | Not started | Eliminate float stack memory traffic |
|
| Float stack-to-local | Not started | Eliminate float stack memory traffic |
|
||||||
| WASM tail calls proposal | Waiting on wasmtime | Would eliminate stack growth for tail-recursive words |
|
| WASM tail calls proposal | Waiting on wasmtime | Would eliminate stack growth for tail-recursive words |
|
||||||
|
|||||||
+4
-2
@@ -282,11 +282,13 @@ When the compiler encounters a word reference during compilation, it emits:
|
|||||||
(call_indirect (type $void) (table 0)) ;; indirect call through the table
|
(call_indirect (type $void) (table 0)) ;; indirect call through the table
|
||||||
```
|
```
|
||||||
|
|
||||||
**Self-recursive optimization**: When a word calls itself (RECURSE), the codegen detects this and emits a direct `call` instead of `call_indirect`, eliminating the table lookup and signature check (~3x faster for recursive words like Fibonacci).
|
**Self-recursive optimization**: When a word calls itself (RECURSE), the codegen detects this and emits a direct `call` instead of `call_indirect`, eliminating the table lookup and signature check (~3x faster for recursive words like Fibonacci). When the word is also typed, that direct call goes to its fast entry -- see below.
|
||||||
|
|
||||||
**After CONSOLIDATE**: All `call_indirect` between words in the consolidated module are replaced with direct `call` instructions, giving similar benefits for cross-word calls.
|
**After CONSOLIDATE**: All `call_indirect` between words in the consolidated module are replaced with direct `call` instructions, giving similar benefits for cross-word calls.
|
||||||
|
|
||||||
At runtime, wasmtime resolves the table entry and calls the target function. Because all functions share the same memory, globals, and table, state passes between words through the data stack in linear memory. There are no function parameters or return values at the WASM level -- everything goes through the stack.
|
At runtime, wasmtime resolves the table entry and calls the target function. Because all functions share the same memory, globals, and table, state passes between words through the data stack in linear memory.
|
||||||
|
|
||||||
|
**Typed entry points**: that last sentence is the default, not the whole story. A word whose stack effect is statically known also gets a _fast_ entry with signature `(i32 x p) -> (i32 x q)`, which takes its arguments as WASM values and returns its results the same way, so they stay in registers across the call instead of round-tripping through linear memory. The `( -- )` function above is then a wrapper around it, and it is the wrapper that keeps the table slot -- so `EXECUTE`, the outer interpreter, host words and `CATCH` see the memory ABI unchanged. Only a direct call inside the same module takes the fast entry: `RECURSE` in the JIT path, and every resolvable call after `CONSOLIDATE`. See [OPTIMIZATIONS.md](OPTIMIZATIONS.md) section 16.
|
||||||
|
|
||||||
This is subroutine threading: each word is a subroutine, and calling a word is an indirect function call.
|
This is subroutine threading: each word is a subroutine, and calling a word is an indirect function call.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user