Skip to content

perf(runtime): promise resolution with an object pays a 78.5% thenable-probe tax (~9% of asyncpipe) #7910

Closed

Description

@proggeramlug

Summary

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.

~9% of asyncpipe.

Why it was not fixed in #7906

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.then after 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.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 12, 2026
  2. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Write-up: gc-handoff/ROOTS-NOTES.md §6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions