Skip to content

In-function Uint8Array reads are 12x slower than the identical top-level loop (560 vs 46 ms) — and the obvious fix returns wrong answers #9342

Description

@proggeramlug

bench_buffer_readwrite's 1.16× is an accident of the benchmark being written at top level. The same loop in a function — where all real code lives — is 12×:

const buf = new Uint8Array(1_000_000);            // module scope
// top level:      let s=0; for (i<SIZE) s += buf[i];   → 46 ms   (node 44)
// in a function:  same loop                             → 560 ms  (node 38)
// typed param:    function f(b: Uint8Array) { ... }     → 547 ms  (node 38)

Root cause, walked to the exact gate

The in-function read emits two calls per element: js_uint8array_index_get_value + js_dynamic_string_or_number_add (the boxed result poisons the accumulator's numeric proof — the same chicken-and-egg #9303 fixed for the range tier).

The routing: Expr::Uint8ArrayGet (arrays_finds.rs:~921) → lower_buffer_load requires a tracked fresh view (buffer_view_slots) with proven bounds, so module-global / param / alias receivers always bail → the #6088 per-element fallback. A locally-constructed array inlines fully (7-instruction loop) because view tracking applies; nothing else does.

is_width_tracked_typed_array_receiver (index_get.rs:143) lists every typed-array kind except Uint8Array — so u8 receivers also never reach the try_lower_ta_param_f64_read checked-inline arm that other kinds get. Discriminators, all structural (emitted-call census per variant): literal vs global vs .length bound — all fail identically (not the index proof); |0 numeric context — flips to a cheaper helper, only 12%; local-constructed — fully inline.

The failed fix, and why — do not repeat it

Wiring try_lower_ta_param_f64_read into the Uint8ArrayGet arm (its own module-global proof arm admits Uint8Array, and its guard claims misses defer safely) compiles, traces ADMITTED, and returns wrong answers: every in-bounds read comes back undefined (chk=NaN where node says 6374692800), and it is slower (1838 ms — per-element guard-miss into the deferral helper). The checked load's length/kind derivation evidently assumes param/view context rather than deriving from the receiver header, so for this receiver the bound check fails on every access. Caught by the correctness battery before commit; reverted; main unaffected.

What a correct fix needs

Either teach lower_checked_typed_array_f64_load to derive data pointer + length from the receiver header per access (guard-validated, receiver-agnostic — then the wiring above becomes correct), or admit Uint8Array to the width-tracked list with a runtime kind guard that distinguishes Buffer-backed receivers. In both cases the accumulator's numeric proof follows automatically (proven by the local-constructed case, where the add is native once the read inlines).

Impact: this is the buffer hot path for every function-shaped workload — cc/pi are full of these loops. Worth more than the whole remaining suite delta.

Not claimed; I have the fixtures and the emitted-call census harness ready to hand over (/tmp/score/proof_arm.ts, idx_proof.ts, u8_adv*.ts).

Activity

  1. proggeramlug commented on Aug 31, 2026

    @proggeramlug
    ContributorAuthor

    Deeper reading corrects where the defect sits. lower_checked_typed_array_f64_load is receiver-agnostic by construction: entry guard = pointer tag + PERRY_TA_VIEW_GUARD == 0 + a 64-slot address+kind cache (PERRY_TA_KIND_CACHE, full-address compare so collisions evict rather than false-hit); bounds = idx ult header.length (offset 0); data at raw + 16. Nothing in the emitted code assumes param/view context.

    So the wrong answers came from one level down, one of:

    1. Kind-cache population — if nothing populates the cache for a plain module-global Uint8Array (fresh views and param slow-paths may be the only writers), every access takes the slow arm; that alone explains the 1838 ms but not the NaN.
    2. js_typed_array_read_f64 given kind tag 1 (Uint8Array) — if the runtime's kind enumeration differs from f64_kind_from_class's tags, or the helper mishandles u8 receivers, in-bounds reads return undefined. This is the arm my broken build actually exercised (the call census showed it), and chk=NaN on fully in-bounds loops means it returned undefined for valid reads.
    3. Header layout for directly-allocated u8 — if offset 0 is not length for js_uint8array_alloc receivers, the bounds check always fails → OOB arm → undefined. Also produces exactly chk=NaN.

    Discriminating between 2 and 3 is one experiment: patch the guard to unconditionally take the slow arm — if NaN persists, it's the helper (2); if it goes correct-but-slow, it's the inline chk/load (3) and the header contract is the fix. Whoever picks this up: run that first, it's ten minutes and decides the whole approach.

  2. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Discriminator run — the defect is fully pinned, three layers down, and it invalidates the TA-path approach entirely.

    With the checked load's guard forced to the slow arm (every read through js_typed_array_read_f64), the module-global receiver STILL returns NaN while a local alias of the same object is correct. The helper:

    pub extern "C" fn js_typed_array_read_f64(ta, index) -> f64 {
        let ta = clean_ta_ptr(ta);
        if ta.is_null() || lookup_typed_array_kind(ta as usize).is_none() {
            return TAG_UNDEFINED;          // <- every read, silently
        }
        js_typed_array_get(ta, index)
    }

    A perry Uint8Array is a buffer-kind object (allocated by js_uint8array_alloc, registered in the buffer registries), and is not in lookup_typed_array_kind's registry — so the helper answers undefined for every in-bounds read. f64_kind_from_class listing "Uint8Array" is aspirational: any u8 receiver reaching the ta_param machinery gets wrong answers, not slow ones. The alias case was correct only because it routes through js_uint8array_index_get_value (buffer-aware) instead.

    Where the fix actually lives

    Not in the TA path at all. The u8 lane needs its own inline checked load in the buffer machinery — i.e. lower_buffer_load (which today demands a tracked fresh view) extended to guard-validated module-global/param/alias receivers: an inline "is this address a registered u8 buffer" test (the #9272/#9291 address-window + Bloom machinery is exactly this check, already optimized), then bounds vs the buffer header length, then the byte load. Misses defer to js_uint8array_index_get_value as today.

    Two hard-won constraints for whoever builds it:

    1. The registered-buffer check is in claudecode's registry lane (perf(runtime): an inline address window in front of the registry probes — cc --help −5.16% instructions, −6.30% cycles #9272/perf(runtime): a Bloom filter for the heap-allocated registries, and #9225's linear scan gated — cc --help −1.25% instructions, −2.37% cycles #9291) — coordinate; an inline version of that probe may already exist or be planned.
    2. The accumulator's numeric proof follows automatically once the read inlines (proven by the tracked-view case) — do not build a separate fix for the dynamic add.

    Diag scaffolding reverted; main untouched throughout.

  3. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Fix implemented (branch perf/9342-u8-inline-read) — design record

    Two coordinated halves, reads only:

    Runtime correctness (ships regardless of perf):

    Inline read lane:

    • New PERRY_U8_INLINE_CACHE (64-slot direct-mapped, full-address entries) in buffer/header.rs. Contract: an entry names a live, mark_as_uint8array-marked, inline-storage BufferHeader — so the emitted reader may do len = *(u32*)addr; byte = *(u8*)(addr+8+idx) directly. Views ARE admissible (their inline copy is kept current by buffer/view.rs write propagation — the reason this is a read-only lane); foreign-backed wrappers are excluded at prime time (header-only allocations). Primed by the new slow helper js_u8_buffer_read_f64 (delegates to js_uint8array_index_get_value for bug-exact semantics incl. Reassigning a new Uint8Array binding drops element writes and reads undefined (the Uint8-specialized twin of #8100) #8111 recovery); invalidated at the single buffer-death chokepoint (finalize_collected_dead_buffer) and at address re-issue (register_buffer).
    • Codegen expr/u8_buffer_read.rs: guarded inline byte load (pointer tag + cache full-address hit → ult bounds vs header length → uitofp byte; OOB merges TAG_UNDEFINED; guard miss → priming helper). Wired in Uint8ArrayGet (after the tracked-view path) and in IndexGet before the TA checked lane — ordering matters: the TA lane's PERRY_TA_KIND_CACHE guard can never admit a BufferHeader, so it would pin u8 receivers to its slow helper forever. Kill switch PERRY_U8_INLINE_READ=0.

    Lifecycle proof (gc/tests/u8_inline_cache.rs): prime contract, foreign rejection, death pruning, re-issue pruning — and sabotage-validated: deleting either invalidation call fails exactly its test (2/2), restored 4/4 green. ABA rides the same #6080 discipline as every other buffer identity table.

    Correctness battery + numbers next.

  4. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    Measured: the fix is correct and worth 2.6×, and the residual is NOT where the theory said

    Correctness battery (release, quiet build, node-differential): all green. proof_arm (module-global / local / alias discriminator), u8_adv (in-bounds, OOB, negative, fractional index, polymorphic site, param), u8_adv2 (GC churn + subarray), u8_adv3 (new — slice-view read-after-backing-write, new Uint8Array(ab) + DataView write propagation, ABA churn with 5000 dying buffers, mixed-tenant recycling) — all byte-identical to node, and identical again under PERRY_GC_HEAP_LIMIT=8 PERRY_GC_FORCE_EVACUATE=1. Kill switch (PERRY_U8_INLINE_READ=0) also node-exact, which independently confirms the runtime miss-arm fix: that build has no inline lane at all, so every read goes through the helpers that used to answer undefined.

    Timing (buf_ctx fixture, SIZE=1e6 × 50, dev box, direction only):

    shape before after node
    top-level (tracked view) 46 49 44
    in-function, module-global 560 216 38
    in-function, typed param 548 576 38

    The residual is the accumulator, not the guard — proved, not assumed

    The obvious story was per-element guard cost (cache probe + compare + branch, where node hoists its receiver check). Discriminator: forced the guard to always take the fast arm — 216 → 218 ms. The guard costs nothing measurable.

    What the IR actually shows in the hot loop:

    %r46.rs4p = load ptr addrspace(1), ptr %r19   ; accumulator, from the GC shadow frame
    ...  guarded_add.numeric / guarded_add.dynamic diamond ...
    store ptr addrspace(1) null, ptr %r19

    lower_guarded_numeric_add roots every leaf expr_produces_canonical_raw_f64 won't vouch for, and it cannot vouch for a Uint8ArrayGet leaf — the node's value is byte-or-undefined (#6884). So the loop pays a shadow-frame load + store + write barrier + tag-test diamond per element. The fast top-level control has none of it: bare fadd, register accumulator, because a tracked view's read is a native u8.

    So the remaining 216→38 is #9303 territory (accumulator numeric proof), not the read lane. Filing separately rather than growing this PR; the honest framing is that a byte-typed read lane needs a value that is provably a raw f64, which means canonicalizing the OOB arm in arithmetic context (the machinery binary.rs already has for the generic tier).

    Param receiver unchanged (576)

    inFnParam(b: Uint8Array) never admits: the class proof comes back empty for a declared-type parameter, so the lane declines. Same follow-up.

    Scope call

    This PR ships the correctness fix (two runtime helpers that returned wrong values for every u8 element read) plus a 2.6× in-function read improvement, with lifecycle proof and an IR ordering pin. It does not claim parity on the in-function shape, and the benchmark row itself (bench_buffer_readwrite) is entirely top-level and already at parity — this cliff only bites bundled code that reads a module-global buffer inside a function, which is exactly the shape #9342 was filed about.

  5. proggeramlug commented on Sep 1, 2026

    @proggeramlug
    ContributorAuthor

    PR #9360 opened with the fix (correctness + inline read lane + lifecycle/ordering pins). Audit follow-up for the remaining 197 miss-consumers: #9347. The accumulator-vouching residual (216 vs node 38 on the in-function shape, plus the unchanged typed-param receiver) is being filed separately as #6904/#9303 territory — this issue stays open until that lands.

  6. proggeramlug commented on Sep 2, 2026

    @proggeramlug
    ContributorAuthor

    Closing the correctness half: fixed in #9436 (landed #9360 with the view-cache aliasing regression fixed) plus #9433. The accumulator-vouching perf residual is tracked in #9361.

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