ReviewOS

zig-utils/zig-js

Threads: profile and reduce no-GIL contention

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

Parent: #1

Goal

Improve true-parallel shared-realm scaling using measured contention evidence while preserving the .gil = true fallback.

Current baseline

zig build threads-profile compares no-GIL and serialized execution across independent compute, mixed GC-cell allocation, shared properties/arrays, Atomics, locks, conditions, async grants, waits, joins, Promise microtasks, global binding churn, and Worker lifecycle/message paths. Queue front shifts and avoidable lock-held allocator growth have been removed from the main hot queues; GC cell fast paths are locked per size class.

The profiler supports exact focused rows with -Dthreads-profile-case=<name>, broad-or-focused worker-row caps with -Dthreads-profile-max-workers=N, and symbolized safety diagnosis with -Dthreads-profile-debug=true. Shared-realm rows now attribute logical contention, native wait/park time, lock versus async grants, Condition.asyncWait versus property waitAsync, condition waiter-queue growth/compaction (cqgrow/cqcomp), task-pump delivery, Worker channel operations, hidden-class shape transition pressure (shape/newsh/syld), LockedArena pressure (aacq/acnt/aspn), Environment binding-lock pressure (eacq/ecnt/espn), Object backing/property/element lock traffic (ob*/op*/oe*), and Promise/microtask queue behavior.

Promise-focused rows now split enqueue/pop/run counts, reaction versus thenable jobs, queue-lock and Promise-state lock traffic, Promise/new-capability allocation shape, and reaction-list / microtask-queue / drain-batch growth. Worker sections can be focused independently with worker messages and worker teardown filters. The global binding churn row exercises shared global lexical binding reads/writes separately from shared-object property traffic.

Recent local evidence shows repeated mutation in shared object props does not perform shape-transition work after warmup, while mixed GC cell allocation performs high cached shape-transition volume with no new child shapes and no-GIL transition-lock yields that grow with thread count. The condition queue now compacts consumed FIFO prefixes before growth and exposes that churn in the profile table. Representative guidance still documents when .gil = true remains preferable: coordination-heavy task/async-condition handoff can be faster serialized, while independent compute remains the no-GIL-friendly shape. These are local workload-shape examples, not portable thresholds.

Completed milestones

  • Add repeatable exact-scenario 1/2/4/8-thread profiling on representative hosts.
  • Add a Debug safety-check mode without changing normal ReleaseFast timing mode.
  • Attribute logical contention, park/wait time, async registrations/settlements, and task delivery by source.
  • Fix and regression-test the GC-poisoned Promise uncovered by the focused async-condition profile.
  • Preserve a complete broad-profile run across shared-realm and Worker scenarios.
  • Add direct Environment/global-binding lock attribution and a focused global binding churn row.
  • Add direct Object backing/property/element lock attribution to shared-realm profile rows.
  • Add Worker channel/message/teardown attribution.
  • Add Promise/microtask attribution, queue-local locking, queue-lock contention counters, and Promise allocation/growth-shape counters.
  • Add condition waiter-queue compaction plus cqgrow / cqcomp attribution.
  • Document workloads where .gil = true remains preferable, with representative rather than universal numbers.

Remaining work

  • Attribute any remaining property/element, promise, waiter, Worker/agent, GC, allocator/context, and task-delivery contention that still hides behind aggregate counters.
  • Optimize only bottlenecks demonstrated by profiles, beginning with async-condition/task delivery, high-thread property wait/notify, Promise microtask overhead, and shape-transition lock pressure if repeated measurements keep showing them.
  • Add regression counters or focused benchmarks for every changed hot path.
  • Record baselines on additional host/OS/core-count shapes.

Acceptance criteria

  • Changes preserve no-GIL TSan and semantic gates.
  • Each optimization includes a focused invariant test and profile evidence.
  • Shared-state lock ordering remains documented in docs/threads/bindings.md.
  • Safety-check and timing modes agree on behavior; only ReleaseFast numbers are used for performance claims.
9 days ago

Profiling follow-up observed while completing #14:

A local zig build threads-profile run completed the independent compute, mixed GC allocation, shared property/array, typed-array Atomics, property wait/notify, and property waitAsync rows, then terminated with SIGSEGV during the Condition.asyncWait matrix after printing the 1-thread and 2-thread rows.

This was outside the new mid-script-GC telemetry path and was not attributed or fixed by 3b17a78. The collector profile was therefore split into the independent zig build midgc-profile command. Reproduce/minimize this broad-profile crash before using the full contention matrix as a stable baseline; treat it as #15 evidence, not as a #14 regression claim.

9 days ago

Landed b9dd15d fix(gc): remember native synchronization edges on main.

Reproduction and root cause

The previously reported threads-profile SIGSEGV was reproduced with:

zig build threads-profile -Dthreads-profile-case='condition asyncWait'

Debug mode produced two decisive stacks:

  • runHoldJob -> promise.resolve dereferenced a GC-poisoned outer Promise.
  • With old-space full collection suppressed, createAsyncCondReacquireJob -> barrierHoldJob found that the async condition waiter's Promise had already been reclaimed.

This isolated the failure to minor collection: an old Condition wrapper stored a young Promise only in its native queue without an owner-aware generational barrier.

Fix

  • Lock, Condition, and ThreadLocal native records now retain their GC wrapper as barrier owner.
  • Queued lock jobs remember outer promises/callbacks through the Lock owner.
  • Async condition queues remember both the Promise and condition-to-lock wrapper edge.
  • ThreadLocal native-map stores use the ThreadLocal wrapper as owner.
  • Native tracing keeps lock wrappers live while jobs, waiters, or release functions still depend on their side records.
  • Added parallel_js nursery remembers native synchronization side-record edges: tenure wrappers, store young values only in native records, force minor GC, then require condition reacquire and ThreadLocal identity to survive.

Profiler tooling and evidence

  • -Dthreads-profile-case='<exact name>' runs one 1/2/4/8-thread scenario.
  • -Dthreads-profile-debug=true provides symbolized safety checks; ReleaseFast remains the timing mode.
  • One Debug and three consecutive ReleaseFast focused condition asyncWait matrices passed after the fix.
  • A complete unfiltered zig build threads-profile then passed every shared-realm and Worker row.
  • The profile still shows poor no-GIL scaling for coordination-heavy async-condition/task-delivery workloads; that is the next optimization target, not a resolved performance claim.

Verification

  • deterministic nursery regression in normal and TSan builds
  • zig build test
  • zig build threads-test — 227/227
  • zig build test -Dtsan=true -Dtest-filter=parallel_js
  • zig build threadfuzz -Dfuzz-iters=20 — 0 failures
  • zig build threadfuzz -Dfuzz-midgc=true -Dfuzz-iters=2 — 90 programs, 0 failures
  • zig build threadfuzz -Dfuzz-verify=true -Dfuzz-iters=300 — 0 failures
  • complete and focused threads-profile runs
  • bun run docs:build
  • git diff --check
9 days ago

Landed 5f06ea4 (docs(threads): document gil fallback guidance (#15)).

Focused profile evidence from the 2026-07-10 local 11-core run:

  • zig build threads-profile -Dthreads-profile-case='condition asyncWait'
    • 2 threads: no-GIL 15,423,417 ns, .gil = true 1,277,125 ns (vs gil 0.08x)
    • 8 threads: no-GIL 128,598,583 ns, .gil = true 5,951,666 ns (vs gil 0.05x)
    • Interpretation: coordination-heavy task delivery / async-condition reacquire can be better serialized today.
  • zig build threads-profile -Dthreads-profile-case='independent compute'
    • 2 threads: no-GIL 124,460,417 ns, .gil = true 319,059,917 ns (vs gil 2.56x)
    • 4 threads: no-GIL 105,714,375 ns, .gil = true 890,543,916 ns (vs gil 8.42x)
    • 8 threads: no-GIL 141,690,875 ns, .gil = true 2,356,258,375 ns (vs gil 16.63x)
    • Interpretation: independent compute remains the no-GIL-friendly workload shape.

Docs now say to treat those as representative local examples, not portable thresholds: rerun the exact focused row on the deployment host and prefer .gil = true when vs gil remains below 1.0x and task-delivery counters dominate.

Validation:

  • focused profile rows above
  • bun run docs:build
  • git diff --check
9 days ago

Landed 326e02f (bench(threads): split lock contention counters (#15)).

This is an attribution-only profiler improvement:

  • threads-profile now prints lcnt for direct contended Lock.hold attempts and aq for queued Lock.asyncHold grants, split out from the existing aggregate events column.
  • Runtime counters already existed; this exposes them in the profile table for both no-GIL and .gil = true rows.
  • Updated docs/threads/testing.md, docs/threads/production-readiness.md, and docs/threads/limits.md so the profile column guide matches the output.

Validation:

  • zig fmt bench/threads.zig
  • zig build threads-profile -Dthreads-profile-case='lock contention'
    • confirmed ng lcnt / gil lcnt populate for contended sync lock rows and aq remains 0 there, as expected.
  • bun run docs:build
  • git diff --check

This moves the first remaining #15 item forward by making existing lock-vs-async-grant attribution visible without changing runtime semantics.

9 days ago

Advanced the async-waiter attribution part of #15 in 89ca516 (bench(threads): split async waiter counters).

threads-profile now keeps the existing aggregate async/done columns but also prints:

  • caw / cad: Condition.asyncWait registrations / completed reacquires
  • paw / pad: property Atomics.waitAsync registrations / settled tickets

That makes the existing internal counters visible in the table instead of hiding condition regrant pressure and property timeout-settlement pressure behind a combined async total.

Focused validation, batched:

  • zig build threads-profile -Dthreads-profile-case='condition asyncWait'
  • zig build threads-profile -Dthreads-profile-case='property waitAsync timeout'
  • bun run docs:build
  • git diff --check

Evidence from the focused rows:

  • condition asyncWait fills caw/cad and leaves paw/pad at zero (for example, 8-thread no-GIL: caw=1050, cad=1050, paw=0, pad=0).
  • property waitAsync timeout fills paw/pad and leaves caw/cad at zero (for example, 8-thread no-GIL: paw=720, pad=720, caw=0, cad=0).

This does not claim an optimization yet; it makes the next async-condition/task-delivery and property-waitAsync changes measurable by source.

9 days ago

Advanced the Worker/agent attribution part of #15 in 6c9dfef (bench(threads): expose worker channel counters).

What changed:

  • Extended the internal opt-in jsthread.ContentionStats profiler counters with Worker channel push, pop, empty-pop, and close counts.
  • Instrumented Worker inbox/outbox channel operations without changing normal Worker semantics.
  • threads-profile Worker message rows now print push, pop, and empty receive null counts alongside timing.
  • Worker teardown rows now print self ops, host ops, and term ops totals, where ops are channel push + pop + empty-pop + close events for that teardown mode.
  • Added exact threads-profile filters for worker messages and worker teardown, so Worker attribution can be checked without running the full shared-realm matrix.
  • Updated the thread docs column guide.

Validation, batched:

  • zig build test -Dtest-filter="jsthread contention stats reset and snapshot"
  • zig build threads-profile -Dthreads-profile-case="worker messages"
  • zig build threads-profile -Dthreads-profile-case="worker teardown"
  • bun run docs:build
  • git diff --check

Focused evidence from the local worker messages run:

  • script rows: push/pop exactly matched 2 * workers * 160 round-trip channel operations.
  • empty receive rows: null exactly matched workers * 3000 polls.

This is still attribution, not an optimization claim. It makes Worker queue/message/teardown pressure visible before changing those hot paths.

9 days ago

Advanced the property/object attribution part of #15 in 644632e (bench(threads): expose shape transition counters).

What changed:

  • Added opt-in shape transition counters: total transition requests, cache hits, cache misses/new child shapes, and transition-lock yields.
  • Exposed the shape module through js.shape so local profiling tools can snapshot those counters without adding import cycles between shape.zig, promise.zig, and jsthread.zig.
  • threads-profile shared-realm rows now print shape, newsh, and syld for both no-GIL and .gil = true rows.
  • Updated the thread docs column guide.

Validation, batched:

  • zig build test -Dtest-filter="shape transition stats reset and snapshot"
  • zig build threads-profile -Dthreads-profile-case="shared object props"
  • zig build threads-profile -Dthreads-profile-case="mixed GC cell allocation"
  • bun run docs:build
  • git diff --check

Focused evidence:

  • shared object props showed shape=0/newsh=0/syld=0 after warmup, so repeated mutation of existing properties is not hidden-class churn.
  • mixed GC cell allocation showed cached shape transition volume (shape=90,000 * thread-count), newsh=0 after warmup, and no-GIL transition-lock yields growing with thread count (for example, 8-thread no-GIL syld=5488 in this local run).

This is attribution only. It identifies shape-transition lock pressure as measurable in allocation-heavy rows before we decide whether optimizing that path is worth the complexity.

9 days ago

Promise/microtask attribution slice landed on main as 5631d8d (profile(threads): attribute promise microtasks).

What changed:

  • added src/promise_profile.zig with atomic counters for microtask enqueue/pop/run plus reaction-vs-thenable job split;
  • instrumented promise.zig enqueue/pop/run paths while keeping counters disabled unless profiling explicitly enables them;
  • added a focused zig build threads-profile -Dthreads-profile-case='promise microtasks' filter instead of widening the default table;
  • documented the new profile hook in README/P7 notes.

Focused local validation passed: zig build threads-profile -Dthreads-profile-case='promise microtasks'. The run produced exact expected counts (for 8 threads: 36,864 enqueue/pop/run, split 24,576 reaction jobs and 12,288 thenable jobs). It also exposed a concrete optimization lead: no-GIL Promise microtasks are much slower than .gil = true despite zero existing contention events, so the next #15 step should profile/reduce the microtask queue / promise job path itself rather than guessing at unrelated locks.

Fresh CI for this push is pending: https://github.com/zig-utils/zig-js/actions/runs/29114200905.

9 days ago

Promise/microtask contention reduction landed on main as caad91e (perf(threads): use per-queue microtask locks).

The focused promise microtasks profile showed the no-GIL path doing exact expected job counts but paying a large cost with zero existing Thread contention events. One concrete choke point was that every interpreter pointed at a realm-wide microtask lock even though spawned Threads allocate independent microtask queues.

What changed:

  • MicrotaskQueue now owns its own lock;
  • no-GIL interpreters lock the current target queue instead of a context-wide lock;
  • retiring thread-local queues transfer pending jobs into the realm queue while locking both source and target queues in address order;
  • P7 notes now document the queue-local invariant.

Focused local validation: zig build threads-profile -Dthreads-profile-case='promise microtasks' passed after the change with the expected enqueue/pop/run counts preserved. This is a partial #15 reduction, not a claim that Promise microtasks are solved; the profile still shows more no-GIL overhead to chase next.

Fresh CI is queued: https://github.com/zig-utils/zig-js/actions/runs/29114710319.

9 days ago

Follow-up Promise/microtask attribution landed on main as 0c5041d (profile(threads): count microtask queue lock traffic).

What changed:

  • promise_profile now records microtask queue lock acquisitions and yield-backed lock contention;
  • MicrotaskQueue.acquire increments those counters only while profiling is enabled;
  • the focused promise microtasks profile prints qlock/qyld columns for no-GIL and .gil = true.

Local validation:

  • zig build test -Dtest-filter="promise profile stats reset and snapshot"
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'

Local evidence after the per-queue lock change: exact enqueue/pop/run counts stayed stable, no-GIL records queue lock acquisitions but qyld=0 for the focused workload, and .gil = true records zero queue lock traffic. So this profile now separates actual lock contention from unavoidable no-GIL queue-lock traffic; the next Promise optimization should target lock-traffic/critical-section frequency rather than assuming contended queue locks.

Fresh CI for this push is pending: https://github.com/zig-utils/zig-js/actions/runs/29115153555. The prior caad91e run had already turned the previously failing tsan-threadfuzz-lifecycle leg green before this follow-up push.

9 days ago

Promise/microtask optimization follow-up landed on main as aa697df (perf(threads): batch microtask queue drains).

Why this was the next logical #15 step: the new qlock/qyld counters showed the focused workload had queue lock traffic but no yield-backed contention after the per-queue lock change. So the bottleneck to reduce was lock frequency / per-job synchronization overhead, not contended waiting.

What changed:

  • drainMicrotasks now atomically moves the current pending burst out of the target queue under one queue lock, then runs that burst unlocked;
  • jobs enqueued by that burst (or by peers) stay in the queue for the next burst, preserving per-queue FIFO;
  • the active dequeued batch is traced as an interpreter GC root, just like current_microtask, so callbacks/GC cannot collect jobs that have been removed from the queue but not run yet;
  • promise_profile has a bulk pop counter so focused profile counts remain exact.

Local validation:

  • zig build test -Dtest-filter="promise profile stats reset and snapshot"
  • zig build test -Dtest-filter="memory model: spawned thread drains its FIFO microtasks before completion"
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'

Focused local evidence: enqueue/pop/run counts remained exact; qlock dropped roughly from ~74k to ~37k at 8 threads with qyld=0, confirming the change reduced queue lock frequency while preserving behavior. Fresh CI is pending: https://github.com/zig-utils/zig-js/actions/runs/29115438190.

9 days ago

Profiler/docs chunk pushed in zig-js@e2dfea1 (profile(threads): compare promise gil gc mode):

  • The focused promise microtasks profile now includes a gil+gc mode/column, so we can separate serialized fallback cost with GC-managed cells from no-GIL queue-lock/scheduling overhead.
  • Docs now describe the queue-local MicrotaskQueue.lock, batch dequeue into interpreter-local active roots, and the updated Promise reaction-list settlement model.
  • Local validation already run before commit:
    • zig build threads-profile -Dthreads-profile-case="promise microtasks"
    • bun run docs:build
    • stale wording scan for old Context.microtask_lock / lock-gate text.
9 days ago

Profiler measurement fix pushed in c90f856 (profile(threads): separate promise timing counters).

The focused promise microtasks row now times a warmed uninstrumented pass, then runs the same warmed scenario again with counters enabled for attribution. That keeps global atomic profiler counters from masquerading as no-GIL runtime contention in the ns columns while preserving the exact enq/pop/run, qlock/qyld, and reaction/thenable split columns.

Focused validation:

  • zig fmt bench/threads.zig
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'
  • bun run docs:build
  • git diff --check

Fresh local signal from the uninstrumented timing pass: no-GIL Promise microtasks remain materially slower than .gil = true even with qyld=0 (8-thread row: no-GIL 3,855,973,375 ns, gil 77,574,833 ns, qlock=37,416, qyld=0). So the next runtime optimization should target per-job/thread scheduling and queue-lock frequency/critical sections, not contended queue waiting or profiler artifacts.

9 days ago

Promise attribution follow-up pushed in 79f33a5 (profile(threads): count promise state locks).

The focused promise microtasks profile now splits queue-lock traffic (qlock/qyld) from per-Promise state-lock traffic (plock/pyld). This fills in the missing #15 attribution for Promise contention before changing the runtime path.

Focused validation:

  • zig fmt bench/threads.zig src/promise.zig src/promise_profile.zig
  • zig build test -Dtest-filter='promise profile stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'
  • bun run docs:build
  • git diff --check

Local evidence from the focused row: the Promise workload shows high state-lock frequency but no observed state-lock yielding (8-thread no-GIL: plock=196,608, pyld=0; queue side qlock=37,416, qyld=0). That points the next optimization at reducing uncontended Promise lock frequency / adding a safe local-owner fast path, not at contended Promise lock waiting.

9 days ago

Arena attribution follow-up pushed in 875356e (profile(threads): expose arena lock contention).

The focused promise microtasks profile now also reports LockedArena traffic:

  • aacq: acquisitions
  • acnt: acquisitions that had to spin at least once
  • aspn: total failed spin attempts before acquisition

Focused validation:

  • zig fmt bench/threads.zig src/context.zig src/jsthread.zig
  • zig build test -Dtest-filter='jsthread contention stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'
  • bun run docs:build
  • git diff --check

Fresh local evidence: the Promise workload has real no-GIL arena contention while the .gil = true counted pass has zero arena contention. 8-thread row: no-GIL aacq=505,284, acnt=16,301, aspn=1,808,971; GIL aacq=1,795,530, acnt=0, aspn=0. That points the next runtime work toward reducing arena-backed allocation in the Promise/Thread microtask path or moving hot per-thread transient allocations off the shared LockedArena, before designing more invasive Promise-state lock shortcuts.

9 days ago

Promise runtime optimization pushed in 058df35 (perf(promise): settle intrinsic reactions directly).

Rooted in the profiler evidence from the previous chunks: the focused Promise workload was spending real no-GIL time in LockedArena contention while creating many intrinsic .then reaction capabilities. Intrinsic .then reactions now store the result *Promise directly in the reaction record and settle it directly when the reaction runs. Custom species/capability paths still use the existing resolve/reject function path, and GC tracing/barriers now mark direct result promises from pending reactions/microtasks.

Focused validation:

  • zig build test -Dtest-filter='Promise' --summary none
  • zig build test -Dtsan=true -Dtest-filter='Promise' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'
  • bun run docs:build
  • git diff --check

Local focused profile signal versus the prior arena-attribution run:

  • 1-thread no-GIL: ~394ms → ~287ms
  • 8-thread no-GIL: ~3.80s → ~2.57s
  • 8-thread no-GIL arena acquisitions: ~505k → ~432k
  • reaction/thenable job counts stayed exact (enq/pop/run=36,864, rxn=24,576, thn=12,288 at 8 threads).

This does not solve all Promise no-GIL overhead, but it removes one measured source of shared-arena pressure without spec-surface changes.

9 days ago

Small Promise follow-up pushed in 441b2c4 (perf(promise): reuse direct result reactions).

This exposes the direct intrinsic result-reaction helper from the prior chunk and reuses it at two internal native-Promise sites:

  • promiseResolveAfterTick
  • module namespace completion forwarding

Both sites target engine-owned native Promise cells, so they no longer allocate native resolve/reject capability closures just to forward an internal result. Custom species/external capability paths still use performThen with callable resolve/reject functions.

Focused validation:

  • zig build test -Dtest-filter='Promise' --summary none
  • zig build test -Dtest-filter='module' --summary none
  • zig build test -Dtsan=true -Dtest-filter='Promise' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise microtasks' (counts stayed exact; this row does not materially exercise the two internal sites)
  • git diff --check

I’m treating this as reuse of the already-measured direct-reaction machinery, not a new standalone performance claim.

9 days ago

Pushed another measured #15 Promise-path optimization in zig-js@1c34c92 (perf(promise): fast path intrinsic resolve reject).

What changed:

  • Promise.resolve with the intrinsic %Promise% constructor now uses the native intrinsic PromiseResolve path directly instead of allocating a full NewPromiseCapability executor/capture plus resolve/reject closures only to call resolve immediately.
  • Promise.reject with intrinsic %Promise% now directly creates/rejects a native Promise for the same reason.
  • Subclass/custom constructors still go through NewPromiseCapability; added a regression proving SubPromise.resolve / SubPromise.reject still invoke the subclass constructor and return subclass instances.

Focused profile evidence (zig build threads-profile -Dthreads-profile-case=promise microtasks):

  • Before this chunk, the 8-thread no-GIL row was 6,894,938,375 ns, aacq=431,556, acnt=9,745, aspn=558,822, with exact enq/pop/run=36,864, rxn=24,576, thn=12,288.
  • After this chunk, the 8-thread no-GIL row is 3,956,258,583 ns, aacq=308,676, acnt=9,313, aspn=99,231, with the same exact enq/pop/run=36,864, rxn=24,576, thn=12,288.
  • 1-thread no-GIL also moved from about 316,854,792 ns / aacq=54,060 to 180,968,542 ns / aacq=38,700.

This confirms the change reduces measured arena-backed allocation pressure without changing microtask accounting. No-GIL Promise microtasks remain much slower than .gil = true, so #15 stays open for further profiling/optimization.

Validation:

  • zig fmt src/context.zig src/interpreter.zig
  • zig build test -Dtest-filter=Promise.resolve/reject intrinsic fast path preserves subclass capability --summary none
  • zig build test -Dtest-filter=Promise --summary none
  • zig build test -Dtsan=true -Dtest-filter=Promise --summary none
  • zig build threads-profile -Dthreads-profile-case=promise microtasks
  • git diff --check
9 days ago

Profiler attribution follow-up pushed in c45a758 (profile(threads): count promise resolving pairs).

What changed:

  • promise_profile now counts transient Promise resolving-function pairs (rpair) and NewPromiseCapability executor creations (cap).
  • The focused promise microtasks profile prints those columns for no-GIL and .gil = true counted passes.
  • The P7 no-GIL notes now document the new Promise allocation-attribution columns.

Focused validation:

  • zig fmt bench/threads.zig src/interpreter.zig src/promise.zig src/promise_profile.zig
  • zig build test -Dtest-filter='promise profile stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise microtasks'
  • bun run docs:build
  • git diff --check

Local profile evidence from the 8-thread row:

threads=8 no-gil ns=10210524500 gil ns=49653000 gil+gc ns=1272398458
ng enq/pop/run=36864/36864/36864 qlock/qyld=37416/0 plock/pyld=196608/0
aacq/acnt/aspn=308676/27702/2463475 rxn/thn=24576/12288 rpair/cap=12288/0

The important new signal is that rpair exactly matches thn, and cap stays zero. So after the intrinsic Promise.resolve/reject and direct-reaction work, this focused workload's remaining Promise-specific transient allocation is the thenable-assimilation resolving pair, not NewPromiseCapability. That gives the next optimization target without speculating from arena contention alone.

9 days ago

Profiler split pushed in 41c7761 (profile(threads): split promise thenable row).

What changed:

  • Added focused threads-profile filters for promise reactions and promise thenables alongside the existing mixed promise microtasks row.
  • The split rows use the same warmed timing + separate counted attribution pass, so timing stays free of profiler atomic noise.
  • README and P7 notes now mention the additional focused Promise filters.

Focused validation:

  • zig fmt bench/threads.zig
  • zig build test -Dtest-filter='promise profile stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • bun run docs:build
  • git diff --check

Local isolated thenable evidence, 8-thread row:

threads=8 no-gil ns=22480424250 gil ns=71049791 gil+gc ns=2203611667
ng enq/pop/run=49152/49152/49152 qlock/qyld=49704/0 plock/pyld=221184/0
aacq/acnt/aspn=443844/38836/1219228 rxn/thn=24576/24576 rpair/cap=24576/0

This isolates the previous mixed-row signal: the thenable-assimilation half alone creates one resolving-function pair per thenable job (rpair == thn) and no NewPromiseCapability executors (cap=0). So the next runtime optimization should focus on reducing or pooling the spec-observable resolving-pair machinery safely, not on capability construction or queue/promise lock contention.

9 days ago

Landed another Promise contention slice in e8f887d (perf(promise): embed resolving pair state).

What changed:

  • Removed the separate arena-allocated promise.Resolving record for native Promise resolve/reject pairs.
  • The resolve function object now owns the shared [[AlreadyResolved]] bit directly; the paired reject function points at that resolve function through private_data.
  • new Promise(executor) now reuses the same native resolving-function implementation as thenable assimilation, which trims duplicate closure code and preserves the shared-state semantics.
  • Added a GC regression that keeps thenable-provided resolving functions alive across gc(), settles through the saved resolve, and verifies late reject/resolve calls are ignored.

Validation:

  • zig build test -Dtest-filter='enable_gc: Promise thenable resolving functions keep shared state' --summary none
  • zig build test -Dtest-filter='Promise' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • git diff --check

Profile signal, 8-thread promise thenables row:

  • before this slice (41c7761): ng aacq=443844, ng rpair=24576
  • after this slice (e8f887d): ng aacq=419268, ng rpair=24576

So the logical resolving pair count is unchanged, but the no-GIL LockedArena acquisition count drops by exactly one allocation per resolving pair (24576). CI is pending at https://github.com/zig-utils/zig-js/actions/runs/29134907796.

9 days ago

Landed profiler attribution for the remaining Promise allocation pressure in 6238805 (profile(threads): attribute promise allocation growth).

What changed:

  • Added Promise-profile counters for pnew (Promise creations), rgr (reaction-list capacity growth), qgr (microtask-queue capacity growth), and bgr (drain-batch capacity growth).
  • Printed those columns in the focused promise microtasks / promise reactions / promise thenables profile table.
  • Documented the new abbreviations in docs/threads/P7-gil-removal.md.

Validation:

  • zig build test -Dtest-filter='promise profile stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • bun run docs:build
  • git diff --check

8-thread promise thenables attribution from the new row:

  • ng aacq=419268
  • ng pnew=49152
  • ng rgr=49152
  • ng qgr=240
  • ng bgr=48
  • ng rpair=24576, ng cap=0

So the remaining arena-acquisition pressure is not mostly queue-buffer growth; the next measured target should be Promise creation / reaction-list growth churn. CI is pending at https://github.com/zig-utils/zig-js/actions/runs/29135074581.

9 days ago

Landed 78913b0 (perf(promise): inline first reactions).

What changed:

  • A pending Promise now stores the first fulfill reaction and first reject reaction inline in the Promise cell.
  • Overflow ArrayListUnmanaged(Reaction) buffers are allocated only after a second reaction is registered on the same side.
  • GC tracing/finalization now accounts for both inline and overflow reactions.

Validation:

  • zig build test -Dtest-filter='Promise reactions keep first entry inline' --summary none
  • zig build test -Dtest-filter='Promise' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • git diff --check

8-thread promise thenables profile signal:

  • before (6238805): ng rgr=49152, gil rgr=49152, ng aacq=419268, gil aacq=1254858
  • after (78913b0): ng rgr=0, gil rgr=0, ng aacq=419268, gil aacq=1205706

So this removes the common one-reaction-per-side backing allocation and improves the serialized/GIL allocator count by exactly that amount. It does not move the no-GIL aacq counter on this benchmark, so the remaining no-GIL work should continue from the pnew=49152 / object-creation side rather than claiming this as the no-GIL contention fix. CI is pending at https://github.com/zig-utils/zig-js/actions/runs/29135229184.

9 days ago

Landed 1231247 (profile(threads): split promise allocation sources).

What changed:

  • Added counters/columns for pcell (Promise state-cell allocations), pobj (Promise wrapper objects), and rfn (native resolving-function objects).
  • Kept pnew as the logical Promise creation count, so the profile can now separate logical Promise creation from the actual object/state allocations it drives.
  • Updated the thread-profile docs for the new columns.

Validation:

  • zig build test -Dtest-filter='promise profile stats' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • bun run docs:build
  • git diff --check

8-thread promise thenables row now splits the remaining object-creation side like this:

  • ng aacq=419268
  • ng pnew=49152
  • ng pcell=49152
  • ng pobj=49152
  • ng rfn=49152
  • ng rgr=0, ng qgr=240, ng bgr=48

This confirms the next meaningful no-GIL allocation cut is the Promise object/state/resolving-function layout itself, not hidden queue/list buffer growth. CI is pending at https://github.com/zig-utils/zig-js/actions/runs/29135403995.

9 days ago

Landed e6f4ec7 (perf(threads): chunk locked arena allocations).

What changed:

  • Context.LockedArena now serves small arena allocations from per-thread 64 KiB bump chunks.
  • Refilling a chunk still takes the arena lock; allocations within the current thread's chunk are lock-free.
  • Large, highly aligned, resize, remap, and non-local free paths keep the serialized fallback.
  • Added cleanup for thread-local arena cache ownership on context/test teardown and create-failure cleanup for the wrapper allocation.
  • Extended the existing concurrent LockedArena test so it proves both correctness and that lock acquisitions stay far below one lock per allocation.
  • Updated docs to clarify that aacq now tracks chunk refills/fallback operations rather than every tiny arena allocation.

Validation:

  • zig build test -Dtest-filter='LockedArena: concurrent allocation from many threads is race-free' --summary none
  • zig build threads-profile -Dthreads-profile-case='promise thenables'
  • zig build test -Dtest-filter='parallel_js' --summary none
  • bun run docs:build
  • git diff --check

8-thread promise thenables profile signal:

  • before (1231247): ng aacq=419268, ng pnew=49152, ng pcell=49152, ng pobj=49152, ng rfn=49152
  • after (e6f4ec7): ng aacq=50407, same logical Promise allocation/source counts (pnew/pcell/pobj/rfn unchanged)

So the workload still creates the same JS-visible Promise/thenable machinery, but the thread-safe arena takes roughly 8.3x fewer locked acquisitions on the 8-thread counted pass. CI is pending at https://github.com/zig-utils/zig-js/actions/runs/29135639848.

9 days ago

Implemented a focused profiler attribution chunk in 13028b9.

What changed:

  • threads-profile shared-realm scenario rows now include LockedArena counters for both no-GIL and serialized/GIL runs:
    • aacq: arena lock acquisitions
    • acnt: contended arena lock acquisitions
    • aspn: failed spin attempts while trying to acquire an arena lock
  • Updated the thread testing and production-readiness docs so allocation-heavy rows can separate allocator pressure from hidden-class, lock/waiter, and task-pump pressure.

Focused validation:

  • zig fmt bench/threads.zig
  • zig build threads-profile -Dthreads-profile-case='mixed GC cell allocation'
  • bun run docs:build
  • git diff --check

Evidence from the focused 8-thread mixed GC cell allocation row after the change:

  • no-GIL: ng shape=360000, ng syld=5091, ng aacq=123, ng acnt=1, ng aspn=502
  • serialized/GIL: gil shape=360000, gil syld=0, gil aacq=123, gil acnt=0, gil aspn=0

That means this row now distinguishes shape-transition contention from the allocator path directly: most of the observed no-GIL contention remains shape-transition lock yielding, while arena locking is visible but only lightly contended in this run. Keeping the issue open for the next actual contention-reduction pass.

9 days ago

Landed a measured shape-transition contention reduction in 3eb36f6 (perf(shape): avoid locks on cached transitions).

Root cause from the previous mixed GC cell allocation attribution: the row had high cached shape-transition volume with newsh=0, so threads were mostly contending on already-created transition hits rather than creating new child shapes or contending on the arena.

What changed:

  • Shape.transition now checks an append-only immutable child list before taking the parent shape's transition lock.
  • New child shapes are still created and inserted only under transition_lock, then release-published onto the child list after full initialization.
  • Cached transition hits traverse the published list with acquire ordering and return without touching the locked hash map.
  • The memory-model docs now record the shape publication rule explicitly.

Focused validation:

  • zig fmt src/shape.zig
  • zig build test -Dtest-filter='shape transitions' --summary none
  • zig build test -Dtsan=true -Dtest-filter='shape transitions' --summary none
  • zig build threads-profile -Dthreads-profile-case='mixed GC cell allocation'
  • bun run docs:build
  • git diff --check

Focused local evidence:

  • Before the change (13028b9): 8-thread no-GIL ng shape=360000, ng newsh=0, ng syld=5091.
  • After the change (3eb36f6): 8-thread no-GIL ng shape=360000, ng newsh=0, ng syld=0.

The same row still shows light arena activity (ng aacq=123, ng acnt=1, ng aspn=412 in this run), but the measured shape-transition lock yielding is eliminated for warmed cached hits. Keeping #15 open for other measured hot paths.

9 days ago

Landed 7ca07f4 (perf(promise): create settled intrinsic promises).

What changed:

  • Added a native already-settled Promise allocation helper for intrinsic paths that have no user-observable resolver or thenable work.
  • Promise.resolve(primitive) now creates an already-fulfilled intrinsic Promise directly.
  • Intrinsic Promise.reject(reason) now creates an already-rejected intrinsic Promise directly.
  • Object/thenable resolution still goes through the general PromiseResolve path, and subclass/custom constructors still use NewPromiseCapability, preserving the existing species behavior.
  • P7 notes now document this as a Promise-path fast path rather than a broad benchmark claim.

Validation:

  • zig build test -Dtest-filter="Promise.resolve/reject intrinsic fast path preserves subclass capability" --summary none
  • zig build test -Dtest-filter="Promise" --summary none
  • zig build threads-profile -Dthreads-profile-case="promise reactions"
  • bun run docs:build
  • git diff --check

Focused profile counts remained exact for the promise reactions row (thn=0, rpair=0, cap=0, expected pnew/pcell/pobj counts), which confirms this trims the general settlement machinery without changing the observable Promise allocation/job shape.

9 days ago

Landed 21e1cf8 (profile(threads): add parked condition async row).

This adds a focused threads-profile control row for the remaining async-condition/task-delivery #15 work:

  • kept the original condition asyncWait row as the scheduler-pressure stress case, where the notifier busy-spins on the shared ready property;
  • added condition asyncWait parked, which keeps the same single-lock Condition.asyncWait shape but parks the notifier with property Atomics.wait;
  • updated README/P7/testing/production-readiness docs so future profile comparisons do not confuse spin-loop interference with condition reacquire delivery.

Focused local validation:

  • zig fmt bench/threads.zig
  • zig build threads-profile -Dthreads-profile-case="condition asyncWait parked"
  • bun run docs:build
  • git diff --check

Local signal from the parked row: removing the notifier busy-spin helps the 2-thread no-GIL case (~6.9 ms parked versus ~13.3 ms from the immediately preceding busy-spin run), but 8-thread no-GIL remains much slower than .gil = true (~140 ms versus ~4.9 ms) with exact async-condition counts (caw=cad=1050). So #15 remains a real runtime/scheduling optimization target; this row makes the next change easier to attribute cleanly.

9 days ago

Landed a small attribution slice in c4aef430 (threads: profile environment binding locks).

What changed:

  • Added internal jsthread.ContentionStats counters for Environment binding-table lock acquisitions, contended acquisitions, and failed spin attempts.
  • Instrumented the centralized Environment.lockBindings() path, so global/environment binding pressure is visible without changing the public API.
  • Extended shared-realm threads-profile rows with eacq / ecnt / espn columns next to the existing aacq / acnt / aspn arena columns.
  • Updated testing/production-readiness docs to explain the new columns.

Validation:

  • zig build test -Dtest-filter="jsthread contention stats" --summary all
  • compile-only threads-profile check via zig build-exe -OReleaseFast ... bench/threads.zig ... -fno-emit-bin

I intentionally stopped the full threads-profile -Dthreads-profile-case="independent compute" runtime smoke after it ran past the small-slice budget; the compile-only check caught the format/table class of regression without spending the full benchmark runtime. This advances the remaining #15 attribution item for environment/global binding contention; optimization should still wait for profile evidence from real rows.

9 days ago

Landed 36bc26f8 (threads: add global binding profile row) as a follow-up to the new Environment binding-lock counters.

What changed:

  • Added a focused threads-profile scenario named global binding churn.
  • The row mutates and reads one shared global lexical binding by bare identifier, so it exercises Environment binding-table lock traffic separately from shared-object property traffic.
  • Updated the thread testing, limits, and production-readiness docs to list the new row.

Validation:

  • zig build-exe -OReleaseFast --dep js -Mroot=bench/threads.zig --dep regex --dep gc -Mjs=src/root.zig -Mregex=../zig-regex/src/root.zig -Mgc=../zig-gc/src/root.zig -lc -fno-emit-bin
  • git diff --check

I tried the exact runtime row via zig build threads-profile -Dthreads-profile-case="global binding churn", but stopped it after it stayed silent past the small-slice budget. I shrank the row to 384 iterations / one round and kept compile-only validation for this commit; real timing should be collected deliberately with the profiler rather than as part of every small push.

9 days ago

Landed 3768adb8 (threads: cap focused profile worker rows) to make the new profile attribution row operationally cheap.

What changed:

  • Added -Dthreads-profile-max-workers=N, used only when -Dthreads-profile-case=... is set.
  • The profiler now accepts the optional worker cap as a second CLI arg, filters the normal 1/2/4/8 row set, and prints the active cap.
  • Added the capped global binding churn example to README and docs/threads/testing.md.
  • Shrunk the global binding churn row to 64 iterations / one round so it remains an attribution row, not a stress test.

Validation:

  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="global binding churn" -Dthreads-profile-max-workers=1
    • This completed and emitted eacq values for both no-GIL and .gil rows (3749 in the local Debug smoke), proving the row is exercising Environment binding-lock attribution.
  • git diff --check

I intentionally stopped the ReleaseFast profile attempt after it spent too long compiling for a small slice. I also found and terminated an unrelated stale 47-minute focused threads-test process that was still running in the workspace, to keep local validation from quietly turning into a soak.

9 days ago

Follow-up landed in 46245541 (threads: allow capped broad profiles).

What changed:

  • -Dthreads-profile-max-workers=N now works for both exact-case and broad profile invocations.
  • When the cap is supplied without -Dthreads-profile-case, the build runner passes an empty filter and the profiler treats that as no filter, then applies the worker-count cap to every shared-realm row.
  • Updated docs/threads/testing.md to make the broad-or-focused cap semantics explicit.

Validation:

  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="global binding churn" -Dthreads-profile-max-workers=1
  • zig build-exe -ODebug --dep js -Mroot=bench/threads.zig --dep regex --dep gc -Mjs=src/root.zig -Mregex=../zig-regex/src/root.zig -Mgc=../zig-gc/src/root.zig -lc -fno-emit-bin
  • git diff --check

The Debug smoke completed in ~15s and still emitted nonzero Environment binding-lock attribution (eacq), so this keeps the profiling path useful without making small validation runs expensive.

9 days ago

Landed b4b4a45c (threads: profile object storage locks) as another #15 attribution slice.

What changed:

  • Added opt-in Object lock counters for backing-store, property, and element lock acquisitions / contended acquisitions / failed spin attempts.
  • Kept the counters in a tiny object_profile module so value.zig can instrument central Object lock helpers without importing jsthread.zig and creating an import cycle.
  • threads-profile shared-realm rows now print obacq/obcnt/obspn, opacq/opcnt/opspn, and oeacq/oecnt/oespn for both no-GIL and .gil = true runs.
  • Updated the thread profile docs so arena, Environment binding, hidden-class shape, Object storage, and waiter pressure can be separated before optimizing.

Validation:

  • zig build test -Dtest-filter="jsthread contention stats reset and snapshot" --summary all
  • Debug compile-only threads-profile check with zig build-exe -ODebug ... bench/threads.zig ... -fno-emit-bin
  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="shared object props" -Dthreads-profile-max-workers=1
  • git diff --check

Focused smoke signal from the 1-worker shared object props row: no-GIL reported opacq=1056304, oeacq=40, and zero Object-lock contention/spins, which is the expected non-contended baseline. This makes future shared-property/shared-array contention reductions measurable instead of guessing at the storage locks.

9 days ago

Follow-up full CI gate for b4b4a45c (threads: profile object storage locks) completed green: https://github.com/zig-utils/zig-js/actions/runs/29183833290

That run includes the full matrix with all no-GIL corpus TSan shards: 31 success, 1 skipped.

9 days ago

Landed cfa0dd8b (profile(threads): list focused contention rows) as a #15 profiling ergonomics / attribution slice.

What changed:

  • threads-profile now prints every exact shared-realm scenario filter directly from the benchmark scenario table at startup.
  • Aggregate filters (worker messages, worker teardown, and the focused Promise rows) are listed separately.
  • README and thread docs now include the condition asyncWait multi-lock command alongside the busy-spin and parked single-lock async-condition controls.
  • The docs explain that the multi-lock row splits the same condition delivery through several lock groups, making FIFO-bursted task enqueue and run-loop grant delivery visible through the hold/cjob split without rediscovering the exact scenario name from source.

Why this matters for #15:

  • The multi-lock async-condition row already existed and is one of the controls for the remaining async-condition/task-delivery optimization work, but it was hidden from the quick command lists and profiler startup help.
  • Keeping the focused rows discoverable makes future profile-before/after optimization passes less error-prone and avoids stale manual filter lists as rows are added.

Focused validation:

  • /Users/chris/.local/bin/zig fmt bench/threads.zig
  • /Users/chris/.local/bin/zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="condition asyncWait multi-lock" -Dthreads-profile-max-workers=2
    • Startup output now lists condition asyncWait multi-lock from the scenario table.
    • The 2-worker row completed with exact async-condition attribution (caw=120, cad=120) and separate task delivery counts (hold/cjob).
  • /Users/chris/.bun/bin/bun run docs:build
  • git diff --check

CI pending: https://github.com/zig-utils/zig-js/actions/runs/29196111238

9 days ago

Follow-up: push CI for cfa0dd8b completed green.

Run: https://github.com/zig-utils/zig-js/actions/runs/29196111238 Result: 31 success, 1 expected skip, 0 failures.

This confirms the focused contention-row discoverability slice under the full push matrix, including no-GIL TSan corpus shards and threadfuzz/midgc/lifecycle legs.

9 days ago

Landed a small condition-queue contention slice in 3535fe3a (threads: compact condition queue head).

What changed:

  • CondRecord.appendWaiter now compacts the consumed FIFO head only when an append would otherwise grow the backing array and the consumed prefix is large enough to amortize the copy.
  • This keeps the existing cursor-based notify hot path, but prevents steady notify/re-wait churn from dragging a large dead prefix into future appends.
  • Added a regression test proving the queue reuses the consumed head without increasing capacity.

Validation:

  • zig build test -Dtest-filter="condition queue" --summary all
  • zig build test -Dtest-filter="condition asyncWait" --summary all
  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="condition asyncWait multi-lock" -Dthreads-profile-max-workers=2 --summary all
  • git diff --check

CI for the pushed commit is queued: https://github.com/zig-utils/zig-js/actions/runs/29200029340

9 days ago

CI note: the initial run for 3535fe3a was cancelled by the follow-up main push, so the relevant downstream gate for this condition-queue change is now the newer run for 045ff0e7, which includes 3535fe3a in its history: https://github.com/zig-utils/zig-js/actions/runs/29200182099

9 days ago

CI routing note: the 045ff0e7 run was also cancelled by the follow-up b8136312 push. The live main-branch gate that includes the condition-queue commit (3535fe3a) is now: https://github.com/zig-utils/zig-js/actions/runs/29200325235

9 days ago

CI routing note: the previous live gate for the condition-queue chunk (run 29200325235) was cancelled by the follow-up main push. The current main-branch gate that includes 3535fe3a and the later history is now: https://github.com/zig-utils/zig-js/actions/runs/29200500880

9 days ago

CI routing note: the previous current gate was superseded by the follow-up #36 push. The current main-branch gate that includes the condition-queue chunk and later history is now: https://github.com/zig-utils/zig-js/actions/runs/29200654888

9 days ago

CI routing note: the previous current gate was superseded by the follow-up #36 iterator-helper push. The current main-branch gate that includes the condition-queue chunk and later history is now: https://github.com/zig-utils/zig-js/actions/runs/29200732250

9 days ago

CI routing note: the previous current gate was superseded by the follow-up #36 async-generator push. The current main-branch gate that includes the condition-queue chunk and later history is now: https://github.com/zig-utils/zig-js/actions/runs/29200819725

9 days ago

CI routing note: the previous current gate was superseded by the follow-up #36 generator-handler push. The current main-branch gate that includes the condition-queue chunk and later history is now: https://github.com/zig-utils/zig-js/actions/runs/29200885804

9 days ago

CI routing note: the previous current gate was superseded by the follow-up mid-GC telemetry push. The current main-branch gate that includes the condition-queue chunk and later history is now: https://github.com/zig-utils/zig-js/actions/runs/29201026809

9 days ago

Pushed a small #15 attribution slice in 03092fb0 (profile(condition): count waiter queue churn).

What changed:

  • Added internal contention counters for condition waiter-queue backing growth (cqgrow) and consumed-head compaction (cqcomp).
  • Printed those counters in zig build threads-profile beside caw/cad, empty/jobs, and hold/cjob, so notify-heavy / Condition.asyncWait rows can separate allocation pressure from amortized FIFO churn.
  • Tightened the direct condition queue compaction unit witness to assert the new counters: growth happened before the queue filled, compaction increments once, and the compaction append does not require a new growth.
  • Updated thread testing and production-readiness docs with the new columns.

Focused validation:

  • zig build test -Dtest-filter="condition queue compacts consumed head before growing" --summary all
  • zig build test -Dtest-filter="jsthread contention stats reset and snapshot" --summary all
  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="condition asyncWait multi-lock" -Dthreads-profile-max-workers=1 --summary all
  • bun run docs:build
  • git diff --check

CI: https://github.com/zig-utils/zig-js/actions/runs/29201263212 is pending for 03092fb0. This does not optimize the queue further by itself; it makes the existing condition queue amortization measurable in the #15 profile table so the next optimization choice can be evidence-based.

9 days ago

Routing note: the CI run linked for 03092fb0 was cancelled by the newer #13 workflow-support push. The #15 condition-profiler code remains on main; use current combined-head CI for 36c0159a: https://github.com/zig-utils/zig-js/actions/runs/29201371564 (queued at the time of this note).

9 days ago

Routing note: the combined-head CI run for 36c0159a was cancelled by the newer #16 nursery-profile push. The #15 condition-profiler counters remain on main; use current combined-head CI for b79f9774: https://github.com/zig-utils/zig-js/actions/runs/29201561685 (queued at the time of this note).

9 days ago

Routing note: current combined-head CI moved with the #12 memory-model docs push. The #15 condition-profiler counters remain on main; use current combined-head CI for d0092f5e: https://github.com/zig-utils/zig-js/actions/runs/29201642565 (queued at the time of this note).

9 days ago

Green gate note for d0092f5e: https://github.com/zig-utils/zig-js/actions/runs/29201642565

The condition waiter-queue compaction/counter attribution and broader profiling updates are covered by the successful combined gate. This keeps the next optimization work tied to measured profile evidence rather than speculative tuning.

9 days ago

Implemented another small #15 contention slice in b09fd1e (shape: bypass transition wait on published cache).

What changed:

  • Shape.transition now re-checks the append-only transition cache while waiting for transition_lock.
  • If another thread wins the race and publishes the same child shape, waiters return the cached transition immediately instead of continuing through the lock just to discover the existing child in the hashmap.
  • Added a focused regression test that holds the transition lock and verifies the wait path observes a published cache entry.

Validation run locally:

  • /Users/chris/.local/bin/zig build test -Dtest-filter="shape transition" --summary all → 5/5 passed.
  • /Users/chris/.local/bin/zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="mixed GC cell allocation" -Dthreads-profile-max-workers=2 --summary all → succeeded; capped row reported ng syld/gil syld as 0 for 1- and 2-worker rows.
  • git diff --check → clean.

CI is queued on main at https://github.com/zig-utils/zig-js/actions/runs/29212985607; I will watch it before taking the next issue slice.

9 days ago

CI follow-up for b09fd1e: the main CI run is now green after rerunning the only failed job.

Final status: https://github.com/zig-utils/zig-js/actions/runs/29212985607 → 31 successful jobs, 1 skipped, 0 failed.

Note: the initial run had a transient threadfuzz-midgc failure in the known midgc termination-reaction did not finish a parallel collection area. Local targeted reproduction passed (THREADFUZZ_SEED_TIMEOUT_MS=300000 zig build threadfuzz -Dfuzz-midgc=true -Dfuzz-iters=1 -Dfuzz-seed=8 --summary all → 45 programs, 0 failures), and the GitHub failed-job rerun passed.

9 days ago

Landed a small #15 optimization slice in 9d8d54ef (perf(object): combine literal property locks).

What changed:

  • Added Object.defineLiteralDataOwn, which performs the object-literal accessor delete/probe and data-property write under one property_lock acquisition.
  • Routed both tree-walker and VM object-literal data initialization through that helper.
  • Added a focused counter regression: ordinary data-only object literals now add one property-lock acquisition per data property over the empty-literal baseline, rather than the old delete-accessor probe + set-own pair.

Why this slice:

  • After b09fd1e5, the focused mixed GC cell allocation profile showed shape transition-lock yields stayed at 0 through 4 workers, so the next visible pressure was object property locking from literal-heavy allocation.
  • This keeps the lock load-bearing for concurrent GC tracing; it only avoids dropping/re-taking it between two operations that already need the same lock.

Focused validation:

  • zig build test -Dtest-filter="object literal data properties take one property lock each" --summary all
  • zig build test -Dtest-filter="object literal" --summary all
  • zig build test -Dtest-filter="interpreter object" --summary all
  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="mixed GC cell allocation" -Dthreads-profile-max-workers=4 --summary all
  • git diff --check

Profile evidence from the local focused row:

  • Before this slice, mixed GC cell allocation reported ng opacq of 240120 / 480186 / 960318 for 1/2/4 workers.
  • After this slice, the same focused row reported ng opacq of 210120 / 420186 / 840318 for 1/2/4 workers.
  • That is exactly 30,000 fewer property-lock acquisitions per worker for this workload, while syld remained 0.

Fresh CI is queued on main for the new head; I’ll track it separately from the prior e132c6fe run.

9 days ago

Landed another measured #15 allocator-contention slice in 7e0afd2e (perf(arena): skip local chunk lock probes).

What changed:

  • LockedArena now records the current thread-local bump chunk start/end.
  • resize, remap, and free first reject/no-op memory from the current local chunk without taking the global arena lock.
  • Older local chunks and non-local arena allocations still use the existing locked local_chunks ownership scan, so this only removes lock traffic for the current thread's current bump chunk.
  • Added a focused counter test proving current-local resize / remap / free do not increment arena_lock_* counters.

Why:

  • The focused promise microtasks profile had qyld=0 and pyld=0, so queue and Promise-state locks were not the bottleneck. The visible contention was LockedArena (aacq/acnt/aspn), mostly from allocator API calls around thread-local bump allocations.

Focused validation:

  • zig build test -Dtest-filter="LockedArena" --summary all
  • zig build threads-profile -Dthreads-profile-debug=true -Dthreads-profile-case="promise microtasks" -Dthreads-profile-max-workers=4 --summary all
  • git diff --check

Profile evidence from the local focused row:

  • Before: 4-worker no-GIL promise microtasks reported ng aacq=19014, ng acnt=2466, ng aspn=465644.
  • After: 4-worker no-GIL promise microtasks reported ng aacq=504, ng acnt=0, ng aspn=0.
  • 1-worker ng aacq similarly dropped from 4722 to 90; 2-worker from 9504 to 246.

Timing remains noisy, so the claim here is deliberately the contention-counter reduction, not a portable wall-clock speedup.

9 days ago

CI routing note: the 7e0afd2e run for the recent no-GIL contention chunks was clean at 24 success / 1 skipped / 0 failures before being superseded by the follow-up mid-GC generation hardening push.

The current combined-head gate that includes the arena-lock and object-literal contention reductions is now: https://github.com/zig-utils/zig-js/actions/runs/29216356732

9 days ago

Closing this measured-contention tracker as completed.\n\nThe delivered profiling surface covers exact 1/2/4/8-thread rows, Debug safety mode, shared properties/elements/environments, shapes, GC/allocators, Lock/Condition and property waiters, tasks, Promise/microtask queues, Worker messaging/teardown, park/wait time, and queue growth/compaction. The issue history records focused evidence and invariant tests for each optimization, including queue-local microtask locks, published shape-transition cache bypass, combined object-literal property locking, current-local LockedArena lock elision, condition FIFO compaction, and the related allocation/queue attribution.\n\nThe supported decision is also documented: independent compute is the no-GIL-friendly shape; coordination-heavy async-condition/task-delivery workloads may remain faster with .gil = true, and embedders should measure the exact row on their deployment host rather than rely on a universal threshold.\n\nAcceptance is met: each changed hot path has focused counters/tests and profile evidence, lock ordering is documented, Debug and ReleaseFast modes agree semantically, and the full no-GIL/TSan/semantic matrix is green: https://github.com/zig-utils/zig-js/actions/runs/29232877388.\n\nAdditional hosts and future contention discovered as the engine evolves are ongoing benchmarking, not unfinished scope. A new measured bottleneck should open a narrow issue with before/after counters rather than keeping this umbrella permanently open.

Sign in to comment on this issue.