Repository navigation
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
Activity
Deeper reading corrects where the defect sits.
lower_checked_typed_array_f64_loadis 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 atraw + 16. Nothing in the emitted code assumes param/view context.So the wrong answers came from one level down, one of:
- 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. js_typed_array_read_f64given kind tag 1 (Uint8Array) — if the runtime's kind enumeration differs fromf64_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), andchk=NaNon fully in-bounds loops means it returned undefined for valid reads.- Header layout for directly-allocated u8 — if offset 0 is not
lengthforjs_uint8array_allocreceivers, the bounds check always fails → OOB arm → undefined. Also produces exactlychk=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.
- Kind-cache population — if nothing populates the cache for a plain module-global
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
Uint8Arrayis a buffer-kind object (allocated byjs_uint8array_alloc, registered in the buffer registries), and is not inlookup_typed_array_kind's registry — so the helper answers undefined for every in-bounds read.f64_kind_from_classlisting"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 throughjs_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 tojs_uint8array_index_get_valueas today.Two hard-won constraints for whoever builds it:
- 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.
- 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.
Fix implemented (branch
perf/9342-u8-inline-read) — design recordTwo coordinated halves, reads only:
Runtime correctness (ships regardless of perf):
js_typed_array_read_f64andjs_typed_array_read_int32miss arms now RECOVER the element instead of inventingundefined/0: registered-buffer receivers read the byte viajs_buffer_index_get_value, everything else takesjs_typed_array_get's fix(typedarray): dispatch, not just validate, when the element-read receiver is not a typed array #8109 classifier. Both helpers previously returned wrong answers for every in-range read through a "Uint8Array"-proven receiver (both kind tables admit"Uint8Array", and a perryUint8Arrayis aBufferHeaderthe TA registry can never contain). The i32 helper's "deref before classify" safety comment was stale —classify_element_read_receiverruns first since fix(typedarray): dispatch, not just validate, when the element-read receiver is not a typed array #8109.
Inline read lane:
- New
PERRY_U8_INLINE_CACHE(64-slot direct-mapped, full-address entries) inbuffer/header.rs. Contract: an entry names a live,mark_as_uint8array-marked, inline-storageBufferHeader— so the emitted reader may dolen = *(u32*)addr; byte = *(u8*)(addr+8+idx)directly. Views ARE admissible (their inline copy is kept current bybuffer/view.rswrite 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 helperjs_u8_buffer_read_f64(delegates tojs_uint8array_index_get_valuefor bug-exact semantics incl. Reassigning anew Uint8Arraybinding drops element writes and readsundefined(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 →ultbounds vs header length →uitofpbyte; OOB mergesTAG_UNDEFINED; guard miss → priming helper). Wired inUint8ArrayGet(after the tracked-view path) and inIndexGetbefore the TA checked lane — ordering matters: the TA lane'sPERRY_TA_KIND_CACHEguard can never admit aBufferHeader, so it would pin u8 receivers to its slow helper forever. Kill switchPERRY_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.
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 underPERRY_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 answerundefined.Timing (
buf_ctxfixture, 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_addroots every leafexpr_produces_canonical_raw_f64won't vouch for, and it cannot vouch for aUint8ArrayGetleaf — 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: barefadd, register accumulator, because a tracked view's read is a nativeu8.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.rsalready 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.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.
- added 4 commits that reference this issue
on Sep 1, 2026
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×: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_loadrequires 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 exceptUint8Array— so u8 receivers also never reach thetry_lower_ta_param_f64_readchecked-inline arm that other kinds get. Discriminators, all structural (emitted-call census per variant): literal vs global vs.lengthbound — all fail identically (not the index proof);|0numeric 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_readinto theUint8ArrayGetarm (its own module-global proof arm admitsUint8Array, and its guard claims misses defer safely) compiles, traces ADMITTED, and returns wrong answers: every in-bounds read comes backundefined(chk=NaNwhere 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_loadto derive data pointer + length from the receiver header per access (guard-validated, receiver-agnostic — then the wiring above becomes correct), or admitUint8Arrayto 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).