You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(runtime): promise resolution with an object pays a 78.5% thenable-probe tax (~9% of asyncpipe) #7910
Resolving a promise with an object costs +78.5% instructions versus resolving with a number, on identical topology — identical promise, microtask and closure counts. The only differing counter is thenable_probe: 0 vs 24 000.
The cost is the spec-mandated Get(resolution, "then") assimilation check. Per probe it pays: a setjmp frame, re-interning the "then" key string, the generic dynamic getter's preamble, and a name-keyed miss walk into Object.prototype — roughly 72 000 times, answering undefined every time.
The safe version needs an Object.prototype mutation-generation counter covering assignment, defineProperty, delete, and setPrototypeOf. Getting that wrong makes a genuine thenable stop being assimilated, and the failure mode is a hang rather than a wrong value — which is exactly the kind of thing that must not be landed against a deadline where each validation cycle is ~40 minutes.
Acceptance
A fast path for the overwhelmingly common case (plain object, no then anywhere on the chain) guarded by a generation counter that is invalidated by every mutation route into Object.prototype.
A test that mutates Object.prototype.thenafter the fast path has been taken and proves assimilation resumes.
Negative test: a real thenable still assimilates.
Evidence
gc-handoff/ASYNC2-NOTES.md, with counters and file:line pointers.
Tested: an active combinator_catch_js/setjmp region does not suppress the copying minor
Asked to check whether a setjmp frame makes gc_safepoint_moving_minor return
early, and if so which guard. Three lines, all pointing the same way.
1. The guard list.gc_safepoint_moving_minor (gc/policy.rs:2564) has
exactly four entry guards: GC_FLAG_IN_ALLOC | GC_FLAG_SUPPRESSED, gc_blocked_by_unsafe_zone() (the GC_UNSAFE_ZONES atomic), GC_ROOT_LOCK_DEPTH != 0, and gc_budgeted_cycle_active(). combinator_catch_js is js_try_push() + setjmp + js_try_end(), and try_push_with_kind writes only into the thread's exception state (handler kind,
shadow-stack savepoint, runtime-handle savepoint, call-method depth,
prototype-resolution depth). It sets no GC flag, enters no unsafe FFI zone and
takes no root-registry lock. There is no path from a try frame to any of the four.
It also cannot cost the copying minor via a conservative scan: conservative_stack_scan_decision_for (gc/roots/scan_mode.rs:199) is a function
of the mode alone and ignores its _shadow_frame_active argument entirely.
2. The real program. Every copying-minor attempt on asyncpipe, both sizes,
stock main: [gc-copy-minor] eligible=true fallback=none preflight_skipped=true.
No conservative_stack, no pinned_young_root, no fallback of any kind. The one
suppressor that does fire is #7909's, and it is counted: safepoints_blocked_by_budgeted=4@120, 0@240.
★ Honest caveat: fallback= only prints for a safepoint that reached the
eligibility evaluation; one rejected at the four entry guards printed nothing, and
only budgeted had a counter. PR #7931 closes that hole — [gc-incremental]
now also prints safepoints_blocked(in_alloc=… unsafe_zone=… root_lock=…), so
this is answerable from a run instead of from an argument about the guard list.
3. The baseline does not reproduce. On ebefba51a, asyncpipe@120 runs 1 copying minor, deterministically (5/5 with TRACE+DIAG, 3/3 with DIAG only) —
not 0. And @121 runs 2 (#7926). A reported "0 → 2" is fully explained by a
base predating #7924 plus the 1→2 step sitting between 120 and 121, a 0.8 %
allocation window. No new suppressor is required, and the boundary explanation is
measured rather than hypothesised.
What this does not overturn: "asyncpipe runs zero GC cycles" was true when #7909 measured it (c4b2c1c8e) and is false now. Conclusions of the form
"asyncpipe is GC-free, so GC levers are irrelevant there" do need re-checking
against a current build — but because main moved, not because the earlier
measurement was wrong.
Activity
docs(changelog): key the #7910 fragment to PR 7927
perf(runtime): answer the promise-assimilation `then` probe without a…
proggeramlug commentedon Aug 12, 2026
Tested: an active
combinator_catch_js/setjmpregion does not suppress the copying minorAsked to check whether a
setjmpframe makesgc_safepoint_moving_minorreturnearly, and if so which guard. Three lines, all pointing the same way.
1. The guard list.
gc_safepoint_moving_minor(gc/policy.rs:2564) hasexactly four entry guards:
GC_FLAG_IN_ALLOC | GC_FLAG_SUPPRESSED,gc_blocked_by_unsafe_zone()(theGC_UNSAFE_ZONESatomic),GC_ROOT_LOCK_DEPTH != 0, andgc_budgeted_cycle_active().combinator_catch_jsisjs_try_push()+setjmp+js_try_end(), andtry_push_with_kindwrites only into the thread's exception state (handler kind,shadow-stack savepoint, runtime-handle savepoint, call-method depth,
prototype-resolution depth). It sets no GC flag, enters no unsafe FFI zone and
takes no root-registry lock. There is no path from a
tryframe to any of the four.It also cannot cost the copying minor via a conservative scan:
conservative_stack_scan_decision_for(gc/roots/scan_mode.rs:199) is a functionof the mode alone and ignores its
_shadow_frame_activeargument entirely.2. The real program. Every copying-minor attempt on
asyncpipe, both sizes,stock main:
[gc-copy-minor] eligible=true fallback=none preflight_skipped=true.No
conservative_stack, nopinned_young_root, no fallback of any kind. The onesuppressor that does fire is #7909's, and it is counted:
safepoints_blocked_by_budgeted=4@120, 0 @240.★ Honest caveat:
fallback=only prints for a safepoint that reached theeligibility evaluation; one rejected at the four entry guards printed nothing, and
only
budgetedhad a counter. PR #7931 closes that hole —[gc-incremental]now also prints
safepoints_blocked(in_alloc=… unsafe_zone=… root_lock=…), sothis is answerable from a run instead of from an argument about the guard list.
3. The baseline does not reproduce. On
ebefba51a,asyncpipe@120 runs1 copying minor, deterministically (5/5 with TRACE+DIAG, 3/3 with DIAG only) —
not 0. And @121 runs 2 (#7926). A reported "0 → 2" is fully explained by a
base predating #7924 plus the 1→2 step sitting between 120 and 121, a 0.8 %
allocation window. No new suppressor is required, and the boundary explanation is
measured rather than hypothesised.
What this does not overturn: "
asyncpiperuns zero GC cycles" was true when#7909 measured it (
c4b2c1c8e) and is false now. Conclusions of the form"asyncpipe is GC-free, so GC levers are irrelevant there" do need re-checking
against a current build — but because main moved, not because the earlier
measurement was wrong.
Write-up:
gc-handoff/ROOTS-NOTES.md§6.perf(json): memoise the JSON.stringify toJSON probe per shape (PerryT…
perf(json): memoise the JSON.stringify toJSON probe per shape (PerryT…
perf(json): memoise the JSON.stringify toJSON probe per shape (#10696)
perf(json): memoise the JSON.stringify toJSON probe per shape (#10696)
perf(runtime): give an INHERITED property read an inline-cache hit
perf(runtime): give an INHERITED property read an inline-cache hit