Skip to content
14 changes: 14 additions & 0 deletions changelog.d/9360-u8-byte-reductions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
**Typed-array hot loops now retain their native numeric reductions.**
Buffer-backed `Uint8Array` reads recover the correct element on typed-array
registry misses and use a guard-validated inline byte-load lane for
module-global and declared-parameter receivers. Construction-proven
module-global numeric views now feed the same Number-by-construction proof as
body-local views, removing the rooted dynamic-add diamond from byte sums.

Bounded byte reductions may carry per-instruction `reassoc` when their complete
integer magnitude proof stays within the exact f64 range, allowing LLVM to
split the serial accumulator without enabling unsound global fast-math.
Module-init accumulators proven never to hold pointers also shed redundant
shadow slots and back-edge GC polls. On the measured `bench_buffer_readwrite`
shape this takes Perry from 94 ms to 34 ms against Node's 81 ms, while
unbounded f64 reductions remain unchanged.
28 changes: 28 additions & 0 deletions crates/perry-codegen/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,34 @@ impl LlBlock {
r
}

/// `fadd reassoc` for ONE instruction, independent of the module's
/// `--fast-math` setting (#9363).
///
/// Only the caller's proof licenses this: the addends are byte reads
/// (magnitude <= 255, or the `undefined`-box NaN out of range) and the
/// enclosing loop's trip count is bounded, so every partial sum is either
/// an exactly-representable integer far below 2^53 — where f64 addition
/// is associative, so ANY grouping is bit-identical — or a NaN, which
/// propagates through every grouping alike. That is an exactness argument
/// about the value range, not a tolerance argument, which is why it does
/// not need `--fast-math` (whose global reassociation is NOT sound for
/// arbitrary f64 chains and is correctly off by default).
///
/// `contract` is deliberately NOT added: FMA fusion changes rounding of
/// multiply/add pairs, which this proof says nothing about.
pub fn fadd_reassoc(&mut self, a: &str, b: &str) -> String {
let r = self.reg();
self.push_inst(crate::inst::LlInst::Bin {
dst: r.clone(),
op: "fadd",
pre: "reassoc ",
ty: "double",
a: a.to_string(),
b: b.to_string(),
});
r
}

