You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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×:
constbuf=newUint8Array(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).
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 at raw + 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_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.
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.
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:
pubextern"C"fnjs_typed_array_read_f64(ta,index) -> f64{let ta = clean_ta_ptr(ta);if ta.is_null() || lookup_typed_array_kind(ta asusize).is_none(){returnTAG_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.
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.
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 IndexGetbefore 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.
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 = loadptraddrspace(1), ptr%r19; accumulator, from the GC shadow frame
... guarded_add.numeric / guarded_add.dynamic diamond ...
storeptraddrspace(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.
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.
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.
Activity
proggeramlug commentedon Aug 31, 2026
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:
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.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.
proggeramlug commentedon Sep 1, 2026
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: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:
Diag scaffolding reverted; main untouched throughout.
proggeramlug commentedon Sep 1, 2026
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:
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).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.
proggeramlug commentedon Sep 1, 2026
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):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:
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.proggeramlug commentedon Sep 1, 2026
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.
perf(codegen): a DECLARED typed-array parameter earns the inline elem…
fix(runtime): module-global Uint8Array receivers, with the view-cache…
fix(runtime): `in` and destructuring on a Uint8Array with own propert…
fix(build-cache): key PERRY_U8_INLINE_READ — it changes emitted code (#…
proggeramlug commentedon Sep 2, 2026
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.