ReviewOS

zig-utils/zig-js

Promise: bound memory growth in the eight-lane async microtask benchmark

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

Problem

The precise-root fixes in #510 and #511 make the representative async/microtask row correct under moving GC, but the publication-scale workload still has excessive memory and wall-time growth.

The post-#511 ReleaseFast runner completes the exact bounded command:

bench-comparison-zig-js independent_steady representative_async_microtasks 6000 7 8

All seven samples return checksum 91956096. Elapsed times are:

1.655669375 s
2.517117958 s
2.587441667 s
2.296234250 s
3.072370166 s
2.956848541 s
2.759787917 s

The seven-sample process reaches 2,787,491,840 bytes maximum RSS with zero swaps. A diagnostic publication-scale run at 60000 jobs, seven samples, and eight independent lanes remained active in young collection/marking after more than an hour and reached roughly 20.5 GB physical footprint before it was interrupted. Worker samples showed active GC and Promise work rather than a deadlock.

The exact parent cannot provide a timing baseline because this row hits the stale-root crashes fixed by #510 and #511.

Required outcome

  • Characterize retained/live allocations per sample using the existing Promise allocation profile and GC evidence.
  • Remove unnecessary Promise, closure, reaction, activation, or queue retention in the engine; do not reduce the benchmark job count, sample count, or lane count.
  • Preserve PromiseJobs FIFO ordering, thenable assimilation, checksums, moving GC, and JIT behavior.
  • Make the full representative_async_microtasks publication row complete within the representative benchmark workflow without pathological memory growth.
  • Add focused regression coverage for the ownership/lifetime defect and report bounded/full-scale memory and wall-time evidence.
  • Run Promise-focused tests, TSan coverage for the affected lifetime path, the full unit suite, and the exact benchmark row.

Relationships

Blocks #460.

Follow-up to #511 and #510.

Part of #474 and #459.

9 days ago

First production slice landed on main as 6b49bf45.

Root cause addressed: every Promise wrapper allocated ObjectStorageState plus ObjectColdState for one state-cell pointer. The wrapper now keeps that edge in a tagged hot word and spills it losslessly only if a C-API host attaches private data. Both hot and spilled forms are traced/relocated; the spill installs the correct active GC realm.

Exact eight-lane 6,000-job, seven-sample ReleaseFast evidence:

  • checksum remains 91956096 in all samples
  • median wall: 2.587441667 s before to 2.303243500 s after, an 11.0% reduction
  • maximum RSS: 2,787,491,840 to 2,752,856,064 bytes
  • full unit suite: 1,534 passed, one expected skip, zero failures/leaks
  • C-API coexistence passes normally and under TSan; 20/20 Promise tests and structural relocation pass

The new optional post-warmup profile reports one measured eight-lane sample creates 576,000 Promise state cells, 576,000 wrappers, 96,000 resolving-function objects, and 336,000 jobs. It also exposes 9,748,601 uncontended Promise-state lock acquisitions with zero yields.

This issue remains open. The full 60,000-job row still needs a bounded completion and memory result; the next slice should eliminate or structurally reduce the measured lock/state overhead without weakening concurrent-GC or no-GIL synchronization.

9 days ago

Landed perf(promise): elide serialized state mutexes (#512) on main.

The representative eight-lane profile showed 9,748,601 Promise-state mutex acquisitions with zero yields in one bounded sample. Promise cells now copy an immutable context policy: single-mutator and GIL-serialized contexts skip the mutex, while concurrent marking and parallel-mutator contexts retain it. Both paths still enter the trace-sensitive allocator-recovery guard, so recovery GC cannot observe half-mutated Promise state.

Measured at 6,000 jobs × 7 samples × 8 lanes:

  • median elapsed: 2.303243500s → 2.205518875s (4.2% faster)
  • maximum RSS: 2,752,856,064 → 2,327,691,264 bytes (15.4% lower)
  • checksum: 91956096 → 91956096
  • serialized Promise mutex acquisitions: 9,748,601 → 0 in the one-sample profile

Validation: focused Promise suite 21/21; the parallel Promise settlement stress test passes under ThreadSanitizer; full unit suite 1,535 passed, 1 skipped, 0 failed/leaked. Test262 was not rerun because this is synchronization-policy-only and does not change JavaScript semantics.

This is a real bounded-scale improvement, but it does not close #512: the publication-scale 60,000-job burst still needs bounded-completion/RSS evidence.

9 days ago

Landed perf(gc): amortize nursery scans over job roots (#512) on main.

A stack sample showed that precise safepoints were forcing nursery collection at a fixed 2 MiB cadence while the explicit Promise-job root frontier was hundreds of MiB. The collector now amortizes heuristic nursery work against that frontier. Remembered-set failure still forces immediate fail-closed collection, and lower explicit nursery thresholds remain authoritative when no queued-job frontier exists.

Evidence:

  • bounded 6,000 jobs × 7 samples × 8 lanes: median 2.205518875s → 1.540351500s (30.2% faster), checksum remains 91956096
  • publication-scale 60,000 jobs × 1 sample × 8 lanes: measured workload 422.708234500s → 36.310953209s (91.4%, 11.6×), real time 443.47s → 50.58s, checksum remains 920578560
  • that single sample reduced peak physical footprint 11,704,903,592 → 11,086,341,408 bytes (5.3%), while maximum RSS rose 4,964,106,240 → 5,302,812,672 bytes (6.8%)
  • full unit suite: 1,536 passed, 1 skipped, 0 failed/leaked; moving-nursery and remembered-set fail-closed regressions are covered

The exact 60,000 jobs × 7 samples × 8 lanes acceptance run still fails the issue's cross-sample requirement. After 1,248.43s it remained CPU-active, had reached 19.2 GB current/peak physical footprint, and a two-second sample showed every active lane dominated by collectYoungInternal tracing. I interrupted that diagnostic rather than claim completion. This is far faster than the prior hour-long noncompletion, but the progressive tenured/collector pressure remains pathological.

#512 stays open. The next slice will target cross-sample reclamation of promoted Promise/job graphs; #460 remains blocked until the exact seven-sample row completes with bounded memory.

9 days ago

Landed perf(gc): reclaim quiescent promotion debt (#512) on main, completing this issue.

The remaining cross-sample growth was promoted Promise/job garbage, not live queue state. A full collection during the burst saw the entire graph live and inflated the old-generation threshold; after the burst became unreachable, that transient threshold prevented another full sweep. Shared GcState now records the promoted total and live baseline at a quiescent full sweep. At the next host boundary it requests a full sweep when promotion debt reaches that trusted live baseline, even when the transient old threshold has not yet been reached. Explicit and quiescent compaction refresh the markers, and sibling realms use the same heap-level decision.

The exact ReleaseFast acceptance row now completes all seven samples at 60,000 jobs × 8 independent lanes:

49.304156125 s
42.738223667 s
50.622881625 s
26.409281125 s
12.020120208 s
11.698269417 s
14.347988875 s
  • median measured workload time: 26.409281125 s
  • total process real time: 217.76 s
  • checksum: 920578560 in all seven samples
  • maximum RSS: 8,001,585,152 bytes
  • peak physical footprint: 18,089,916,888 bytes

The final bounded 6,000-job row has median 0.980488667 s, maximum RSS 2,005,286,912 bytes, and peak physical footprint 1,333,791,264 bytes. Relative to the immediately preceding collector slice, that is 36.3% faster with maximum RSS down 21.9% and peak footprint down 47.8%; all seven checksums remain 91956096.

A focused regression promotes a protected linked graph, drops the only root, disables ordinary old-threshold collection, and proves the quiescent promotion-debt sweep reclaims it. The full unit inventory passes: 1,537 passed, 1 expected skip, 0 failures/leaks. Test262 was not rerun because this changes collector scheduling only and has no JavaScript semantic or corpus expectation change.

This removes the full-timing blocker from #460 while preserving PromiseJobs FIFO ordering, exact checksums, moving GC, and heap-level coordination.

Sign in to comment on this issue.