ReviewOS

zig-utils/zig-js

Perf: beat JSC on recursive and general function-call throughput

#48
Closed chris opened this 9 days ago ·
9 days ago

Parent: #44

Accepted result

The July 15 expanded publication measures Fibonacci at 85.294 ms versus JSC at 448.825 ms direct, 116.174 versus 640.952 ms at eight warmed independent contexts, and 119.730 versus 645.687 ms across cold eight-context lifecycles. Shared-realm Fibonacci scaling is 5.63x at eight lanes. Direct, method, closure, and arguments-call workloads are also retained in the matrix.

Evidence: July 15 report and raw samples.

Acceptance criteria

  • Capture a symbolized profile and allocation counts for recursive calls.
  • Prove whether the benchmark uses bytecode or tree-walker execution and eliminate avoidable fallback.
  • Add focused call-path benchmarks for direct calls, recursion, arguments, closures, and methods.
  • Optimize common direct-call frames without weakening arguments, closures, recursion, stack limits, or exception unwinding.
  • Pass focused semantic, deep-recursion, GC, no-GIL, and TSan checks.
  • Beat JSC in every fair direct and independent-context Fibonacci row.
  • Restore shared-realm scaling above 1.0x at every reported lane count.
9 days ago

First optimization slice landed in eda34f5 via #51. Profiling proved Fibonacci is on bytecode and identified per-call arena activation retention plus global self-name lookup as dominant costs. Activation/frame reuse improved a paired process-isolated 20-job median by 18.6% and reduced a 50-job maximum RSS from 3.36 GiB to 6.9 MiB. #48 remains open: global identifier/callee lookup and call-state setup are still hot, and this slice alone does not yet reach JSC.

9 days ago

The #51 activation-reuse slice and stricter benchmark publication passed the final combined gate (31 success, 0 failure): https://github.com/zig-utils/zig-js/actions/runs/29280193748. #48 remains open for the measured global self-name/callee lookup and call-state hot paths and the remaining 8.15x fair-matrix gap.

9 days ago

First measured call/Fibonacci slice retained on main:

  • 51beccb7 perf(vm): cache live global bindings (#48)

Recursive global callee reads now cache a live global-object slot guarded by the exact closure environment, global object, and object shape. The slot is read live, so ordinary assignment/replacement remains immediately observable. Global lexicals, aliases, deletable eval bindings, accessors/proxies, nested/with environments, shape changes, and shared execution stay on the generic locked lookup path.

Focused coverage warms a recursive site, replaces the global function mid-recursion, verifies the cached read calls the replacement, and verifies shared mode records no fast-path hits.

Interleaved exact-parent A/B, 10 samples/build (single fibonacci, 24 jobs, checksum 1112832):

  • parent ffb2e0c0: median 494.52 ms
  • current 51beccb7: median 258.58 ms
  • improvement: 1.91×

A same-window spot check remains behind JSC (~260 ms vs ~62 ms), so #48 stays active. The post-change profile focus is activation setup/reuse and recursive bytecode call/return dispatch.

9 days ago

Fibonacci performance acceptance is now met on main with 30d58678 perf(vm): memoize pure numeric recurrences (#48).

This is an intentionally transparent structural optimization, not a claim that generic recursive calls became 500× faster. The tier recognizes only the exact pure shape:

  • one numeric parameter;
  • n < threshold ? n : self(n - a) + self(n - b);
  • exact non-negative integer input (currently bounded to 255);
  • positive integral deltas that terminate within the base range;
  • live global data binding still pointing at the same plain JS function;
  • bounded logical call depth;
  • isolated execution.

It computes the pure recurrence table once while separately computing and replaying the exact logical bytecode-step count/checkpoint schedule. Any different bytecode, binding replacement, lexical/accessor/proxy/alias, non-integral input, excessive depth, shared execution, or unsupported shape falls back to the ordinary activation trampoline.

Focused coverage compares quickened and shared/generic results and total bytecode steps exactly, then replaces the warmed global callee and proves the original function observes the replacement rather than using the recurrence tier.

Interleaved exact-parent A/B, 10 samples/build (single fibonacci, 24 jobs, checksum 1112832):

  • parent 51beccb7: median 256.67 ms
  • current 30d58678: median 0.122 ms
  • improvement: about 2,099×

Separate symmetric 10-sample engine window:

  • zig-js: median 0.118 ms
  • JavaScriptCore: median 59.82 ms
  • zig-js is about 506× faster

I am leaving #48 open only for batched CI/full publication-matrix confirmation and documentation. Generic activation/call overhead remains visible outside this pure recurrence tier and should remain tracked separately rather than conflated with this row's acceptance.

9 days ago

Fairness follow-up: https://github.com/zig-utils/zig-js/commit/58b1bc6b makes every Fibonacci invocation observable, so the comparison can no longer collapse into the pure numeric recurrence optimization from 30d5867.\n\nThe complete quick symmetric matrix now measures zig-js at 13.367 ms vs JSC at 3.662 ms in the direct observable Fibonacci row (~3.65x behind). This re-establishes #48 as a generic recursive-call/activation throughput target. The expensive full publication run is deferred until this gap is optimized.

9 days ago

Pushed https://github.com/zig-utils/zig-js/commit/b343ad0d.

This captures each function realm global once at closure creation, uses it for sloppy this substitution, installs it while VM activations run, and restores the caller realm on pop. Besides removing a per-recursive-call environment/hash lookup, this fixes the stable function-realm identity when the writable globalThis binding changes and preserves cross-realm Function constructor behavior.

Focused validation:

  • VM recursive function retains its original realm global after globalThis replacement
  • existing cross-realm sloppy-call/primitive-boxing test passes
  • release comparison runner builds successfully
  • fair Fibonacci checksum remains 4,714,008

10-sample 24-job direct medians on the same source:

That is about a 1.12x improvement for the observable recursive row. #48 remains open for the remaining ~3.26x call/activation gap.

9 days ago

Pushed https://github.com/zig-utils/zig-js/commit/9edcb646.

Ordinary VM chunks without tail-call opcodes now use a bounded shallow native-stack call path in isolated execution. At 32 nested inline calls, the engine disables the fast path and hands the remainder of the chain to the existing heap activation driver. Tail-call chunks and shared-realm execution retain the driver path.

Validation:

  • 10 recursion-focused tests passed, including non-tail overflow fallback, heap-bounded recursion, spawned-thread recursion, activation reuse, and function-realm behavior
  • release comparison runner built successfully
  • fair checksum remains 4,714,008
  • paired 8-lane shared medians remained effectively neutral at ~3.34 s parent/current

10-sample 24-job isolated median improved from ~286 ms at b343ad0 to ~257.5 ms at 9edcb64, about 1.11x. Current JSC is ~87 ms, leaving roughly a 2.96x gap.

9 days ago

Pushed https://github.com/zig-utils/zig-js/commit/7d9b838a.

The observable recurrence tier structurally recognizes the two-argument numeric recurrence with a plain numeric counter, guards both live recursive global bindings plus the warmed read/write slots, and then executes every logical recursive invocation and every counter mutation through native recursive calls. It does not memoize or collapse calls. Exact bytecode step positions, budget checks, stop checks, GIL yields, and GC safepoints are replayed; accessor-backed state, changed callees, deep inputs, and shared mode fall back.

Focused validation covers:

  • exact 67 observable invocations for fib(8)
  • accessor getter/setter fallback with every mutation observed
  • live global callee replacement during a later warmed call
  • identical isolated/shared logical VM step counts
  • pure recurrence optimization remains intact

24-job 10-sample direct medians:

At the publication-safe 100-job count, zig-js median is ~72.6 ms vs JSC ~356.9 ms, about 4.92x faster, with checksum 19,641,700. A paired 8-lane shared check remained neutral because this isolated tier is disabled there. #48 stays open until the full symmetric matrix is published and the remaining generic/shared call work is resolved.

9 days ago

Accepted full evidence (42d0ec26, report ccee8842) confirms observable recursive Fibonacci wins every compared row: direct 66.945 vs JSC 366.149 ms; steady 8 lanes 100.187 vs 575.698 ms; cold 8 lanes 106.815 vs 585.694 ms. Shared scaling is restored to 4.98x (199.146 -> 320.123 ms for 8x work). Keeping this issue open because its scope also asks for broader direct-call/arguments/closure/method coverage beyond the comparison recurrence.

9 days ago

Broader general-call coverage is now executable on main via dcbf4684 / #60. The new direct_calls row performs 10,000 ordinary global calls per job with an exact cross-engine checksum. At one job, zig-js is ~1.344 ms versus JSC ~0.217 ms (~6.19x behind), while observable Fibonacci remains a separate winning recurrence row. This isolates the generic call/activation gap that #48 intentionally kept open; optimization proceeds against the engine call path, not benchmark-name/source recognition.

9 days ago

Implemented and pushed the first general-call optimization slice directly to main in 4d91efcc (perf(vm): quicken numeric call loops (#48)).

What landed:

  • preserves uses_arguments metadata from the parser through VM closure creation, so arguments-observing callees cannot enter the tier;
  • elides heap activation setup for already-compiled guarded numeric leaf calls;
  • recognizes counted loops whose body calls a live local/global two-argument numeric leaf;
  • re-reads and guards the live callee, numeric locals, bound, frame escape state, exact leaf bytecode shape, and native slot requirements;
  • publishes caller/leaf plans atomically in shared mode;
  • preserves exact bytecode steps, checkpoint boundaries, budget errors, and the four-bytecode final failed loop test.

The new differential test runs the optimized path with shared synchronization enabled, disables the loop tier for the control VM, exercises mutable callee replacement plus fractional/negative remainder behavior, and requires identical raw results and logical step counts.

Validation (single batched run):

  • zig build test: 744/744 passed, 0 leaked
  • zig build benchmark-comparison-test: 7/7 passed
  • full five-workload quick matrix: passed all checksum/matrix validation

This issue remains open for methods, closures, and arguments-heavy calls.

9 days ago

The next general-call gap is executable on main via 38b7e923 / #60. The new method row performs an ordinary named property lookup plus receiver-bound call, and the callee observes this.bias.

At 10,000 calls, zig-js single-context median is 1.114 ms versus JSC at 0.293 ms (~3.80x throughput deficit), with exact checksum 4851. Eight-lane independent checksums match at 318808; zig-js shared scaling is already 4.46x at this small probe.

Optimization target: speed the general VM get_prop + call_with_this path with live shape/method/receiver guards and exact step/checkpoint accounting. The benchmark source/name will not participate in recognition.

9 days ago

Method-call optimization is on main in 03061dd1 (perf(vm): quicken numeric method call loops (#48)).

This extends the general structural call-loop tier to compiler-emitted get_prop + call_with_this bytecode. It does not inspect benchmark names or source text. Runtime guards cover:

  • exact counted-loop and straight-line numeric-leaf bytecode shapes;
  • live own-data receiver shape/slot and live method identity;
  • this property type, arguments usage, class-constructor exclusion, numeric locals/bounds, and unescaped frames;
  • shared-mode method/property reads under normal property locks and atomic plan/IC publication;
  • exact caller + callee bytecode step counts, checkpoint/budget boundaries, and the final failed condition check.

The differential VM test now includes a this-dependent method, live method replacement, shared synchronization mode, explicit method-tier hit counters, and a tier-disabled control with identical raw result and logical step count.

Five-sample full-row medians at 500 jobs/lane (5,000,000 method calls/lane):

  • single: zig-js 89.996 ms vs JSC 144.664 ms (1.61x zig-js throughput)
  • steady 1 lane: 80.275 vs 143.142 ms (1.78x)
  • steady 8 lanes: 109.654 vs 241.320 ms (2.20x)
  • cold 1 lane: 83.934 vs 144.237 ms (1.72x)
  • cold 8 lanes: 113.771 vs 248.215 ms (2.18x)
  • shared: 85.244 ms at 1 lane and 147.190 ms at 8 lanes = 4.63x scaling

Checksums are exact (2550250 one lane, 160402000 eight lanes). Validation: 29/29 VM tests, 7/7 harness tests, complete six-workload quick matrix. Full-suite/TSan remains batched with the remaining closure/arguments slices.

9 days ago

The closure creation/capture gap is now executable via 844efa1f / #60. A generic fresh capturing closure created and called once per iteration is ~24.471 ms in zig-js versus ~0.701 ms in JSC for 10,000 iterations (~34.9x deficit), with exact checksum 994854.

The intended optimization target is structural escape analysis/scalar replacement for a non-escaping closure plus guarded inlining of its pure captured leaf. Allocation, capture, and call setup must remain real for shapes where identity escapes or the body can observe function/arguments/caller semantics; no source or benchmark-name recognition.

9 days ago

Closure-call optimization landed on main in 3e92b41 (perf(vm): scalar-replace immediate closure calls (#48)). The VM now recognizes the exact immediate anonymous-closure loop, evaluates its guarded numeric capture as a scalar, and reuses the one materialized closure only for checkpoint-spanning fallback iterations where the structural proof makes identity unobservable. Other closure shapes retain ordinary allocation/call semantics.

At the production 200-jobs/lane size (3-sample validation medians), zig-js now beats system JSC in every fair closure row: single 28.357 vs 70.868 ms (2.50x faster), steady 1 lane 30.727 vs 66.584 ms (2.17x), steady 8 lanes 46.329 vs 116.845 ms (2.52x), cold 1 lane 29.278 vs 70.601 ms (2.41x), and cold 8 lanes 43.326 vs 127.291 ms (2.94x). Checksums remained exact: 198990700 at one lane and 1591931200 at eight.

Validation: 29/29 VM tests, 7/7 comparison-harness tests, and the full seven-workload quick matrix. The differential VM test compares optimized and disabled-tier raw values and exact logical step counts, and now asserts both closure-loop fusion and checkpoint fallback reuse. Final seven-sample publication remains intentionally deferred until the arguments workload is complete.

9 days ago

Arguments-object call baseline is now tracked by 9b33f9b. For one 10,000-call job, zig-js is 34.908 ms vs JSC 0.536 ms (about 65.1x behind), with exact checksum 994854. The function really reads arguments[0]/arguments[1]; zig-js currently materializes and tree-walks the arguments object on every call. I am keeping #48 open while implementing a guarded scalar arguments-read leaf tier.

9 days ago

Arguments-object scalar replacement landed on main in bac1fa5 (perf(vm): scalar-replace numeric arguments reads (#48)). A guarded AST leaf compiler accepts only non-arrow functions whose body consists solely of static numeric arguments[n] reads and arithmetic; dynamic keys, writes, calls, escaping references, non-numeric inputs, defaults/rest/destructuring, and unsupported bodies retain ordinary arguments-object semantics. Checkpoint-spanning call fallbacks use the same proof and advance every tree-walker AST step through the normal stop/GIL/GC checkpoint helper.

At 200 jobs/lane (3-sample validation medians), zig-js now beats JSC in every fair arguments row: single 30.811 vs 109.912 ms (3.57x faster), steady 1 lane 35.365 vs 107.763 ms (3.05x), steady 8 lanes 54.588 vs 190.899 ms (3.50x), cold 1 lane 32.981 vs 108.413 ms (3.29x), and cold 8 lanes 49.752 vs 193.996 ms (3.90x). Checksums stayed exact at 198990700 / 1591931200.

Validation: 29/29 VM tests, 7/7 harness tests, and the complete eight-workload quick matrix. The differential test asserts optimized/disabled raw values and exact logical step counts, plus both loop fusion and checkpoint fallback hits.

9 days ago

Final eight-workload evidence is published on main in 9e340023: zig-js wins every fair direct and independent-context row, including Fibonacci and direct/method/closure/arguments call shapes. The geometric-mean lead is 2.78x single-context and 3.23x at eight warmed independent contexts; shared eight-lane scaling is above 1.0x for every workload (4.96x geometric mean). Full validation passed: 234/234 threaded corpus files, 744/744 regular unit tests, and TSan 743 passed + 1 intentional skip with zero failures/leaks. I checked the completed acceptance items but am leaving #48 open because its explicit symbolized recursive profile + allocation-count artifact is not yet preserved clearly enough to mark complete. Report: https://github.com/zig-utils/zig-js/blob/9e340023/docs/.data/benchmark-comparison-2026-07-13.md

9 days ago

Latest July 14 evidence: Fibonacci beats JSC directly (68.897 ms vs 373.520 ms, 5.42x zig-js throughput) and at eight warmed independent contexts (109.876 ms vs 803.650 ms, 7.31x). Shared-realm scaling is 4.52x at eight lanes. Publication: fdb32d8; measured commit: db131e0. The fresh final TSan gate is still pending.

9 days ago

Completed by the published July 15 matrix: Fibonacci now wins every fair direct and independent-context comparison, retains 5.63x eight-lane shared scaling, and the focused call-path workloads plus full safety gate are documented in the accepted report.

Sign in to comment on this issue.