Skip to content

feature/js-plains - #974

Open
MattCozendey wants to merge 2 commits into
bendlang:mainfrom
MattCozendey:feature/js-plains
Open

MattCozendey wants to merge 2 commits into
bendlang:mainfrom
MattCozendey:feature/js-plains

Conversation

@MattCozendey

Copy link
Copy Markdown
Contributor

The JS lane stops paying for calls: selector intrinsics + plain defs off the trampoline

Two changes to bend2/comp.ts, both invisible to the C emitter, the device
and the checker (no base.bend, bend.ts or Lean surface is touched).

1. Selector intrinsics (b4ebc8b)

JS-only OPERATIONS entries for bool_and, bool_not, bool_pick,
u32_min, u32_max, f32_min, f32_max, nat_min, nat_max.

Before, U32.min(a, b) emitted a trampolined call into $U32$min$, which
trampolined again into $Bool$pick$ — three frames and two $JMP
allocations for one compare. Now the ternary emits in place.

Each template mirrors its base.bend body exactly:

  • pick c a b = c ? a : b, min = a < b ? a : b, max = a < b ? b : a
  • F32 NaN answers keep following is_lt, as Bool.pick of F32.is_lt does
  • Nat.min/max's Succ-peeling recursion (O(min(a,b)) BigInt steps) becomes
    one BigInt compare
  • bool_pick stays JS-only because its A is polymorphic; on native lanes
    no single word shape fits

2. Plain defs are called directly (8ff7eab)

A def is plain when its emitted JS can never return a $JMP: no tail
self-call and no closure applied in tail position. Every other return comes
back through the machine stack, whose depth the def call DAG bounds — Bend
has no mutual recursion and no forward calls.

js_call now calls a plain callee directly (tail or not), so the JIT can
inline it. A self tail call keeps run_jump; a tail call into a trampolined
callee absorbs its $JMPs with run_loop in place, costing one bounded
frame. plain_of reads the property off the body with term_any's tail
flag; a closure body counts as tail, which only errs toward keeping the
trampoline.

Measurements (WSL, bun, medians of three, identical outputs and RSS)

workload (10M iterations) before after speedup
Base selector ops (min/max/pick/is_even) 1.30 s 0.51 s ~2.5x
chain of three small user defs per step 1.26 s 0.49 s ~2.6x
single pure Nat tail loop (control) 0.98 s 0.94 s ~1.0x

The control row is expected: a def that never calls anything gains nothing;
its costs (per-iteration run_jump allocation, BigInt Nat arithmetic) are
follow-up work, not this PR.

Verification

  • A scratch program exercising every intrinsic through constant and
    variable arguments prints the same value interpreted and emitted.
  • 73 tests across tests/base, tests/eval and tests/io pass on both
    the run path and the emitted-JS path.
  • The cluster gates did not run (no ssh access from this machine); the
    perf pins and the device lane still need the usual gate pass.

🤖 Generated with Claude Code

MattCozendey and others added 2 commits September 23, 2026 07:56
JS-only OPERATIONS entries for bool_and, bool_not, bool_pick, u32_min,
u32_max, f32_min, f32_max, nat_min and nat_max: each mirrors its
base.bend body exactly (pick c a b = c ? a : b; min = a < b ? a : b;
max = a < b ? b : a, so F32's NaN answers follow is_lt, and Nat's
Succ-peeling recursion becomes one BigInt compare). Before, each call
cost a run_jump trampoline into the def and, for min/max, a second one
into Bool.pick; now the ternary emits in place. The C lane is untouched:
intr_of without js sees no JS-only entry, so fuse, spins and the borrow
inference read the same book as before.

Verified through WSL on the JS lane: a scratch program exercising every
entry through variable and constant arguments prints the same value
interpreted and emitted, and 14 tests (num_kit, nat_ops, list_ops,
list_fold, list_kit, string_ops, text_ops, float_roundtrip, u32_divmod,
rand, tuple_suite, bool_algebra, fold, readback_sugars) pass run and
emitted-JS alike. The cluster gates did not run (no ssh from this
machine).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A def is plain when its emitted JS returns no $JMP: a self call or a
closure applied in tail position rides the trampoline, so its callers
must run_loop; everything else returns through the machine stack, whose
depth the def DAG bounds, since defs never call forward. plain_of reads
that off the body with term_any's tail flag (a closure body counts as
tail, which only errs toward the trampoline). js_call then calls a
plain callee directly, tail or not, so the JIT can inline it; a self
tail call keeps run_jump, and a tail call to a trampolined callee
absorbs its $JMPs with run_loop in place, one bounded frame. The C
lane and the device read none of this.

Verified through WSL on the JS lane: 73 tests across base, eval and io
pass run and emitted-JS alike (three tests/run fails are the @unsafe
stderr note the local runner merges into stdout; they fail identically
on the parent commit), and a 10M-iteration loop over a three-def chain
runs 1.14-1.42 s before, 0.45-0.54 s after, same output and RSS. The
cluster gates did not run (no ssh from this machine).

Co-Authored-By: Claude Fable 5 <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