Repository navigation
Take closures, var-refs and frames off Swift ARC; native calls over the VM stack #1
Description
Architecture change, so filed first per CONTRIBUTING.
Why
On THD's watch hot path (bench/watch_deepmerge.js), JeffJS still runs at ~2.1x QuickJS: 9.0 G vs 4.3 G instructions per merge pass (docs/PERFORMANCE_PLAN.md, "perf/watch-cpu"). The interpreter core is already low level (NaN-boxed values, raw bytecode/stack pointers, shapes + ICs, superinstructions, trace tier), and int/float loops are at 1.0–1.3x. What's left is runtime lifecycle cost:
| JeffJS | QuickJS | |
|---|---|---|
| JS→JS call + return | ~460 instr | 276 |
| Native call | ~750 | 310 |
| Native→JS callback | ~1.3k | 0.4k |
| Closure create + free | ~3.4k | 2.1k |
| Swift retain/release share of native profile | ~35% | — |
JeffJSVarRef, JeffJSStackFrame and JeffJSObject are Swift classes (Core/JeffJSObject.swift), so they pay Swift ARC on top of the JS refcount, and builtins take [JeffJSValue] argument arrays. A native/AOT compiler wouldn't help here: compiled code calls the same paths, and page JS can't be AOT'd on iOS/watchOS anyway.
Proposal
- Var-refs and closure environments without Swift objects. Manually managed storage (slab/arena of fixed-size records via
UnsafeMutablePointer), JS refcount as the only count, GC header kept compatible with the cycle collector and Metal GC. - Frames materialised only when observed. The inline call keeps a plain record on the VM stack; a
JeffJSStackFrameis created only for closures capturing the frame,arguments, exceptions/stack traces, and generators/async. - One native-call ABI over the VM stack. Builtins take
(ctx, this, argv: UnsafePointer<JeffJSValue>, argc); native→JS callbacks (forEach/map/sort/call/apply/bind) write arguments into the callee frame directly. Keep an adapter for existing[JeffJSValue]builtins and migrate hot ones first. - Follow-on, if the above lands: same treatment for
JeffJSObjectlifecycle.
Ownership rules unchanged (callee borrows, caller releases; builtins storing/returning an arg or this dup).
How it's measured
Each step behind a JEFFJS_INTERP_<NAME>=0 A/B switch like the round-8/watch-cpu work. Instructions retired + cycles on watch_deepmerge, watch_callbacks, kernels, realworld, and the THD landing/PLP/PIP in the Release watch simulator. Conformance and swift test green, probes clean, JEFFJS_ZOMBIES / JEFFJS_TRACK_RC scans clean, page teardown leaves no engine objects.
Target: close most of the 2.1x on deepmerge (estimate, not measured: 1.4–1.7x from 1+2, 1.1–1.2x from 3).
Risks
- Use-after-free moves from "impossible" to "possible"; mitigated by keeping zombie/RC tracking builds working on the new storage.
- GC integration (cycle collector + Metal sweep) must see the new records.
- Large diff touching
callInternal, closure creation and every builtin signature; split into one PR per step.
Out of scope
JIT (not allowed on iOS/watchOS); AOT to native for shipped JS (revisit after parity); a C dispatch loop (breaks pure-Swift).
Activity
docs: execution-architecture rethink (closure trees, method maps) wit…
jbachand commentedon Oct 21, 2026
Status 2026-10-21, from the round-14 docket (
perf/round14-docket, docs/PERFORMANCE_PLAN.md "Round 14"):(ctx, this, argv: UnsafePointer<JeffJSValue>, argc)(JSCFunctionType.argv,newCFunctionArgv),jeffJS_nativeCallFromSlotscalls them straight from the frame's stack slots,.apply/ spread go through stack slots, and the hot intrinsics were migrated. Measured: the array-free path alone was ~10 % ofnative-calls; the[weak self]captures in the intrinsic closures were the other 40 % (a side-table retain/release per call), now gone. Native→JS callbacks (forEach/map/sort) still build the callee frame the ordinary way.argumentsbefore var-refs, so the plan is step 4's first half (closure objects + mappedargumentsoff ARC) first, with a gate of ≥ 10 % onwatch_deepmergebefore plain objects; then step 1, then step 2. Each behindJEFFJS_INTERP_<NAME>=0.Suite after round 14:
bench/realworld.js3.7x → 2.5x QuickJS,watch_deepmerge1407 → 1153 ms (QuickJS 520).jbachand commentedon Oct 21, 2026
Status after round 14 (2026-10-21, branch
feat/execution-rethink):2d8d4fe), accessors run as inline calls through the inline cache (7a2f2c7), spread calls go through the frame's stack slots (711c923). native-calls went 4.8x → 1.7x QuickJS; the array-free path was ~10 % of that, the weak-self capture the other 40 %. Still open from step 3: native→JS callbacks (forEach/map/sort/call/apply/bind) writing arguments into the callee frame, and a native-method inline cache oncall_methodsites with the DOM bridge methods migrated to the argv ABI.perf/arcless-varrefs(interp.arclessVarRefs, ~3 %); not merged.8fd23d8), which is the groundwork.bench/realworld.jsputs Swift retain/release at 32 % and malloc/free at 16 % of all time, so this is the next step to take, in gated pieces: closure/function objects + mappedargumentsfirst (gate ≥ 10 % onwatch_deepmerge), then plain objects, frames, strings.Overall: realworld 6488 → 4408 ms (3.7x → 2.5x QuickJS),
watch_deepmerge1407 → 1153 ms (QuickJS 520). Details indocs/PERFORMANCE_PLAN.md"Round 14" anddocs/EXECUTION_ARCHITECTURE_RETHINK.md.