Skip to content

comp.ts back under its cap by removing mechanisms at their root: a global File, one way to read a shared cell, and effects that park themselves - #1281

Draft
nicolas-abril wants to merge 12 commits into
bendlang:mainfrom
nicolas-abril:comp-simplify

Conversation

@nicolas-abril

@nicolas-abril nicolas-abril commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Context

#1285 brought comp.ts back under its permanent 64,000 cap, but only just: main is at 63,980 ttok. This PR brings it to 62,798.

The cuts are simplifications at the root rather than trimming: each removes a mechanism that existed only because another part behaved a certain way, or a second way of doing one job. Each was traced to the commit that added it, to check the reason no longer holds.

Changes (one commit each)

  • The compiler's File is global and holds every cache. The caches were module globals cleared per book, so the compiler already held one book at a time, yet 40 functions took book or fl only to reach it. FL is now the book being compiled and everything memoized about it; FL.memo holds what one def's walk memoizes. type File lists every field, FL.memo's too. show_main now reads this book's caches, not the previous book's (it ran before the caches were cleared): this restores Initialize compiler caches before building printer layouts #961's fix, which main has lost. Two books compiled in one process now each emit what a fresh compile emits; on main the second either gets the first's stale layout or throws.
  • Intr.call goes: an intrinsic that arr_op lays out has C: null; the flag's third use (blocking a fold rebuild) was redundant.
  • A foreign def's segment opens and returns through emit_open/emit_put, like any def's.
  • A constructor's own binders open as DUMMY, so a constructor's erased binder cannot pass for the def's parameter (6200daa), and the local flag threaded through facts_hot/facts_ctr goes. FL.ctrs memoizes those telescopes.
  • lit_call goes: a Nat literal past 256 is read like any literal, not as U32.to_nat(word) (942573f's compatibility shim). Literals are capped at 2^32-1 by the parser, so a constant never nears the 2^48 immediate. nat_pattern_deep takes its 300 from U32.to_nat, so it still exercises the rebuild it pins.
  • No scan for the word undefined in the emitted C: a leaked one is an undeclared name, which clang refuses.
  • corpus_eval is one loop with one post-condition, and three guards for states that cannot occur go.
  • A redirect cell has one read, term_peek (one relaxed 64-bit load, as A shared Array's redirect is read without a race and without an atomic on the device, once per spin (#975) #1155 made blk_loc do), and rfc_view goes; ctr_take acquires only as the last holder, before it owns the node. Shared Array redirect lookup races with atomic reference-count updates #975's reproducer under ThreadSanitizer: 0 reports in 10 runs on both trees (and 6 with the read made plain, as a control).
  • A JS foreign op is a node tagged with its def's name, as in C, and an IO.OP match keeps its default arm in both lanes, so the JS-only $FFI goes.
  • An effect that waits parks itself: io_eff takes no need, and the loop runs every request one way. IO.sleep parks on its deadline; TCP.accept and UDP.recv_from already parked themselves on EAGAIN.
  • The book and the emitter's scope are two things: FL is the File being compiled, and a Scope (segment, local names, bindings, spare nodes, def) is where the emitter writes, so a nested arm, closure or fork copies only its scope.

Breaking

  • The effect API: io_eff(cid, run) takes no need. A third argument is a compile error in C, and JS ignores it. Every waiting effect already had the self-parking form (io_wait_on / io_park_on), so nothing becomes impossible; guide/EFFECTS.md says so and shows how to wait on time alone. All 62 C registrations in bend2/effs and tests are updated.
  • JS registry check: a foreign def without a registered JS effect fails when its request runs ("an alien request"), as in C, not at load.

Checks

After the rebase onto main (a3f1782), on an Apple M4:

  • Emitted output: for all 1626 .bend files under tests/, bench/ and demos/, each commit that says its output is byte-identical emits byte-identical C, JS and .mjs against its parent. The lit_call commit changes the same 32 C files and no JS, as before.
  • Local full suite (gates/test.ts's shard script run on this Mac): 1541/1543. The two failures are gfx_clicks and gfx_window, which need a display.
  • Cluster gates (gates/_run.ts): repo 50/50, test 1543/1543, perf 121/124. Rerun back to back with main, alternating, perf scored 124/124 and 123/124 on this branch and 121/124 and 124/124 on main. Every cell over 1.15x, on either tree, was a PAR-GPU time under 0.1 s (mandelbrot, merkle, nbody). ping was skipped because the site repo is not checked out here.
  • Demos: all build in C and JS.
  • Not re-run since the rebase: the JS-lane timings and the commit audit below.

Before the rebase, on an Apple M4 against main (fb6f32a):

  • Emitted output: the compiler-only commits keep the emitted C, JS and .mjs byte-identical for all 1608 .bend files under tests/, bench/ and demos/; the rest change it as described in their messages.
  • Local full suite (gates/test.ts's shard script run on this Mac; every lane, Metal for the ! tests): all pass but gfx_clicks and gfx_window, which need no display and fail on main too.
  • Cluster gates: repo 49/49, test 1526/1526, perf 115/117 against main's 117/117 back to back (geomeans: compiler 1.032, SEQ 0.993, PAR-CPU 1.015, PAR-GPU 1.035). The two cells over 1.15 are Metal times (nbody 1.17, terrain 1.19), which swing that much between runs on main too; on this M4, interleaved best of 3, Metal is 0.997 of main (nbody 1.018, terrain 0.991).
  • JS lane (no gate covers it): the 16 benches compiled to JS, 2-3 interleaved runs each, geomean 0.983, every bench within ±2%; within_busy 0.75.
  • Demos: all 15 build in C and JS.
  • Audit of every commit since 2.0.0 (453 commits, a7e0600 to fb6f32a): for each, what it did, whether it aimed at compile or runtime performance or an edge case, its linked issues and PRs, and whether comp.ts back under its cap by removing mechanisms at their root: a global File, one way to read a shared cell, and effects that park themselves #1281 touches the same mechanism; where it does, the commit's or issue's reproducer was run on both trees. 123 preserved (verified), 329 no interaction, 1 regression, fixed: removing FID_EXIT's continuation fast path (87a1e9c) cost 28% more instructions at 8 threads in code that applies a closure which may fork (9.03G -> 11.54G; no bench takes that path), so the fast path stays. The 34 commits that aimed at performance are all preserved or untouched.
  • Tried and dropped, with measurements: removing FID_EXIT's fast path (above), element-wise block copy (10% slower on a block-heavy micro-benchmark), a shared device/host drain loop (Metal compiled the work loop twice), JS Nat matches through == (a host's raw BigInt), dropping fl.consts (b84b004's 8 s compile guard), and two emitter shortcuts that relied on term identity.
  • Not run: CUDA (the device read path and the CUDA gpu_make change are untested) and Linux.

🤖 Generated with Claude Code

@nicolas-abril nicolas-abril changed the title comp.ts back under its cap by removing mechanisms at their root: a global File, one way to enter a task, one way to read a shared cell, and effects that park themselves comp.ts back under its cap by removing mechanisms at their root: a global File, one way to read a shared cell, and effects that park themselves Oct 2, 2026
@nicolas-abril
nicolas-abril force-pushed the comp-simplify branch 4 times, most recently from 63ace6a to 391d842 Compare October 3, 2026 18:18
nicolas-abril and others added 12 commits October 3, 2026 21:52
…ds the book and no cache is cleared by hand

The caches were module globals cleared per book, so the compiler already
held one book at a time; 40 functions still took `book` or `fl` only to
reach it. Now FL is the book being compiled and all that is memoized
about it: compile_book and js_lib make a new File first, so show_main
reads this book's caches rather than the last one's, and FL.memo holds
what one def's walk memoizes (memo_gc clears it as a whole). type File
lists every field, FL.memo's too. The root segment mints no C id: no
name is empty, so its FID_ never collided with anything. Emitted C, JS
and .mjs are byte-identical for all 1626 .bend files under tests/,
bench/ and demos/. comp.ts: 63980 -> 63502 ttok.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Intr.call meant three things: an array op stays intrinsic in C with no C
text, emit_intr and js_expr send it to arr_op, and emit_fold does not
rebuild a call with folded arguments. The last was redundant (emit_intr
folds each argument as it emits it), so the flag only said 'arr_op lays
this out', which C: null now says. Emitted C, JS and .mjs are
byte-identical for all 1626 files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t_open and emit_put

compile_book named a foreign def's parameters, opened its segment and
wrote its return by hand, which is what emit_open and emit_put do; the
one difference is the continuation, a parameter past the live ones, which
emit_open now names k. Emitted C, JS and .mjs are byte-identical for all
1626 files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l flag

facts_hot matches an erased Var against the current def's telescope by
name and index; a constructor's own erased binder (AuditHide{-A, value:
A}, 6200daa) unbound as Var(A, i) could pass for the def's parameter, so
a local flag ran through facts_hot and facts_ctr to switch the
match off under such a constructor. ctr_tail now opens each binder as
DUMMY, named ~, which no parameter can match: the collision cannot arise,
for erased and live dependent fields alike, and the flag goes. Emitted C,
JS and .mjs are byte-identical for all 1626 files (seal_var_scope and
seal_clone_heat among them). A constructor's own telescope, which the
teles memo served through tele_unbind, is memoized in FL.ctrs, so a
1000-arm Nat literal match emits in the same time as on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…_nat shim goes

942573f made a literal one Lit node and kept the old parser's desugar of
a Nat past NAT_LITERAL_MAX, U32.to_nat of its word, so the emitted C and
JS stayed the same. Nothing needs it: ctr_adt reads a Nat literal in one
step, as it reads U32 and F32 ones, and FOLD_FUEL caps any unrolling. A
big literal now counts as constant: 32 C files change, most only from
((u64)300ull) to 300ull; a constant list of big Nats becomes a static
image (walkers_test), and calls on big literals fold
(literal_rows_cubic, array_map_loop). nat_pattern_deep, which pinned the
default arm's one checked sum and reached it through the shim, takes its
300 from U32.to_nat, so down still runs its match: its C again holds
nat_chk(e, _305_0 + 300). The 22 changed tests pass interpreted, in JS
and in C; the 5 changed benches print their pins in C and JS. JS and
.mjs are byte-identical everywhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ses a leaked one

A JS undefined leaked into the generated C is an undeclared name in a
segment, a spin or a table, all of which clang compiles on the host, so
the build fails there; the scan only reworded that failure, and had a
false positive of its own (effect_undefined_word, whose comment now says
so). Emitted C, JS and .mjs are byte-identical for all 1626 files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… impossible states go

- corpus_eval: work_loop returning 0, a seam delivery returning 0 and a
  pool turn all end when the root is done, so the three exits and their
  two 'delivery lost' messages are one loop and one check after it.
- gpu_make (CUDA) took a NULL path no caller passes; ring_rewind skipped
  a ring holding more than RING_LEN, which a host push's err_fail and the
  device's error check after its pass both rule out; run_tail wrapped its
  one argument in an array that run_loop spread back.

Full local suite (gates/test.ts's shards on this Mac, every lane, Metal
for the bang tests): every test passes but the two display-dependent gfx
tests, as on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- term_peek reads a redirect cell with one relaxed 64-bit load (plain on
  the device), as bendlang#1155 made blk_loc do: the target bits never change, so
  a torn read still finds them, and a shared node's fields never change
  either (span_fade only bumps its children). rfc_view's two atomic
  halves and its acquire go; ctr_take reads the count itself after
  copying and acquires only when it is the last holder, before it frees
  the cell and owns the node. blk_loc is term_peek for a shared block.

FID_EXIT's continuation fast path stays: without it, code that applies a
closure which may fork retires 28% more instructions at 8 threads.

Full local suite on every lane: all pass but the two display-dependent
gfx tests, as on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… IO.OP match keeps its default arm in both lanes, so $FFI goes

C refuses a request reaching a Bend match over IO.OP through the match's
kept default arm (ERR_TAGS); JS threw the request object and io_run's
catch turned it into the fail-stop. mat_ctrs now keeps the default arm
for IO.OP itself, so JS takes the same arm, and io_run's catch only maps
a stack overflow. A foreign op is { $: name, args, kont }, built like
C's constructor; the loop looks its effect up by tag and refuses an
alien request at run time, as C does, so js_lib's load-time registry
check goes with $FFI. request_out_of_band's header says so; its lines are
unchanged. Full local suite on every lane: all pass but the two
display-dependent gfx tests, as on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… has one way to run a request

The loop could park a request before its effect ran, from a need given at
registration (IO_READ, IO_TIME in C; a need function in JS), and only
IO.sleep, TCP.accept and UDP.recv_from had one. Each effect already
parks itself the other way (io_wait_on in C, io_park_on in JS): accept
and recv_from on EAGAIN, so they just try first; sleep now parks on its
deadline. So io_eff takes (cid, run), IoEff and io_exec go, and io_step
takes a request's fields once for Halt and for an effect. Nothing an
effect could do is lost: guide/EFFECTS.md now says an effect that waits
parks itself, and how to wait on time alone.

Breaking for effects outside Base: a C io_eff with a third argument no
longer compiles, and JS ignores one. All 62 C registrations in
bend2/effs and tests drop their third argument. effect_need_collision
keeps its point (two effect files define the same function name) with
the helper as a plain function. All 136 io tests pass on every lane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…he helper tests stop saying need

- file_book takes the root file and returns nothing; compile_book and
  js_lib bind it once (const fl = FL = file_new(...)) and say fl, where
  they mixed fl and FL for the same object.
- js_lib's comment registered effects with io_eff(CID(k), run, need?),
  which takes no need now: it says io_eff(CID(k), run).
- effect_need_collision(_reverse) and their two .js files become
  effect_helper_*: with needs gone they pin that two effect sources may
  define the same function name, and the name says so.

Emitted C, JS and .mjs are byte-identical for every file but the renamed
tests, which pass on every lane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… compiled, a Scope is where the emitter writes

File held both what lives for a book (its facts, its output, its caches)
and what lives for a scope (the segment written, the locals' names, the
bindings and spare nodes held, the def), so every { ...fl } copy for a
match arm, closure or fork carried the book's dozen caches, and fl.hot
and FL.hot named the same set. Now file_new builds the File (FL) and
scope_new a Scope, which a nested scope copies; functions read the book
through FL and take a scope (sc) only to write. Six that took the file
only to reach the book (arr_q, brw_of, c_ids, effect_srcs, js_host,
js_marshal) take none. Emitted C, JS and .mjs are byte-identical
for all 1626 files.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant