ReviewOS

zig-utils/zig-js

Perf: split rare object state to reach the 256-byte GC slab

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

Parent: #62 Related: #97, #44, #86, #87, #119

Evidence

An exact-row profile on 5100a966 sampled warmed eight-context object_churn for 20 seconds. The seven-sample screen measured exact checksum 8309369344; zig-js was 335.523 ms median versus JSC 184.239 ms. One-context zig-js remained faster (109.035 vs 115.561 ms), so the deficit was scaling under concurrent private-heap memory pressure rather than a shared runtime lock.

The profiler attributed the active worker stacks primarily to young sweeping/collection and 512-byte ordinary-object allocation/reuse. Object was 448 bytes and the zig-gc header brought each managed allocation to 496 bytes, forcing the 512-byte slab. The benchmark literal used three inline values while most of the cell was default-initialized exotic/callable/typed-array/Temporal state.

The completed refactor moved rare state behind cold sidecars, reduced ordinary objects to the 256-byte GC slab, and enlarged object chunks without exceeding the fixed reuse bitmap. The accepted order-balanced run at zig-js 0a74f7d1, published by 6c71f46f, preserved all 1,540 raw samples and beat JSC in every direct, warmed-eight, and cold-eight workload. Object-churn medians were 80.380 vs 117.343 ms direct, 170.179 vs 187.303 ms at eight warmed contexts, and 177.256 vs 189.788 ms across cold eight-context lifecycles.

Scope completed

  • Split rare callable, primitive, proxy, buffer/view, weak/finalization, module, iterator-helper, Temporal, and other exotic state behind cold typed storage while keeping ordinary shape/inline-slot/prototype and dense-array access fast.
  • Kept the ordinary managed Object plus zig-gc header within the 256-byte slab class and added a compile-time/layout regression test.
  • Routed cold storage through context/GC ownership and finalizer accounting; traced moved managed edges and preserved no-GIL synchronization.
  • Preserved prototypes, property/accessor semantics, callbacks/constructors, arrays, proxies, weak collections, typed arrays, Temporal, OOM rollback, GC tracing, and exact benchmark checksums.
  • Landed the work as reviewable, compiling slices before the batched full normal/TSan/publication gates.

Acceptance

  • Managed ordinary objects use the 256-byte GC slab with a guarded layout test.
  • Beat JSC in direct and 1/2/4/8 independent object_churn rows on an order-balanced exact-parent A/B.
  • Materially improve shared object_churn without regressing the other nine benchmark workloads.
  • Pass focused object/exotic/GC/OOM/no-GIL tests, then one batched full normal and TSan gate.
  • Publish clean report/TSV evidence and refresh README/docs with the latest scores and linked commits.

Accepted report: https://github.com/zig-utils/zig-js/blob/main/docs/.data/benchmark-comparison-2026-07-15.md

Raw samples: https://github.com/zig-utils/zig-js/blob/main/docs/.data/benchmark-comparison-2026-07-15.tsv

Final full normal/TSan verification: https://github.com/zig-utils/zig-js/actions/runs/29466730465

9 days ago

Layout audit follow-up: Object is exactly 448 bytes (align(16)), while zig-gc adds a 48-byte aligned header, yielding a 496-byte allocation in the 512-byte slab. A viable 256-byte target exists without shrinking the four inline property values: retaining allocator/backing ownership, the cold pointer, three locks, shape + slots + 4 inline values, prototype, accessor/private-brand/key-order metadata, dense elements, attrs, extensibility/indexed-array state, array length, restriction owner, and holes totals about 195 bytes and aligns to 208; 208 + 48 = 256 exactly. The moved state should therefore be the primitive/callable/error/proxy/buffer-view/weak/module/iterator/Temporal/private-data families. The existing cold payloads are mutually exclusive enough to use typed/category unions rather than one oversized sidecar, avoiding a cold-lifecycle regression for function objects.

9 days ago

First seven bisectable layout slices are now on main:

  • e88232b moves the mutually exclusive Symbol/BigInt payload cold;
  • 1f09a03 moves error instance/constructor names cold;
  • c2cb1d0 moves Date, module-namespace, and WeakRef scalar state cold;
  • b2df6ad moves C-ABI host callback state cold;
  • aad4556 moves boxed primitive-wrapper payloads cold;
  • 6c2790d moves generator and iterator-helper links cold;
  • 338d209 moves bound-function links cold;
  • 6268a1a unions those disjoint rare categories so an exotic object pays only for its active payload.

Measured layout progress: Object is 304 bytes, down from 448 (32.1% smaller). The zig-gc header therefore yields 352 bytes today; the 208-byte payload / 256-byte slab target still requires another 96 bytes. The cold sidecar briefly grew as fields moved, then the tagged union reduced ObjectColdState from 312 to 208 bytes.

Focused gates are green with zero leaks across Symbol/BigInt, Error, Date, WeakRef/GC, module namespace, C-API callbacks, primitive wrappers, generators/concurrent GC, iterator helpers, and bind behavior. No benchmark claim yet: 304+48 still occupies the 512-byte slab, so publication remains gated on reaching 208 bytes and completing the batched semantic/TSan gates.

9 days ago

Layout milestone reached on main (small commits eca9cda9 through 2788b0d3):

  • Object: 448 -> 208 bytes (53.6% smaller).
  • Current zig-gc cellAllocationBytes(Object): 240 bytes, so ordinary objects now use the 256-byte slab instead of 512.
  • ObjectColdState: 176 bytes; its GC allocation also remains below 256 bytes.
  • Added permanent gc.test.Object and cold sidecar fit the 256-byte GC slab coverage.
  • Added private-brand GC lifecycle coverage and corrected its backing-store accounting.
  • Focused Proxy, ArrayBuffer, TypedArray, DataView, structured-clone, Temporal, Promise, sparse-array, instanceof, private-brand, function, and RegExp groups pass with zero failures/leaks.

I have not published a performance claim yet. The batched full normal/TSan gates and fresh zig-js single-/multi-thread vs JSC comparison are next; README/report numbers will only change from measured post-layout results.

9 days ago

The layout milestone is now fully benchmarked and published on main.

Runtime and correctness commits after the 208-byte layout:

  • 2788b0d3: permanent 256-byte slab guard;
  • e5b6c476: recalibrate GC pressure for the smaller object allocation;
  • 7dc00259: synchronize lazy cold-sidecar publication/tracing after the batched full TSan run found the WeakMap marker race;
  • 1d1f7810: update conformance artifacts to the cold error metadata accessors;
  • c1f519c8: use 256 KiB chunks for the current 256-byte object bucket and 384 KiB for adjacent/larger object buckets;
  • 0a74f7d1: refresh stale source comments before the clean measurement;
  • 6c71f46f: publish the final report, all 1,540 raw samples, README scorecard, and benchmark guide.

Final July 15 object-churn medians (lower is better):

  • direct: 80.380 ms zig-js vs 117.343 ms JSC (1.46x zig-js throughput);
  • warmed independent 1/2/4/8: 80.296 / 83.120 / 113.644 / 170.179 ms vs JSC 118.308 / 122.179 / 127.398 / 187.303 ms;
  • cold independent 1/2/4/8: 82.903 / 87.084 / 106.125 / 177.256 ms vs JSC 119.362 / 123.081 / 128.605 / 189.788 ms;
  • shared 1/2/4/8: 123.180 / 246.548 / 463.608 / 1,922.828 ms, with exact checksums.

zig-js therefore wins every direct and independent object-churn row. It also wins all 10 workloads at the eight-lane steady and cold endpoints. Shared eight-lane wall time is lower than the preceding published 2,374.329 ms, but its 0.51x mode-local scaling remains open under #97; no duplicate follow-up is needed.

Validation: focused object/exotic/GC/OOM/no-GIL groups are clean; the exact runtime revision passed 772/772 normal tests with zero failures/leaks; the full TSan batch exposed the cold-sidecar race, and the corrected exact reproducer passed 2/2 under TSan with no sanitizer report. The final main CI run is still in progress, so I am leaving this issue open until that batched remote gate confirms the complete pushed stack.

Report: https://github.com/zig-utils/zig-js/blob/main/docs/.data/benchmark-comparison-2026-07-15.md

Raw samples: https://github.com/zig-utils/zig-js/blob/main/docs/.data/benchmark-comparison-2026-07-15.tsv

9 days ago

Replacement CI at 5cd13df confirmed the #122 OOM rollback fix (unit shard 1 passed) and all ordinary unit/fuzz gates, but exposed one new rare-sidecar publication root cause across four TSan jobs. Tracked as #123 and fixed in c982953; both focused TSan reproducers and the exact Date CVE corpus case are clean locally. c982953 also picks up zig-gc decff85 (#26), so its fresh main run is now the combined final gate.

9 days ago

Correctness follow-up #124 is now on main as d35841a. The authoritative combined full-main gate is CI run 29462319298; the prior run was superseded by this small publication fix. Published benchmark scores remain unchanged because the README matrix was measured at the documented runtime/zig-gc revisions, and I will only replace it after a complete fresh comparison matrix.

9 days ago

The final Date atomic-lowering follow-up #126 is now on main as 4bad7a7. Replacement full-main CI is run 29463400611. README/report scores remain the latest complete measured matrix; the rejected #125 and zig-gc#27 prototypes were fully restored and therefore do not warrant a benchmark republication.

9 days ago

Completed across the #120 commit series, ending with the accepted benchmark publication in 6c71f46f.

  • Managed ordinary objects now use the guarded 256-byte GC slab (208-byte payload, 240 raw bytes including allocator metadata).
  • The exact, order-balanced comparison beats JSC in direct and every 1/2/4/8 independent object_churn row.
  • The published run retained all ten workload wins in direct, warmed-eight, and cold-eight panels; shared object churn materially improved while preserving exact checksums.
  • Focused object/exotic/GC/OOM/no-GIL coverage passed, followed by the complete normal and TSan CI gate in run 29466730465.
  • The report, all 1,540 raw samples, README, and benchmark documentation are published and linked from the README.

Later exploratory publication-batching work improved shared eight-lane object churn further, but its full matrix had a noisy cold-two outlier and missed the separate shared-scaling contract at two/four lanes. It was correctly rejected as a README replacement and remains tracked under #97.

All acceptance criteria for the rare-object-state/slab objective are complete.

Sign in to comment on this issue.