Skip to content

Take closures, var-refs and frames off Swift ARC; native calls over the VM stack #1

Open

Description

@jbachand

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

  1. 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.
  2. Frames materialised only when observed. The inline call keeps a plain record on the VM stack; a JeffJSStackFrame is created only for closures capturing the frame, arguments, exceptions/stack traces, and generators/async.
  3. 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.
  4. Follow-on, if the above lands: same treatment for JeffJSObject lifecycle.

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

  1. self-assigned this
    on Oct 18, 2026
  2. jbachand commented on Oct 21, 2026

    @jbachand
    OwnerAuthor

    Status 2026-10-21, from the round-14 docket (perf/round14-docket, docs/PERFORMANCE_PLAN.md "Round 14"):

    • Step 3 landed, partly. Builtins can take (ctx, this, argv: UnsafePointer<JeffJSValue>, argc) (JSCFunctionType.argv, newCFunctionArgv), jeffJS_nativeCallFromSlots calls 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 % of native-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.
    • Not started: steps 1 (var-refs / closure environments off ARC) and 2 (lazy frames).
    • Order change: the docket's profiles put the remaining ARC cost on closure/function objects and mapped arguments before var-refs, so the plan is step 4's first half (closure objects + mapped arguments off ARC) first, with a gate of ≥ 10 % on watch_deepmerge before plain objects; then step 1, then step 2. Each behind JEFFJS_INTERP_<NAME>=0.

    Suite after round 14: bench/realworld.js 3.7x → 2.5x QuickJS, watch_deepmerge 1407 → 1153 ms (QuickJS 520).

  3. jbachand commented on Oct 21, 2026

    @jbachand
    OwnerAuthor

    Status after round 14 (2026-10-21, branch feat/execution-rethink):

    • Step 3 (native-call ABI over the VM stack) has landed in part: builtins run over the VM stack with an argv native ABI and intrinsic closures without weak self (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 on call_method sites with the DOM bridge methods migrated to the argv ABI.
    • Step 1 (var-refs off ARC) exists as the experiment perf/arcless-varrefs (interp.arclessVarRefs, ~3 %); not merged.
    • Step 2 (lazy frames) not started. Generators/async now keep their frame alive across yield/await (8fd23d8), which is the groundwork.
    • Step 4 (object lifecycle off ARC) not started; the 2026-10-20 profile of bench/realworld.js puts 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 + mapped arguments first (gate ≥ 10 % on watch_deepmerge), then plain objects, frames, strings.

    Overall: realworld 6488 → 4408 ms (3.7x → 2.5x QuickJS), watch_deepmerge 1407 → 1153 ms (QuickJS 520). Details in docs/PERFORMANCE_PLAN.md "Round 14" and docs/EXECUTION_ARCHITECTURE_RETHINK.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions