ReviewOS

zig-utils/zig-js

Perf: remove no-GIL scaling bottlenecks in the comparison workloads

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

Parent: #44 Related: #47, #48, #62, #97, #116, #118, #119, #120, #131

Goal

Make shared-realm parallel execution scale predictably without hiding contention behind comparisons to isolated JSC contexts. The fair matrix uses symmetric independent-context rows to separate engine-local throughput from shared-GC/runtime contention; shared-realm results remain a distinct zig-js capability panel with no invented public-JSC equivalent.

Completed baseline

The July 29, 2026 steady-shared matrix measures clean a7cf92c7 and is published by e4187c0f with 1,540 raw samples. Eight-lane shared scaling is:

  • arithmetic 5.53x; properties 5.82x; polymorphic properties 5.38x;
  • object churn 1.20x; arrays 5.54x;
  • direct calls 4.55x; method calls 5.43x; closure calls 4.60x; arguments calls 5.84x;
  • recursive Fibonacci 4.87x.

The shared geometric mean is 4.54x. The sole former blocker, #97, now has every lane above 1.0x with low-dispersion repeated evidence. Shared results remain correctly separate from JSC because its public API has no equivalent shared-object-graph mode.

Landed attribution and optimization

Dedicated thread/GC/mid-script-GC profiles report allocator, backing-slab, shape/binding/object/element locks, safepoint publication, collector attempts/aborts/sweeps, native wait attribution, task/waiter queues, and thread lifecycle/join behavior. General changes removed or amortized shared allocation and lock traffic with explicit ownership/tracing invariants, including lock-batched GC slabs, fixed-shape allocation, queue head cursors/capacity chunks, nursery collection, and the 128-byte ordinary-object layout.

Profiles and rejected exact-parent experiments now isolate #97 to remaining object allocation/backing/sweep serialization. In particular, merely enabling the abort-safe collector, polling nursery pressure at every checkpoint, widening the fixed-shape checkpoint batch, and a zig-gc owned-batch splice did not solve the eight-lane scaling contract. Continue through #97 with causal exact-parent evidence rather than broad speculative lock removal.