pub fn fsub(&mut self, a: &str, b: &str) -> String {
let r = self.reg();
self.push_inst(crate::inst::LlInst::Bin {
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/codegen/closure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,8 @@ pub(super) fn compile_closure(
classes,
&cross_module.compile_time_constants,
&cross_module.module_dispatch,
// #9363: a closure body reads the same module-scope views.
&cross_module.module_global_proven_types,
);
if !versioned_loop_callback {
if let Some(callback_shapes) = cross_module.array_callback_shapes.get(&func_id) {
Expand Down
58 changes: 56 additions & 2 deletions crates/perry-codegen/src/codegen/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ pub(super) fn compile_module_entry(
main.mark_entry_init_boundary();
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let (main_shadow_slot_map, main_shadow_slot_clears_after_stmt) =
let (mut main_shadow_slot_map, _) =
enable_module_init_shadow_frame(main, &hir.init, &flat_const_ids);

let main_boxed_vars = module_boxed_vars.clone();
Expand Down Expand Up @@ -749,7 +749,34 @@ pub(super) fn compile_module_entry(
classes,
&cross_module.compile_time_constants,
&cross_module.module_dispatch,
// #9363: module-scope views need their construction proofs here
// too — passing an empty map kept top-level accumulator loops on
// the rooted/guarded path while in-function ones were clean.
&cross_module.module_global_proven_types,
);
// #9363: the same redundant-shadow-slot pruning `codegen/function.rs`
// does, which module init never got. The shadow map is built above
// from the CONSERVATIVE pointer-typed-locals scan, before the fact
// graph exists; a local the whole-write proof later shows can only
// hold a Number keeps a root slot it can never need. That slot is not
// just wasted stores: `local_is_inert_primitive` refuses any local
// with one, so the accumulator of `sum += buf[i]` was never inert,
// `loop_may_allocate` stayed true, and the inner loop kept a
// per-iteration volatile GC poll that blocks vectorization. In a
// function body the same loop was already clean — this was the whole
// top-level/in-function asymmetry.
//
// `enable_post_init_shadow_frame` sized the frame from the unpruned
// map, so the retained slot indices stay valid with holes, exactly as
// in the function-body twin.
main_shadow_slot_map.retain(|id, _| {
!main_native_facts
.number_by_construction_locals()
.contains(id)
});
let main_shadow_slot_clears_after_stmt =
crate::collectors::collect_shadow_slot_clear_points(&hir.init, &main_shadow_slot_map);

// #7109: the program-entry body participates in canonical (i32/u32/Str)
// selection on exactly the per-value rules a function body uses. There
// is no structural context reason to deny — see
Expand Down Expand Up @@ -1435,7 +1462,7 @@ pub(super) fn compile_module_entry(
init_fn.mark_entry_init_boundary();
let flat_const_ids: std::collections::HashSet<u32> =
cross_module.flat_const_arrays.keys().copied().collect();
let (init_shadow_slot_map, init_shadow_slot_clears_after_stmt) =
let (mut init_shadow_slot_map, _) =
enable_module_init_shadow_frame(init_fn, &hir.init, &flat_const_ids);

let init_boxed_vars = module_boxed_vars.clone();
Expand Down Expand Up @@ -1463,7 +1490,34 @@ pub(super) fn compile_module_entry(
classes,
&cross_module.compile_time_constants,
&cross_module.module_dispatch,
// #9363: module-scope views need their construction proofs here
// too — passing an empty map kept top-level accumulator loops on
// the rooted/guarded path while in-function ones were clean.
&cross_module.module_global_proven_types,
);
// #9363: the same redundant-shadow-slot pruning `codegen/function.rs`
// does, which module init never got. The shadow map is built above
// from the CONSERVATIVE pointer-typed-locals scan, before the fact
// graph exists; a local the whole-write proof later shows can only
// hold a Number keeps a root slot it can never need. That slot is not
// just wasted stores: `local_is_inert_primitive` refuses any local
// with one, so the accumulator of `sum += buf[i]` was never inert,
// `loop_may_allocate` stayed true, and the inner loop kept a
// per-iteration volatile GC poll that blocks vectorization. In a
// function body the same loop was already clean — this was the whole
// top-level/in-function asymmetry.
//
// `enable_post_init_shadow_frame` sized the frame from the unpruned
// map, so the retained slot indices stay valid with holes, exactly as
// in the function-body twin.
init_shadow_slot_map.retain(|id, _| {
!init_native_facts
.number_by_construction_locals()
.contains(id)
});
let init_shadow_slot_clears_after_stmt =
crate::collectors::collect_shadow_slot_clear_points(&hir.init, &init_shadow_slot_map);

// #7109: the module-init body participates in canonical (i32/u32/Str)
// selection on exactly the per-value rules a function body uses. There
// is no structural context reason to deny — see
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,10 @@ pub(super) fn compile_function(
&spec_i32_params,
&spec_numeric_params,
&spec_number_array_params,
// #9363: module-scope bindings whose CONSTRUCTION (not annotation)
// proves a numeric typed-array/Uint8Array kind, so `g[i]` off one is
// Number-or-`undefined` exactly as a body-local `const` view is.
&cross_module.module_global_proven_types,
);

// A Number-by-construction local cannot ever hold a GC pointer, so it
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/codegen/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ pub(super) fn compile_method(
classes,
&cross_module.compile_time_constants,
&cross_module.module_dispatch,
// #9363: a method body reads the same module-scope views.
&cross_module.module_global_proven_types,
);
let mut index_clone_integer_locals = native_facts.integer_locals().clone();
index_clone_integer_locals.extend(index_param_ids.iter().copied());
Expand Down Expand Up @@ -1565,6 +1567,8 @@ pub(super) fn compile_static_method(
classes,
&cross_module.compile_time_constants,
&cross_module.module_dispatch,
// #9363: a method body reads the same module-scope views.
&cross_module.module_global_proven_types,
);

// Representation-selection context gates (see codegen/function.rs).
Expand Down
28 changes: 28 additions & 0 deletions crates/perry-codegen/src/collectors/hir_facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pub(crate) struct RepresentationFacts {
/// — it never widens the parallel-shadow `needs_i32_slot` gate. See
/// `collectors/loop_bounded_i32.rs`.
pub loop_bounded_i32_locals: HashSet<u32>,
/// #9363: accumulators whose `acc = acc + <byte read>` chain provably
/// stays below 2^53, so the update's `fadd` may carry `reassoc` and the
/// reduction can be split into parallel partial sums. Same trip-count
/// proof as `loop_bounded_i32_locals`, weaker conclusion — it changes no
/// storage decision, only an FMF flag. See `collectors/loop_bounded_i32.rs`.
pub reassociable_f64_accumulators: HashSet<u32>,
/// Locals whose canonical-i32 promotion is PROVABLE but not PROFITABLE
/// (#7128): written after declaration, no i32-consuming read anywhere in
/// the body, and at least one double-consuming read inside a loop — so the
Expand Down Expand Up @@ -224,6 +230,10 @@ impl TypeFacts {
&self.representation.loop_bounded_i32_locals
}

pub(crate) fn reassociable_f64_accumulators(&self) -> &HashSet<u32> {
&self.representation.reassociable_f64_accumulators
}

pub(crate) fn unprofitable_canonical_i32_locals(&self) -> &HashSet<u32> {
&self.representation.unprofitable_canonical_i32_locals
}
Expand Down Expand Up @@ -464,6 +474,7 @@ pub(crate) fn collect_type_facts(
spec_i32_params: &HashSet<u32>,
spec_numeric_params: &HashSet<u32>,
spec_number_array_params: &HashSet<u32>,
module_global_proven_types: &HashMap<u32, perry_hir::types::Type>,
) -> TypeFacts {
// #7700: which locals hold a NUMBER, so a `u8[k]` keyed on one is a byte
// read rather than a property read. Computed once here because
Expand Down Expand Up @@ -540,6 +551,14 @@ pub(crate) fn collect_type_facts(
} else {
HashSet::new()
};
// #9363: the reassociation admission runs independently of the canonical
// i32 gate — it is not a storage decision, so `PERRY_CANONICAL_I32_LOCALS=0`
// must not silently disable it.
let reassociable_f64_accumulators =
super::loop_bounded_i32::collect_reassociable_f64_accumulators(
stmts,
compile_time_constants,
);
// #7123: this set now includes accumulators whose integer-ness and full
// range were proved together (for example `sum += i % 1000`). The older
// integer provenance collector deliberately does not accept bare `%`, so
Expand All @@ -560,6 +579,7 @@ pub(crate) fn collect_type_facts(
spec_ta_lens,
spec_numeric_params,
&not_bigint_locals,
module_global_proven_types,
);
let (mut array_facts, effect_facts, materialization_hazards) =
collect_array_facts(stmts, params, module_globals, binding_types);
Expand Down Expand Up @@ -714,6 +734,7 @@ pub(crate) fn collect_type_facts(
not_bigint_locals,
int_valued_ta_locals,
loop_bounded_i32_locals,
reassociable_f64_accumulators,
unprofitable_canonical_i32_locals,
number_by_construction_locals,
},
Expand Down Expand Up @@ -775,6 +796,7 @@ pub(crate) fn collect_native_region_fact_graph(
classes: &HashMap<String, &perry_hir::Class>,
compile_time_constants: &HashMap<u32, f64>,
module_dispatch: &super::ModuleDispatchFacts,
module_global_proven_types: &HashMap<u32, perry_hir::types::Type>,
) -> NativeRegionFactGraph {
collect_native_region_fact_graph_with_spec_params(
stmts,
Expand All @@ -792,6 +814,7 @@ pub(crate) fn collect_native_region_fact_graph(
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
module_global_proven_types,
)
}

Expand All @@ -815,6 +838,7 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_params(
spec_i32_params: &HashSet<u32>,
spec_numeric_params: &HashSet<u32>,
spec_number_array_params: &HashSet<u32>,
module_global_proven_types: &HashMap<u32, perry_hir::types::Type>,
) -> NativeRegionFactGraph {
collect_type_facts(
stmts,
Expand All @@ -832,6 +856,7 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_params(
spec_i32_params,
spec_numeric_params,
spec_number_array_params,
module_global_proven_types,
)
}

Expand Down Expand Up @@ -861,6 +886,7 @@ pub(crate) fn collect_hir_facts(
&HashSet::new(),
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
)
}

Expand Down Expand Up @@ -2189,6 +2215,7 @@ mod tests {
&HashMap::new(),
&constants,
&crate::collectors::ModuleDispatchFacts::default(),
&HashMap::new(),
);

assert!(graph.known_noalias_buffer_locals().contains(&1));
Expand Down Expand Up @@ -2280,6 +2307,7 @@ mod tests {
&HashMap::new(),
&HashMap::new(),
&crate::collectors::ModuleDispatchFacts::default(),
&HashMap::new(),
);

assert!(graph.integer_locals().contains(&1));
Expand Down
Loading
Loading