Skip to content

perf: array element store pays a cross-crate guard call per element (15% of bench_array_ops) #7396

Closed

Description

@proggeramlug

bench_array_ops is 4.5× slower than Node (262 vs 58 by the benchmark's own timer), and more than half the time is outside the generated code.

Measured

sample over a --debug-symbols build:

samples frame
326 perry_fn_…__runArrayBenchmark (generated)
103 js_typed_feedback_numeric_array_index_set_guard
89 js_array_fill_f64_iota_extend
87 js_array_grow
52 js_gc_temp_root_{push,get,truncate}

The workload is pure integer array traffic — arr[i] = i, sum += arr[i], a swap loop, arr[i] % 2.

What the guard actually is

Not overhead for a disabled feature — I assumed that first and it is wrong. With typed feedback off (the default) js_typed_feedback_numeric_array_index_set_guard does the real work:

if !typed_feedback_enabled() {
    return (index >= 0
        && is_numeric_value_bits(value.to_bits())
        && numeric_array_index_set_guard(arr, index, require_in_bounds)) as i32;
}

It is the check that authorises the fast store. The problem is where it lives: a 5-argument cross-crate call per element, for a predicate whose common case is

  • index >= 0 — statically known for an i32 loop counter
  • value is numeric — often statically known
  • plain array + in bounds — one length load and compare
  • (*header)._reserved & GC_ARRAY_RAW_F64_LAYOUT — one flag-bit test on the GC header

Every one of those is a handful of inline instructions. This is the "native-able primitive became a runtime call" pattern the earlier audits named, in its clearest form.

Suggested direction

Emit the fast-path predicate inline in codegen and keep the helper as the slow-path fallback, so a store that stays on the raw-f64 layout never leaves the generated function. The layout flag test is the load-bearing part and is a single masked load.

js_array_grow (87) and js_array_fill_f64_iota_extend (89) are the growth path for arr[i] = i on an initially-empty array and are worth a separate look — Node pre-sizes far more aggressively.

Caveat on the measurement

Taken on a machine at load ~5.9, using minimum-of-N and the benchmark's own internal timer rather than wall clock. The sample proportions are stable across runs but the absolute ratio should be re-confirmed on a quiet host before it anchors any target.

Activity

  1. proggeramlug commented on Aug 4, 2026

    @proggeramlug
    ContributorAuthor

    Negative result on the growth path — measured properly, do not retry.

    js_array_grow HOLE-initialises new slack with a ptr::write loop, and I proposed converting it to a slice::fill on the theory that the loop emitted one scalar store per slot. That theory came from a vacuous check: I ran otool | awk '/_js_array_grow:/…' against a stripped binary, the symbol was absent, grep -c returned 0 both before and after, and I read "0 paired stores" as evidence.

    Redone with a check that can fail — resolve the address via nm, extract from there:

    extracted 201 lines
      6 stp
      1 str
    

    LLVM already vectorises that loop. The paired stores are there in the baseline. The slice::fill rewrite is therefore a no-op, which is exactly what the timings said (min 238 vs 244 across 8 runs — inside the noise band even before load became a problem).

    So of #7396's 176 samples across js_array_grow + js_array_fill_f64_iota_extend, the hole-init is not the addressable part. If there is a win in the growth path it is in the policy — Node pre-sizes far more aggressively for arr[i] = i on an empty array, so the number of grow-and-copy cycles is the lever, not the cost of each one.

    The store-guard half of this issue (103 samples, js_typed_feedback_numeric_array_index_set_guard) is untouched by this and remains the real target.

    Method note worth carrying: a disassembly check that greps a symbol name only works on a --debug-symbols build, and grep -c returning 0 is indistinguishable from "pattern never matched". Resolve the address with nm first and assert the extraction is non-empty.

  2. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    Re-measured on the release profile before implementing the suggested direction. The direction should change: the guard is not in the release profile at all.

    The original profile was taken on a non-shipping configuration

    js_typed_feedback_numeric_array_index_set_guard is a cross-crate call. The perry-dev profile is opt-level 1 with no LTO, so it cannot be inlined there and shows up as the #1 runtime cost (103 samples). The shipping profile uses thin LTO, which inlines it: grepping the release profile for the symbol returns 0 frames. Emitting the predicate inline in codegen would therefore have optimised a cost that does not exist in shipped binaries.

    Release profile (2500 timed iterations, --debug-symbols, 13,530 samples)

    % frame
    23.4% perry_fn_…__runArrayBenchmark (generated)
    12.2% _tlv_get_addr
    7.3% js_gc_temp_root_push
    2.3% js_array_fill_f64_iota_extend
    2.2% js_gc_temp_root_get
    1.8% js_gc_temp_root_truncate
    1.8% js_array_grow

    Temp-root machinery 11.3% + _tlv_get_addr 12.2% = 23.5% — as much as the generated code itself, and the _tlv_get_addr callers are dominated by those same js_gc_temp_root_* entries. This is macOS dynamic TLS resolution: every thread_local! access in the runtime goes through the resolver rather than a direct offset.

    So the lever on this benchmark is Layer 3 runtime rooting and its thread-local access cost, not the array guard.

    The gap is also half what this issue records

    Min-of-9, arms interleaved to cancel drift, host load ~5.5: perry 123 / node 53 = 2.32×, not 4.5×. The dev-runtime arm gives 2.25×, so the ratio is essentially profile-independent — what changes between profiles is where the time goes, not how much. The 262 figure predates recent work.

    It is not the Layer 2 backend

    A/B'd the root backends on the same binary: shadow stack (PERRY_RS4GC=0) 121 vs statepoints 123 — within noise. Statepoints address generated-code liveness; these temp roots are runtime-side (Layer 3), so the backend choice does not touch them. Worth stating because "reduce root density" could otherwise be mistaken for a Layer 2 task here.

    Suggested redirect

    1. TLS access model for the runtime's hot thread-locals — 12.2% going to _tlv_get_addr is the single largest non-generated term, and it is a representation choice, not an algorithmic one.
    2. Temp-root push/get/truncate volume on the array paths — this is the runtime-cost evidence for the plan's "reduce root density" lever, which had previously measured null on the binary-size axis. Size and speed do not agree here; the speed side is real.
    3. The array-guard inlining as originally proposed is not worth doing on this evidence. js_array_grow + js_array_fill_f64_iota_extend together are only 4.1% in release, so the growth-path follow-up is also smaller than the dev profile implied.

    Caveats: host load ~5.5 (not a quiet box); sample attribution is statistical, and the _tlv_get_addr → temp-root attribution is from caller context rather than exact accounting.

  3. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    Correcting my previous comment — its central claim is wrong. The original direction stands.

    I claimed the guard was absent from the release profile and had been inlined by thin LTO. That came from a broken profile parse, and two direct checks refute it:

    • nm on both libperry_runtime.a and the linked --debug-symbols benchmark binary shows js_typed_feedback_numeric_array_index_set_guard present. It is a real call, not inlined away.
    • The emitted IR for runArrayBenchmark contains 6 guard call sites in the hot loops — 2 numeric_array_index_set_guard and 4 numeric_array_index_get_guard, alongside 4 js_array_index_get_fallback_boxed and 4 js_array_numeric_value_to_raw_f64.

    So the guard is called per element exactly as this issue describes, and inlining the fast-path predicate in codegen is still the right direction.

    Also retracting the percentage table. sample's call graph reports inclusive counts per tree node; I summed them across all nodes, which double-counts every frame along each stack — that is why start and main each appeared at ~16% next to their own callee. The "temp roots 11.3% + _tlv_get_addr 12.2% = 23.5%" figure is not supported. Two later attempts to re-derive self time from the same file produced obviously impossible results (start as a leaf holding 98%), so I have no reliable profile breakdown to offer and am not replacing the table with another guess.

    What survives, because it is direct timing rather than sampling:

    measurement result
    release, min-of-9, arms interleaved, load ~5.5 perry 123 / node 53 = 2.32×
    dev runtime, same method 128 / 57 = 2.25×
    PERRY_RS4GC=0 (shadow stack) vs statepoints, same binary 121 vs 123 — within noise

    Two things follow that are worth keeping:

    1. The gap is 2.32×, not the 4.5× recorded above. Recent work has roughly halved it, so the target in the issue body is stale even though the mechanism is unchanged.
    2. This is not a Layer 2 concern. The root-backend A/B is flat, so statepoints-vs-shadow-stack does not move this benchmark either way.

    My apologies for the noise — I posted an analysis that would have redirected this issue away from a valid fix.

  4. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    This is already fixed on main — by #7421, merged today — and the issue was never closed. Posting the evidence so the next person does not reimplement it as I just did.

    crates/perry-codegen/src/expr/index.rs carries an inline_write_tier whose comment names this issue directly: the tier previously required the RHS to be canonical-raw-f64 by construction (expr_produces_canonical_raw_f64), which gated it off for element-to-element traffic (arr[i] = arr[j]) — the reverse-in-place loop this issue profiled. #7421 replaced that static flag with a runtime test of the same predicate, so the tier now covers those stores.

    That also explains the discrepancy I reported earlier: I measured 2.32× against this issue's recorded 4.5×. The gap did not close through unrelated drift — #7421 closed the specific term this issue identified.

    What is genuinely left

    The remaining js_typed_feedback_numeric_array_index_set_guard calls in bench_array_ops' IR are all on the extend path — they pass require_in_bounds = 0 and sit in idxset.check_cap / idxset.extend_inline. There are zero idxset.bounded_numeric blocks in the module, i.e. the in-bounds diamond this issue's suggested fix targeted is not even reached by this benchmark.

    So the append case (arr[i] = i growing an initially-empty array — the benchmark's first loop) still pays a five-argument cross-crate call per element. That is the residue, and it is a different problem from the one in the issue body: it has to reason about capacity, sealing and the extend policy, which the in-bounds tier gets to ignore.

    Suggest either closing this as fixed by #7421 and opening a narrow issue for the extend path, or retitling it to the extend path explicitly. Leaving it open with the original body is what cost me this round.

    Correcting my own record here

    My first comment claimed the guard was absent from the release profile and inlined by LTO. That was wrong and I retracted it; nm shows the symbol present and the IR has real call sites. The percentage table in it came from summing sample's inclusive call-graph counts, which double-counts every frame along a stack — ignore that table entirely. The only numbers in this thread I would still stand behind are the direct timings: 123 vs 53 release, 128 vs 57 dev, and shadow-stack 121 vs statepoints 123.

  5. proggeramlug commented on Aug 5, 2026

    @proggeramlug
    ContributorAuthor

    Measured confirmation that this is complete, including the extend case — and a retraction of the "residue" I claimed above.

    I said the append path still pays a per-element guard call because the remaining IR call sites carry require_in_bounds = 0. That was reading static call sites as if they were dynamic cost, which is the same mistake twice in this thread. Those sites are the tier's cold arm; inline_write_tier's guard_ok never requires in-bounds — it checks index_nonnegative, length_sane, capacity_sane and length_within_capacity, then branches to guarded, with check_cap/extend_inline downstream. Extend is inside the tier, not outside it.

    The A/B

    No rebuild and no env knob (PERRY_TYPED_FEEDBACK=1 would also switch on observation, confounding the result). Instead, one source line that trips the sticky latch the tier reads:

    (Object.prototype as any)[987654321] = 1;   // sets OBJECT_PROTO_HAS_INDEX
                                                // → invalidate_array_index_fast_path()

    Same binary, same host, 7 runs each, min reported:

    bench_array_ops TOTAL
    tier live 125
    tier forced cold 1731

    13.8×. The tier is carrying essentially the entire benchmark; the out-of-line guard is not on the steady-state path at all.

    Caveat stated plainly: prototype pollution also changes hole/OOB semantics, so some of that 1731 is other slow paths rather than the guard call alone. It bounds the tier's value from below, it does not attribute all 13.8× to one call.

    Conclusion

    #7421 closed this issue's mechanism completely — in-bounds and extend. The 4.5× in the body is stale; the current gap is 2.32× (release, min-of-9, interleaved), and it is not in the array store guard, nor in the root backend (shadow stack 121 vs statepoints 123).

    Recommend closing as fixed by #7421. I would not open a follow-up for the extend path — the measurement above says there is nothing there. Where the remaining 2.32× lives is genuinely unknown; I could not get a trustworthy profile (see the retraction above), and I would rather leave that unanswered than post a third guess.

  6. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Closing as fixed by #7421 (merged 2026-08-05), per the evidence in the two comments above:

    • The inline write tier in expr/index.rs covers exactly the element-to-element stores this issue profiled (its comment cites this issue number), including the extend path — the remaining IR guard calls are the cold arm.
    • Measured A/B via prototype pollution on the same binary: tier live 125 ms, tier forced cold 1731 ms — the tier carries the benchmark.
    • Current gap re-measured at 2.32× vs the 4.5× in this issue's body (release, min-of-9, interleaved); where the remaining 2.32× lives is not the array store guard and not the root backend (shadow stack 121 vs statepoints 123).

    The remaining gaps live in the measured backlog in docs/engine-plan.md step 7 (json/map/batch rows) rather than here.

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