Acceptance criteria

  • Add focused counters/profiles for GC phases, safepoint waits, allocator/backing contention, hot shared locks, native waits, thread startup, and join behavior.
  • Profile arithmetic, properties, polymorphic properties, object churn, arrays, direct/method/closure/arguments calls, and recursion at 1/2/4/8 lanes.
  • Attribute every remaining sub-1.0x scaling row to measured contention; #97 owns object churn.
  • Reduce global locks and shared allocator traffic only with explicit ownership and tracing invariants.
  • Keep semantic no-GIL, lifecycle, mid-GC, broad-fuzz, full-unit, and suppression-free TSan coverage green.
  • Keep shared-realm scaling at or above 1.0x at every reported lane count for every workload (#97).
  • Make no shared-realm-vs-JSC throughput claim without a semantically equivalent public JSC mode.
9 days ago

Fairness follow-up from https://github.com/zig-utils/zig-js/commit/58b1bc6: the invocation-local observable Fibonacci workload passed the complete quick symmetric matrix, but shared-realm scaling is only 0.93x at 8 lanes (while arithmetic is 5.20x, properties 4.45x, and arrays 1.74x).\n\nKeeping #49 open for the observable recursive-call scaling bottleneck before regenerating the full publication report.

9 days ago

Call-path update https://github.com/zig-utils/zig-js/commit/9edcb646 deliberately restricts bounded native-stack call inlining to isolated execution. An initial shared attempt regressed the 8-lane observable Fibonacci row, so the final patch leaves shared mode on the synchronized heap driver. Paired parent/current 8-lane medians were effectively unchanged at ~3.34 s after that guard.

9 days ago

Pushed synchronized observable recurrence support as https://github.com/zig-utils/zig-js/commit/baf5ed98.

The shared tier is limited to immutable named-function-expression self bindings. Mutable global recursion still falls back. Recurrence plans publish atomically, each observable property read/write retains synchronized object semantics, generic accessors and nonnumeric addition remain available, and exact VM checkpoints/steps are preserved. The focused test proves shared activation only for the immutable self binding; mutable replacement and accessor cases retain fallback behavior.

Publication-sized 100-job Fibonacci medians:

  • zig-js shared 1 lane: ~205 ms
  • zig-js shared 2 lanes: ~213 ms
  • zig-js shared 4 lanes: ~200 ms
  • zig-js shared 8 lanes: ~273 ms
  • JSC independent steady 1 lane: ~377 ms
  • JSC independent steady 8 lanes: ~573 ms

That is about 6.0x zig-js shared scaling at 8 lanes versus about 5.3x for JSC, and zig-js is roughly 2.1x faster in 8-lane wall time. The complete quick symmetric matrix passed every mode and checksum. #49 remains open for the still-weaker shared array scaling and full publication run.

9 days ago

Shared-array progress: 80ff72fd reduces generic synchronized property/index overhead and improves the 8-lane shared arrays 80 median from ~1.01 s to ~0.400 s at identical work/checksum. One-lane improves similarly, so absolute throughput is fixed but scaling remains the next target; profile evidence points to the common Array-prototype lock on every push. Tracking the checkpoint-bounded packed-loop follow-up in #47.

9 days ago

960bedc8 moves synchronized packed-array loops into race-safe, checkpoint-bounded batches. At full jobs=450, the shared 8-lane median is now ~205.8 ms versus ~206.7 ms for JSC independent steady 8 lanes, while zig-js independent steady is ~120.3 ms. Shared effective scaling is ~2.43x from its ~62.5 ms one-lane baseline, so absolute throughput now reaches the comparison target but scaling/allocator contention remains open in #47/#49.

9 days ago

3c4e0e43 adds atomic, checkpoint-bounded synchronized property kernels. Shared properties jobs=80 now measure ~30.8 ms at one lane and ~44.6 ms at eight lanes (7-sample medians), an effective ~5.52x scaling factor and ~8.9x lower 8-lane wall time than the parent. Arithmetic/Fibonacci shared checks were neutral. Along with 960bedc8 for arrays, every shared workload now has a guarded batched path where the benchmark structure permits it.

9 days ago

The accepted full report at ccee8842 measures shared 8-lane scaling of 6.32x arithmetic, 5.66x properties, 2.43x arrays, and 4.98x observable Fibonacci (4.56x geometric mean). Every workload is above 1.0x and the prior property/Fibonacci collapse is gone. Keeping #49 open for the remaining array scaling ceiling and broader no-GIL contention work.

9 days ago

The new fair closure row (844efa1f, #60) exposes a distinct shared-realm allocation bottleneck: one lane takes 7.957 ms for 10,000 fresh capturing closures, while eight lanes take 243.642 ms for 8x work, only 0.26x effective scaling. Independent eight-lane contexts take 55.962 ms, so the collapse is specific to shared capture/function/frame allocation rather than CPU saturation alone.

The first fix will scalar-replace provably non-escaping closure identities (#48), which should remove avoidable shared allocation entirely. Any residual general escaping-closure allocator contention remains in this issue.

9 days ago

Closure shared-realm scaling is fixed by 3e92b41. At 200 jobs/lane, the 3-sample median was 30.090 ms at one lane and 43.444 ms at eight lanes, or 5.54x aggregate throughput scaling. Exact checksums were 198990700 and 1591931200 respectively. The complete quick matrix independently measured 5.25x closure scaling. Final seven-sample publication is deferred until the remaining arguments workload is added.

9 days ago

New arguments-object baseline from 9b33f9b: shared 8-lane execution is 586.048 ms median for one job/lane (checksum 7958860), while the single-context median is 34.908 ms. This shape currently scales poorly because every call materializes/tree-walks an arguments object; it is now the active shared-scaling target.

9 days ago

Arguments shared scaling is fixed by bac1fa5. At 200 jobs/lane, the 3-sample median is 32.693 ms at one lane and 47.748 ms at eight lanes, or 5.48x aggregate throughput scaling, with exact checksums 198990700 / 1591931200. The complete quick matrix independently measured 4.39x at its intentionally tiny 10-job size, where thread lifecycle is a larger fraction.

9 days ago

The final expanded report is published in 9e340023. Shared-realm eight-lane scaling is now 6.23x arithmetic, 5.97x properties, 3.02x arrays, 5.88x direct calls, 3.99x methods, 5.52x closures, 5.08x arguments, and 4.99x Fibonacci (4.96x geometric mean), and shared eight-lane wall time beats the corresponding independent JSC row for all eight workloads. Full regular/no-GIL/TSan validation is green. I marked only the evidence-backed criteria complete and am keeping #49 open for its remaining per-run counters, lane-by-lane profiling, and explicit contention attribution work. Report: https://github.com/zig-utils/zig-js/blob/9e340023/docs/.data/benchmark-comparison-2026-07-13.md

9 days ago

Current #97 update: the earlier unconditional zig-gc splice remains correctly rejected, but the combined thresholded design has now landed. A lone worker retains the 17-cell compact path; multiple contending workers may reserve 272 precisely rooted objects; zig-gc O(1)-splices only all-owned batches of at least 64 cells.

Exact seven-pair 1/2/4/8-lane medians improve from 99.901/168.035/272.951/1542.720 ms to 100.562/112.393/141.772/1239.637 ms. Every contended pair wins; scaling improves from 1.19x/1.46x/0.52x to 1.79x/2.84x/0.65x. Evidence: https://github.com/zig-utils/zig-js/blob/main/docs/.data/object-churn-amortized-publication-ab-2026-07-16.md

#49 remains open because its final contract is unchanged: #97 still has an eight-lane row below 1.0x. The next dependency direction is zig-gc#33 after re-profiling current binaries.

9 days ago

New evidence for this issue from the PR-249 tail, where the no-GIL scaling cost is now the only thing blocking a case that is otherwise ready.

cve/mc-val-fire-vs-link.js (4 threads: 1 main hot loop + 1 foreign-transition firer, 40 rounds × 20,000 hot iterations):

serialized (GIL)        32,275 ms   PASS   optimizer publications=40
serialized + TSan      235,447 ms   PASS   optimizer publications=40, no race
no-GIL (parallel-js)     >650,000 ms   no result

Same binary, same workload, same assertions. Turning the GIL off — which is meant to let those threads run in parallel — makes it at least 20× slower rather than faster. That is the shape #49 is about, isolated to a small reproducible file rather than a benchmark harness.

Two more cases from the same sweep share the signature, both giving no result in 300 s serialized and worse in no-GIL:

  • checktraps-invalidation.js — 3 worker threads spin while (!Atomics.load(control, 0)) running a 5,000-iteration kernel, while the main thread must complete 2,050 calls of that same kernel before it can set the stop flag. Every main-thread step checkpoint hands the lock to three hot spinners. Under the GIL this is a fairness/handoff cost; with the GIL off the spinners occupy real cores.
  • jit/ic-publish-reset-loops.js — 3 reader threads spinning on getF against a flipper doing 200 rounds × 200 shape-alternating writes.

The common structure is N threads doing real work while one thread must reach a completion point before anyone stops. Whatever the fix is, these three are cheap, self-contained reproductions with a clear pass/fail oracle, and they are already vendored — they may be more tractable to profile than the eight-lane object-churn matrix in #445.

Cross-referenced on #429, where these three are the remaining tail. Their optimizer dependency is satisfied: mc-val-fire-vs-link.js publishes 40 optimizer artifacts, exactly one per round.

9 days ago

Correction and a hard number. My earlier comment on this issue gave 32,275 ms serialized against >650,000 ms no-GIL. Both were measured in a Debug build — optimize defaults to Debug and the corpus binary inherits it — so the profile was dominated by the debug allocator capturing a stack trace per allocation. Those figures should not be used. Re-measured at 4989ed66 with -Doptimize=ReleaseSafe:

modewalloptimizer evidence
serialized3,294 mspublications=40 invalidations=0 collections=0
no-GIL43,002 mspublications=271,244 invalidations=271,244 collections=1 reclaimed=271,244

The case does finish no-GIL — it was never a hang. But the counters name the cost exactly, and it is not allocator contention or ordinary lock overhead:

The engine publishes an optimizer artifact and immediately jettisons it, 271,244 times, one invalidation per publication. The same workload needs 40 — one per ROUNDS generation, which is what serialized mode does. That is 6,781× the necessary compile work, and it accounts for the 13× wall-clock gap on its own.

The mechanism is that Class-A invalidation is global. clearJitCodeFromInterpreter fires per qualifying named-property mutation from a parallel realm, takes the shared JIT/GC conductor, waits for every in-flight compilation to drain, then Owner.clear() jettisons every tier — baseline and optimizer, for every function, not just the ones speculating on the mutated shape. mc-val-fire-vs-link.js's firer thread writes o["foreign" + n] = n in a tight loop, so each write throws away everything the main thread has compiled, and the main thread recompiles into the next write. Serialized never shows this because no Class-A stop fires at all there (invalidations=0).

a757cd1a already narrowed which mutations fire, via the shape filter. The remaining gap is what a firing mutation does: the shape filter decides whether to stop, but the stop is still all-or-nothing. Per-artifact invalidation keyed on the guarded shape — jettison the artifacts that speculate on the mutated shape, not the whole owner — would turn 271,244 into something near 40. That is a design change rather than a tuning knob, so I am filing the evidence rather than attempting it inside this batch.

Two process notes that matter for anyone measuring this issue:

  • Measure the corpus in ReleaseSafe. Debug is ~10× slower here (32.3 s → 3.3 s on the same case) and its allocator profile buries real signals — my first stack sample of this case showed only allocator frames and led me to reject the invalidation hypothesis that the ReleaseSafe counters then confirmed.
  • CI runs both corpus legs at the default Debug (ci.yml lines 65-73, and nogil-corpus-gate.py --deadline 600 at line 556). Cases are therefore being gated at roughly 10× their real cost, and the estimatedSerializedShardMs shard-balancing table in conformance/threads_test.zig holds Debug numbers.
9 days ago

Refining the proposal in my previous comment. I said the fix was per-artifact invalidation keyed on the guarded shape. Having read the firing site, I think that is the wrong half of the problem, and there is a sharper question underneath it.

Interpreter.invalidateJitHeapFactsFor(obj) already computes the pre-store shape token and consults owner.shapeMayInvalidate before firing. It just does not pass the token onward, so clearJitCodeFromInterpreter → Owner.clear() is all-or-nothing. Threading the token through and jettisoning only the tiers that speculate on it is mechanical.

But it would not help the measured case, and that is the interesting part. mc-val-fire-vs-link.js's firer writes to the same objects the main thread's hot function reads, so those artifacts genuinely do guard the mutated shape. Per-artifact filtering would still jettison them, and 271,244 would stay 271,244.

The real question is whether that mutation needs a stop at all. o["foreign" + (n & 7)] = n adds a property, which transitions the object to a new shape. Optimized code guards the receiver's live shape before touching storage — NativePropertyCache's contract is explicit that every use revalidates the live shape and slot range. So after the transition the guard simply misses and takes the canonical path. Nothing stale is reachable, and the stop buys nothing while costing a full jettison of every tier.

That suggests the distinction is not which artifacts to jettison but which mutations are Class-A at all:

  • a shape-transitioning add invalidates nothing a live-shape guard could wrongly pass — the guard fails on its own;
  • an in-place mutation that keeps the shape — making a property non-writable, deleting, replacing the prototype, converting to dictionary/exotic — is what can leave a guard passing against changed facts, and is what genuinely needs the stop.

If that holds, this workload's invalidations go to near zero rather than being redistributed, because every firing mutation in it is an add.

I am deliberately not landing this. It changes when a concurrent JIT invalidates, the soundness argument rests on every guard re-reading the live shape on every execution with no elision anywhere, and validating it needs repeated full-corpus runs. That is an owner-level call and a batch of its own, not a tail-end change. Filing the analysis and the measurement instead:

cve/mc-val-fire-vs-link.js, ReleaseSafe
  serialized   3,294 ms   publications=40       invalidations=0
  no-GIL      43,002 ms   publications=271,244  invalidations=271,244

Reproduce with zig build threads-test-bin -Doptimize=ReleaseSafe then ./zig-out/bin/threads-test parallel-js one cve/mc-val-fire-vs-link.js. The case is promoted as of 77f37934, so this stays measurable from the corpus rather than needing a bespoke harness.

9 days ago

Completed by #97 and the July 29 steady-shared publication.

Runtime/tooling/evidence chain: 979049f, e690a86, a7cf92c, e4187c0. Every shared workload is now above 1.0x at every reported lane. Eight-lane scaling ranges from 1.20x for object churn to 5.84x for arguments calls; the shared geometric mean is 4.54x.

Final evidence:

#44 remains open for the separate directly comparable independent object-churn rows; no shared-vs-JSC ratio is claimed.

Sign in to comment on this issue.