Skip to content

Race: a fourth lane, Lean's own interpreter on QED64 (wasm64), and pluggable lanes - #8

Merged
FawadHa1der merged 12 commits into
mainfrom
claude/qed64-race-lane
Oct 27, 2026
Merged

FawadHa1der merged 12 commits into
mainfrom
claude/qed64-race-lane

Conversation

@FawadHa1der

@FawadHa1der FawadHa1der commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

This adds a fourth contender to the race: the same Lean apps, run by Lean's own interpreter inside QED64's wasm64 build of Lean 4.35, in a Web Worker. It also makes the race's lanes modules. The new lane lives in qed64/, apart from everything else, like vir/. It is optional: off unless a visitor ticks "Add Lean's interpreter on QED64 (about 42 MB)" under the race, or asks for it with ?lanes=.

What the lane is

  • Host program: QED64's lean.wasm runs lean --run on a small host program (qed64/LeanUIQed/Host.lean). The host reads events from stdin and writes LeanUI's update buffers to stdout, through a shared-memory ring and a stdout tap in the Worker. The page applies the buffers with the unchanged runtime/leanui.js.
  • What is interpreted: LeanUI, the diff and the apps run in Lean's IR interpreter. Only Init and Std run as compiled code, inside lean.wasm.
  • What the browser downloads: the pinned runtime (lean.wasm 106 MB, 11.0 MB with brotli) and one header snapshot of the apps (114 MB, 30.9 MB gzipped). That is 41.9 MB per session, cached afterwards.
  • What is fetched and built: everything is fetched and verified from qed64/qed64.lock.json (sha256 per chunk, two sources). The Std modules, the shimmed LeanUI and the snapshot are built by lean.wasm itself. Nothing large is committed.
  • The LeanUI shim: a copy of LeanUI with its two inline-C helpers made plain Lean, because the interpreter cannot run inline C. The output is byte-identical.

Pluggable lanes (bench/race/lanes.mjs)

The race reads its lanes from a registry. A lane can be:

  • optional: offered as a box only where its page was built;
  • asynchronous: the referee awaits window.__idle() after each click, and clicks nothing else meanwhile, so the time still runs from the click to the page updated.

A lane can also report its own figures: downloads, start phases, and Lean / transport / DOM time. The three existing lanes behave and measure as before.

Checks

  • qed64/test/node.mjs (Node 24): the lane's updates are byte-identical to a compiled reference host in all 8 scenarios. It also runs the 10,000-row create under a 0.49 MiB pthread stack, and 20 empty pings.
  • tests/browser/race.mjs --lanes vanilla,wasm64,vir,qed64:
    • Chromium: Life and the table. All four lanes agree after every operation, with no stalls.
    • Firefox: Life.
  • CI: builds the lane cold with caches (about 3–4 minutes extra cold, cached after), runs the Node test and the four-lane race, and smoke-tests the lane in the site container.
  • Review: an adversarial review found five small problems, all fixed:
    • the snapshot copy is now freed after the mount;
    • a failed lane is reported at once;
    • QED64=0 is honoured;
    • the lane's credits show only when it was built;
    • two README slips.

Numbers

CI machine, to be re-measured on the reference Mac.

Chromium, ms per click (vanilla / wasm64 / lean-vir / QED64):

Operation vanilla wasm64 lean-vir QED64
Life step 0.06 0.55 14.6 15.3
create 1,000 rows 5.1 9.4 154 141
create 10,000 rows 39 89 1532 1395
  • QED64 is about as fast as lean-vir: two interpreters, side by side.
  • Of a Life step's 15.3 ms, 14.4 is Lean, 0.8 the Worker round trip and 0.1 the DOM.
  • The lane is ready about 1.8 s after its page loads.
  • Memory: about 500 MB of renderer memory in Chromium, about 1.3 GB in Firefox.
  • Firefox: boots in about 9 s, almost all of it compiling the wasm.

The site

  • Route: /race/qed64-rt/ is served immutable and cross-origin isolated, like /race/.
  • Staging: stage.sh keeps the lane's prebuilt brotli and gzip files.
  • Container size: the container grows by about 165 MB.
  • Container memory: the server reads a whole file per response, so a burst of visitors adding the lane costs container memory (8 at once: 128 → 363 MiB). docs/DEPLOY.md notes this. An edge cache in the Worker is a possible follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd

claude added 12 commits October 26, 2026 18:14
The lanes move to a registry (bench/race/lanes.mjs), so a lane can be added
as a module of its own, optional or asynchronous. Snapshot of work in
progress; not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
bench/race/lanes.mjs is the registry of lanes (data only): key, name, the
lane its ratio is against, what it needs, whether it is optional (off unless
asked for, with ?lanes= or a box under the race) or asynchronous (its app
answers from a Worker: the referee awaits window.__idle() after each click,
and nothing else is clicked meanwhile). A lane's page may report its own
figures (window.__lane: downloads, start phases, Lean / transport / DOM
time), which the panels, the results and window.__race show. The three
current lanes behave and measure as before; tests/browser/race.mjs takes
--lanes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
Snapshot of stage 1 in progress: the lock of QED64's runtime, Init pack, Std
digests and Node 24; the verified fetcher; the LeanUI shim for Lean's
interpreter; the Node host for lean.wasm. Not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
Snapshot of stage 1 in progress: LeanUIQed/Host.lean (events on stdin,
update buffers on stdout), run/AppsRun.lean, the native reference build
(lakefile), and tools/bake.sh. Not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
Snapshot of stage 1 in progress. Not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
qed64/ runs the race's apps (examples/Bench.lean, examples/Life.lean) in
Lean's own IR interpreter inside QED64's wasm64 build of Lean 4.35
(lean.wasm), as a module of its own like vir/ (qed64/README.md).

- qed64.lock.json pins every download by size and sha256: the runtime's
  chunks, QED64's Init pack, the digests of QED64's build of the 8 Std
  modules, the licences and Node 24. tools/fetch.mjs downloads and verifies
  them into qed64/.cache (or $LEANUI_QED64_CACHE); tools/node.sh fetches
  the pinned Node.
- tools/shim.py writes LeanUI's core for the interpreter: the two inline-C
  helpers as Lean definitions and the HashMap import narrowed to
  Std.Data.HashMap.Basic; it refuses unless each edit applies once.
- LeanUIQed/Host.lean (with the p ping) and run/AppsRun.lean; lakefile.toml
  builds the compiled reference hosts against LeanUI as it is.
- tools/wasm-lean.mjs hosts lean.wasm under Node: run (one lean command),
  make (compiles in dependency order, in parallel) and lane (a session as
  the page boots one). lane/core.js holds what the browser Worker will
  share: the command line, the stdin ring, the reply parser.
- tools/bake.sh compiles Std (every pinned facet must be QED64's) and
  LeanUI, the apps and the host, and bakes the header snapshot from Init's
  and Std's .olean files alone (with every facet it would be 352 MB, not
  114 MB); the .deps sidecar, gzip, lean.wasm's .br and .gz; STAMPs skip
  what has not changed. build.sh assembles dist/qed64/{rt,bench,life}/.
- test/node.mjs: every update byte-identical to the compiled reference in
  8 scenarios, 20 pings, create 10,000 rows with 0.49 MiB pthread stacks.

Std's .olean.private is not compared: lean.wasm writes it a few hundred
bytes apart from the native build QED64's packs come from, and only
`import all` reads it; the other four facets of all 8 modules match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
…ages

qed64/lane/qed64.worker.js runs lean.wasm in a classic Worker, Emscripten's
main runtime thread, as startLane does in Node: its own shared memory, the
snapshot fetched and unpacked with DecompressionStream while lean.wasm
compiles, the .deps sidecar and the run file in MEMFS, the stdin ring, the
per-byte stdout tap, the exit hook, the 1 s mailbox kick, and the snapshot's
MEMFS copy deleted after the mount. Each reply carries Lean's time and the
Worker's, and a reply held up by more than 500 ms counts as a stall.

qed64/lane/leanui-qed64.js is the page side: QedTransport, asynchronous like
RemoteTransport, with idle(), and LeanUIQed64.start, which shows the start's
progress, takes the floor (20 pings) and gives the race what lanes.mjs asks
for: bytes (the Worker's downloads, 42.0 MB), phases and stats(). If Lean
stops, idle() rejects, the container gets leanui:error and the Worker is
terminated. lane/core.js gains the message framing.

qed64/pages/{bench,life}.html are vir's pages for this lane, with an icon
link. build.sh assembles dist/qed64/{bench,life}/ (index.html, leanui.js,
leanui-qed64.js, qed64.json with the hashed worker) and the two stylesheets
one level up, as in the race.

Checked by hand in Playwright's Chromium 141 and Firefox 142, served --coop:
Life after 100 steps shows board bdbe4aa6 with 53 alive; Bench's create 1k,
update, swap, select, remove, append, clear, create 10k and clear show the
wasm64 build's rows after each; __idle and __lane are set, no stalls, no
console errors; a missing snapshot and Lean exiting both stop the page with
the reason. test/node.mjs still passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
Snapshot in progress: the qed64 entry in bench/race/lanes.mjs, its copy in
bench/race/build.sh, and the runner and race test changes. Not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
The race gets its fourth lane, qed64 (bench/race/lanes.mjs): optional, offered
as "Add Lean's interpreter on QED64 (about 42 MB)", asynchronous, and only where
the browser has Memory64 and the page is cross-origin isolated.

- bench/race/build.sh copies dist/qed64 into dist/race when it is built: the
  runtime once, as qed64-rt/ (hard links, with lean.wasm's .br and .gz), and
  the pages as life/qed64/ and table/qed64/, pointing at it. Without it, a
  notice; QED64=require makes that an error, before dist/race is touched.
- The referee empties a lane's panel before loading its page again and waits
  its unloadMs (2.5 s for qed64), because lean.wasm's pthreads outlive their
  page by about 2 s; tests/browser/race.mjs does the same between its races,
  and checks that qed64's Worker fetched more than 30 MB itself.
- tests/browser/run-all.sh builds the lane with --build (LEANUI_QED64=0 skips
  it), serves the race from a second, cross-origin isolated server, and runs it
  with --lanes vanilla,wasm64,vir,qed64 whenever dist/race has the lane
  (LEANUI_QED64=require makes its absence a failure).
- The race page's footer says how an asynchronous lane is timed;
  docs/BENCHMARKS.md has the lane's numbers and their Lean / transport / DOM
  split (bench/race/ci/race-qed64-*.json); qed64/README.md, DESIGN.md,
  DEPLOY.md and README.md mention it.

Checked on the CI machine (Playwright's Chromium 141 and Firefox 142): the
three-lane race passes as before; with the fourth lane, Life and the table in
Chromium and Life in Firefox pass, with no stalls. In Chromium the lane takes
1.11x lean-vir's time in Life and 1.00x on the table (geometric mean), and Lean's
own time is 80-97% of each click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
Snapshot in progress: the lane's caches and Node test in CI, the site's
immutable /race/qed64-rt route, stage.sh keeping the lane's precompressed
files, and docs. Not yet tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
CI builds, tests and races the QED64 lane on every push and pull request, and
the site serves it at /race/ with the other lanes.

- .github/workflows/ci.yml: the test job sets LEANUI_QED64=require, so the race
  must have the lane (run-all.sh passes it on to bench/race/build.sh). The elan
  cache key and install include qed64/lean-toolchain. qed64/.cache is restored
  and saved explicitly in two parts: per lock (qed64.lock.json and
  lean-toolchain: the downloads, Std compiled by lean.wasm, lean.wasm's .br and
  .gz, about 620 MB unpacked) and per source (the rest of the bake and
  qed64/.lake, about 70 MB, restored from the newest build of the lock; bake.sh's
  stamp decides what to redo). A new step runs qed64/test/node.mjs on the Node 24
  that qed64/tools/node.sh pins. The bench-labelled race runs with
  --lanes vanilla,wasm64,vir,qed64. The container smoke test fetches the lane's
  qed64.json, its lean.wasm (brotli, immutable) and its snapshot (HEAD), and the
  post-deploy check adds the lane's lean.wasm to the files that must arrive as
  the server's own .br.
- examples/SiteServer.lean: /race/qed64-rt is served cross-origin isolated, like
  /race, with year-long immutable caching (its names change with their content).
- deploy/cloudflare/stage.sh: the pass that deletes every .br and .gz leaves out
  dist/race/qed64-rt (its apps.<h>.snap.gz is the snapshot itself), and the
  brotli 11 pass leaves out its lean.wasm, which keeps qed64/build.sh's siblings.
- qed64/build.sh names the licences LICENSE-lean4.txt and LICENSE-qed64.txt
  (text/plain), and the race page's footer links them.
- Docs: DEPLOY.md (the caches, the Node test, the stage.sh exception, what the
  container holds and why, its memory under the lane's downloads, the hand
  deploy), TOOLCHAIN.md (qed64's toolchain next to vir's), qed64/README.md
  ("In CI and on the site"), README.md and BENCHMARKS.md (a line each).

Checked here: actionlint 1.7.12 (no new findings; shellcheck reports the same
two warnings as before); qed64/build.sh and bench/race/build.sh; the CI cache
paths alone give a no-op lane build; qed64/test/node.mjs passes (28.5 s);
leanui-site built natively; stage.sh, then the image built and run with docker
and the smoke test's curls against it, including the new ones; the four-lane
race passed in Chromium against the container and in Firefox (Life) against the
staged site. The staged site grows from 28 MB to 193 MB and site-container.tgz
from 11 MB to 86 MB. Eight simultaneous downloads of the lane took the
container from 128 MiB to 363 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
- The Worker drops its last reference to the snapshot's bytes once they are
  in MEMFS, so the unlink after the mount frees the 114 MB.
- A lane page that fails to start sets window.__failed, and the race says
  "did not start" at once instead of waiting out the 300 s boot limit.
- QED64=0 leaves the lane out of the race even if dist/qed64 is there.
- The race footer's QED64 credits and licence links show only when the lane
  was built.
- qed64/README.md: the run file and the keyed ratio range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016R1ctPHanJvc74mwoJrYgd
@FawadHa1der
FawadHa1der marked this pull request as ready for review October 26, 2026 22:34
@FawadHa1der
FawadHa1der merged commit 24570e2 into main Oct 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants