From c90904a8f4a21b4d2fd1f7fd44a0e7ae3ae47fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 22 Sep 2026 00:39:59 +0000 Subject: [PATCH 1/3] step 4b slice 2: a run of reads across STATEMENTS, guarded ONCE (#10884) Slice 1 guarded runs that already sat inside one `+` tree. Slice 2 takes the runs spelled across statements, which is the larger population: const a = o.a; const b = o.b; const c = o.c; // one run, ONE guard On a tsc compile it forms 568 regions covering 1511 reads, against slice 1's 28 / 56, and against 623 candidate runs / 1778 candidate reads - 91% of the runs, 85% of the reads. The guard is now shared. `expr/region_guard.rs` holds the state word, R1, R2, the bounded prime and the miss edges; the two slices are matchers over it, so they cannot drift into two guards with two soundness arguments. Slice 1's cells are unchanged by the move (k4 54.00, w4 119.00, kp4 53.00). Slice 2 cannot duplicate the run the way `masked_window_region` does, and that module's doc says why: it refuses `Stmt::Let` outright, because a Let lowered once per copy allocates an entry alloca per copy and `ctx.locals[id]` then names only the last one. Lets ARE this slice's population. "Load all, phi, bind" is also unavailable: the bail arm would hold earlier values in registers across later reads, and a generic read can reach a getter, allocate and move the heap. So: declare each binding first through the ordinary Let path (one slot, dominating both arms), then one R1 for the run; the fast arm stores each loaded slot into its binding immediately, so a value is rooted before the next read; the bail arm assigns the same slots in source order through today's lowering. No phi. R3 is not needed here - nothing is hoisted across an operator - so unlike slice 1 this admits string- and object-valued fields. Two things measurement changed: * A binding whose initialiser refines its type is DECLINED. `let_stmt` refines a declared Any from the initialiser, and this slice declares without one, so every later use of such a binding deoptimised: 670 -> 2694 instructions per iteration on four string fields, a 4x regression with the region working as designed. Reaching that population means declaring with the refined type AND discharging its store obligations, which is a later slice. * The census now walks CLOSURE bodies. Without that it undercounted tsc 6.5x (88 runs reported while the matcher formed 568), because a CJS bundle keeps nearly all of its code inside the factory closure. --- crates/perry-codegen/src/codegen/mod.rs | 2 +- crates/perry-codegen/src/expr/mod.rs | 1 + crates/perry-codegen/src/expr/region_guard.rs | 426 ++++++++++++++++++ .../perry-codegen/src/expr/region_read_run.rs | 301 +------------ crates/perry-codegen/src/stmt/mod.rs | 19 + .../src/stmt/region_read_stmts.rs | 421 +++++++++++++++++ 6 files changed, 887 insertions(+), 283 deletions(-) create mode 100644 crates/perry-codegen/src/expr/region_guard.rs create mode 100644 crates/perry-codegen/src/stmt/region_read_stmts.rs diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index b62a7de742..7e0794e2ad 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -431,7 +431,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let triple = opts.target.clone().unwrap_or_else(default_target_triple); // `PERRY_REGION_DIAG=1`: report step 4b's regions and the statement-level // runs it does not reach, when this module's codegen ends. - let _region_diag = crate::expr::region_read_run::ModuleDiag::start(hir); + let _region_diag = crate::expr::region_guard::ModuleDiag::start(hir); let fp_flags = crate::block::FpFlags::new(opts.fast_math, opts.fp_contract_mode); // #5334 lever B: decide ONCE, up front, whether this module is large enough diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 14fd13db58..a54e39513a 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2995,6 +2995,7 @@ pub(crate) mod calls; mod child_proc; mod closure; mod compare; +pub(crate) mod region_guard; pub(crate) mod region_read_run; pub(crate) use compare::lower_string_literal_strict_eq; #[cfg(test)] diff --git a/crates/perry-codegen/src/expr/region_guard.rs b/crates/perry-codegen/src/expr/region_guard.rs new file mode 100644 index 0000000000..8a1b91a9fe --- /dev/null +++ b/crates/perry-codegen/src/expr/region_guard.rs @@ -0,0 +1,426 @@ +//! The region guard itself (#10884) — one entry, shared by every slice. +//! +//! A region is a single-entry run of accesses over which one receiver's +//! `(unmasked pointer, ShapeId)` pair is held, entered through ONE shape +//! compare, whose failure leaves for a generic copy of the whole run and never +//! rejoins it (design doc §L7.1–L7.3). What varies between slices is only +//! WHERE the run is found and what is done with the loaded values: +//! +//! * slice 1 (`crate::expr::region_read_run`) — a run inside one `+` tree; +//! * slice 2 (`crate::stmt::region_read_stmts`) — a run across statements. +//! +//! Everything else — the state word, R1, R2, the bounded prime, the miss +//! edges — lives here, so the two slices cannot drift into two guards with two +//! soundness arguments. The emitted sequence is: +//! +//! ```text +//! [R1] guard tag test + unmask + ONE ShapeId compare ─┐ the only two +//! [R2] load every key's slot, from one atomic region word │ bail edges, +//! (a slice may then verify, then use) ─┘ before any effect +//! ``` +//! +//! # Supplier +//! +//! The expected ShapeId is learned (supplier (b), §L14.18.4): a per-region +//! atomic word primed on a miss by `js_region_guard_prime`. The id and every +//! key's slot live in ONE word so a concurrent prime can never pair one +//! shape's id with another's slots. A link-time constant (step 4) would +//! replace the word load and nothing else. +//! +//! # The miss price, measured +//! +//! A region that never hits costs **~19 instructions per execution**: 17 for +//! this prologue (it must materialise the operands before the compare can +//! exist) and 3 for the retirement bookkeeping, which every miss pays even +//! after priming retires. Measured with the kill switch on an `Object.create` +//! receiver, whose fields spill and therefore can never pack a word: +//! `ocr2` 75 → 94, `ocr4` 450 → 470, against `lit4` (same source, literal +//! receiver) 216 → 53. Price a guard by everything that must execute to reach +//! its branch, not by the branch (§L7.6.3). + +use std::cell::Cell; + +use crate::expr::FnCtx; +use crate::nanbox::POINTER_MASK_I64; +use crate::types::{DOUBLE, I32, I64, PTR}; + +/// Keys one word can address. Must equal +/// `perry_runtime::object::shapes::REGION_GUARD_MAX_KEYS`. +pub(crate) const MAX_KEYS: usize = 5; +/// Must equal the runtime's slot width. +const SLOT_BITS: u32 = 6; +/// `REGION_GUARD_WORD_EMPTY`: low half `u32::MAX`, never a live ShapeId. +const EMPTY_WORD: &str = "4294967295"; +/// Primes attempted per region before it stops trying (process lifetime). +const PRIME_ATTEMPTS: &str = "8"; +/// A small-handle band sits under the pointer tag; its ids are not addresses. +const SMALL_HANDLE_MAX: &str = "1048575"; + +thread_local! { + /// Non-zero while a generic copy is being lowered. The generic copy lowers + /// the SAME code through the ordinary dispatch, which would otherwise form + /// the same region again inside itself. + static SUPPRESS: Cell = const { Cell::new(0) }; + static REGIONS_EXPR: Cell = const { Cell::new(0) }; + static READS_EXPR: Cell = const { Cell::new(0) }; + static REGIONS_STMT: Cell = const { Cell::new(0) }; + static READS_STMT: Cell = const { Cell::new(0) }; +} + +pub(crate) struct Suppressed; + +impl Suppressed { + pub(crate) fn enter() -> Self { + SUPPRESS.with(|s| s.set(s.get() + 1)); + Suppressed + } +} + +impl Drop for Suppressed { + fn drop(&mut self) { + SUPPRESS.with(|s| s.set(s.get() - 1)); + } +} + +pub(crate) fn suppressed() -> bool { + SUPPRESS.with(|s| s.get()) > 0 +} + +/// `PERRY_REGION_READS=0` — the kill switch both slices honour, so an A/B can +/// switch the feature off in ONE compiler instead of comparing two builds +/// (§L7.6.2: two builds of the same program differ by ~1.2% on tsc). +pub(crate) fn disabled() -> bool { + matches!( + std::env::var("PERRY_REGION_READS").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ) +} + +/// A region is not formed while a profiling build is recording guard pass/fail +/// on the per-access towers: a read served before them would change a signal +/// that must stay byte-identical. +pub(crate) fn emission_allowed() -> bool { + !suppressed() && !disabled() && !crate::expr::typed_feedback_emission_enabled() +} + +pub(crate) fn note_expr_region(reads: u64) { + REGIONS_EXPR.with(|c| c.set(c.get() + 1)); + READS_EXPR.with(|c| c.set(c.get() + reads)); +} + +pub(crate) fn note_stmt_region(reads: u64) { + REGIONS_STMT.with(|c| c.set(c.get() + 1)); + READS_STMT.with(|c| c.set(c.get() + reads)); +} + +/// One region's state: the learned word and its bounded attempt counter. +pub(crate) struct Sites { + pub(crate) word_g: String, + pub(crate) tries_g: String, +} + +pub(crate) fn state_globals(ctx: &mut FnCtx<'_>) -> Sites { + let site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let base = crate::expr::inline_cache_global_name(ctx, site); + let word_g = format!("@{base}_region"); + let tries_g = format!("@{base}_region_tries"); + ctx.typed_parse_rodata.push(format!( + "{word_g} = private global i64 {EMPTY_WORD}, align 8" + )); + ctx.typed_parse_rodata + .push(format!("{tries_g} = private global i32 0, align 4")); + Sites { word_g, tries_g } +} + +/// What R1 proved and what R2 needs: the unmasked receiver pointer (held in a +/// register for the whole region — reads 2..n re-pay none of the tag test), +/// the loaded word, and the receiver's own ShapeId for the prime path. +pub(crate) struct Entry { + pub(crate) handle: String, + pub(crate) word: String, + pub(crate) sid: String, +} + +/// R1, emitted from the CURRENT block. On a hit control reaches `hit_l`; a +/// shape mismatch goes to `miss_l`; a receiver that is not a heap object at +/// all goes straight to `generic_l`. +pub(crate) fn emit_r1( + ctx: &mut FnCtx<'_>, + recv: &str, + sites: &Sites, + hit_l: &str, + miss_l: &str, + generic_l: &str, +) -> Entry { + let handle_idx = ctx.new_block("region.handle"); + let r1_idx = ctx.new_block("region.r1"); + let handle_l = ctx.block_label(handle_idx); + let r1_l = ctx.block_label(r1_idx); + + // R1, part 1: the receiver is a heap object pointer. + let bits = ctx.block().bitcast_double_to_i64(recv); + let top = ctx.block().lshr(I64, &bits, "48"); + let is_ptr = ctx.block().icmp_eq(I64, &top, "32765"); // 0x7FFD, the pointer tag + ctx.block().cond_br(&is_ptr, &handle_l, generic_l); + + ctx.current_block = handle_idx; + let handle = ctx.block().and(I64, &bits, POINTER_MASK_I64); + let real = ctx.block().icmp_ugt(I64, &handle, SMALL_HANDLE_MAX); + ctx.block().cond_br(&real, &r1_l, generic_l); + + // R1, part 2: ONE shape compare against the learned region word. By + // #10828's rule 3 only a GC_TYPE_OBJECT carrying that shape can match, so + // this compare is the whole receiver classification. + ctx.current_block = r1_idx; + let word = ctx.block().load_atomic_monotonic(I64, &sites.word_g, 8); + let expected = ctx.block().trunc(I64, &word, I32); + let sid_addr = ctx.block().add(I64, &handle, "4"); + let sid_ptr = ctx.block().inttoptr(I64, &sid_addr); + let sid = ctx.block().load(I32, &sid_ptr); + let hit = ctx.block().icmp_eq(I32, &sid, &expected); + ctx.block().cond_br(&hit, hit_l, miss_l); + + Entry { handle, word, sid } +} + +/// R2: every key's slot, addressed from the same unmasked pointer and the same +/// word. Uniform addressing is what lets LLVM turn the run into one +/// `vgatherqpd` (§L7.6.1). +pub(crate) fn emit_slot_loads(ctx: &mut FnCtx<'_>, entry: &Entry, keys: usize) -> Vec { + let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let fields = ctx.block().add(I64, &entry.handle, &header); + let fields_ptr = ctx.block().inttoptr(I64, &fields); + let mut values = Vec::with_capacity(keys); + for i in 0..keys { + let shift = (32 + SLOT_BITS * i as u32).to_string(); + let shifted = ctx.block().lshr(I64, &entry.word, &shift); + let slot = ctx.block().and(I64, &shifted, "63"); + let field_ptr = ctx.block().gep(DOUBLE, &fields_ptr, &[(I64, &slot)]); + values.push(ctx.block().load(DOUBLE, &field_ptr)); + } + values +} + +/// The miss block: prime at most `PRIME_ATTEMPTS` times for the life of the +/// process, so a polymorphic or spill-located site stops paying for it. +pub(crate) fn emit_miss( + ctx: &mut FnCtx<'_>, + sites: &Sites, + prime_l: &str, + generic_l: &str, +) -> String { + let tries = ctx.block().load(I32, &sites.tries_g); + let may_prime = ctx.block().icmp_ult(I32, &tries, PRIME_ATTEMPTS); + ctx.block().cond_br(&may_prime, prime_l, generic_l); + tries +} + +/// The prime block: bump the counter and hand the runtime the site word plus +/// the receiver's own shape and this region's keys. +/// +/// The runtime packs AND publishes (`js_region_guard_prime`): a cache word's +/// store belongs to the code that owns its memory ordering, the same split the +/// property IC uses. Emitting the store here instead cost a real program — +/// `store atomic` parses in the textual backend but NOT in perry's native IR +/// construction, which every large module takes, so tsc failed codegen in 20 +/// of 50 units while every fixture built. +pub(crate) fn emit_prime( + ctx: &mut FnCtx<'_>, + sites: &Sites, + entry: &Entry, + tries: &str, + keys: &[&str], + generic_l: &str, +) { + let next_tries = ctx.block().add(I32, tries, "1"); + ctx.block().store(I32, &next_tries, &sites.tries_g); + let mut key_bits: Vec = Vec::with_capacity(MAX_KEYS); + for i in 0..MAX_KEYS { + if let Some(key) = keys.get(i) { + let idx = ctx.strings.intern(key); + let handle_global = format!("@{}", ctx.strings.entry(idx).handle_global); + let boxed = ctx.block().load(DOUBLE, &handle_global); + key_bits.push(ctx.block().bitcast_double_to_i64(&boxed)); + } else { + key_bits.push("0".to_string()); + } + } + let n = keys.len().to_string(); + let word_ptr = sites.word_g.clone(); + ctx.block().call( + I64, + "js_region_guard_prime", + &[ + (PTR, &word_ptr), + (I32, &entry.sid), + (I32, &n), + (I64, &key_bits[0]), + (I64, &key_bits[1]), + (I64, &key_bits[2]), + (I64, &key_bits[3]), + (I64, &key_bits[4]), + ], + ); + ctx.block().br(generic_l); +} + +/// `PERRY_REGION_DIAG=1`: per module, what each slice formed and how many +/// statement-level runs the static census found — so "what is still uncovered" +/// is a measured number rather than an estimate. Slice 1's PR reported 28 +/// regions / 56 reads covered on a tsc compile against 88 runs / 205 reads +/// uncovered; slice 2 is aimed at the second pair. +pub(crate) struct ModuleDiag { + census: Option<(u64, u64)>, + name: String, +} + +impl ModuleDiag { + pub(crate) fn start(hir: &perry_hir::Module) -> Self { + REGIONS_EXPR.with(|c| c.set(0)); + READS_EXPR.with(|c| c.set(0)); + REGIONS_STMT.with(|c| c.set(0)); + READS_STMT.with(|c| c.set(0)); + let on = std::env::var("PERRY_REGION_DIAG").ok().as_deref() == Some("1"); + ModuleDiag { + census: on.then(|| statement_run_census(hir)), + name: hir.name.clone(), + } + } +} + +impl Drop for ModuleDiag { + fn drop(&mut self) { + if let Some((runs, reads)) = self.census { + eprintln!( + "[perry region] module={} regions={} reads_covered={} stmt_regions={} stmt_reads_covered={} statement_runs_seen={} statement_reads_seen={}", + self.name, + REGIONS_EXPR.with(|c| c.get()), + READS_EXPR.with(|c| c.get()), + REGIONS_STMT.with(|c| c.get()), + READS_STMT.with(|c| c.get()), + runs, + reads + ); + } + } +} + +/// Runs of two or more consecutive same-receiver reads bound across +/// STATEMENTS. This is the population slice 2 targets; the difference between +/// it and `stmt_regions` is what slice 2 still declines. +/// +/// It walks CLOSURE bodies too. Leaving them out undercounted a real program +/// by 6.5x — a CJS bundle puts nearly all of its code inside the factory +/// closure, so tsc reported 88 candidate runs while the matcher was forming +/// 568 regions. A census that cannot see where the code lives is not a +/// census. +fn statement_run_census(hir: &perry_hir::Module) -> (u64, u64) { + let mut acc = (0u64, 0u64); + census_with_closures(&hir.init, &mut acc); + for f in &hir.functions { + census_with_closures(&f.body, &mut acc); + } + for c in &hir.classes { + for m in c.methods.iter().chain(c.static_methods.iter()) { + census_with_closures(&m.body, &mut acc); + } + if let Some(ctor) = &c.constructor { + census_with_closures(&ctor.body, &mut acc); + } + } + acc +} + +/// A statement list and every closure body inside it. A CJS bundle keeps +/// nearly all of its code in the factory closure, so a census that stops at +/// the statement list undercounts it — tsc reported 88 candidate runs while +/// the matcher was forming 568 regions. +fn census_with_closures(stmts: &[perry_hir::Stmt], acc: &mut (u64, u64)) { + census_stmts(stmts, acc); + let mut seen = std::collections::HashSet::new(); + let mut closures = Vec::new(); + crate::collectors::collect_closures_in_stmts(stmts, &mut seen, &mut closures); + for (_, expr) in &closures { + if let perry_hir::Expr::Closure { body, .. } = expr { + census_stmts(body, acc); + } + } +} + +/// The receiver a `const x = r.k;` statement reads, if it is one. +pub(crate) fn let_read_receiver(stmt: &perry_hir::Stmt) -> Option { + let perry_hir::Stmt::Let { + init: Some(perry_hir::Expr::PropertyGet { object, .. }), + .. + } = stmt + else { + return None; + }; + match object.as_ref() { + perry_hir::Expr::LocalGet(id) => Some(*id), + _ => None, + } +} + +fn census_stmts(stmts: &[perry_hir::Stmt], acc: &mut (u64, u64)) { + use perry_hir::Stmt; + let mut run_receiver: Option = None; + let mut run_len = 0u64; + let flush = |len: u64, acc: &mut (u64, u64)| { + if len >= 2 { + acc.0 += 1; + acc.1 += len; + } + }; + for stmt in stmts { + match let_read_receiver(stmt) { + Some(r) if run_receiver == Some(r) => run_len += 1, + Some(r) => { + flush(run_len, acc); + run_receiver = Some(r); + run_len = 1; + } + None => { + flush(run_len, acc); + run_receiver = None; + run_len = 0; + } + } + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + census_stmts(then_branch, acc); + if let Some(eb) = else_branch { + census_stmts(eb, acc); + } + } + Stmt::For { body, .. } | Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + census_stmts(body, acc) + } + Stmt::Try { + body, + catch, + finally, + } => { + census_stmts(body, acc); + if let Some(c) = catch { + census_stmts(&c.body, acc); + } + if let Some(f) = finally { + census_stmts(f, acc); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + census_stmts(&case.body, acc); + } + } + _ => {} + } + } + flush(run_len, acc); +} diff --git a/crates/perry-codegen/src/expr/region_read_run.rs b/crates/perry-codegen/src/expr/region_read_run.rs index 99dae59c5d..7ef8c2dc99 100644 --- a/crates/perry-codegen/src/expr/region_read_run.rs +++ b/crates/perry-codegen/src/expr/region_read_run.rs @@ -53,54 +53,14 @@ //! there). Priming is bounded to `PRIME_ATTEMPTS` per region for the life of //! the process, so a polymorphic or inherited-read site stops paying for it. -use std::cell::Cell; - use anyhow::Result; use perry_hir::{BinaryOp, Expr}; +use super::region_guard::{ + self, emit_miss, emit_prime, emit_r1, emit_slot_loads, state_globals, MAX_KEYS, +}; use super::{lower_expr, FnCtx}; -use crate::nanbox::POINTER_MASK_I64; -use crate::types::{DOUBLE, I1, I32, I64, PTR}; - -/// Must equal `perry_runtime::object::shapes::REGION_GUARD_MAX_KEYS`. -const MAX_KEYS: usize = 5; -/// Must equal the runtime's slot width. -const SLOT_BITS: u32 = 6; -/// `REGION_GUARD_WORD_EMPTY`: low half `u32::MAX`, never a live ShapeId. -const EMPTY_WORD: &str = "4294967295"; -/// Primes attempted per region before it stops trying (process lifetime). -const PRIME_ATTEMPTS: &str = "8"; -/// A small-handle band sits under the pointer tag; its ids are not addresses. -const SMALL_HANDLE_MAX: &str = "1048575"; - -thread_local! { - /// Non-zero while the generic copy of a region is being lowered. The - /// generic copy lowers the SAME tree through the ordinary dispatch, which - /// would otherwise form the same region again inside itself. - static SUPPRESS: Cell = const { Cell::new(0) }; - static REGIONS_FORMED: Cell = const { Cell::new(0) }; - static READS_COVERED: Cell = const { Cell::new(0) }; -} - -struct Suppressed; -impl Suppressed { - fn enter() -> Self { - SUPPRESS.with(|s| s.set(s.get() + 1)); - Suppressed - } -} -impl Drop for Suppressed { - fn drop(&mut self) { - SUPPRESS.with(|s| s.set(s.get() - 1)); - } -} - -fn disabled() -> bool { - matches!( - std::env::var("PERRY_REGION_READS").as_deref(), - Ok("0") | Ok("off") | Ok("false") - ) -} +use crate::types::{DOUBLE, I1}; enum Leaf<'a> { /// A read of the region's receiver; the index names its key. @@ -197,40 +157,21 @@ pub(crate) fn try_lower_region_add_tree( ctx: &mut FnCtx<'_>, expr: &Expr, ) -> Result> { - if SUPPRESS.with(|s| s.get()) > 0 || disabled() { - return Ok(None); - } - // Profiling builds record guard pass/fail on the per-access towers; a read - // served before them would change a signal that must stay byte-identical. - if crate::expr::typed_feedback_emission_enabled() { + if !region_guard::emission_allowed() { return Ok(None); } let Some(plan) = plan(expr) else { return Ok(None); }; - REGIONS_FORMED.with(|c| c.set(c.get() + 1)); - READS_COVERED.with(|c| { - c.set( - c.get() - + plan - .leaves - .iter() - .filter(|l| matches!(l, Leaf::Region(_))) - .count() as u64, - ) - }); + region_guard::note_expr_region( + plan.leaves + .iter() + .filter(|l| matches!(l, Leaf::Region(_))) + .count() as u64, + ); // Region state: one atomic word (id + slots) and a prime-attempt counter. - let site = ctx.ic_site_counter; - ctx.ic_site_counter += 1; - let base = crate::expr::inline_cache_global_name(ctx, site); - let word_g = format!("@{base}_region"); - let tries_g = format!("@{base}_region_tries"); - ctx.typed_parse_rodata.push(format!( - "{word_g} = private global i64 {EMPTY_WORD}, align 8" - )); - ctx.typed_parse_rodata - .push(format!("{tries_g} = private global i32 0, align 4")); + let sites = state_globals(ctx); // The non-receiver leaves first. They are effect-free, and lowering them // before the receiver means nothing that could allocate runs between the @@ -252,16 +193,12 @@ pub(crate) fn try_lower_region_add_tree( } } - let handle_idx = ctx.new_block("region.handle"); - let r1_idx = ctx.new_block("region.r1"); let r2_idx = ctx.new_block("region.r2"); let fold_idx = ctx.new_block("region.fold"); let miss_idx = ctx.new_block("region.miss"); let prime_idx = ctx.new_block("region.prime"); let generic_idx = ctx.new_block("region.generic"); let merge_idx = ctx.new_block("region.merge"); - let handle_l = ctx.block_label(handle_idx); - let r1_l = ctx.block_label(r1_idx); let r2_l = ctx.block_label(r2_idx); let fold_l = ctx.block_label(fold_idx); let miss_l = ctx.block_label(miss_idx); @@ -269,45 +206,14 @@ pub(crate) fn try_lower_region_add_tree( let generic_l = ctx.block_label(generic_idx); let merge_l = ctx.block_label(merge_idx); - // R1, part 1: the receiver is a heap object pointer. + // R1: one guard for the whole run (shared emitter). let recv = lower_expr(ctx, &Expr::LocalGet(plan.receiver))?; - let bits = ctx.block().bitcast_double_to_i64(&recv); - let top = ctx.block().lshr(I64, &bits, "48"); - let is_ptr = ctx.block().icmp_eq(I64, &top, "32765"); // 0x7FFD, the pointer tag - ctx.block().cond_br(&is_ptr, &handle_l, &generic_l); - - ctx.current_block = handle_idx; - let handle = ctx.block().and(I64, &bits, POINTER_MASK_I64); - let real = ctx.block().icmp_ugt(I64, &handle, SMALL_HANDLE_MAX); - ctx.block().cond_br(&real, &r1_l, &generic_l); - - // R1, part 2: ONE shape compare against the learned region word. By - // #10828's rule 3 only a GC_TYPE_OBJECT carrying that shape can match, so - // this compare is the whole receiver classification. - ctx.current_block = r1_idx; - let word_ptr = word_g.clone(); - let word = ctx.block().load_atomic_monotonic(I64, &word_ptr, 8); - let expected = ctx.block().trunc(I64, &word, I32); - let sid_addr = ctx.block().add(I64, &handle, "4"); - let sid_ptr = ctx.block().inttoptr(I64, &sid_addr); - let sid = ctx.block().load(I32, &sid_ptr); - let hit = ctx.block().icmp_eq(I32, &sid, &expected); - ctx.block().cond_br(&hit, &r2_l, &miss_l); + let entry = emit_r1(ctx, &recv, &sites, &r2_l, &miss_l, &generic_l); // R2 + R3: every key's slot from the same word, then prove every leaf is a // Number before any addition runs. ctx.current_block = r2_idx; - let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let fields = ctx.block().add(I64, &handle, &header); - let fields_ptr = ctx.block().inttoptr(I64, &fields); - let mut key_values: Vec = Vec::with_capacity(plan.keys.len()); - for i in 0..plan.keys.len() { - let shift = (32 + SLOT_BITS * i as u32).to_string(); - let shifted = ctx.block().lshr(I64, &word, &shift); - let slot = ctx.block().and(I64, &shifted, "63"); - let field_ptr = ctx.block().gep(DOUBLE, &fields_ptr, &[(I64, &slot)]); - key_values.push(ctx.block().load(DOUBLE, &field_ptr)); - } + let key_values = emit_slot_loads(ctx, &entry, plan.keys.len()); let mut leaf_values: Vec = Vec::with_capacity(plan.leaves.len()); for (i, leaf) in plan.leaves.iter().enumerate() { leaf_values.push(match leaf { @@ -337,55 +243,18 @@ pub(crate) fn try_lower_region_add_tree( let fast_end = ctx.block().label.clone(); ctx.block().br(&merge_l); - // Miss: prime at most PRIME_ATTEMPTS times for the life of the process. + // Miss: prime a bounded number of times, then retire (shared emitter). ctx.current_block = miss_idx; - let tries = ctx.block().load(I32, &tries_g); - let may_prime = ctx.block().icmp_ult(I32, &tries, PRIME_ATTEMPTS); - ctx.block().cond_br(&may_prime, &prime_l, &generic_l); - + let tries = emit_miss(ctx, &sites, &prime_l, &generic_l); ctx.current_block = prime_idx; - let next_tries = ctx.block().add(I32, &tries, "1"); - ctx.block().store(I32, &next_tries, &tries_g); - let mut key_bits: Vec = Vec::with_capacity(MAX_KEYS); - for i in 0..MAX_KEYS { - if let Some(key) = plan.keys.get(i) { - let idx = ctx.strings.intern(key); - let handle_global = format!("@{}", ctx.strings.entry(idx).handle_global); - let boxed = ctx.block().load(DOUBLE, &handle_global); - key_bits.push(ctx.block().bitcast_double_to_i64(&boxed)); - } else { - key_bits.push("0".to_string()); - } - } - let n = plan.keys.len().to_string(); - // The runtime packs AND publishes: a cache word's store belongs to the - // code that owns its memory ordering (`js_region_guard_prime`), the same - // split the property IC uses. Emitting the store here instead cost a real - // program: `store atomic` parses in the textual backend but not in - // perry's native IR construction, which every large module takes, so tsc - // failed codegen in 20 of 50 units while every fixture built. - ctx.block().call( - I64, - "js_region_guard_prime", - &[ - (PTR, &word_ptr), - (I32, &sid), - (I32, &n), - (I64, &key_bits[0]), - (I64, &key_bits[1]), - (I64, &key_bits[2]), - (I64, &key_bits[3]), - (I64, &key_bits[4]), - ], - ); - ctx.block().br(&generic_l); + emit_prime(ctx, &sites, &entry, &tries, &plan.keys, &generic_l); // Generic copy: the same tree through the ordinary dispatch, in source // order — the code this region replaces. ctx.current_block = generic_idx; crate::expr::emit_versioned_loop_callback_deopt(ctx); let slow = { - let _suppressed = Suppressed::enter(); + let _suppressed = region_guard::Suppressed::enter(); lower_expr(ctx, expr)? }; let slow_end = ctx.block().label.clone(); @@ -398,138 +267,6 @@ pub(crate) fn try_lower_region_add_tree( )) } -/// `PERRY_REGION_DIAG=1`: per module, how many regions slice 1 formed, how many -/// reads they cover, and — the number that sizes slice 2 on real code — how -/// many runs of two or more consecutive same-receiver reads sit across -/// STATEMENTS where this slice does not reach them. -pub(crate) struct ModuleDiag { - census: Option<(u64, u64)>, - name: String, -} - -impl ModuleDiag { - pub(crate) fn start(hir: &perry_hir::Module) -> Self { - REGIONS_FORMED.with(|c| c.set(0)); - READS_COVERED.with(|c| c.set(0)); - let on = std::env::var("PERRY_REGION_DIAG").ok().as_deref() == Some("1"); - ModuleDiag { - census: on.then(|| statement_run_census(hir)), - name: hir.name.clone(), - } - } -} - -impl Drop for ModuleDiag { - fn drop(&mut self) { - if let Some((runs, reads)) = self.census { - eprintln!( - "[perry region] module={} regions={} reads_covered={} statement_runs_uncovered={} statement_reads_uncovered={}", - self.name, - REGIONS_FORMED.with(|c| c.get()), - READS_COVERED.with(|c| c.get()), - runs, - reads - ); - } - } -} - -fn statement_run_census(hir: &perry_hir::Module) -> (u64, u64) { - let mut acc = (0u64, 0u64); - census_stmts(&hir.init, &mut acc); - for f in &hir.functions { - census_stmts(&f.body, &mut acc); - } - for c in &hir.classes { - for m in c - .methods - .iter() - .chain(c.static_methods.iter()) - .chain(c.constructor.iter()) - { - census_stmts(&m.body, &mut acc); - } - } - acc -} - -/// A statement-level read: `let x = .`. -fn let_read_receiver(stmt: &perry_hir::Stmt) -> Option { - let perry_hir::Stmt::Let { - init: Some(Expr::PropertyGet { object, .. }), - .. - } = stmt - else { - return None; - }; - match object.as_ref() { - Expr::LocalGet(id) => Some(*id), - _ => None, - } -} - -fn census_stmts(stmts: &[perry_hir::Stmt], acc: &mut (u64, u64)) { - use perry_hir::Stmt; - let mut run_receiver: Option = None; - let mut run_len = 0u64; - let flush = |len: u64, acc: &mut (u64, u64)| { - if len >= 2 { - acc.0 += 1; - acc.1 += len; - } - }; - for stmt in stmts { - match let_read_receiver(stmt) { - Some(r) if run_receiver == Some(r) => run_len += 1, - Some(r) => { - flush(run_len, acc); - run_receiver = Some(r); - run_len = 1; - } - None => { - flush(run_len, acc); - run_receiver = None; - run_len = 0; - } - } - match stmt { - Stmt::If { - then_branch, - else_branch, - .. - } => { - census_stmts(then_branch, acc); - if let Some(eb) = else_branch { - census_stmts(eb, acc); - } - } - Stmt::For { body, .. } | Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { - census_stmts(body, acc) - } - Stmt::Try { - body, - catch, - finally, - } => { - census_stmts(body, acc); - if let Some(c) = catch { - census_stmts(&c.body, acc); - } - if let Some(f) = finally { - census_stmts(f, acc); - } - } - Stmt::Switch { cases, .. } => { - for case in cases { - census_stmts(&case.body, acc); - } - } - _ => {} - } - } - flush(run_len, acc); -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 34931bcadf..31e8893c59 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -43,6 +43,7 @@ mod masked_window_region; mod prealloc_module_global_tests; #[cfg(test)] mod prealloc_tdz_path_tests; +mod region_read_stmts; pub(crate) mod stable_packed_accumulator; pub(crate) mod stable_packed_loop; mod stable_packed_typed_array; @@ -233,6 +234,24 @@ fn lower_stmts_inner(ctx: &mut FnCtx<'_>, stmts: &[Stmt], emit_shadow_clears: bo } continue; } + // Step 4b slice 2 (#10884): a run of reads on ONE receiver bound + // across statements is guarded ONCE, not once per read. Slice 1 takes + // the runs that sit inside one `+` tree; this takes the runs spelled + // across statements, which a tsc census puts at 3.7x as many reads. + if let Some(run) = region_read_stmts::try_match(ctx, &stmts[i..]) { + let end = i + run.len; + region_read_stmts::lower(ctx, &stmts[i..end], &run)?; + if emit_shadow_clears { + for j in i..end { + emit_shadow_clears_after_stmt(ctx, j); + } + } + i = end; + if ctx.block().is_terminated() { + break; + } + continue; + } lower_stmt(ctx, &stmts[i])?; // Representation-selection Phase 2: a TOP-LEVEL `Stmt::Let` of a // pre-pass-proven typed-array binding makes the binding "ready" — the diff --git a/crates/perry-codegen/src/stmt/region_read_stmts.rs b/crates/perry-codegen/src/stmt/region_read_stmts.rs new file mode 100644 index 0000000000..30d60b818a --- /dev/null +++ b/crates/perry-codegen/src/stmt/region_read_stmts.rs @@ -0,0 +1,421 @@ +//! Step 4b, stage 2, slice 2 — a read region formed across STATEMENTS +//! (#10884). +//! +//! Slice 1 guarded a run of reads that already sat inside one `+` tree. The +//! same program spelled across statements is the larger population: on a tsc +//! compile slice 1 covered 28 regions / 56 reads while the census counted +//! **88 runs / 205 reads** in this shape, which is what this slice takes. +//! +//! ```text +//! const a = o.a; const b = o.b; const c = o.c; // one run, three reads +//! ``` +//! +//! # Why this cannot be slice 1 with a different matcher +//! +//! [`crate::stmt::masked_window_region`] — the existing straight-line +//! statement-run speculation — refuses `Stmt::Let` outright, and its reason is +//! this slice's whole design problem: it emits a fast copy and a slow copy of +//! the run, and a `Let` lowered once per copy allocates an entry alloca PER +//! COPY, so `ctx.locals[id]` ends up naming the last copy's slot and every +//! post-region read sees only that one. Duplicating the run is not available +//! here, because `Let`s are this slice's population. +//! +//! Restructuring into "load every value, phi, then bind" is not available +//! either: in the bail arm the earlier values would sit in registers across +//! later reads, and a generic read can reach a getter, allocate and move the +//! heap — the unrooted-across-safepoint hazard of `gc-rooting-invariant.md` +//! case 3. +//! +//! # The structure: declare once, assign in both arms +//! +//! 1. Declare each binding first, through the ordinary `Stmt::Let` path with +//! no initialiser, so the slot, its type registration and its shadow-slot +//! binding are exactly what today's code makes. One slot, dominating both +//! arms. +//! 2. Fast arm: R1 once, then per read a slot load **stored immediately into +//! that binding's slot**, so each value is rooted before the next read. +//! 3. Bail arm: the same reads in source order through today's lowering, each +//! assigning the same slot (`Expr::LocalSet`). +//! 4. Merge. No phi — both arms write the same rooted slots. +//! +//! # R3 is not needed here, and that widens the slice +//! +//! Slice 1 had to prove every leaf a primitive Number because the fold hoisted +//! reads above the additions. This slice hoists nothing across an operator: +//! the reads happen in source order and the values are bound as they are. So +//! there is no type condition at all, and a `string`- or object-valued field +//! qualifies where slice 1 had to decline it. The soundness argument is only: +//! one guard proves the shape for the whole run, no user code can run between +//! the reads (every key in the word is an own data property, so no getter can +//! be reached), and nothing allocates between them, so the unmasked pointer +//! cannot go stale. +//! +//! # What ends a run +//! +//! The same R1–R4 events as slice 1 — a call, a store, an allocation, an +//! unverified operator — which mechanically means: any statement that is not a +//! `Stmt::Let` of a static-key read on the same receiver local. + +use anyhow::Result; +use perry_hir::{Expr, Stmt}; + +use crate::expr::region_guard::{ + self, emit_miss, emit_prime, emit_r1, emit_slot_loads, state_globals, Entry, MAX_KEYS, +}; +use crate::expr::{lower_expr, FnCtx}; +use crate::types::DOUBLE; + +/// One binding in the run: the statement's local, and which of the region's +/// keys it reads. +struct Bind { + id: u32, + key: usize, +} + +pub(crate) struct StmtRun<'a> { + receiver: u32, + keys: Vec<&'a str>, + binds: Vec, + /// Statements this run consumes — always `binds.len()`, named separately + /// because the caller advances by it. + pub(crate) len: usize, +} + +/// Would this binding's storage be a plain double slot, AND does declaring it +/// without its initialiser cost nothing? +/// +/// Only a plain slot can be assigned by a bare store in the fast arm, and the +/// answer must be decidable BEFORE anything is lowered: an undecidable binding +/// declines the whole run and the statements lower unchanged, rather than +/// being discovered after the slots exist, when backing out would mean +/// emitting a shape of `Let` the ordinary path never emits. +/// +/// The second half of the question is the one measurement had to teach me. +/// `let_stmt` refines a declared `Any` from the INITIALISER +/// (`refine_type_from_init`), and this slice declares the binding without one, +/// so a binding whose init refines would lose that type and every later use of +/// it would deoptimise. Measured on four `string`-valued fields: 670 -> 2694 +/// instructions per iteration, a 4x REGRESSION, while the region itself was +/// working exactly as designed. Declining a refinable binding keeps today's +/// facts intact; carrying the refinement through the declaration is what a +/// later slice has to do to reach that population. +fn binding_is_plain_slot( + ctx: &FnCtx<'_>, + id: u32, + ty: &perry_hir::types::Type, + init: &Expr, +) -> bool { + // An `Any` binding of a property read takes `let_stmt`'s plain path: the + // i32, canonical-string, POD, typed-array and scalar-replacement tiers all + // key off an initialiser shape or a declared type this is not. A typed + // binding is not WRONG here, it is simply not proven to be a plain slot at + // this point, so this slice leaves it to a later one and the census counts + // it as still uncovered. + matches!(ty, perry_hir::types::Type::Any) + && crate::type_analysis::refine_type_from_init(ctx, init).is_none() + && !ctx.boxed_vars.contains(&id) + && !ctx.prealloc_boxes.contains(&id) + && !ctx.tdz_boxes.contains(&id) + && !ctx.module_globals.contains_key(&id) + && !ctx.pod_records.contains_key(&id) + && !ctx.spec_ta_bindings.contains_key(&id) + && !ctx.integer_locals.contains(&id) + && !ctx.local_slot_reps.contains_key(&id) +} + +/// The maximal run of same-receiver static-key reads at the head of `stmts`. +pub(crate) fn try_match<'a>(ctx: &FnCtx<'_>, stmts: &'a [Stmt]) -> Option> { + if !region_guard::emission_allowed() { + return None; + } + scan(stmts, |id, ty, init| { + binding_is_plain_slot(ctx, id, ty, init) + }) +} + +/// The matcher proper, taking "is this binding a plain slot?" as a function so +/// the run rules can be tested without building a `FnCtx`. +fn scan<'a>( + stmts: &'a [Stmt], + plain: impl Fn(u32, &perry_hir::types::Type, &Expr) -> bool, +) -> Option> { + let mut receiver: Option = None; + let mut keys: Vec<&'a str> = Vec::new(); + let mut binds: Vec = Vec::new(); + + for stmt in stmts { + let Stmt::Let { + id, + ty, + init: Some(init), + .. + } = stmt + else { + break; + }; + let Expr::PropertyGet { + object, property, .. + } = init + else { + break; + }; + let Expr::LocalGet(r) = object.as_ref() else { + break; + }; + match receiver { + None => receiver = Some(*r), + Some(prev) if prev == *r => {} + Some(_) => break, + } + // The receiver must not be one of the run's own bindings, and a + // binding must not be re-declared inside the run: either would make + // the single entry guard cover a receiver it did not prove. + if *r == *id || binds.iter().any(|b| b.id == *id) { + break; + } + if !plain(*id, ty, init) { + break; + } + let key = match keys.iter().position(|k| *k == property.as_str()) { + Some(i) => i, + None => { + if keys.len() == MAX_KEYS { + break; + } + keys.push(property.as_str()); + keys.len() - 1 + } + }; + binds.push(Bind { id: *id, key }); + } + + // One read already pays one guard; there is nothing to share below two. + if binds.len() < 2 { + return None; + } + Some(StmtRun { + receiver: receiver?, + keys, + len: binds.len(), + binds, + }) +} + +/// Lower the run: declare every binding, then one guard for all of them. +pub(crate) fn lower(ctx: &mut FnCtx<'_>, stmts: &[Stmt], run: &StmtRun<'_>) -> Result<()> { + region_guard::note_stmt_region(run.binds.len() as u64); + + // 1. Declare each binding with no initialiser, through the ordinary path, + // so the slot and its registrations are the ones today's code makes. + for (bind, stmt) in run.binds.iter().zip(stmts.iter()) { + let Stmt::Let { + id, + name, + ty, + mutable, + .. + } = stmt + else { + unreachable!("try_match admitted only Stmt::Let"); + }; + debug_assert_eq!(*id, bind.id); + super::lower_stmt( + ctx, + &Stmt::Let { + id: *id, + name: name.clone(), + ty: ty.clone(), + mutable: *mutable, + init: None, + }, + )?; + } + let slots: Vec = run + .binds + .iter() + .map(|b| ctx.locals.get(&b.id).cloned()) + .collect::>>() + .expect("a declared plain binding has a slot"); + + let sites = state_globals(ctx); + let fast_idx = ctx.new_block("region.stmt.fast"); + let miss_idx = ctx.new_block("region.stmt.miss"); + let prime_idx = ctx.new_block("region.stmt.prime"); + let generic_idx = ctx.new_block("region.stmt.generic"); + let merge_idx = ctx.new_block("region.stmt.merge"); + let fast_l = ctx.block_label(fast_idx); + let miss_l = ctx.block_label(miss_idx); + let prime_l = ctx.block_label(prime_idx); + let generic_l = ctx.block_label(generic_idx); + let merge_l = ctx.block_label(merge_idx); + + // 2. R1 once for the whole run. + let recv = lower_expr(ctx, &Expr::LocalGet(run.receiver))?; + let entry: Entry = emit_r1(ctx, &recv, &sites, &fast_l, &miss_l, &generic_l); + + // 3. Fast arm: R2, and each value into its own slot as it is loaded, so a + // loaded pointer is rooted before the next load runs. + ctx.current_block = fast_idx; + let values = emit_slot_loads(ctx, &entry, run.keys.len()); + for (bind, slot) in run.binds.iter().zip(slots.iter()) { + let value = values[bind.key].clone(); + ctx.block().store(DOUBLE, &value, slot); + } + ctx.block().br(&merge_l); + + // 4. Miss: prime at most a bounded number of times, then retire. + ctx.current_block = miss_idx; + let tries = emit_miss(ctx, &sites, &prime_l, &generic_l); + ctx.current_block = prime_idx; + emit_prime(ctx, &sites, &entry, &tries, &run.keys, &generic_l); + + // 5. Generic copy: the same reads, in source order, assigning the same + // slots — the code this region replaces. + ctx.current_block = generic_idx; + crate::expr::emit_versioned_loop_callback_deopt(ctx); + { + let _suppressed = region_guard::Suppressed::enter(); + for stmt in stmts.iter().take(run.len) { + let Stmt::Let { + id, + init: Some(init), + .. + } = stmt + else { + unreachable!("try_match admitted only initialised Stmt::Let"); + }; + super::lower_stmt( + ctx, + &Stmt::Expr(Expr::LocalSet(*id, Box::new(init.clone()))), + )?; + } + } + ctx.block().br(&merge_l); + + ctx.current_block = merge_idx; + Ok(()) +} + +#[cfg(test)] +mod tests { + use perry_hir::types::Type; + use perry_hir::{Expr, Stmt}; + + use super::scan; + + fn read_let(id: u32, recv: u32, key: &str) -> Stmt { + Stmt::Let { + id, + name: format!("v{id}"), + ty: Type::Any, + mutable: false, + init: Some(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(recv)), + property: key.to_string(), + byte_offset: 0, + }), + } + } + + fn plain_always(_: u32, ty: &Type, _: &Expr) -> bool { + matches!(ty, Type::Any) + } + + #[test] + fn consecutive_reads_of_one_receiver_form_one_run() { + let stmts = vec![ + read_let(10, 1, "a"), + read_let(11, 1, "b"), + read_let(12, 1, "c"), + ]; + let run = scan(&stmts, plain_always).expect("three reads of one receiver are a run"); + assert_eq!(run.len, 3, "the run consumes all three statements"); + assert_eq!(run.keys, vec!["a", "b", "c"]); + } + + /// One read already pays one guard, so there is nothing to share. + #[test] + fn a_single_read_is_not_a_run() { + assert!(scan(&[read_let(10, 1, "a")], plain_always).is_none()); + } + + /// A repeated key is one slot in the word, read twice. + #[test] + fn a_repeated_key_shares_its_slot() { + let stmts = vec![ + read_let(10, 1, "a"), + read_let(11, 1, "b"), + read_let(12, 1, "a"), + ]; + let run = scan(&stmts, plain_always).unwrap(); + assert_eq!(run.keys, vec!["a", "b"], "two distinct keys"); + assert_eq!(run.len, 3, "but three bindings"); + } + + /// A second receiver needs a second guard: the run ends where it appears. + #[test] + fn a_different_receiver_ends_the_run() { + let stmts = vec![ + read_let(10, 1, "a"), + read_let(11, 1, "b"), + read_let(12, 2, "a"), + ]; + let run = scan(&stmts, plain_always).unwrap(); + assert_eq!(run.len, 2); + } + + /// Any statement that is not such a read is an R1-R4 event: a call, a store, + /// an allocation or an unverified operator all arrive here as "not a read". + #[test] + fn a_non_read_statement_ends_the_run() { + let call = Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(7)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }); + let stmts = vec![ + read_let(10, 1, "a"), + read_let(11, 1, "b"), + call, + read_let(12, 1, "c"), + ]; + let run = scan(&stmts, plain_always).unwrap(); + assert_eq!(run.len, 2, "the call ends the run before the third read"); + } + + /// One word addresses five keys; the sixth distinct key ends the run rather + /// than silently dropping a read out of it. + #[test] + fn the_sixth_distinct_key_ends_the_run() { + let stmts: Vec = ["a", "b", "c", "d", "e", "f"] + .iter() + .enumerate() + .map(|(i, k)| read_let(10 + i as u32, 1, k)) + .collect(); + let run = scan(&stmts, plain_always).unwrap(); + assert_eq!(run.len, 5); + assert_eq!(run.keys.len(), 5); + } + + /// Reading into the receiver's own binding would make one guard cover a + /// receiver it did not prove. + #[test] + fn a_binding_that_is_the_receiver_ends_the_run() { + let stmts = vec![ + read_let(10, 1, "a"), + read_let(11, 1, "b"), + read_let(1, 1, "c"), + ]; + let run = scan(&stmts, plain_always).unwrap(); + assert_eq!(run.len, 2); + } + + /// A binding whose storage is not a plain slot declines the whole run: the + /// fast arm assigns it with a bare store, which only a plain slot accepts. + #[test] + fn a_binding_that_is_not_a_plain_slot_declines() { + let stmts = vec![read_let(10, 1, "a"), read_let(11, 1, "b")]; + assert!(scan(&stmts, |_, _, _| false).is_none()); + } +} From 60c2416289b933306a7b422124787e403a3dc9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 23 Sep 2026 22:26:33 +0200 Subject: [PATCH 2/3] chore(scripts): shape census baseline follows the header-size callsite into region_guard.rs Slice 2 moves the region guard's object_header_size_bytes callsite from expr/region_read_run.rs to expr/region_guard.rs; the census key relocates, the multiset count is unchanged. --- scripts/shape_descriptor_census_baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 5159a26a23..10e8d5dcb4 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -12,7 +12,7 @@ "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 1, "crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 3, - "crates/perry-codegen/src/expr/region_read_run.rs|let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, + "crates/perry-codegen/src/expr/region_guard.rs|let header = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, "crates/perry-codegen/src/lower_call/property_get/imported_object.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, From b2d7e2c01cbc2613e85a1c8c682b9ca3438c5485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 24 Sep 2026 04:56:07 +0200 Subject: [PATCH 3/3] changelog: fragment for #10946 --- changelog.d/10946-region-reads-across-statements.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/10946-region-reads-across-statements.md diff --git a/changelog.d/10946-region-reads-across-statements.md b/changelog.d/10946-region-reads-across-statements.md new file mode 100644 index 0000000000..664f5ef217 --- /dev/null +++ b/changelog.d/10946-region-reads-across-statements.md @@ -0,0 +1,7 @@ +**perf(codegen): guard a run of property reads spelled across statements once (#10884 step 4b, slice 2).** `const a = o.a; const b = o.b; const c = o.c;` is now one region with one shape guard instead of three separately guarded reads. + +The guard itself (state word, R1/R2, bounded prime, miss edges) moves into the shared `expr/region_guard.rs`, so the within-expression slice and this one match over a single guard with a single soundness argument. Slice 1's measurements are unchanged by the move. + +This slice can't duplicate the run into fast and slow copies: `Stmt::Let` would allocate an entry alloca per copy. It can't phi the loaded values either, because in the bail arm a generic read can reach a getter and move the heap. Instead each binding is declared once through the ordinary `Let` path. The fast arm stores each loaded slot into its binding immediately, so every value is rooted before the next read, and the bail arm assigns the same slots in source order. + +Nothing is hoisted across an operator, so there is no type condition: string- and object-valued fields qualify too. The kill switch is `PERRY_REGION_READS=0`.