From 24b3ae54415e62802b6e7d59c1eabb618217a4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:52:57 +0200 Subject: [PATCH] perf(codegen): un-root typed-array-param numeric accumulators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses #8619 for the typed-array PARAMETER case. A function that folds a spec-ABI-proven typed-array parameter into an accumulator (`let x = arr[i] + 1.0; s = s + x`) kept its numeric locals `x` and `s` as NaN-boxed GC roots with a per-write `js_write_barrier_root_nanbox`, and lowered `s`'s update to the opaque `js_dynamic_string_or_number_add` instead of an inline `fadd` — even though every value is a genuine Number. Root cause: the `number_by_construction` fixpoint's `numeric_view_value_or_undefined` (collectors/ptr_shape_numeric.rs) recognised a typed-array element read as "Number-or-undefined, never a pointer" only for a LOCAL view with a compiler-visible `TypedArrayNew` init — not for a spec-proven `TaPtr` parameter. So the fresh, read-derived `x` failed the numeric proof, which cascaded to the loop-carried accumulator `s = s + x`. Fix: the fixpoint now also treats a read off a `spec_ta_lens` binding as Number-or-undefined. `spec_ta_lens` is keyed exactly by `SpecParamRep::TaPtr` parameters, and `collectors::spec_abi_sites` admits a `TaPtr` only for `spec_ta_kind_is_numeric` kinds (the BigInt typed arrays are never `TaPtr`), so `arr[numeric_index]` off one is provably a Number in-bounds and `undefined` out of range, which `+` launders into a genuine Number (NaN at worst). The `rec(index)` guard is retained: a non-numeric key reads a property, which can be a pointer. Soundness rests on the entry contract, not the erased annotation, so a reassigned or unproven receiver is untouched. Measured on a 200000x4096 Float64Array reduction passed by parameter: the accumulator's per-iteration dynamic add + root barrier become a single `fadd` in a raw double slot — ~5x faster (5.1-7.3s -> ~1.0s), byte-identical output to the rooted build under every moving-GC configuration and to Node. Tests: unit (perry-codegen) `spec_ta_param_view_admits_read_derived_number_locals` and `ta_read_without_spec_proof_stays_dynamic` prove the fix is load-bearing; integration (perry) `gc_ta_view_accumulator_unroot_8619` is a rooted-vs-fix differential across the moving-GC matrix, covering Float64Array/Int32Array kinds and OOB/negative indices. Not covered: the issue's module-global reproducer — on main that read is still a runtime call (module-global read inlining, #8617, is unmerged), so its rooting is a secondary cost; extending the same proof to `module_global_proven_types` is the follow-up once the read inlines. Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- ...19-ta-view-param-number-by-construction.md | 38 ++++ .../src/collectors/number_by_construction.rs | 88 ++++++++ .../perry-codegen/src/collectors/ptr_shape.rs | 4 + .../ptr_shape_group_numeric_tests.rs | 4 + .../src/collectors/ptr_shape_numeric.rs | 36 +++ .../gc_ta_view_accumulator_unroot_8619.rs | 205 ++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 changelog.d/8619-ta-view-param-number-by-construction.md create mode 100644 crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs diff --git a/changelog.d/8619-ta-view-param-number-by-construction.md b/changelog.d/8619-ta-view-param-number-by-construction.md new file mode 100644 index 0000000000..346f3fc38b --- /dev/null +++ b/changelog.d/8619-ta-view-param-number-by-construction.md @@ -0,0 +1,38 @@ +A hot function that folds a typed-array **parameter** into an accumulator — +`function reduce(arr: Float64Array) { let s = 0.0; for (…) { let x = arr[i] + 1.0; +s = s + x; } }` — no longer keeps its numeric locals as NaN-boxed GC roots. + +The spec-ABI already proves such a parameter (`TaPtr`) permanently holds one +specific numeric-kind, non-view typed array, and specializes the body so the +element read inlines. But the `number_by_construction` fixpoint +(`collectors/ptr_shape_numeric.rs`) only recognised a typed-array element read as +"a Number or `undefined`, never a pointer" for a **local** view with a +compiler-visible `TypedArrayNew` initializer — not for a proven `TaPtr` +parameter. So the fresh, read-derived `x` failed the numeric proof, which +cascaded to the loop-carried accumulator `s = s + x`. Both then kept a shadow +root slot with a per-write `js_write_barrier_root_nanbox`, and `s`'s update +lowered to the opaque `js_dynamic_string_or_number_add` call instead of an inline +`fadd`. + +The fixpoint now also treats a read off a `spec_ta_lens` binding as +Number-or-`undefined`. `spec_ta_lens` is keyed exactly by `SpecParamRep::TaPtr` +parameters, and `collectors::spec_abi_sites` admits a `TaPtr` only for +`spec_ta_kind_is_numeric` kinds (the BigInt typed arrays — whose elements are +BigInt pointers — are never `TaPtr`), so `arr[numeric_index]` off one is provably +a Number in-bounds and `undefined` out of range, which `+`/`-` launders into a +genuine Number (`NaN` at worst). The `rec(index)` guard is retained — a +non-numeric key would read a property, which can be a pointer. Soundness rests on +the entry contract, not on the erased `Float64Array` annotation, so a reassigned +or unproven receiver is untouched. + +Effect on a 200000×4096 `Float64Array` reduction passed by parameter: the +accumulator's per-iteration `js_dynamic_string_or_number_add` and root barrier +become a single `fadd` in a raw `double` slot — ~5× faster (measured 5.1–7.3s → +~1.0s), with byte-identical output to the rooted build under every moving-GC +configuration and to Node. + +Does not yet cover a typed array read through a **module-global** binding (the +issue #8619 reproducer): on `main` that read is still a runtime call (module- +global read inlining, #8617, is unmerged), so its accumulator rooting is a +secondary cost there; extending the same proof to `module_global_proven_types` +is the natural follow-up once the read inlines. diff --git a/crates/perry-codegen/src/collectors/number_by_construction.rs b/crates/perry-codegen/src/collectors/number_by_construction.rs index 480bc46a90..d12160fda9 100644 --- a/crates/perry-codegen/src/collectors/number_by_construction.rs +++ b/crates/perry-codegen/src/collectors/number_by_construction.rs @@ -92,12 +92,23 @@ pub(crate) fn collect_number_by_construction_locals( if !enabled() { return HashSet::new(); } + // #8619: spec-ABI `TaPtr` parameters are proven to permanently hold one + // specific NUMERIC-kind, non-view typed array — `spec_ta_lens` is keyed + // exactly by those params (its only source is `SpecParamRep::TaPtr`, which + // `collectors::spec_abi_sites` admits only for `spec_ta_kind_is_numeric` + // kinds; the BigInt kinds are never TaPtr). A read `arr[numeric_index]` off + // one is therefore a Number (in-bounds) or `undefined` (OOB), never a + // pointer/string, so the fixpoint may treat it like a compiler-visible + // local typed-view constructor on one side of `+` (where `undefined` + // becomes the Number NaN rather than selecting string concatenation). + let numeric_ta_views: HashSet = spec_ta_lens.keys().copied().collect(); let mut numeric = super::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis( stmts, boxed_vars, module_globals, not_bigint_locals, &HashMap::new(), + &numeric_ta_views, ); numeric.extend(collect_number_at_read_after_undefined( stmts, @@ -545,4 +556,81 @@ mod tests { assert!(!run(&stmts).contains(&N)); } + + // #8619: a spec-ABI `TaPtr` parameter is proven to permanently hold one + // specific NUMERIC-kind, non-view typed array, so `arr[numeric_index]` is a + // Number (in-bounds) or `undefined` (OOB) — never a pointer. The + // number-by-construction fixpoint must therefore admit a fresh + // read-derived local `let x = arr[i] + 1.0` (whose value is a genuine + // Number, `NaN` at worst) and cascade to the loop-carried accumulator + // `s = s + x`, so both drop their GC-root slot and their arithmetic stays + // an inline `fadd` instead of `js_dynamic_string_or_number_add`. + fn ta_view_stmts(arr: u32, s_id: u32, x_id: u32) -> Vec { + vec![ + Stmt::Let { + id: s_id, + name: "s".to_string(), + ty: HirType::Number, + mutable: true, + init: Some(Expr::Number(0.0)), + }, + Stmt::Let { + id: x_id, + name: "x".to_string(), + ty: HirType::Number, + mutable: false, + init: Some(add( + Expr::IndexGet { + object: Box::new(Expr::LocalGet(arr)), + index: Box::new(Expr::Integer(0)), + }, + Expr::Number(1.0), + )), + }, + Stmt::Expr(Expr::LocalSet( + s_id, + Box::new(add(Expr::LocalGet(s_id), Expr::LocalGet(x_id))), + )), + ] + } + + fn run_fixpoint(stmts: &[Stmt], ta_views: &HashSet) -> HashSet { + crate::collectors::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis( + stmts, + &HashSet::new(), + &HashMap::new(), + &HashSet::new(), + &HashMap::new(), + ta_views, + ) + } + + #[test] + fn spec_ta_param_view_admits_read_derived_number_locals() { + let (arr, s_id, x_id) = (10u32, 20u32, 21u32); + let stmts = ta_view_stmts(arr, s_id, x_id); + + let with = run_fixpoint(&stmts, &HashSet::from([arr])); + assert!( + with.contains(&x_id), + "fresh `arr[i] + 1.0` local must be Number by construction" + ); + assert!( + with.contains(&s_id), + "accumulator must cascade to Number by construction" + ); + } + + #[test] + fn ta_read_without_spec_proof_stays_dynamic() { + // Same body, but the receiver is NOT a spec-proven typed array: the read + // could be a string/property access on an arbitrary receiver, so neither + // local may be un-rooted. + let (arr, s_id, x_id) = (10u32, 20u32, 21u32); + let stmts = ta_view_stmts(arr, s_id, x_id); + + let without = run_fixpoint(&stmts, &HashSet::new()); + assert!(!without.contains(&x_id)); + assert!(!without.contains(&s_id)); + } } diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index caf576bc23..223ece44ad 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -479,6 +479,10 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields( module_globals, not_bigint_locals, &const_local_inits, + // #8619: this is the `Ptr` provenance pass (feeds `is_numeric_expr`), + // not the local rooting proof; it has no specialized `TaPtr` context, so + // no view binding is spec-proven here. + &HashSet::new(), ); // A spec entry has validated these parameters before entering this body. // Unlike a TypeScript annotation, that is runtime evidence, so derived diff --git a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs index d5aa913e45..26f5f0512f 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_group_numeric_tests.rs @@ -785,6 +785,7 @@ fn numeric_locals_of(stmts: &[Stmt]) -> HashSet { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ) } @@ -942,6 +943,7 @@ fn non_numeric_writes_and_bindings_are_excluded() { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ) .contains(&7), "a boxed local's write set is not this region's to enumerate" @@ -976,6 +978,7 @@ fn update_value_resolves_via_not_bigint() { &HashMap::new(), ¬_bigint, &HashMap::new(), + &HashSet::new(), ); assert!(with_fact.contains(&21)); let without_fact = numeric::collect_numeric_by_construction_locals( @@ -984,6 +987,7 @@ fn update_value_resolves_via_not_bigint() { &HashMap::new(), &HashSet::new(), &HashMap::new(), + &HashSet::new(), ); assert!( !without_fact.contains(&22), diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index f4e5ee07bc..798a8c9465 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -74,6 +74,10 @@ pub(super) fn prove_numeric_fields( const_local_inits: &HashMap>, numeric_locals: &HashSet, ) -> HashSet { + // #8619: the class-field numeric proof has no specialized-entry `TaPtr` + // context, so no view binding is spec-proven here. Passing empty keeps this + // proof bit-identical to before the local `TaPtr` extension. + let no_ta_views: HashSet = HashSet::new(); let mut numeric: HashSet = HashSet::new(); for class in chain { for field in &class.fields { @@ -119,6 +123,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }) @@ -145,6 +150,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }) @@ -219,6 +225,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ) }; @@ -243,6 +250,7 @@ pub(super) fn prove_numeric_fields( not_bigint_locals, const_local_inits, numeric_locals, + &no_ta_views, 0, ), }; @@ -429,6 +437,9 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( module_globals: &HashMap, not_bigint_locals: &HashSet, const_local_inits: &HashMap>, + // #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI + // `TaPtr` params). Empty for the `Ptr` type-analysis caller. + numeric_ta_views: &HashSet, ) -> HashSet { // ONE write walker for both fixpoints (`collect_not_bigint_locals` and // this one) — see its doc for why sharing is load-bearing. `None` = a @@ -471,6 +482,7 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( not_bigint_locals, &stable_local_inits, &numeric, + numeric_ta_views, 0, ), }) @@ -505,6 +517,15 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals: &HashSet, const_local_inits: &HashMap>, numeric_locals: &HashSet, + // #8619: view bindings PROVEN to permanently hold a numeric-kind typed + // array — a spec-ABI `TaPtr` parameter (the entry contract binds the raw + // header of a proven numeric non-view typed array). A read + // `view_id[numeric_index]` is then a Number (in-bounds) or `undefined` + // (OOB) by construction, never a pointer/string, which the Add rule below + // launders into a genuine Number. Empty on every path that is not a + // specialized-entry local proof (the class-field provers, the `Ptr` + // pass). + numeric_ta_views: &HashSet, depth: usize, ) -> bool { if depth > 16 { @@ -520,6 +541,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ) }; @@ -537,6 +559,18 @@ pub(super) fn expr_numeric_by_construction( let Expr::LocalGet(view_id) = object.as_ref() else { return false; }; + // #8619: a spec-proven numeric typed-array binding (`TaPtr` parameter) + // has no compiler-visible `TypedArrayNew` init in this body, but its + // entry contract is a STRONGER proof than an inline constructor: the + // call-site pre-pass proved the argument is one specific numeric-kind, + // non-view typed array, never reassigned. So `view_id[numeric_index]` + // is a Number-or-`undefined` exactly as the local-constructor case + // below — never a pointer. The `rec(index)` guard is retained: a + // non-numeric key (symbol/string) would read a property, which can be a + // pointer. + if numeric_ta_views.contains(view_id) { + return rec(index); + } let Some(Some(init)) = const_local_inits.get(view_id) else { return false; }; @@ -676,6 +710,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ) }) == Some(true) @@ -699,6 +734,7 @@ pub(super) fn expr_numeric_by_construction( not_bigint_locals, const_local_inits, numeric_locals, + numeric_ta_views, depth + 1, ); } diff --git a/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs new file mode 100644 index 0000000000..6cc723e7d9 --- /dev/null +++ b/crates/perry/tests/gc_ta_view_accumulator_unroot_8619.rs @@ -0,0 +1,205 @@ +//! #8619 — a proven-Number f64 accumulator derived from a typed-array read is +//! un-rooted (raw double, not a nanbox GC root) without corrupting the heap. +//! +//! A function that reads a spec-ABI-proven typed-array parameter and folds the +//! elements into an accumulator (`let x = arr[i] + 1.0; s = s + x`) used to keep +//! BOTH `x` and `s` in nanbox GC-root slots with a per-write root barrier, even +//! though every value is a genuine Number (a typed-array element is a Number +//! in-bounds and `undefined` — never a pointer — out of range, which `+` +//! launders into a Number). #8619 teaches the number-by-construction fixpoint +//! that a `TaPtr` view read is Number-or-`undefined`, so the accumulator drops +//! its root slot and its arithmetic becomes an inline `fadd`. +//! +//! This is a differential test with NO node oracle. The same program is compiled +//! twice from identical source: +//! +//! * `PERRY_NUMBER_BY_CONSTRUCTION=0` — the fact is empty, so the accumulator +//! stays a NaN-boxed GC root updated through `js_dynamic_string_or_number_add` +//! (the pre-#8619 lowering); +//! * unset (default) — the accumulator is proven Number by construction and +//! kept in a raw `double` slot with an inline `fadd`. +//! +//! Both binaries run under every moving-collector configuration and MUST produce +//! byte-identical output. If the un-rooting were unsound — if the accumulator +//! could ever hold a pointer the collector no longer tracks — a relocating minor +//! would leave a stale pointer and the checksum would diverge (or the run would +//! crash) in the default arm only. The interleaved `keep` array forces nursery +//! collections while the un-rooted accumulator is live, so the collector is +//! actually exercised against the changed frame. + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +const SOURCE: &str = r#" +// Hot reducer over a typed-array PARAMETER: spec-ABI proves `arr` is one +// specific Float64Array, so `arr[i]` inlines and `x`/`s` are Number by +// construction under #8619. The `keep` array churns the nursery so a moving +// minor runs while the (un-rooted) accumulator is live. +function reduce(arr: Float64Array, n: number): number { + let s = 0.0; + const keep: number[] = []; + for (let i = 0; i < n; i++) { + let x = arr[i] + 1.0; + s = s + x * 0.5; + if ((i & 31) === 0) { + keep.push(x); + if (keep.length > 64) keep.shift(); + } + } + let t = 0.0; + for (const k of keep) { t = t + k; } + return s + t; +} + +// Out-of-range / negative integer indices: a typed-array read is `undefined` +// there, and `undefined + 1.0` is the Number NaN — never a pointer or a string. +function edges(arr: Float64Array): number { + let acc = 0.0; + for (let i = -2; i < 6; i++) { + let x = arr[i] + 1.0; + acc = acc + (x !== x ? 100.0 : x); + } + return acc; +} + +// A different numeric kind, folded with subtraction. +function reduceI32(arr: Int32Array, n: number): number { + let s = 0.0; + for (let i = 0; i < n; i++) { + let x = arr[i] - 3.0; + s = s + x; + } + return s; +} + +let f = new Float64Array(256); +for (let i = 0; i < 256; i++) { f[i] = i * 0.25; } +let g = new Int32Array(256); +for (let i = 0; i < 256; i++) { g[i] = i - 128; } + +let acc = 0.0; +for (let r = 0; r < 6000; r++) { + acc = acc + reduce(f, 256) + reduceI32(g, 256) + edges(f); +} +console.log("acc:" + acc); +"#; + +/// Collector knobs cleared before each run so a developer's exported kill switch +/// cannot turn every arm into the never-relocates control. +const GC_ENV_OVERRIDES: &[&str] = &[ + "PERRY_GEN_GC", + "PERRY_GC_SCAVENGE", + "PERRY_GC_SCAVENGE_NURSERY_MB", + "PERRY_GC_MOVING_SAFEPOINT", + "PERRY_GC_MOVING_LOOP_POLLS", + "PERRY_GC_FORCE_EVACUATE", + "PERRY_CONSERVATIVE_STACK_SCAN", + "PERRY_WRITE_BARRIERS", + "PERRY_GC_INCREMENTAL", + "PERRY_GC_HEAP_LIMIT", +]; + +/// `number_by_construction`: unset = default (the #8619 un-rooting); "0" = +/// disabled (pre-#8619 rooted accumulator). Keyed into the object cache, so +/// `--no-cache` is belt-and-suspenders. +fn compile(dir: &std::path::Path, nbc: Option<&str>) -> PathBuf { + let entry = dir.join("main.ts"); + let label = nbc.unwrap_or("default"); + let output = dir.join(format!("bin_nbc_{label}")); + std::fs::write(&entry, SOURCE).expect("write entry"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-auto-optimize"); + cmd.env_remove("PERRY_NUMBER_BY_CONSTRUCTION"); + if let Some(v) = nbc { + cmd.env("PERRY_NUMBER_BY_CONSTRUCTION", v); + } + let out = cmd.output().expect("run perry compile"); + assert!( + out.status.success(), + "perry compile (PERRY_NUMBER_BY_CONSTRUCTION={label}) failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + output +} + +fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str) -> String { + let mut arms: Vec> = vec![vec![]]; + for mb in ["1", "2", "4"] { + arms.push(vec![("PERRY_GC_SCAVENGE_NURSERY_MB", mb)]); + } + arms.push(vec![("PERRY_GEN_GC", "0")]); + + let mut first: Option = None; + for arm in &arms { + let mut cmd = Command::new(binary); + cmd.current_dir(dir); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + for (k, v) in arm { + cmd.env(k, v); + } + let run = cmd.output().expect("run compiled binary"); + let arm_label = if arm.is_empty() { + format!("{label}/default") + } else { + format!( + "{label}/{}", + arm.iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") + ) + }; + assert!( + run.status.success(), + "[{arm_label}] compiled binary failed (exit {:?})\nstderr:\n{}", + run.status.code(), + String::from_utf8_lossy(&run.stderr), + ); + let stdout = String::from_utf8_lossy(&run.stdout).into_owned(); + match &first { + None => first = Some(stdout), + Some(f) => assert_eq!( + &stdout, f, + "[{arm_label}] output differs between collector arms — a moving \ + minor left a stale root in this configuration" + ), + } + } + first.expect("at least one arm ran") +} + +#[test] +fn ta_view_accumulator_unroot_is_gc_correct() { + let dir = tempfile::tempdir().expect("tempdir"); + + // Rooted reference (fact disabled) and #8619 un-rooted arm, identical source. + let rooted_bin = compile(dir.path(), Some("0")); + let unrooted_bin = compile(dir.path(), None); + + let rooted_out = run_arms(&rooted_bin, dir.path(), "rooted"); + let unrooted_out = run_arms(&unrooted_bin, dir.path(), "unrooted"); + + assert!( + rooted_out.starts_with("acc:"), + "unexpected program output: {rooted_out:?}" + ); + assert_eq!( + rooted_out, unrooted_out, + "un-rooting the typed-array-derived accumulator (#8619) changed observable \ + output vs the rooted build — an un-rooted slot that can hold a pointer, or \ + a semantic divergence in the arithmetic fast path" + ); +}