Skip to content

perf(codegen): #8084's callee rooting costs pipeline 3.9%, consuming #8157's win on that row #8159

Description

@proggeramlug

Summary

pipeline is the only corpus row that got slower across 2026-08-15's twenty merges: +0.28% instructions on main @ d6d7d0efe versus 83b6b8c69. Every other row is flat or faster, several enormously.

The number is small but it is 2× the measured noise floor (±0.15%, established by compiling main twice into separate cache/out dirs and confirming all 22 binaries byte-identical), and the run ranges do not overlap: base 3.7881–3.7936 G, main 3.8015–3.8053 G. Peak RSS is unchanged.

It is a give-back, not a shortfall

Building the intervening merge points attributes it to a single step:

arm pipeline instructions Δ
83b6b8c69 (session start) 3.7881 G —
8b1b4b909 (#8157 merge) 3.6547 G −3.52%
53d63aad2 (#8084 merge) 3.8003 G +3.95%
d6d7d0efe (today's main) 3.7988 G −0.00%

#8157 delivered its full claimed −3.52% here. The next step gave all of it back and a little more.

Every other row moves by ≤0.03% across that same at8157 → at8084 step, so this is one localized change, not drift.

Attribution

The window is {#8158, #8084}. #8158 is a 7-line addition to BUILD_CACHE_ENV_VARS and cannot change emitted code, which leaves #8084 — specifically fix(codegen): root the callee across argument evaluation in three call arms, touching new_dynamic.rs, call_spread.rs and early_branches.rs. pipeline is spread- and call-heavy, so the mechanism fits: each rooted callee adds an open_rooted_group / adopt / reread / release sequence around a call site.

This is not a request to revert

#8084 fixes a real moving-GC rooting bug — a callee held in a bare register across argument evaluation is a pre-move address, and the dependency-scale corpus found 16 sites of that shape. Correctness wins over 3.9% on one row, and #8084 should stay.

Filing it because nobody wrote the cost down. A correctness fix that silently consumes an entire optimisation's win on a row is exactly the thing that later gets rediscovered as an unexplained regression, and #8157's pipeline win currently looks like it never happened.

Worth looking at

The rooting sequence is emitted per call site. Whether it can be narrowed — e.g. skipping the reread when nothing between adopt and use can allocate, which is a property the tree already computes elsewhere for loop polls (loop_purity::loop_may_allocate) — is the obvious question. That would keep the soundness property while paying for it only where a collection is actually reachable.

Measurement protocol: /usr/bin/time -l, min-of-5, arms interleaved round-robin with order reshuffled each round, per-arm PERRY_RUNTIME_DIR and PERRY_CACHE_DIR, PERRY_NO_AUTO_OPTIMIZE=1, all arms' libperry_runtime.a cmp-verified to differ, stdout sha256 identical across arms.

Refs #8084, #8157.

Activity

  1. proggeramlug commented on Aug 16, 2026

    @proggeramlug
    ContributorAuthor

    Narrowed it (PR #8240) — and the narrowing recovers none of the 3.9%, because pipeline never reaches the arms #8084 touched.

    The attribution to the hunk is falsified

    pipeline's inner loop is rec = stage(rec), where stage is a Stage-typed local. That does not lower through try_lower_closure_typed_local_call. It lowers through lower_dynamic_closure_call (lower_call/console_promise.rs) — the #7154 lowering, which has rooted its callee and re-read its arguments below the unbox since long before #8084.

    Three independent confirmations, all on 07c8040bf:

    1. The emitted IR for gc-handoff/apps/pipeline.ts is byte-identical across a codegen change to exactly those three arms. Not "within noise" — cmp returns 0.
    2. Both js_closure_call1 sites in that IR are preceded by call i64 @js_closure_unbox_callee_checked(...), which only lower_dynamic_closure_call emits.
    3. js_new_function_construct and js_closure_call_apply_with_spread appear in the module only as declare lines. There is no call to either.

    So the window {#8158, #8084} is right and the commit is right; fix(codegen): root the callee across argument evaluation in three call arms is not the mechanism. It could not have been — that hunk emits zero instructions into this program.

    That also means the suggestion in the issue ("skipping the reread when nothing between adopt and use can allocate") would not have helped even if it worked perfectly. It does work — PR #8240 implements it, sabotage-checked from both sides — and it moves pipeline by +0.02%, interp −0.04%, iso_miss −0.05%, asyncpipe +0.11%, shapes −0.18%. Same protocol as this issue's: identical runtime archives on both arms, per-arm cache dirs, PERRY_NO_AUTO_OPTIMIZE=1, min-of-5, stdout sha-identical. Where it does move something is zod, the population that actually has these shapes: dep-native live bundles 36611 → 36598.

    Where the 3.9% probably is

    #8084 is ~2,500 lines beyond that hunk, and most of it is GC runtime that a program allocating ~1.4M objects exercises constantly: gc_map.rs (+355), gc/roots/stack_maps.rs + _decode + _sections (~1,660 changed), gc/copying.rs + copying_pointer_set.rs, pin.rs (+209), tenuring.rs (+41), object/native_call_method.rs, object/this_binding.rs.

    Sharpest suspect, stated as a hypothesis I have not measured: js_implicit_this_set. It is pipeline's hottest FFI function — the IR has 4 call sites to it against 2 closure-call sites (a save and a restore per call), and the dynamic count is ~2.9M calls per run (400 rounds x 900 records x 3 stages, plus idf). #8084 turned it from a bare TLS replace into this_set_check(value, …); replace; this_set_check(previous, …), and each check opens with a OnceLock::get_or_init — an atomic load and a branch on the fast path, paid twice per call, for a diagnostic (PERRY_GC_THIS_SET_CHECK) that is default-off.

    Back-of-envelope that is ~1%, not 3.9%, so it is at most part of it — but it is a default-off diagnostic sitting on the hottest path, which is the shape CLAUDE.md's GC-knob policy exists to prevent, and it is cheap to test: one runtime rebuild, no codegen change.

    Given the IR result, I'd bisect within #8084 by file rather than re-measure the whole commit — you already know the answer is not in the part everyone assumed.

    Two main-side reds found on the way

    Both reproduce on clean 07c8040bf with no PR applied (both arms of my A/B report them identically), and both were invisible until #8207 fixed --audit-poll-capable, because the job aborted before reaching the corpus steps:

    • curated-native is red: 1 stale hazard against --max-stale 0 — test_gap_class_forward_capture_6523::perry_closure_…__7, unmasked->js_box_set, MOVING via js_object_get_field_by_name_f64 — and seeded violations 39/40 caught, 1 MISSED where CI requires 40.
    • dep-native reads unrooted 2, not the 3 the workflow budgets. Slack nobody re-measures is where the next regression lands green. I deliberately did not tighten it in perf(codegen): compute the callee-rooting window instead of hardcoding it (#8159) #8240: the number is LLVM-version dependent and I measured with homebrew opt, not CI's, so lowering a budget I cannot verify in CI is how the gate goes red for the next person. Worth CI measuring it and ratcheting.

    (dep-native itself is green and non-vacuous on clean main: 12,957 functions / 81 modules, 49,114 safepoints, seeded 40/40 caught, --self-test OK.)

    Leaving this issue open rather than closing it with #8240 — the PR answers the "can it be narrowed?" question and records the cost, but the 3.9% is still unattributed, and that is what the title is about.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions