From 9229e0af3660bac5982783ceeafff1a7adde02dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 07:28:59 +0200 Subject: [PATCH 01/11] feat(codegen): match the Intl.Segmenter for-of, and count whether it fires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the fourth member of the escape-analysis family (escape_news / escape_arrays / escape_objects): `collectors/segview.rs` recognises `for (let {segment: O} of X.segment(q))` and proves the segment RECORD never escapes, so the loop can eventually drive a native cursor instead of materialising one record per grapheme. That loop is the target: the allocation census ranks it 1/2/3 by count (172,032 records + 124,928 + 122,880 substrings per 400-character reply, 58 % of the top-30 allocation count), and the sample puts 60-85 % of active main-thread CPU inside it, under ink's wrapText. What is proven, and what deliberately is not. The only proof is that the record does not escape — every use of `__destruct_N` is one of the destructuring field reads the loop head itself emits. A use of the segment STRING is never a rejection: any use no view entry point answers is served by materialising the substring once into the same local, which is exactly what the loop costs today. So `O`'s uses are classified and counted, not gated. That is what separates "the record is gone" (v1) from "the loop allocates nothing" (v2, which needs the runtime's regexp_test). The escape proof is a count, and it is taken with perry_hir's collect_local_refs_stmt — the LocalId collector that handles every LocalId-bearing variant explicitly and delegates the rest to the walker whose match the compiler forces to be exhaustive. A new HIR variant embedding a LocalGet is therefore a compile error in the walker, not a silently missed use of the record. The `O`-use classifier is hand-written and can miss a shape, so it is checked against that same sound count and every unclassified occurrence is booked as "must materialise": an unrecognised use can make a site look less optimisable than it is, never more. No lowering. The tier's fact is populated and unread; the runtime's view-mode entry points do not exist yet. The counter is the point of the commit. A tier can be correct and never match (#9824), so PERRY_SEGVIEW_DIAG=1 reports every for-of site examined, the verdict, the rejection reason and the per-use tally — and it runs at the HIR-trace point, the last place before codegen, where the statements scanned are exactly the statements codegen consumes. That makes "does it fire on the real bundle?" answerable in HIR-lowering time instead of a full LLVM build. The env var is excluded from the build-level cache for the same reason --opt-report is: a cached build never lowers HIR, and a report that prints nothing reads exactly like a tier that never fired. Unit tests pin the matcher against the HIR shape a real --trace hir dump produces, including the two negative controls that matter: a record use hidden inside a closure rejects, and a `{segment, index}` head declines under its own name rather than firing. --- .../perry-codegen/src/collectors/hir_facts.rs | 16 + crates/perry-codegen/src/collectors/mod.rs | 3 + .../perry-codegen/src/collectors/segview.rs | 886 ++++++++++++++++++ .../src/collectors/segview_tests.rs | 268 ++++++ crates/perry-codegen/src/lib.rs | 5 + .../perry/src/commands/compile/build_cache.rs | 8 + .../src/commands/compile/run_pipeline.rs | 15 + 7 files changed, 1201 insertions(+) create mode 100644 crates/perry-codegen/src/collectors/segview.rs create mode 100644 crates/perry-codegen/src/collectors/segview_tests.rs diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 0257f43348..08cce2b446 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -167,6 +167,17 @@ pub(crate) struct EscapeFacts { pub fusible_uppercase_locals: HashSet, pub non_escaping_object_literals: HashMap>, pub non_escaping_object_literal_used_fields: HashMap>, + /// #9846, the fourth member of this family: `for (let {segment: O} of + /// X.segment(q))` sites whose segment RECORD provably never escapes, so + /// the loop can drive a native cursor instead of materialising one record + /// per grapheme (census site 1 — 172,032 allocations per 400-character cc + /// reply, the largest single site by count). + /// + /// Populated but not yet consumed: the lowering waits on the runtime's + /// view-mode entry points (`INTERFACE_segments_view.md` §9b). Same + /// in-progress shape as `PurityFacts` / `ShapeStabilityFacts` above. + #[allow(dead_code)] + pub segment_for_of_sites: Vec, } #[derive(Debug, Clone, Default)] @@ -677,6 +688,10 @@ pub(crate) fn collect_type_facts( stmts, &non_escaping_object_literals, ); + // #9846: the segment-record member of the escape family. Cheap by + // construction — `collect_segment_for_of_sites` walks the region only + // when it holds a `for…of` whose subject is an `X.segment(q)` call. + let segment_for_of_sites = super::segview::collect_segment_for_of_sites(stmts); let scalar_replaceable_object_locals = non_escaping_news .keys() .chain(non_escaping_object_literals.keys()) @@ -773,6 +788,7 @@ pub(crate) fn collect_type_facts( fusible_uppercase_locals, non_escaping_object_literals, non_escaping_object_literal_used_fields, + segment_for_of_sites, }, purity: PurityFacts { pure_helper_function_ids: clamp_fn_ids.clone(), diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 3257750191..f576335f32 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -51,6 +51,9 @@ mod repsel_benefit; mod safepoint_sites; mod scalar_method_dispatch; mod scalar_methods; +pub mod segview; +#[cfg(test)] +mod segview_tests; mod shadow_slots; pub(crate) mod spec_abi_sites; mod this_as_value; diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs new file mode 100644 index 0000000000..c9e82043a8 --- /dev/null +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -0,0 +1,886 @@ +//! Segment-view for-of matcher — the fourth member of the escape-analysis +//! family (`escape_news`, `escape_arrays`, `escape_objects`). +//! +//! # What it looks for +//! +//! `for (let {segment: O} of X.segment(q))` — the loop `string-width` runs, +//! which the allocation census ranks 1/2/3 by count (172k records + 125k + +//! 123k substrings per 400-character claude-code reply, 58 % of the top-30 +//! allocation count) and which the sample ranks as 60–85 % of active +//! main-thread CPU under ink's `wrapText`. +//! +//! After lowering, that loop is a fixed HIR shape (verified against +//! `--trace hir`, not assumed): +//! +//! ```text +//! Let { id: A, name: "__arr_A", init: GetIterator(Call { PropertyGet(S, "segment"), [input] }) } +//! For { +//! init: Let { id: R, name: "__result_R", init: Call(ExternFuncRef "js_for_of_next", [LocalGet(A)]) }, +//! condition: Not(PropertyGet(LocalGet(R), "done")), +//! update: LocalSet(R, Call(ExternFuncRef "js_for_of_next", [LocalGet(A)])), +//! body: [ Let { id: D, name: "__destruct_D", init: PropertyGet(LocalGet(R), "value") }, +//! Let { id: O, name: , init: PropertyGet(LocalGet(D), "segment") }, +//! ] +//! } +//! ``` +//! +//! # What it proves +//! +//! Only one thing, and it is the one the v1 runtime interface +//! (`INTERFACE_segments_view.md` §9b) needs: **the segment record `D` never +//! escapes**, because its every use is one of the destructuring field reads +//! that the loop head itself emits. When that holds the record is never +//! observed and never has to exist — census site 1, 172,032 allocations a +//! reply, all of it in the loop head. +//! +//! Whether the segment *substring* can also be skipped is a separate, weaker +//! question, and it is not a precondition: any use of `O` that no view entry +//! point answers is served by materialising the substring once into the same +//! local (`js_segments_view_segment`), which is exactly what the loop costs +//! today. So `O`'s uses are *classified and counted*, never fatal. The tally +//! is what says whether a site is also v2-ready (zero allocations per +//! grapheme) or only v1-ready (record elided, substring kept). +//! +//! # Soundness +//! +//! The escape proof is a *count*, and it is taken with +//! `perry_hir::collect_local_refs_stmt` — the repo's LocalId collector, which +//! handles every LocalId-bearing variant explicitly and delegates the rest to +//! `perry_hir::walker::walk_expr_children`, a match the compiler forces to be +//! exhaustive. A new HIR variant that embeds a `LocalGet` therefore cannot +//! silently hide a use of the record from this pass; it is a compile error in +//! the walker instead. That is the same reasoning `local_refs.rs`'s +//! `mark_all_candidate_refs_in_expr` catch-all exists for (#150), reached by +//! borrowing the sound walker rather than by re-deriving a conservative one. +//! +//! The `O`-use classifier is a hand-written recursive match, so it *can* fail +//! to recognise a shape — but it is checked against the same sound counter, +//! and every occurrence it did not classify is booked as `materialise`. An +//! unclassified use can therefore only make a site look *less* optimisable +//! than it is; it can never make one look more. +//! +//! # The counter is the falsifier +//! +//! A tier can be correct and never match (#9824). `PERRY_SEGVIEW_DIAG=1` +//! reports every for-of site examined, the verdict, and the rejection reason, +//! before codegen runs — so "it fires on the real bundle" is a measured line, +//! not an inference from the shape above. + +use std::collections::HashMap; + +use perry_hir::{Expr, Stmt, UnaryOp}; + +/// How each use of the destructured `segment` binding would be served. +/// +/// Only `code_point_at` is answerable by a v1 runtime cursor +/// (`INTERFACE_segments_view.md` §9b); the rest are counted so the v1/v2 line +/// is measured rather than assumed. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SegmentUseTally { + /// `O.codePointAt(k)` — `js_segments_view_code_point_at`. v1. + pub code_point_at: u32, + /// `O.charCodeAt(k)` — v2 (`_char_code_at` was dropped from v1, §5). + pub char_code_at: u32, + /// `O.length` — v2 (`_length`, §5). + pub length: u32, + /// `RegExpTest { regex, string: LocalGet(O) }` — a *statically* proven + /// regex receiver. v2 (`_regexp_test`). + pub regexp_test_static: u32, + /// `recv.test(O)` where `recv` is an arbitrary expression — cc's + /// `g54.default().test(O)`. v2, and only behind the three-valued decline + /// (§5): `is RegExp` at the call site does not rule out a patched + /// `RegExp.prototype.test`. + pub regexp_test_dynamic: u32, + /// Everything else, including every occurrence the classifier did not + /// recognise. Each one forces the substring to be materialised, which is + /// what the loop pays today — never a rejection. + pub materialise: u32, +} + +impl SegmentUseTally { + /// Total occurrences of the binding, from the sound counter. + pub fn total(&self) -> u32 { + self.code_point_at + + self.char_code_at + + self.length + + self.regexp_test_static + + self.regexp_test_dynamic + + self.materialise + } + + /// True when no use needs the substring: the loop reaches zero + /// allocations per grapheme once the v2 entry points exist. + pub fn view_answerable_v2(&self) -> bool { + self.materialise == 0 + } + + /// True when every use is answered by the two in-loop v1 entry points. + pub fn view_answerable_v1(&self) -> bool { + self.total() == self.code_point_at + } +} + +/// Why a `for…of` whose subject is an `X.segment(q)` call did or did not +/// admit record elision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SegViewVerdict { + /// The record provably never escapes: every use is a destructuring read + /// of `segment` in the loop head. v1 applies. + Fires, + /// The `For` head is not the canonical `js_for_of_next` protocol (a + /// collection-view rewrite, an index arm, an async iterator…). Not a + /// failure of the proof — a different lowering, which this tier does not + /// speak. + HeadNotCanonical, + /// The head destructures no `segment` key, so this is somebody else's + /// `.segment(x)`. + NoSegmentKey, + /// The record binding is boxed (captured by a closure that outlives the + /// step). Rejected before the count, because a box read is not a + /// `LocalGet` of the record. + BoxedRecord, + /// The record is used somewhere other than the head's own field reads. + RecordEscapes { + uses: usize, + destructure_reads: usize, + }, + /// The record does not escape, but the head reads fields v1 cannot answer + /// (`index` / `input` / `isWordLike` are v2 symbols, §5). The spec path + /// runs, at no cost, until those exist. + RecordFieldsBeyondV1 { keys: Vec }, +} + +impl SegViewVerdict { + pub fn reason(&self) -> &'static str { + match self { + SegViewVerdict::Fires => "fires", + SegViewVerdict::HeadNotCanonical => "head_not_canonical", + SegViewVerdict::NoSegmentKey => "no_segment_key", + SegViewVerdict::BoxedRecord => "boxed_record", + SegViewVerdict::RecordEscapes { .. } => "record_escapes", + SegViewVerdict::RecordFieldsBeyondV1 { .. } => "record_fields_beyond_v1", + } + } +} + +/// One examined `for (… of X.segment(q))` site. +#[derive(Debug, Clone)] +pub struct SegmentForOfSite { + /// `__arr_A`, the `GetIterator` local. + pub iter_id: u32, + /// `__result_R`, the iteration-result local. + pub result_id: u32, + /// `__destruct_D`, the segment record. + pub record_id: u32, + pub record_name: String, + /// The local the `segment` key is destructured into, when there is one. + pub segment_id: Option, + /// The subject is the `X.segment(q)` call itself, so `open(segmenter, + /// input)` — the two-argument form (§3) — is matchable and no `Segments` + /// object is ever built. False for a for-of over a variable that already + /// holds one, which takes the weaker `open_segments`. + pub two_arg_open: bool, + /// Field keys the head destructures off the record, in head order. + pub record_keys: Vec, + /// Uses of `__arr_A` beyond the two `js_for_of_next` calls in the head — + /// the iterator-close protocol (`it.return`) contributes 2. Reported + /// because the lowering replaces `A` with a cursor and has to serve them. + pub iter_extra_uses: usize, + pub segment_uses: SegmentUseTally, + pub verdict: SegViewVerdict, +} + +impl SegmentForOfSite { + pub fn fires(&self) -> bool { + self.verdict == SegViewVerdict::Fires + } +} + +/// Collect every `for (… of X.segment(q))` site in one lowered region +/// (function body, method body, module init), including sites inside nested +/// closures. +/// +/// Cheap by construction: the region is only walked at all when it contains a +/// `GetIterator` whose subject is a `.segment(…)` call. +pub fn collect_segment_for_of_sites(stmts: &[Stmt]) -> Vec { + let mut candidates: Vec = Vec::new(); + for_each_stmt_list(stmts, &mut |list| find_candidates_in_list(list, &mut candidates)); + // A statement list should be visited exactly once by `for_each_stmt_list`; + // pin that rather than trusting it, so a descent bug shows up as a missing + // site and never as a double-counted one. + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + candidates.retain(|c| seen.insert(c.iter_id)); + if candidates.is_empty() { + return Vec::new(); + } + + // One sound reference census for the whole region, taken with the repo's + // exhaustive LocalId collector. Multiset: a local referenced three times + // contributes three entries. + let mut refs: Vec = Vec::new(); + let mut visited = std::collections::HashSet::new(); + for stmt in stmts { + perry_hir::collect_local_refs_stmt(stmt, &mut refs, &mut visited); + } + let mut ref_counts: HashMap = HashMap::new(); + for id in refs { + *ref_counts.entry(id).or_insert(0) += 1; + } + + // Boxed locals: a `Preallocate*`/`ReleaseBoxes` mention means the binding + // lives in a heap cell a closure can reach, and a read of it is not a + // `LocalGet` this census would see. + let mut boxed: std::collections::HashSet = std::collections::HashSet::new(); + for_each_stmt_list(stmts, &mut |list| { + for s in list { + match s { + Stmt::PreallocateBoxes(ids) + | Stmt::PreallocateTdzBoxes(ids) + | Stmt::ReleaseBoxes(ids) => boxed.extend(ids.iter().copied()), + _ => {} + } + } + }); + + candidates + .into_iter() + .map(|c| finish_candidate(c, &ref_counts, &boxed)) + .collect() +} + +// ── candidate discovery ──────────────────────────────────────────────────── + +struct Candidate { + iter_id: u32, + result_id: u32, + record_id: u32, + record_name: String, + segment_id: Option, + two_arg_open: bool, + record_keys: Vec, + head_canonical: bool, + /// The whole `For` statement, for the `O`-use classification pass. + for_stmt: Stmt, +} + +fn find_candidates_in_list(list: &[Stmt], out: &mut Vec) { + for (i, s) in list.iter().enumerate() { + let Stmt::Let { + id: iter_id, + init: Some(Expr::GetIterator(subject)), + .. + } = s + else { + continue; + }; + // The subject decides which `open` form the lowering can use, and + // whether this is a segment loop at all. + let two_arg_open = match subject.as_ref() { + Expr::Call { callee, args, .. } => { + matches!(callee.as_ref(), Expr::PropertyGet { property, .. } if property == "segment") + && args.len() == 1 + } + _ => false, + }; + if !two_arg_open { + // A for-of over a variable already holding a `Segments` is the + // weaker one-argument form. Nothing in the measured workload has + // that shape, and matching it would need a type fact this pass + // does not have, so it is not a candidate — and not a rejection + // either, because it is not known to be a segment loop. + continue; + } + // The `For` is the next statement, possibly inside a label. + let Some(for_stmt) = next_for_stmt(list, i + 1) else { + continue; + }; + let Stmt::For { + init, + condition, + update, + body, + } = for_stmt + else { + continue; + }; + + let head_canonical = head_is_canonical(*iter_id, init, condition, update); + let (result_id, record_id, record_name, record_keys, segment_id) = + match destructure_head(body) { + Some(v) => v, + None => continue, + }; + + out.push(Candidate { + iter_id: *iter_id, + result_id, + record_id, + record_name, + segment_id, + two_arg_open, + record_keys, + head_canonical, + for_stmt: for_stmt.clone(), + }); + } +} + +fn next_for_stmt(list: &[Stmt], idx: usize) -> Option<&Stmt> { + let mut s = list.get(idx)?; + while let Stmt::Labeled { body, .. } = s { + s = body.as_ref(); + } + matches!(s, Stmt::For { .. }).then_some(s) +} + +/// `init`/`condition`/`update` are the `js_for_of_next` protocol over +/// `iter_id`. Anything else is a different lowering (collection view, index +/// arm, async iterator) that this tier does not speak. +fn head_is_canonical( + iter_id: u32, + init: &Option>, + condition: &Option, + update: &Option, +) -> bool { + let Some(init) = init else { return false }; + let Stmt::Let { + id: result_id, + init: Some(init_expr), + .. + } = init.as_ref() + else { + return false; + }; + if !is_for_of_next_call(init_expr, iter_id) { + return false; + } + let done_ok = matches!( + condition, + Some(Expr::Unary { op: UnaryOp::Not, operand }) + if matches!(operand.as_ref(), + Expr::PropertyGet { object, property, .. } + if property == "done" && matches!(object.as_ref(), Expr::LocalGet(r) if r == result_id)) + ); + let update_ok = matches!( + update, + Some(Expr::LocalSet(r, v)) if r == result_id && is_for_of_next_call(v, iter_id) + ); + done_ok && update_ok +} + +fn is_for_of_next_call(e: &Expr, iter_id: u32) -> bool { + let Expr::Call { callee, args, .. } = e else { + return false; + }; + let Expr::ExternFuncRef { name, .. } = callee.as_ref() else { + return false; + }; + name == "js_for_of_next" + && args.len() == 1 + && matches!(args[0], Expr::LocalGet(a) if a == iter_id) +} + +/// Peel the leading destructuring reads the for-of head emits: +/// `Let D = result.value`, then one `Let = D.` per destructured field. +type HeadShape = (u32, u32, String, Vec, Option); + +fn destructure_head(body: &[Stmt]) -> Option { + let Stmt::Let { + id: record_id, + name: record_name, + init: Some(Expr::PropertyGet { + object, property, .. + }), + .. + } = body.first()? + else { + return None; + }; + if property != "value" { + return None; + } + let Expr::LocalGet(result_id) = object.as_ref() else { + return None; + }; + + let mut keys = Vec::new(); + let mut segment_id = None; + for s in body.iter().skip(1) { + let Stmt::Let { + id, + init: + Some(Expr::PropertyGet { + object, property, .. + }), + .. + } = s + else { + break; + }; + if !matches!(object.as_ref(), Expr::LocalGet(r) if r == record_id) { + break; + } + if property == "segment" && segment_id.is_none() { + segment_id = Some(*id); + } + keys.push(property.clone()); + } + + Some(( + *result_id, + *record_id, + record_name.clone(), + keys, + segment_id, + )) +} + +// ── the proof ────────────────────────────────────────────────────────────── + +fn finish_candidate( + c: Candidate, + ref_counts: &HashMap, + boxed: &std::collections::HashSet, +) -> SegmentForOfSite { + let record_uses = ref_counts.get(&c.record_id).copied().unwrap_or(0); + let destructure_reads = c.record_keys.len(); + // The two `js_for_of_next(A)` calls the head itself emits. + let iter_extra_uses = ref_counts + .get(&c.iter_id) + .copied() + .unwrap_or(0) + .saturating_sub(2); + + let mut segment_uses = SegmentUseTally::default(); + if let Some(seg_id) = c.segment_id { + let sound_total = ref_counts.get(&seg_id).copied().unwrap_or(0) as u32; + // One of those is the head's own `Let O = D.segment` init? No — that + // is a use of the RECORD, not of `O`. Every counted reference of + // `seg_id` is a real use in the body. + classify_segment_uses_in_stmt(&c.for_stmt, seg_id, &mut segment_uses); + // The classifier is hand-written and can miss a shape; the census + // above cannot. Book the difference as "must materialise" so an + // unrecognised use can only understate what the view buys. + let classified = segment_uses.total(); + if sound_total > classified { + segment_uses.materialise += sound_total - classified; + } + } + + let verdict = if !c.head_canonical { + SegViewVerdict::HeadNotCanonical + } else if c.segment_id.is_none() { + SegViewVerdict::NoSegmentKey + } else if boxed.contains(&c.record_id) { + SegViewVerdict::BoxedRecord + } else if record_uses != destructure_reads { + SegViewVerdict::RecordEscapes { + uses: record_uses, + destructure_reads, + } + } else if c.record_keys.iter().any(|k| k != "segment") { + SegViewVerdict::RecordFieldsBeyondV1 { + keys: c.record_keys.clone(), + } + } else { + SegViewVerdict::Fires + }; + + SegmentForOfSite { + iter_id: c.iter_id, + result_id: c.result_id, + record_id: c.record_id, + record_name: c.record_name, + segment_id: c.segment_id, + two_arg_open: c.two_arg_open, + record_keys: c.record_keys, + iter_extra_uses, + segment_uses, + verdict, + } +} + +// ── `O`-use classification ───────────────────────────────────────────────── + +fn classify_segment_uses_in_stmt(stmt: &Stmt, seg: u32, t: &mut SegmentUseTally) { + // Deep: every expression owned by `stmt` OR by any statement nested in it. + // Closure bodies are reached from `classify_segment_uses_in_expr`'s own + // `Expr::Closure` arm, which is the only path into them, so nothing is + // visited twice. + for_each_expr_in_stmt_shallow(stmt, &mut |e| classify_segment_uses_in_expr(e, seg, t)); + for_each_child_stmt(stmt, &mut |s| classify_segment_uses_in_stmt(s, seg, t)); +} + +/// Every statement nested directly inside `stmt` (branches, loop bodies, +/// catch/finally, switch cases, a `For` init). Does NOT enter closure bodies: +/// those hang off expressions and are handled by the expression classifier. +fn for_each_child_stmt(stmt: &Stmt, f: &mut impl FnMut(&Stmt)) { + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().for_each(&mut *f); + if let Some(e) = else_branch { + e.iter().for_each(&mut *f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => body.iter().for_each(f), + Stmt::For { init, body, .. } => { + if let Some(i) = init { + f(i); + } + body.iter().for_each(f); + } + Stmt::Labeled { body, .. } => f(body), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().for_each(&mut *f); + if let Some(c) = catch { + c.body.iter().for_each(&mut *f); + } + if let Some(fin) = finally { + fin.iter().for_each(&mut *f); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + c.body.iter().for_each(&mut *f); + } + } + _ => {} + } +} + +fn classify_segment_uses_in_expr(e: &Expr, seg: u32, t: &mut SegmentUseTally) { + // Recognise the parent shapes BEFORE descending, so the `LocalGet(seg)` + // inside them is attributed rather than falling into `materialise`. + match e { + Expr::Call { callee, args, .. } => { + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { + // `O.codePointAt(k)` / `O.charCodeAt(k)` + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == seg) + && args.len() == 1 + && (property == "codePointAt" || property == "charCodeAt") + { + if property == "codePointAt" { + t.code_point_at += 1; + } else { + t.char_code_at += 1; + } + for a in args { + classify_segment_uses_in_expr(a, seg, t); + } + return; + } + // `recv.test(O)` — the receiver is arbitrary (cc's + // `g54.default()`), so the runtime decides per call. + if property == "test" + && args.len() == 1 + && matches!(&args[0], Expr::LocalGet(id) if *id == seg) + && !matches!(object.as_ref(), Expr::LocalGet(id) if *id == seg) + { + t.regexp_test_dynamic += 1; + classify_segment_uses_in_expr(object, seg, t); + return; + } + } + } + Expr::RegExpTest { regex, string } => { + if matches!(string.as_ref(), Expr::LocalGet(id) if *id == seg) { + t.regexp_test_static += 1; + classify_segment_uses_in_expr(regex, seg, t); + return; + } + } + Expr::PropertyGet { + object, property, .. + } => { + if property == "length" && matches!(object.as_ref(), Expr::LocalGet(id) if *id == seg) { + t.length += 1; + return; + } + } + Expr::LocalGet(id) if *id == seg => { + t.materialise += 1; + return; + } + Expr::Closure { body, .. } => { + for s in body { + classify_segment_uses_in_stmt(s, seg, t); + } + // Param defaults are Expr children; the walker below covers them. + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |child| { + classify_segment_uses_in_expr(child, seg, t) + }); +} + +// ── generic descent ──────────────────────────────────────────────────────── + +/// Call `f` on every statement list in the region, including the bodies of +/// nested closures. The `Stmt` arms are enumerated here; the `Expr` descent +/// that finds `Expr::Closure` delegates to the exhaustive walker. +fn for_each_stmt_list(stmts: &[Stmt], f: &mut impl FnMut(&[Stmt])) { + f(stmts); + for s in stmts { + for_each_stmt_list_in_stmt(s, f); + } +} + +fn for_each_stmt_list_in_stmt(s: &Stmt, f: &mut impl FnMut(&[Stmt])) { + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + for_each_stmt_list(then_branch, f); + if let Some(e) = else_branch { + for_each_stmt_list(e, f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => for_each_stmt_list(body, f), + Stmt::For { init, body, .. } => { + if let Some(i) = init { + for_each_stmt_list_in_stmt(i, f); + } + for_each_stmt_list(body, f); + } + Stmt::Labeled { body, .. } => for_each_stmt_list_in_stmt(body, f), + Stmt::Try { + body, + catch, + finally, + } => { + for_each_stmt_list(body, f); + if let Some(c) = catch { + for_each_stmt_list(&c.body, f); + } + if let Some(fin) = finally { + for_each_stmt_list(fin, f); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + for_each_stmt_list(&c.body, f); + } + } + _ => {} + } + // Closure bodies hanging off any expression in this statement. + for_each_expr_in_stmt_shallow(s, &mut |e| for_each_closure_body_in_expr(e, f)); +} + +fn for_each_closure_body_in_expr(e: &Expr, f: &mut impl FnMut(&[Stmt])) { + if let Expr::Closure { body, .. } = e { + for_each_stmt_list(body, f); + } + perry_hir::walker::walk_expr_children(e, &mut |child| for_each_closure_body_in_expr(child, f)); +} + +/// Every expression owned directly by `stmt` (not by its nested statements). +fn for_each_expr_in_stmt_shallow(stmt: &Stmt, f: &mut impl FnMut(&Expr)) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + f(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => f(e), + Stmt::Return(e) => { + if let Some(e) = e { + f(e); + } + } + Stmt::If { condition, .. } => f(condition), + Stmt::While { condition, .. } | Stmt::DoWhile { condition, .. } => f(condition), + Stmt::For { + init, + condition, + update, + .. + } => { + if let Some(i) = init { + for_each_expr_in_stmt_shallow(i, f); + } + if let Some(c) = condition { + f(c); + } + if let Some(u) = update { + f(u); + } + } + Stmt::Switch { discriminant, .. } => f(discriminant), + Stmt::Labeled { body, .. } => for_each_expr_in_stmt_shallow(body, f), + _ => {} + } +} + +// ── the counter ──────────────────────────────────────────────────────────── +// +// A tier can be correct and never match (#9824), and this campaign has hit +// "exists in source, not helping the binary" five times. So the matcher ships +// with the instrument that decides whether it fires on the workload, and the +// instrument runs at the HIR-trace point — after every transform, on exactly +// the statements codegen consumes — which a 10 MB bundle reaches in minutes +// rather than the hours a full LLVM build costs. + +/// `PERRY_SEGVIEW_DIAG=1`. +pub fn segview_diag_enabled() -> bool { + matches!(std::env::var("PERRY_SEGVIEW_DIAG"), Ok(v) if !v.is_empty() && v != "0") +} + +/// Every `Let _ = GetIterator(subject)` in a region, split by whether the +/// subject is an `X.segment(q)` call. The first number is the denominator +/// this tier is judged against: how many `for…of` loops exist at all. +pub fn count_for_of_sites(stmts: &[Stmt]) -> (usize, usize) { + let (mut all, mut segment) = (0usize, 0usize); + for_each_stmt_list(stmts, &mut |list| { + for s in list { + if let Stmt::Let { + init: Some(Expr::GetIterator(subject)), + .. + } = s + { + all += 1; + if let Expr::Call { callee, args, .. } = subject.as_ref() { + if args.len() == 1 + && matches!(callee.as_ref(), + Expr::PropertyGet { property, .. } if property == "segment") + { + segment += 1; + } + } + } + } + }); + (all, segment) +} + +/// Accumulated diagnostic over a whole compilation. +#[derive(Debug, Default)] +pub struct SegViewDiag { + /// `for…of` sites of every kind (the denominator). + pub for_of_sites: usize, + /// Of those, sites whose subject is an `X.segment(q)` call. + pub segment_subject_sites: usize, + /// Every examined segment site, with the region it was found in. + pub sites: Vec<(String, SegmentForOfSite)>, +} + +impl SegViewDiag { + pub fn scan_region(&mut self, region: &str, stmts: &[Stmt]) { + let (all, seg) = count_for_of_sites(stmts); + self.for_of_sites += all; + self.segment_subject_sites += seg; + if seg == 0 { + return; + } + for site in collect_segment_for_of_sites(stmts) { + self.sites.push((region.to_string(), site)); + } + } + + /// Scan one lowered module: init statements, every free function, and + /// every class constructor / method / accessor / static method. Sites + /// inside a nested closure are attributed to the named region that + /// encloses them, which is what a minified bundle gives us to name. + pub fn scan_module(&mut self, path: &str, m: &perry_hir::Module) { + self.scan_region(&format!("{path}::"), &m.init); + for f in &m.functions { + self.scan_region(&format!("{path}::{}", f.name), &f.body); + } + for c in &m.classes { + if let Some(ctor) = &c.constructor { + self.scan_region(&format!("{path}::{}.constructor", c.name), &ctor.body); + } + for meth in c.methods.iter().chain(c.static_methods.iter()) { + self.scan_region(&format!("{path}::{}.{}", c.name, meth.name), &meth.body); + } + for (name, f) in c.getters.iter().chain(c.setters.iter()) { + self.scan_region(&format!("{path}::{}.{name}", c.name), &f.body); + } + } + } + + /// Print the report to stderr. The lines are the falsifier: "fires=0" with + /// a named reason is a result, "fires=0" with no reason is the failure + /// mode this campaign keeps hitting. + pub fn report(&self) { + let mut fires = 0usize; + let mut v1_only = 0usize; + let mut v2_ready = 0usize; + let mut by_reason: std::collections::BTreeMap<&'static str, usize> = + std::collections::BTreeMap::new(); + + for (region, s) in &self.sites { + *by_reason.entry(s.verdict.reason()).or_insert(0) += 1; + if s.fires() { + fires += 1; + if s.segment_uses.view_answerable_v2() { + v2_ready += 1; + } else { + v1_only += 1; + } + } + let u = &s.segment_uses; + eprintln!( + "[segview] {region} record={} (id={}) verdict={} open={} keys=[{}] \ + iter_extra_uses={} O-uses: code_point_at={} char_code_at={} length={} \ + regexp_test_static={} regexp_test_dynamic={} materialise={}", + s.record_name, + s.record_id, + describe(&s.verdict), + if s.two_arg_open { "2-arg" } else { "1-arg" }, + s.record_keys.join(","), + s.iter_extra_uses, + u.code_point_at, + u.char_code_at, + u.length, + u.regexp_test_static, + u.regexp_test_dynamic, + u.materialise, + ); + } + + eprintln!( + "[segview] TOTALS for_of_sites={} segment_subject_sites={} examined={} fires={} \ + (v1_only={} v2_ready={})", + self.for_of_sites, + self.segment_subject_sites, + self.sites.len(), + fires, + v1_only, + v2_ready, + ); + let reasons = by_reason + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" "); + eprintln!("[segview] TOTALS verdicts: {reasons}"); + } +} + +fn describe(v: &SegViewVerdict) -> String { + match v { + SegViewVerdict::RecordEscapes { + uses, + destructure_reads, + } => format!("record_escapes(uses={uses},head_reads={destructure_reads})"), + SegViewVerdict::RecordFieldsBeyondV1 { keys } => { + format!("record_fields_beyond_v1({})", keys.join(",")) + } + other => other.reason().to_string(), + } +} diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs new file mode 100644 index 0000000000..090a9d7ec8 --- /dev/null +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -0,0 +1,268 @@ +//! #9846: the segment-view for-of matcher, pinned against the HIR shape that +//! `--trace hir` actually produces for +//! `for (let {segment: O} of X.segment(q))`. +//! +//! The shape below was transcribed from a real `perry compile --trace hir` +//! dump, not invented — the campaign's rule about never hand-typing a constant +//! into emitted code applies to hand-typing an IR shape into a test just as +//! much ([[perry-emitted-constant-transcription]]). If the lowering moves, +//! `head_not_canonical` is what these tests report, which is the honest +//! failure and the one the real-bundle counter would also report. +//! +//! The distinction each test exists to pin: **a use of the segment STRING is +//! never a rejection** (it costs one materialisation, which is what the loop +//! pays today); only a use of the segment RECORD is. + +use super::segview::{collect_segment_for_of_sites, SegViewVerdict}; +use perry_hir::types::Type; +use perry_hir::{CatchClause, Expr, Stmt, UnaryOp}; + +const ITER: u32 = 5; +const RESULT: u32 = 7; +const RECORD: u32 = 11; +const SEG: u32 = 9; + +fn pget(obj: Expr, prop: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(obj), + property: prop.to_string(), + byte_offset: 0, + } +} + +fn call(callee: Expr, args: Vec) -> Expr { + Expr::Call { + callee: Box::new(callee), + args, + type_args: vec![], + byte_offset: 0, + } +} + +fn for_of_next() -> Expr { + call( + Expr::ExternFuncRef { + name: "js_for_of_next".to_string(), + param_types: vec![Type::Any], + return_type: Type::Any, + }, + vec![Expr::LocalGet(ITER)], + ) +} + +fn let_(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(init), + } +} + +/// `let __arr_5 = GetIterator(rR_.segment(q))`. +fn iter_let() -> Stmt { + let_( + ITER, + "__arr_5", + Expr::GetIterator(Box::new(call( + pget(Expr::LocalGet(0), "segment"), + vec![Expr::LocalGet(3)], + ))), + ) +} + +/// The `For` with the canonical `js_for_of_next` head and the given body +/// after the destructuring lets. +fn for_stmt(destructure: Vec, body: Vec) -> Stmt { + let mut all = destructure; + // The real lowering wraps the user body in the iterator-close protocol. + all.push(Stmt::Try { + body, + catch: Some(CatchClause { + param: Some((12, "__forof_err_12".to_string())), + body: vec![Stmt::Throw(Expr::LocalGet(12))], + }), + finally: None, + }); + Stmt::For { + init: Some(Box::new(let_(RESULT, "__result_7", for_of_next()))), + condition: Some(Expr::Unary { + op: UnaryOp::Not, + operand: Box::new(pget(Expr::LocalGet(RESULT), "done")), + }), + update: Some(Expr::LocalSet(RESULT, Box::new(for_of_next()))), + body: all, + } +} + +/// `let __destruct_11 = __result_7.value; let O = __destruct_11.segment;` +fn destructure_segment_only() -> Vec { + vec![ + let_( + RECORD, + "__destruct_11", + pget(Expr::LocalGet(RESULT), "value"), + ), + let_(SEG, "O", pget(Expr::LocalGet(RECORD), "segment")), + ] +} + +fn region(body: Vec) -> Vec { + vec![iter_let(), for_stmt(destructure_segment_only(), body)] +} + +/// cc's body, in shape: one `codePointAt` and one regex test on the segment. +fn cc_body() -> Vec { + vec![ + let_( + 10, + "w", + call(pget(Expr::LocalGet(SEG), "codePointAt"), vec![Expr::Integer(0)]), + ), + Stmt::If { + condition: Expr::RegExpTest { + regex: Box::new(Expr::LocalGet(1)), + string: Box::new(Expr::LocalGet(SEG)), + }, + then_branch: vec![Stmt::Continue], + else_branch: None, + }, + ] +} + +#[test] +fn fires_on_the_cc_shape_and_names_the_two_view_entry_points() { + let sites = collect_segment_for_of_sites(®ion(cc_body())); + assert_eq!(sites.len(), 1, "exactly one segment for-of site"); + let s = &sites[0]; + assert_eq!( + s.verdict, + SegViewVerdict::Fires, + "the record's only uses are the head's own field reads" + ); + assert!(s.two_arg_open, "the subject is the `X.segment(q)` call itself"); + assert_eq!(s.record_keys, vec!["segment".to_string()]); + assert_eq!(s.segment_id, Some(SEG)); + assert_eq!(s.segment_uses.code_point_at, 1); + assert_eq!(s.segment_uses.regexp_test_static, 1); + assert_eq!( + s.segment_uses.materialise, 0, + "every use of the segment string is view-answerable, so this site is v2-ready" + ); + assert!(s.segment_uses.view_answerable_v2()); + assert!( + !s.segment_uses.view_answerable_v1(), + "the regex test is a v2 entry point; v1 must still materialise here" + ); +} + +/// The distinction the whole design rests on: an unclassifiable use of the +/// segment STRING costs a materialisation, it does not reject the site. +#[test] +fn a_use_of_the_segment_string_is_a_materialisation_not_a_rejection() { + let mut body = cc_body(); + body.push(Stmt::Expr(call( + Expr::LocalGet(42), + vec![Expr::LocalGet(SEG)], + ))); + let sites = collect_segment_for_of_sites(®ion(body)); + assert_eq!(sites[0].verdict, SegViewVerdict::Fires); + assert_eq!(sites[0].segment_uses.materialise, 1); + assert!(!sites[0].segment_uses.view_answerable_v2()); +} + +/// `recv.test(O)` with an opaque receiver — cc's `g54.default().test(O)` — is +/// classified apart from the statically-proven `RegExpTest`, because the +/// runtime declines it three-valued (§5 of the interface). +#[test] +fn an_opaque_test_receiver_is_counted_separately() { + let body = vec![Stmt::Expr(call( + pget(call(pget(Expr::LocalGet(54), "default"), vec![]), "test"), + vec![Expr::LocalGet(SEG)], + ))]; + let sites = collect_segment_for_of_sites(®ion(body)); + assert_eq!(sites[0].segment_uses.regexp_test_dynamic, 1); + assert_eq!(sites[0].segment_uses.materialise, 0); +} + +/// A use of the RECORD outside the head is the one thing that rejects, and it +/// must be caught even when it hides inside a closure the walker only reaches +/// through `Expr::Closure`. +#[test] +fn a_record_use_inside_a_closure_rejects() { + let mut body = cc_body(); + body.push(Stmt::Expr(Expr::Closure { + func_id: 1, + params: vec![], + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::LocalGet(RECORD)))], + captures: vec![RECORD], + mutable_captures: vec![], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + })); + let sites = collect_segment_for_of_sites(®ion(body)); + assert!( + matches!(sites[0].verdict, SegViewVerdict::RecordEscapes { uses: 2, destructure_reads: 1 }), + "expected record_escapes, got {:?}", + sites[0].verdict + ); +} + +/// `{segment: O, index: I}` does not escape the record, but `index` is a v2 +/// symbol — the site must decline with its own reason rather than fire. +#[test] +fn a_second_destructured_field_declines_with_its_own_reason() { + let destructure = vec![ + let_( + RECORD, + "__destruct_11", + pget(Expr::LocalGet(RESULT), "value"), + ), + let_(SEG, "O", pget(Expr::LocalGet(RECORD), "segment")), + let_(8, "I", pget(Expr::LocalGet(RECORD), "index")), + ]; + let stmts = vec![iter_let(), for_stmt(destructure, cc_body())]; + let sites = collect_segment_for_of_sites(&stmts); + match &sites[0].verdict { + SegViewVerdict::RecordFieldsBeyondV1 { keys } => { + assert_eq!(keys, &vec!["segment".to_string(), "index".to_string()]) + } + other => panic!("expected record_fields_beyond_v1, got {other:?}"), + } +} + +/// A `for…of` lowered any other way (a collection view, an index arm) is not +/// this tier's shape and must say so rather than silently matching. +#[test] +fn a_non_canonical_head_declines_by_name() { + let mut stmts = region(cc_body()); + if let Stmt::For { update, .. } = &mut stmts[1] { + *update = Some(Expr::LocalSet(RESULT, Box::new(Expr::Undefined))); + } else { + panic!("shape"); + } + let sites = collect_segment_for_of_sites(&stmts); + assert_eq!(sites[0].verdict, SegViewVerdict::HeadNotCanonical); +} + +/// A `for…of` over anything but an `X.segment(q)` call is not examined at +/// all — the denominator, not a rejection. +#[test] +fn an_unrelated_for_of_is_not_a_candidate() { + let stmts = vec![ + let_( + ITER, + "__arr_5", + Expr::GetIterator(Box::new(Expr::LocalGet(99))), + ), + for_stmt(destructure_segment_only(), cc_body()), + ]; + assert!(collect_segment_for_of_sites(&stmts).is_empty()); +} diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 3a676a2d0b..7bf3f24751 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -80,6 +80,11 @@ pub use codegen::{ NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; +// #9846: the segment-view for-of matcher's counter. Exported so the +// driver can run it at the HIR-trace point — after every transform, on +// exactly the statements codegen consumes — instead of only inside a +// codegen run, which a 10 MB bundle does not reach in a usable time. +pub use collectors::segview::{segview_diag_enabled, SegViewDiag}; /// Return the guarded proven-`this` method-clone capabilities a native module /// may safely publish to importing codegen units. The first map contains all diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index f2eb61c696..61aef4bf38 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -855,6 +855,14 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { // impossible. if std::env::var("PERRY_NATIVEINST_DIAG").is_ok() { return Err("nativeinst-diag".to_string()); + // #9846: same reasoning as `opt-report` above, and the reason it is not + // optional. A cached build reuses the finished binary and never lowers + // HIR, so the segment-view counter would print nothing — and "nothing" + // reads exactly like "the tier never fired", which is the phantom-green + // this campaign keeps hitting. Excluded from the cache so a zero is a + // measured zero. + if std::env::var("PERRY_SEGVIEW_DIAG").is_ok() { + return Err("segview-diag".to_string()); } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index a146350f3b..75c8f42bf7 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1024,6 +1024,21 @@ pub fn run_with_parse_cache( perry_transform::module_const_fold::run(hir_module); } + // #9846: the segment-view for-of matcher's hit counter, taken here for + // the same reason the HIR trace is taken here — this is the last point + // before codegen, so the statements scanned are exactly the statements + // codegen consumes. Running it at this point (rather than only inside + // `collect_type_facts` on a rayon worker) is what makes "does the tier + // fire on the real bundle?" answerable in HIR-lowering time instead of a + // full LLVM build. Gated on `PERRY_SEGVIEW_DIAG`; costs nothing otherwise. + if perry_codegen::segview_diag_enabled() { + let mut diag = perry_codegen::SegViewDiag::default(); + for (path, hir_module) in &ctx.native_modules { + diag.scan_module(&path.display().to_string(), hir_module); + } + diag.report(); + } + if trace_hir { dump_hir_for_debug(&ctx, args.focus.as_deref()); } From a9b46b28c86c0e564cb49e80ddbc9766dbc55716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 07:33:55 +0200 Subject: [PATCH 02/11] style: cargo fmt the segment-view matcher and its tests --- crates/perry-codegen/src/collectors/segview.rs | 11 ++++++----- .../src/collectors/segview_tests.rs | 18 +++++++++++++++--- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index c9e82043a8..8a15f0cff6 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -204,7 +204,9 @@ impl SegmentForOfSite { /// `GetIterator` whose subject is a `.segment(…)` call. pub fn collect_segment_for_of_sites(stmts: &[Stmt]) -> Vec { let mut candidates: Vec = Vec::new(); - for_each_stmt_list(stmts, &mut |list| find_candidates_in_list(list, &mut candidates)); + for_each_stmt_list(stmts, &mut |list| { + find_candidates_in_list(list, &mut candidates) + }); // A statement list should be visited exactly once by `for_each_stmt_list`; // pin that rather than trusting it, so a descent bug shows up as a missing // site and never as a double-counted one. @@ -408,10 +410,9 @@ fn destructure_head(body: &[Stmt]) -> Option { for s in body.iter().skip(1) { let Stmt::Let { id, - init: - Some(Expr::PropertyGet { - object, property, .. - }), + init: Some(Expr::PropertyGet { + object, property, .. + }), .. } = s else { diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 090a9d7ec8..df6289d3b8 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -118,7 +118,10 @@ fn cc_body() -> Vec { let_( 10, "w", - call(pget(Expr::LocalGet(SEG), "codePointAt"), vec![Expr::Integer(0)]), + call( + pget(Expr::LocalGet(SEG), "codePointAt"), + vec![Expr::Integer(0)], + ), ), Stmt::If { condition: Expr::RegExpTest { @@ -141,7 +144,10 @@ fn fires_on_the_cc_shape_and_names_the_two_view_entry_points() { SegViewVerdict::Fires, "the record's only uses are the head's own field reads" ); - assert!(s.two_arg_open, "the subject is the `X.segment(q)` call itself"); + assert!( + s.two_arg_open, + "the subject is the `X.segment(q)` call itself" + ); assert_eq!(s.record_keys, vec!["segment".to_string()]); assert_eq!(s.segment_id, Some(SEG)); assert_eq!(s.segment_uses.code_point_at, 1); @@ -209,7 +215,13 @@ fn a_record_use_inside_a_closure_rejects() { })); let sites = collect_segment_for_of_sites(®ion(body)); assert!( - matches!(sites[0].verdict, SegViewVerdict::RecordEscapes { uses: 2, destructure_reads: 1 }), + matches!( + sites[0].verdict, + SegViewVerdict::RecordEscapes { + uses: 2, + destructure_reads: 1 + } + ), "expected record_escapes, got {:?}", sites[0].verdict ); From 24b7ea0917052ba5b3c0c7d8e7e1767b83b42c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 07:34:12 +0200 Subject: [PATCH 03/11] docs(changelog): add the segment-view matcher fragment --- .../9846-segment-view-for-of-matcher.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 changelog.d/9846-segment-view-for-of-matcher.md diff --git a/changelog.d/9846-segment-view-for-of-matcher.md b/changelog.d/9846-segment-view-for-of-matcher.md new file mode 100644 index 0000000000..4ca62c52bd --- /dev/null +++ b/changelog.d/9846-segment-view-for-of-matcher.md @@ -0,0 +1,28 @@ +Compile-time matcher for the `Intl.Segmenter` `for…of` loop, with the counter +that decides whether it fires. + +`for (let {segment: O} of X.segment(q))` is where claude-code spends most of a +turn: the allocation census ranks it 1/2/3 by count (172,032 segment records +plus 247,808 substrings per 400-character reply, 58 % of the top-30 allocation +count), and a `sample` puts 60–85 % of active main-thread CPU inside it, under +ink's `wrapText`. The loop reads one code point per grapheme and retains +nothing. + +`collectors/segview.rs` joins `escape_news` / `escape_arrays` / +`escape_objects` as the family's fourth member. It proves one thing: the +segment RECORD never escapes, because every use of the synthetic +`__destruct_N` binding is one of the destructuring field reads the loop head +itself emits. Uses of the segment STRING are classified and counted but never +gate the proof — a use no view entry point can answer is served by +materialising the substring once, which is what the loop costs today. + +The escape proof is taken with `perry_hir::collect_local_refs_stmt`, whose +descent bottoms out in the walker the compiler forces to be exhaustive, so a +new HIR variant embedding a `LocalGet` cannot silently hide a use of the +record. + +No lowering yet: the fact is populated and unread until the runtime's +segment-view entry points exist. `PERRY_SEGVIEW_DIAG=1` reports every site +examined, its verdict and its per-use tally at the HIR-trace point, and is +excluded from the build-level cache so a report of zero is a measured zero +rather than a build that never lowered HIR. From fee3cd296a3dfe118e2d2ab09207a3c6afef1a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 07:35:25 +0200 Subject: [PATCH 04/11] docs: point the segment-view references at the issue that exists (#9843) The first draft cited #9846, a number I had not checked and which does not exist. Comment-only change; the fragment is renamed to match. This is the same failure the segmenter lane caught in the brief's '#8364', which has no reference anywhere in the tree either. --- ...-for-of-matcher.md => 9843-segment-view-for-of-matcher.md} | 0 crates/perry-codegen/src/collectors/hir_facts.rs | 4 ++-- crates/perry-codegen/src/collectors/segview_tests.rs | 2 +- crates/perry-codegen/src/lib.rs | 2 +- crates/perry/src/commands/compile/build_cache.rs | 3 ++- crates/perry/src/commands/compile/run_pipeline.rs | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) rename changelog.d/{9846-segment-view-for-of-matcher.md => 9843-segment-view-for-of-matcher.md} (100%) diff --git a/changelog.d/9846-segment-view-for-of-matcher.md b/changelog.d/9843-segment-view-for-of-matcher.md similarity index 100% rename from changelog.d/9846-segment-view-for-of-matcher.md rename to changelog.d/9843-segment-view-for-of-matcher.md diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 08cce2b446..5a65978bb1 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -167,7 +167,7 @@ pub(crate) struct EscapeFacts { pub fusible_uppercase_locals: HashSet, pub non_escaping_object_literals: HashMap>, pub non_escaping_object_literal_used_fields: HashMap>, - /// #9846, the fourth member of this family: `for (let {segment: O} of + /// #9843, the fourth member of this family: `for (let {segment: O} of /// X.segment(q))` sites whose segment RECORD provably never escapes, so /// the loop can drive a native cursor instead of materialising one record /// per grapheme (census site 1 — 172,032 allocations per 400-character cc @@ -688,7 +688,7 @@ pub(crate) fn collect_type_facts( stmts, &non_escaping_object_literals, ); - // #9846: the segment-record member of the escape family. Cheap by + // #9843: the segment-record member of the escape family. Cheap by // construction — `collect_segment_for_of_sites` walks the region only // when it holds a `for…of` whose subject is an `X.segment(q)` call. let segment_for_of_sites = super::segview::collect_segment_for_of_sites(stmts); diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index df6289d3b8..39c9cd8041 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -1,4 +1,4 @@ -//! #9846: the segment-view for-of matcher, pinned against the HIR shape that +//! #9843: the segment-view for-of matcher, pinned against the HIR shape that //! `--trace hir` actually produces for //! `for (let {segment: O} of X.segment(q))`. //! diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 7bf3f24751..c7be76002c 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -80,7 +80,7 @@ pub use codegen::{ NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; -// #9846: the segment-view for-of matcher's counter. Exported so the +// #9843: the segment-view for-of matcher's counter. Exported so the // driver can run it at the HIR-trace point — after every transform, on // exactly the statements codegen consumes — instead of only inside a // codegen run, which a 10 MB bundle does not reach in a usable time. diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 61aef4bf38..948648f4cf 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -855,7 +855,8 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { // impossible. if std::env::var("PERRY_NATIVEINST_DIAG").is_ok() { return Err("nativeinst-diag".to_string()); - // #9846: same reasoning as `opt-report` above, and the reason it is not + } + // #9843: same reasoning as `opt-report` above, and the reason it is not // optional. A cached build reuses the finished binary and never lowers // HIR, so the segment-view counter would print nothing — and "nothing" // reads exactly like "the tier never fired", which is the phantom-green diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 75c8f42bf7..b191edef52 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1024,7 +1024,7 @@ pub fn run_with_parse_cache( perry_transform::module_const_fold::run(hir_module); } - // #9846: the segment-view for-of matcher's hit counter, taken here for + // #9843: the segment-view for-of matcher's hit counter, taken here for // the same reason the HIR trace is taken here — this is the last point // before codegen, so the statements scanned are exactly the statements // codegen consumes. Running it at this point (rather than only inside From 2ef9b2a21b9a1414e9db7a644ae7d349ecbf14af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 08:04:44 +0200 Subject: [PATCH 05/11] fix(codegen): classify the folded StringCodePointAt node as a segment view use The bundle counter reported `code_point_at=0, materialise=1` for `N$6` in cli_2.1.112.js -- the string-width loop that is 60-85 % of claude-code's active main-thread CPU -- where the probe had reported 1 and 0. Cause: perry's JS pipeline folds `O.codePointAt(k)` into the dedicated `Expr::StringCodePointAt { string, index }` node. The classifier matched only the generic `Call(PropertyGet(O, "codePointAt"), [k])` shape, which is what a TypeScript probe produces. Exactly one occurrence moved buckets, which is the signature of a single unmatched shape and nothing else. Two things this does not change: the escape proof (the record's non-escape is a count from `collect_local_refs_stmt`, not from this classifier) and any verdict. Only the per-use tally moves, and only in the direction of reporting more of what the runtime view can answer. Why the wrong number was visible at all: every occurrence the classifier does not recognise is reconciled against that sound count and booked as "must materialise", so an unmatched shape under-reports optimisability and can never over-report it. A classifier that guessed instead of reconciling would have reported `code_point_at=0, materialise=0` here and looked correct. That property is the reason the tallies can be believed. The rule the miss establishes, now recorded in the module docs: a shape that reproduces on a probe is not proof it reproduces on the bundle. The bundle counter is 32 seconds -- run it after every change to this classifier. --- .../perry-codegen/src/collectors/segview.rs | 25 +++++++++++++ .../src/collectors/segview_tests.rs | 36 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 8a15f0cff6..5a71b4989e 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -59,6 +59,17 @@ //! unclassified use can therefore only make a site look *less* optimisable //! than it is; it can never make one look more. //! +//! That property is not decorative: it is what caught this pass's own blind +//! spot. The first version matched only the generic +//! `Call(PropertyGet(O, "codePointAt"), [k])` shape — which is what a +//! TypeScript probe produces — and on `cli_2.1.112.js` it reported +//! `code_point_at=0, materialise=1` for the one loop that is 60-85 % of +//! claude-code's CPU, because the JS pipeline folds that call into +//! `Expr::StringCodePointAt`. A classifier that guessed instead of +//! reconciling would have reported `code_point_at=0, materialise=0` and looked +//! correct. **A shape that reproduces on a probe is not proof it reproduces on +//! the bundle**; run the bundle counter (32 seconds) after every change here. +//! //! # The counter is the falsifier //! //! A tier can be correct and never match (#9824). `PERRY_SEGVIEW_DIAG=1` @@ -594,6 +605,20 @@ fn classify_segment_uses_in_expr(e: &Expr, seg: u32, t: &mut SegmentUseTally) { } } } + // `O.codePointAt(k)` AFTER the JS pipeline has folded it. This arm is + // the one the real bundle needed and the TypeScript probe did not: + // perry lowers a proven string receiver's `.codePointAt` to this + // dedicated node, while the probe kept the generic `Call(PropertyGet…)` + // shape above. Measuring the bundle is what found it — the sound count + // saw the occurrence, this match did not, and the difference was booked + // as `materialise`, so the tally under-reported and never over-reported. + Expr::StringCodePointAt { string, index } => { + if matches!(string.as_ref(), Expr::LocalGet(id) if *id == seg) { + t.code_point_at += 1; + classify_segment_uses_in_expr(index, seg, t); + return; + } + } Expr::RegExpTest { regex, string } => { if matches!(string.as_ref(), Expr::LocalGet(id) if *id == seg) { t.regexp_test_static += 1; diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 39c9cd8041..812d51d505 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -278,3 +278,39 @@ fn an_unrelated_for_of_is_not_a_candidate() { ]; assert!(collect_segment_for_of_sites(&stmts).is_empty()); } + +/// The bundle regression. `O.codePointAt(0)` survives as a generic +/// `Call(PropertyGet…)` when the receiver's type is unknown — which is what a +/// TypeScript probe produces — but perry's JS pipeline folds it into +/// `Expr::StringCodePointAt`. The first version of the classifier matched only +/// the former, so on `cli_2.1.112.js` the loop that is 60-85 % of claude-code's +/// CPU reported `code_point_at=0, materialise=1`. +/// +/// The reconciliation against the sound counter is why that read as a blind +/// spot rather than as a correct answer, and this test is why it cannot come +/// back. +#[test] +fn the_folded_code_point_at_node_is_classified_like_the_generic_call() { + let body = vec![let_( + 10, + "w", + Expr::StringCodePointAt { + string: Box::new(Expr::LocalGet(SEG)), + index: Box::new(Expr::Integer(0)), + }, + )]; + let sites = collect_segment_for_of_sites(®ion(body)); + assert_eq!(sites[0].verdict, SegViewVerdict::Fires); + assert_eq!( + sites[0].segment_uses.code_point_at, 1, + "the folded node must count as a code_point_at use, not a materialisation" + ); + assert_eq!( + sites[0].segment_uses.materialise, 0, + "nothing is left over for the sound counter to book conservatively" + ); + assert!( + sites[0].segment_uses.view_answerable_v1(), + "a loop whose only use is the folded codePointAt is answerable by v1 alone" + ); +} From 446ad8371408384a613a198d20eb5b998804fc0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 09:34:52 +0200 Subject: [PATCH 06/11] feat(codegen): lower a proven segment for-of to the runtime view mode (v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 per `INTERFACE_segments_view.md` §9b: `js_segments_view_open` + `_next` in the loop, `_segment` once per step for the body. The body is NOT rewritten, so every use of the segment binding still sees an ordinary string. This removes the 48-byte segment RECORD per grapheme -- the allocation census's site 1, 172,032 per 400-character claude-code reply -- and the whole eager `build_segments` array with its two per-call closures. The substring stays; per-use `_code_point_at` / `_regexp_test` is the next increment. Emitted shape, for a site the matcher proves: Let recv = // hoisted, evaluated ONCE Let inp = // hoisted, evaluated ONCE Let cur = js_segments_view_open(recv, inp) // 0.0 on decline Let A = cur != 0 ? undefined : GetIterator(recv.segment(inp)) For { init: Let R = cur != 0 ? _next(cur) : js_for_of_next(A), cond: cur != 0 ? R == 1 : !R.done, update: R = cur != 0 ? _next(cur) : js_for_of_next(A), body: [Let O = cur != 0 ? _segment(cur) : R.value.segment, ] } Three properties this shape exists to get right, each with a test. The receiver and the input are HOISTED. Both appear on the accept path as `open`'s arguments and on the decline path as `recv.segment(inp)`, so leaving them in place would evaluate them twice: `getSegmenter().segment(next())` would call each twice. That is a miscompile, and claude-code's own `rR_.segment(q)` would never have exposed it because both operands there are side-effect-free. The `.segment` PROPERTY GET stays inside the decline arm. Hoisting the receiver does not hoist the member access, so a receiver whose `segment` is an accessor runs it exactly once, in its original position, on the path that needs it -- the ordering obligation §9f places on `open`'s decline path, honoured from the compiler side. The body is left byte-identical. That is what keeps `break` / `continue` / labels correct and avoids duplicating any `Expr::Closure` the body contains, which would carry a duplicate `FuncId`. The ternaries are real branches: `lower_conditional` emits a four-block CFG with a phi, so the decline arm's `GetIterator` does not run when `open` accepted. Verified before relying on it -- an eager select-style lowering would build the `Segments` on every loop and lose the entire per-call saving. Fresh LocalIds are seeded above every id the module mentions, declarations included and not only references: a local declared and never read still owns its id. DEFAULT OFF, behind `PERRY_SEGVIEW=1`. The runtime's view entry points do not exist yet, so an on-by-default rewrite would emit calls that fail to link. --- .../perry-codegen/src/collectors/segview.rs | 457 ++++++++++++++++++ .../src/collectors/segview_tests.rs | 101 ++++ crates/perry-codegen/src/lib.rs | 4 +- .../src/commands/compile/run_pipeline.rs | 11 + 4 files changed, 572 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 5a71b4989e..be0a9b6e15 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -910,3 +910,460 @@ fn describe(v: &SegViewVerdict) -> String { other => other.reason().to_string(), } } + +// ── the lowering ─────────────────────────────────────────────────────────── +// +// v1 per `INTERFACE_segments_view.md` §9b: `open` + `_next` in the loop, and +// `_segment` once per step for the body. The body is NOT rewritten — every use +// of the segment binding still sees an ordinary string — so this removes the +// 48-byte record per grapheme (census site 1, 172,032 per 400-character reply) +// and the whole eager `build_segments` array plus its two per-call closures, +// and leaves the substring. Per-use `_code_point_at` / `_regexp_test` is the +// next increment and needs the body rewritten site by site. +// +// Shape emitted for a firing site (`cur`, `recv`, `inp` are fresh locals): +// +// ```text +// Let recv = // hoisted: evaluated ONCE +// Let inp = // hoisted: evaluated ONCE +// Let cur = js_segments_view_open(recv, inp) // 0.0 on decline +// Let A = cur != 0 ? undefined : GetIterator(recv.segment(inp)) +// For { init: Let R = cur != 0 ? _next(cur) : js_for_of_next(A), +// cond: cur != 0 ? R == 1 : !R.done, +// update: R = cur != 0 ? _next(cur) : js_for_of_next(A), +// body: [Let O = cur != 0 ? _segment(cur) : R.value.segment, +// ] } +// ``` +// +// Three things this shape is chosen to get right. +// +// **The receiver and the input are hoisted.** Both appear on the accept path +// (as `open`'s arguments) and on the decline path (as `recv.segment(inp)`), so +// leaving them in place would evaluate them twice. `getSegmenter().segment(next())` +// would call each twice, which is a miscompile — and cc's own `rR_.segment(q)` +// would not have shown it, because both operands there are side-effect-free. +// +// **The `.segment` PROPERTY GET stays on the decline path only.** Hoisting the +// receiver does not hoist the member access, so a receiver whose `segment` is +// an accessor still runs it exactly once, in its original position, on the +// path that needs it. That is the ordering obligation §9f puts on `open`'s +// decline path, honoured from this side. +// +// **The ternaries are real branches.** `lower_conditional` emits a four-block +// CFG with a phi, so the decline arm's `GetIterator(recv.segment(inp))` does +// not execute when `open` accepted. A `select`-style eager lowering would +// build the `Segments` on every loop and lose the entire per-call saving. +// +// The body is left byte-identical, which is what keeps `break` / `continue` / +// labels correct and avoids duplicating any closure the body contains — a +// duplicated `Expr::Closure` would carry a duplicate `FuncId`. + +/// `PERRY_SEGVIEW=1`. **Default OFF**: the runtime's view entry points do not +/// exist yet, so an on-by-default rewrite would emit calls that fail to link. +pub fn segview_lowering_enabled() -> bool { + matches!(std::env::var("PERRY_SEGVIEW"), Ok(v) if !v.is_empty() && v != "0") +} + +fn extern_call(name: &str, args: Vec) -> Expr { + let param_types = vec![perry_hir::types::Type::Any; args.len()]; + Expr::Call { + callee: Box::new(Expr::ExternFuncRef { + name: name.to_string(), + param_types, + return_type: perry_hir::types::Type::Any, + }), + args, + type_args: vec![], + byte_offset: 0, + } +} + +fn let_any(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: perry_hir::types::Type::Any, + mutable: true, + init: Some(init), + } +} + +/// `cur != 0` — the accept test. `open` returns `0.0` when it declines. +fn cursor_live(cur: u32) -> Expr { + Expr::Compare { + op: perry_hir::CompareOp::Ne, + left: Box::new(Expr::LocalGet(cur)), + right: Box::new(Expr::Number(0.0)), + } +} + +fn pick(cur: u32, accept: Expr, decline: Expr) -> Expr { + Expr::Conditional { + condition: Box::new(cursor_live(cur)), + then_expr: Box::new(accept), + else_expr: Box::new(decline), + } +} + +/// Rewrite one firing site in place. `list[i]` is the `Let A = GetIterator(…)` +/// and `list[i + 1]` (possibly inside a `Labeled`) is the `For`. +/// +/// Returns the number of statements inserted, so the caller can advance its +/// index correctly. +fn rewrite_site(list: &mut Vec, i: usize, site: &SegmentForOfSite, fresh: &mut u32) -> usize { + // Pull the receiver and the input out of the `GetIterator(X.segment(q))`. + let (recv_expr, input_expr) = match &list[i] { + Stmt::Let { + init: Some(Expr::GetIterator(subject)), + .. + } => match subject.as_ref() { + Expr::Call { callee, args, .. } => match callee.as_ref() { + Expr::PropertyGet { object, .. } if args.len() == 1 => { + (object.as_ref().clone(), args[0].clone()) + } + _ => return 0, + }, + _ => return 0, + }, + _ => return 0, + }; + + let recv = *fresh; + let inp = *fresh + 1; + let cur = *fresh + 2; + *fresh += 3; + + // The decline path rebuilds exactly what the site had, from the hoisted + // operands: `GetIterator(recv.segment(inp))`. The `.segment` property get + // is INSIDE this arm, so an accessor receiver runs it once, here, only. + let decline_iter = Expr::GetIterator(Box::new(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(recv)), + property: "segment".to_string(), + byte_offset: 0, + }), + args: vec![Expr::LocalGet(inp)], + type_args: vec![], + byte_offset: 0, + })); + + // Head rewrite. + let iter_id = site.iter_id; + let result_id = site.result_id; + if let Stmt::For { + init, + condition, + update, + body, + } = unwrap_for_mut(&mut list[i + 1]) + { + if let Some(init_stmt) = init { + if let Stmt::Let { init: Some(e), .. } = init_stmt.as_mut() { + *e = pick( + cur, + extern_call("js_segments_view_next", vec![Expr::LocalGet(cur)]), + extern_call("js_for_of_next", vec![Expr::LocalGet(iter_id)]), + ); + } + } + // `cur != 0 ? (R == 1) : !R.done` + *condition = Some(pick( + cur, + Expr::Compare { + op: perry_hir::CompareOp::Eq, + left: Box::new(Expr::LocalGet(result_id)), + right: Box::new(Expr::Number(1.0)), + }, + Expr::Unary { + op: UnaryOp::Not, + operand: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(result_id)), + property: "done".to_string(), + byte_offset: 0, + }), + }, + )); + *update = Some(Expr::LocalSet( + result_id, + Box::new(pick( + cur, + extern_call("js_segments_view_next", vec![Expr::LocalGet(cur)]), + extern_call("js_for_of_next", vec![Expr::LocalGet(iter_id)]), + )), + )); + + // Body head: drop the record `Let` entirely (this IS the elision) and + // bind the segment from the view, or from `R.value.segment` on the + // decline path. + if let Some(seg_id) = site.segment_id { + let seg_name = match &body[1] { + Stmt::Let { name, .. } => name.clone(), + _ => "O".to_string(), + }; + let bind = Stmt::Let { + id: seg_id, + name: seg_name, + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(pick( + cur, + extern_call("js_segments_view_segment", vec![Expr::LocalGet(cur)]), + Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(result_id)), + property: "value".to_string(), + byte_offset: 0, + }), + property: "segment".to_string(), + byte_offset: 0, + }, + )), + }; + body.remove(0); // the `Let __destruct_N = R.value` + body[0] = bind; // was `Let O = __destruct_N.segment` + } + } + + // Statement rewrite: hoist, open, and the conditional iterator. + list[i] = let_any(recv, "__segview_recv", recv_expr); + list.insert(i + 1, let_any(inp, "__segview_input", input_expr)); + list.insert( + i + 2, + let_any( + cur, + "__segview_cursor", + extern_call( + "js_segments_view_open", + vec![Expr::LocalGet(recv), Expr::LocalGet(inp)], + ), + ), + ); + list.insert( + i + 3, + let_any( + iter_id, + "__segview_iter", + pick(cur, Expr::Undefined, decline_iter), + ), + ); + 3 +} + +fn unwrap_for_mut(s: &mut Stmt) -> &mut Stmt { + let mut cur = s; + loop { + match cur { + Stmt::Labeled { body, .. } => cur = body.as_mut(), + other => return other, + } + } +} + +/// Rewrite every firing site in one statement list and its nested lists. +fn rewrite_stmts(list: &mut Vec, fresh: &mut u32, count: &mut usize) { + // Nested lists first: rewriting an outer window never moves an inner one, + // but doing children first keeps the indices below trivially valid. + for s in list.iter_mut() { + rewrite_in_stmt(s, fresh, count); + } + + let mut i = 0usize; + while i + 1 < list.len() { + let sites = collect_segment_for_of_sites(std::slice::from_ref(&list[i])); + // `collect_segment_for_of_sites` needs the window, not one statement. + let window: Vec = list[i..=i + 1].to_vec(); + let sites = if sites.is_empty() { + collect_segment_for_of_sites(&window) + } else { + sites + }; + if let Some(site) = sites.iter().find(|s| s.fires()) { + let inserted = rewrite_site(list, i, site, fresh); + if inserted > 0 { + *count += 1; + i += inserted + 2; + continue; + } + } + i += 1; + } +} + +fn rewrite_in_stmt(s: &mut Stmt, fresh: &mut u32, count: &mut usize) { + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + rewrite_stmts(then_branch, fresh, count); + if let Some(e) = else_branch { + rewrite_stmts(e, fresh, count); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => rewrite_stmts(body, fresh, count), + Stmt::For { init, body, .. } => { + if let Some(i) = init { + rewrite_in_stmt(i, fresh, count); + } + rewrite_stmts(body, fresh, count); + } + Stmt::Labeled { body, .. } => rewrite_in_stmt(body, fresh, count), + Stmt::Try { + body, + catch, + finally, + } => { + rewrite_stmts(body, fresh, count); + if let Some(c) = catch { + rewrite_stmts(&mut c.body, fresh, count); + } + if let Some(f) = finally { + rewrite_stmts(f, fresh, count); + } + } + Stmt::Switch { cases, .. } => { + for c in cases.iter_mut() { + rewrite_stmts(&mut c.body, fresh, count); + } + } + _ => {} + } + // Closure bodies hang off expressions. + for_each_expr_in_stmt_shallow_mut(s, &mut |e| rewrite_closure_bodies(e, fresh, count)); +} + +fn rewrite_closure_bodies(e: &mut Expr, fresh: &mut u32, count: &mut usize) { + if let Expr::Closure { body, .. } = e { + rewrite_stmts(body, fresh, count); + } + perry_hir::walker::walk_expr_children_mut(e, &mut |child| { + rewrite_closure_bodies(child, fresh, count) + }); +} + +fn for_each_expr_in_stmt_shallow_mut(stmt: &mut Stmt, f: &mut impl FnMut(&mut Expr)) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + f(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => f(e), + Stmt::Return(e) => { + if let Some(e) = e { + f(e); + } + } + Stmt::If { condition, .. } => f(condition), + Stmt::While { condition, .. } | Stmt::DoWhile { condition, .. } => f(condition), + Stmt::For { + init, + condition, + update, + .. + } => { + if let Some(i) = init { + for_each_expr_in_stmt_shallow_mut(i, f); + } + if let Some(c) = condition { + f(c); + } + if let Some(u) = update { + f(u); + } + } + Stmt::Switch { discriminant, .. } => f(discriminant), + Stmt::Labeled { body, .. } => for_each_expr_in_stmt_shallow_mut(body, f), + _ => {} + } +} + +/// The largest LocalId the module mentions anywhere — declarations included, +/// not only references. A local that is declared and never read still owns its +/// id, so seeding fresh ids from the reference maximum alone would collide +/// with it. +fn max_local_id_in_module(m: &perry_hir::Module) -> u32 { + let mut max = 0u32; + let mut note_stmts = |stmts: &[Stmt], max: &mut u32| { + for_each_stmt_list(stmts, &mut |list| { + for s in list { + match s { + Stmt::Let { id, .. } => *max = (*max).max(*id), + Stmt::PreallocateBoxes(ids) + | Stmt::PreallocateTdzBoxes(ids) + | Stmt::ReleaseBoxes(ids) => { + for id in ids { + *max = (*max).max(*id); + } + } + Stmt::Try { catch: Some(c), .. } => { + if let Some((id, _)) = &c.param { + *max = (*max).max(*id); + } + } + _ => {} + } + } + }); + let mut refs = Vec::new(); + let mut visited = std::collections::HashSet::new(); + for s in stmts { + perry_hir::collect_local_refs_stmt(s, &mut refs, &mut visited); + } + for id in refs { + *max = (*max).max(id); + } + }; + note_stmts(&m.init, &mut max); + for f in &m.functions { + for p in &f.params { + max = max.max(p.id); + } + note_stmts(&f.body, &mut max); + } + for c in &m.classes { + let mut fns: Vec<&perry_hir::Function> = Vec::new(); + if let Some(ctor) = &c.constructor { + fns.push(ctor); + } + fns.extend(c.methods.iter()); + fns.extend(c.static_methods.iter()); + fns.extend(c.getters.iter().map(|(_, f)| f)); + fns.extend(c.setters.iter().map(|(_, f)| f)); + for f in fns { + for p in &f.params { + max = max.max(p.id); + } + note_stmts(&f.body, &mut max); + } + } + max +} + +/// Rewrite every firing segment for-of in a module. Three new locals are +/// minted per site, seeded above every id the module already uses. Returns how +/// many sites were rewritten. +pub fn segview_rewrite_module(m: &mut perry_hir::Module) -> usize { + let mut fresh = max_local_id_in_module(m).saturating_add(1); + let mut count = 0usize; + rewrite_stmts(&mut m.init, &mut fresh, &mut count); + for f in m.functions.iter_mut() { + rewrite_stmts(&mut f.body, &mut fresh, &mut count); + } + for c in m.classes.iter_mut() { + if let Some(ctor) = c.constructor.as_mut() { + rewrite_stmts(&mut ctor.body, &mut fresh, &mut count); + } + for meth in c.methods.iter_mut().chain(c.static_methods.iter_mut()) { + rewrite_stmts(&mut meth.body, &mut fresh, &mut count); + } + for (_, f) in c.getters.iter_mut().chain(c.setters.iter_mut()) { + rewrite_stmts(&mut f.body, &mut fresh, &mut count); + } + } + if segview_diag_enabled() { + eprintln!("[segview] REWROTE {count} site(s) in module {}", m.name); + } + count +} diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 812d51d505..09cf02a9ef 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -314,3 +314,104 @@ fn the_folded_code_point_at_node_is_classified_like_the_generic_call() { "a loop whose only use is the folded codePointAt is answerable by v1 alone" ); } + +// ── the lowering ─────────────────────────────────────────────────────────── + +use super::segview::segview_rewrite_module; + +fn module_with(stmts: Vec) -> perry_hir::Module { + let mut m = perry_hir::Module::new("t"); + m.init = stmts; + m +} + +fn render(m: &perry_hir::Module) -> String { + format!("{:?}", m.init) +} + +/// The shape the lowering must emit, pinned on the parts that carry meaning. +#[test] +fn the_rewrite_elides_the_record_and_keeps_a_spec_path() { + let mut m = module_with(region(cc_body())); + assert_eq!(segview_rewrite_module(&mut m), 1); + let out = render(&m); + + assert!( + out.contains("js_segments_view_open"), + "the two-argument open must be emitted: {out}" + ); + assert!( + out.contains("js_segments_view_next"), + "the in-loop advance must be emitted" + ); + assert!( + out.contains("js_segments_view_segment"), + "v1 materialises the segment once per step" + ); + assert!( + out.contains("js_for_of_next"), + "the spec path must survive for the decline case" + ); + assert!( + !out.contains("__destruct_"), + "the record binding is what this removes; it must be gone: {out}" + ); +} + +/// The receiver and the input appear on BOTH arms, so they must be evaluated +/// once and read from locals — not re-evaluated in the decline arm. A receiver +/// with a side effect would otherwise run twice. +#[test] +fn the_receiver_and_input_are_hoisted_exactly_once() { + let mut m = module_with(region(cc_body())); + assert_eq!(segview_rewrite_module(&mut m), 1); + let out = render(&m); + assert!(out.contains("__segview_recv"), "receiver hoisted"); + assert!(out.contains("__segview_input"), "input hoisted"); + // `LocalGet(0)` was the receiver and `LocalGet(3)` the input in `region`. + // After the rewrite each must appear exactly ONCE — in its hoist. + assert_eq!( + out.matches("LocalGet(0)").count(), + 1, + "the receiver is evaluated once, not on both arms: {out}" + ); + assert_eq!( + out.matches("LocalGet(3)").count(), + 1, + "the input is evaluated once, not on both arms: {out}" + ); +} + +/// The `.segment` property get must stay inside the decline arm, so a receiver +/// whose `segment` is an accessor runs it exactly once, in its original +/// position, and never on the accepted path. +#[test] +fn the_segment_property_get_stays_on_the_decline_arm_only() { + let mut m = module_with(region(cc_body())); + segview_rewrite_module(&mut m); + let out = render(&m); + assert_eq!( + out.matches("property: \"segment\"").count(), + 2, + "exactly two: the decline arm's `recv.segment(inp)` and the decline \ + arm's `R.value.segment` — never on the accepted path: {out}" + ); +} + +/// A site that does not fire must be left byte-identical. +#[test] +fn a_declining_site_is_not_rewritten() { + let destructure = vec![ + let_( + RECORD, + "__destruct_11", + pget(Expr::LocalGet(RESULT), "value"), + ), + let_(SEG, "O", pget(Expr::LocalGet(RECORD), "segment")), + let_(8, "I", pget(Expr::LocalGet(RECORD), "index")), + ]; + let mut m = module_with(vec![iter_let(), for_stmt(destructure, cc_body())]); + let before = render(&m); + assert_eq!(segview_rewrite_module(&mut m), 0); + assert_eq!(before, render(&m), "a declining site must be untouched"); +} diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index c7be76002c..3e3ea4fd81 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -84,7 +84,9 @@ pub use collectors::CjsPreambleCensus; // driver can run it at the HIR-trace point — after every transform, on // exactly the statements codegen consumes — instead of only inside a // codegen run, which a 10 MB bundle does not reach in a usable time. -pub use collectors::segview::{segview_diag_enabled, SegViewDiag}; +pub use collectors::segview::{ + segview_diag_enabled, segview_lowering_enabled, segview_rewrite_module, SegViewDiag, +}; /// Return the guarded proven-`this` method-clone capabilities a native module /// may safely publish to importing codegen units. The first map contains all diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index b191edef52..cc75c585e5 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1031,6 +1031,17 @@ pub fn run_with_parse_cache( // `collect_type_facts` on a rayon worker) is what makes "does the tier // fire on the real bundle?" answerable in HIR-lowering time instead of a // full LLVM build. Gated on `PERRY_SEGVIEW_DIAG`; costs nothing otherwise. + // #9843: the segment-view lowering. Default OFF (`PERRY_SEGVIEW=1`) because + // the runtime's view entry points do not exist yet, so an on-by-default + // rewrite would emit calls that fail to link. Runs here, at the same point + // as the counter and the HIR trace, so what it rewrites is exactly what + // codegen consumes. + if perry_codegen::segview_lowering_enabled() { + for hir_module in ctx.native_modules.values_mut() { + perry_codegen::segview_rewrite_module(hir_module); + } + } + if perry_codegen::segview_diag_enabled() { let mut diag = perry_codegen::SegViewDiag::default(); for (path, hir_module) in &ctx.native_modules { From 122b49139b5a6b4fab892f4e81ee9c8142a3f6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 12:37:28 +0200 Subject: [PATCH 07/11] diag(codegen): make the lowering report which entry point each site was lowered to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counter and the rewrite answered two different questions and only one was being reported. `PERRY_SEGVIEW_DIAG=1` reports the CLASSIFICATION -- what each use of the segment binding could be answered by -- and it runs before the rewrite, because after it the shape is gone. So there was no way to confirm what was actually EMITTED, which was the counter's original purpose. Reordering the passes would trade one blind spot for the other. Instead the rewrite reports itself, and the two lines together say classification and emission: [segview] …::N$6 verdict=fires … code_point_at=1 regexp_test_dynamic=2 materialise=0 [segview-lower] __destruct_118613 open=1 next=1 segment=1 code_point_at=0 regexp_test=0 declined=none (classifier: code_point_at=1 regexp_test=2 materialise=0) Note deliberately that the emission line reports `code_point_at=0 regexp_test=0` even on a site the classifier scores as fully answerable. That is not a bug and it is not rounding: v1 emits `_segment` once per step and leaves the body untouched, so no use is answered from the view yet. The gap between the two lines IS the v1/v2 boundary, and having the instrument state it is better than having a reader infer from the design that v1 already routes `codePointAt` through the cursor. It will close when the per-use rewrite lands. Also fixes an `unused_mut` this pass introduced in `max_local_id_in_module`, which would have failed a `-D warnings` gate. Found by type-checking against the existing release artifacts -- zero disk cost, which mattered because the box is at 8 GiB and the integration build was killed by a disk watchdog. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../perry-codegen/src/collectors/segview.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index be0a9b6e15..014fb06b13 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -1124,6 +1124,24 @@ fn rewrite_site(list: &mut Vec, i: usize, site: &SegmentForOfSite, fresh: } } + if segview_diag_enabled() { + // What was actually EMITTED, per site. Pairs with the classifier's + // `[segview]` line: that one says what each use of the segment binding + // COULD be answered by, this one says which entry point it now IS. + // v1 emits `_segment` once per step and leaves the body alone, so + // `code_point_at` and `regexp_test` are 0 here even on a site the + // classifier scored as answerable -- that difference is the v1/v2 gap, + // stated by the instrument instead of being inferred from the design. + let u = &site.segment_uses; + eprintln!( + "[segview-lower] {} open=1 next=1 segment=1 code_point_at=0 regexp_test=0 \ + declined=none (classifier: code_point_at={} regexp_test={} materialise={})", + site.record_name, + u.code_point_at, + u.regexp_test_static + u.regexp_test_dynamic, + u.materialise, + ); + } // Statement rewrite: hoist, open, and the conditional iterator. list[i] = let_any(recv, "__segview_recv", recv_expr); list.insert(i + 1, let_any(inp, "__segview_input", input_expr)); @@ -1285,7 +1303,7 @@ fn for_each_expr_in_stmt_shallow_mut(stmt: &mut Stmt, f: &mut impl FnMut(&mut Ex /// with it. fn max_local_id_in_module(m: &perry_hir::Module) -> u32 { let mut max = 0u32; - let mut note_stmts = |stmts: &[Stmt], max: &mut u32| { + let note_stmts = |stmts: &[Stmt], max: &mut u32| { for_each_stmt_list(stmts, &mut |list| { for s in list { match s { From 078c2667ca949e8498bd36b73d6b7752d191774f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 14:11:10 +0200 Subject: [PATCH 08/11] =?UTF-8?q?feat(codegen):=20v2=20=E2=80=94=20answer?= =?UTF-8?q?=20the=20segment's=20uses=20from=20the=20view,=20materialising?= =?UTF-8?q?=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 bound the segment with `_segment` once per step and left the body alone, so it removed the record and kept the substring. v2 rewrites the USES: on a site where the classifier found nothing that needs the string, the accepted path materialises nothing at all and the loop reaches zero allocations per grapheme. That is where the remaining time is. perry-b4's I2 table puts ink's wrapText subtree at 80.2 % of active main-thread CPU (`E46`/`tI1` 4,238 of 5,152 samples, `u_N_24_6` 4,131 — about 4.1 s of a 5.2 s turn), with no dominant collector leaf left; the collector's share is minors landing inside this loop. v1 does not reach that. v2 does. Two substitutions, with very different risk. `O.codePointAt(k)` becomes `js_segments_view_code_point_at(cursor, k)` — a pure expression swap. `k` is unchanged: it is segment-relative and segment-bounded by the runtime's contract (§9d), the same bound the materialised substring had. `recv.test(O)` is the hard one. Read from #9870 rather than assumed: `js_segments_view_regexp_test(cursor, regex)` — CURSOR FIRST — returns true, false, or `undefined` meaning "I declined" (global/sticky regex, patched `RegExp.prototype.test` or an own `test`), and the runtime does NOT fall back internally, so the compiler must. `recv` is arbitrary — in cc it is `g54.default()`, an opaque call that must run exactly once per evaluation — so it cannot be repeated in the fallback arm. The emitted form is a pure expression, so no control flow is restructured: Sequence([ LocalSet(t_recv, ), // opaque call, ONCE LocalSet(t_res, _regexp_test(cursor, t_recv)), t_res === undefined ? t_recv.test(_segment(cursor)) : t_res ]) The materialisation is inside the decline arm, so the accepted path allocates nothing. Every rewritten use is GUARDED, not replaced: `cur != 0 ? : `. The loop body is shared between the accepted and declined paths, so the original expression must survive for the decline arm, where `O` holds a real string. On acceptance `O` is bound to `undefined` and never read, because every use takes the view arm — which is what makes the accepted path allocation-free without duplicating the body. A site with even one unanswerable use stays on v1: paying per-use guards on top of a materialisation that happens anyway is strictly worse. WHAT SUBSTITUTES FOR THE TESTS THIS COULD NOT BE RUN AGAINST. This box cannot build (its target was deleted to recover disk), so `rustfmt` and reading are the only gates. The pass therefore rewrites a CLONE of the body and keeps it only if the emission matches the classification exactly — same `code_point_at` count, same `regexp_test` count. If they disagree, some use was not rewritten and would read an unbound segment on the accepted path, so the clone is discarded and v1 is used. The check is the mechanism, not a comment. `[segview-lower]` now reports which arm was taken, so classifier and emission can be compared on the real bundle: [segview-lower] open=1 next=1 segment=0 code_point_at=1 regexp_test=2 declined=none (v2: …) [segview-lower] open=1 next=1 segment=1 code_point_at=0 regexp_test=0 declined=none (v1: …) Decline paths are unchanged. Three HIR-level tests added beside the v1 ones. NOT COMPILED AND NOT RUN — see the commit message above and §v2 of HANDOFF_segview_e2e.md for exactly what is unverified. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- .../perry-codegen/src/collectors/segview.rs | 325 ++++++++++++++++-- .../src/collectors/segview_tests.rs | 87 +++++ 2 files changed, 374 insertions(+), 38 deletions(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 014fb06b13..172a7d1cd5 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -1100,48 +1100,105 @@ fn rewrite_site(list: &mut Vec, i: usize, site: &SegmentForOfSite, fresh: Stmt::Let { name, .. } => name.clone(), _ => "O".to_string(), }; - let bind = Stmt::Let { - id: seg_id, - name: seg_name, - ty: perry_hir::types::Type::Any, - mutable: false, - init: Some(pick( - cur, - extern_call("js_segments_view_segment", vec![Expr::LocalGet(cur)]), - Expr::PropertyGet { - object: Box::new(Expr::PropertyGet { - object: Box::new(Expr::LocalGet(result_id)), - property: "value".to_string(), - byte_offset: 0, - }), - property: "segment".to_string(), - byte_offset: 0, - }, - )), + let spec_bind = Expr::PropertyGet { + object: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(result_id)), + property: "value".to_string(), + byte_offset: 0, + }), + property: "segment".to_string(), + byte_offset: 0, }; - body.remove(0); // the `Let __destruct_N = R.value` - body[0] = bind; // was `Let O = __destruct_N.segment` + + // v2 is attempted only when the classifier found NO use that needs + // the substring. If even one does, materialising once (v1) is + // strictly better than materialising once AND paying the guards. + let answerable = site.segment_uses.regexp_test_static + + site.segment_uses.regexp_test_dynamic + + site.segment_uses.code_point_at; + let mut v2: Option<(V2Emission, Vec)> = None; + if site.segment_uses.materialise == 0 && answerable > 0 { + // Rewrite a CLONE and keep it only if the emission matches the + // classification exactly. This check stands in for the tests + // this pass could not be run against: if the two disagree, some + // use was not rewritten and would read an unbound segment on + // the accepted path, so the clone is discarded and v1 is used. + let mut trial: Vec = body[2..].to_vec(); + let mut probe_fresh = *fresh; + let mut emitted = V2Emission { + code_point_at: 0, + regexp_test: 0, + decls: Vec::new(), + }; + for st in trial.iter_mut() { + rewrite_uses_in_stmt(st, seg_id, cur, &mut probe_fresh, &mut emitted); + } + let agrees = emitted.code_point_at == site.segment_uses.code_point_at + && emitted.regexp_test + == site.segment_uses.regexp_test_static + + site.segment_uses.regexp_test_dynamic; + if agrees { + *fresh = probe_fresh; + v2 = Some((emitted, trial)); + } + } + + match v2 { + Some((emitted, trial)) => { + // The segment is never materialised on the accepted path: + // `O` is bound only for the decline arm, and every use is + // guarded, so on acceptance it is undefined and never read. + let bind = Stmt::Let { + id: seg_id, + name: seg_name, + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(pick(cur, Expr::Undefined, spec_bind)), + }; + let mut new_body = vec![bind]; + new_body.extend(emitted.decls.iter().cloned()); + new_body.extend(trial); + *body = new_body; + if segview_diag_enabled() { + eprintln!( + "[segview-lower] {} open=1 next=1 segment=0 code_point_at={} \ + regexp_test={} declined=none (v2: nothing materialised on the \ + accepted path)", + site.record_name, emitted.code_point_at, emitted.regexp_test, + ); + } + } + None => { + let bind = Stmt::Let { + id: seg_id, + name: seg_name, + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(pick( + cur, + extern_call("js_segments_view_segment", vec![Expr::LocalGet(cur)]), + spec_bind, + )), + }; + body.remove(0); // the `Let __destruct_N = R.value` + body[0] = bind; // was `Let O = __destruct_N.segment` + if segview_diag_enabled() { + let u = &site.segment_uses; + eprintln!( + "[segview-lower] {} open=1 next=1 segment=1 code_point_at=0 \ + regexp_test=0 declined=none (v1: classifier code_point_at={} \ + regexp_test={} materialise={})", + site.record_name, + u.code_point_at, + u.regexp_test_static + u.regexp_test_dynamic, + u.materialise, + ); + } + } + } } } - if segview_diag_enabled() { - // What was actually EMITTED, per site. Pairs with the classifier's - // `[segview]` line: that one says what each use of the segment binding - // COULD be answered by, this one says which entry point it now IS. - // v1 emits `_segment` once per step and leaves the body alone, so - // `code_point_at` and `regexp_test` are 0 here even on a site the - // classifier scored as answerable -- that difference is the v1/v2 gap, - // stated by the instrument instead of being inferred from the design. - let u = &site.segment_uses; - eprintln!( - "[segview-lower] {} open=1 next=1 segment=1 code_point_at=0 regexp_test=0 \ - declined=none (classifier: code_point_at={} regexp_test={} materialise={})", - site.record_name, - u.code_point_at, - u.regexp_test_static + u.regexp_test_dynamic, - u.materialise, - ); - } // Statement rewrite: hoist, open, and the conditional iterator. list[i] = let_any(recv, "__segview_recv", recv_expr); list.insert(i + 1, let_any(inp, "__segview_input", input_expr)); @@ -1385,3 +1442,195 @@ pub fn segview_rewrite_module(m: &mut perry_hir::Module) -> usize { } count } + +// ── v2: answer the uses from the view instead of materialising ───────────── +// +// v1 binds the segment with `_segment` once per step and leaves the body +// alone, so it removes the record and keeps the substring. v2 rewrites the +// USES, so on a site the classifier scored `materialise=0` nothing is +// materialised at all and the loop reaches zero allocations per grapheme. +// +// Two substitutions, and they have very different risk. +// +// `O.codePointAt(k)` -> `js_segments_view_code_point_at(cursor, k)` is a pure +// expression swap: same arity, same value, no temporaries, no control flow. +// `k` stays as written -- it is segment-relative and segment-bounded by the +// runtime's contract (§9d), which is the same bound the materialised substring +// had, so no clamping is added or removed here. +// +// `recv.test(O)` is the hard one, because `js_segments_view_regexp_test` +// returns THREE values: true, false, or `undefined` meaning "I declined" +// (global/sticky regex, or a patched `RegExp.prototype.test`). Read from +// #9870: the runtime does NOT fall back internally, so the compiler must. And +// `recv` is an arbitrary expression -- in cc it is `g54.default()`, an opaque +// call that must run exactly once per evaluation -- so it cannot simply be +// repeated in the fallback arm. +// +// The emitted form is a pure expression, so it works in any position without +// restructuring the body's control flow: +// +// ```text +// Sequence([ +// LocalSet(t_recv, ), // opaque call, ONCE +// LocalSet(t_res, _regexp_test(cursor, t_recv)), +// Conditional { cond: t_res === undefined, +// then: t_recv.test(_segment(cursor)), // materialise LAZILY, +// else: t_res } // only on decline +// ]) +// ``` +// +// The materialisation sits inside the `then` arm, so the accepted path -- which +// is every step unless the program patched `RegExp.prototype.test` -- allocates +// nothing. Both temporaries are declared at the top of the loop body, because +// a bare `LocalSet` to an id with no `Stmt::Let` has no slot. + +struct V2Emission { + code_point_at: u32, + regexp_test: u32, + decls: Vec, +} + +fn is_undefined_cmp(id: u32) -> Expr { + Expr::Compare { + op: perry_hir::CompareOp::Eq, + left: Box::new(Expr::LocalGet(id)), + right: Box::new(Expr::Undefined), + } +} + +/// Rewrite the answerable uses of `seg` in one expression. Returns how many of +/// each kind were replaced and any temporaries that must be declared. +fn rewrite_uses_in_expr(e: &mut Expr, seg: u32, cur: u32, fresh: &mut u32, out: &mut V2Emission) { + // `O.codePointAt(k)` -> `cur != 0 ? _code_point_at(cursor, k) : ` + let e_original = e.clone(); + let mut replaced = None; + if let Expr::Call { callee, args, .. } = e { + if let Expr::PropertyGet { + object, property, .. + } = callee.as_ref() + { + if property == "codePointAt" + && args.len() == 1 + && matches!(object.as_ref(), Expr::LocalGet(id) if *id == seg) + { + // Guarded, NOT replaced. The loop body is shared between the + // accepted and declined paths, so the original expression must + // survive for the decline arm, where `O` holds a real string. + replaced = Some(pick( + cur, + extern_call( + "js_segments_view_code_point_at", + vec![Expr::LocalGet(cur), args[0].clone()], + ), + e_original.clone(), + )); + out.code_point_at += 1; + } else if property == "test" + && args.len() == 1 + && matches!(&args[0], Expr::LocalGet(id) if *id == seg) + && !matches!(object.as_ref(), Expr::LocalGet(id) if *id == seg) + { + let t_recv = *fresh; + let t_res = *fresh + 1; + *fresh += 2; + out.decls + .push(let_any(t_recv, "__segview_test_recv", Expr::Undefined)); + out.decls + .push(let_any(t_res, "__segview_test_res", Expr::Undefined)); + let recv_expr = object.as_ref().clone(); + let view_form = Expr::Sequence(vec![ + Expr::LocalSet(t_recv, Box::new(recv_expr)), + Expr::LocalSet( + t_res, + Box::new(extern_call( + "js_segments_view_regexp_test", + vec![Expr::LocalGet(cur), Expr::LocalGet(t_recv)], + )), + ), + Expr::Conditional { + condition: Box::new(is_undefined_cmp(t_res)), + then_expr: Box::new(Expr::Call { + callee: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(t_recv)), + property: "test".to_string(), + byte_offset: 0, + }), + args: vec![extern_call( + "js_segments_view_segment", + vec![Expr::LocalGet(cur)], + )], + type_args: vec![], + byte_offset: 0, + }), + else_expr: Box::new(Expr::LocalGet(t_res)), + }, + ]); + replaced = Some(pick(cur, view_form, e_original.clone())); + out.regexp_test += 1; + } + } + } + if let Some(new_e) = replaced { + *e = new_e; + return; + } + if let Expr::Closure { body, .. } = e { + for s in body.iter_mut() { + rewrite_uses_in_stmt(s, seg, cur, fresh, out); + } + } + perry_hir::walker::walk_expr_children_mut(e, &mut |child| { + rewrite_uses_in_expr(child, seg, cur, fresh, out) + }); +} + +fn rewrite_uses_in_stmt(s: &mut Stmt, seg: u32, cur: u32, fresh: &mut u32, out: &mut V2Emission) { + for_each_expr_in_stmt_shallow_mut(s, &mut |e| rewrite_uses_in_expr(e, seg, cur, fresh, out)); + let mut kids: Vec<&mut Stmt> = Vec::new(); + collect_child_stmts_mut(s, &mut kids); + for k in kids { + rewrite_uses_in_stmt(k, seg, cur, fresh, out); + } +} + +fn collect_child_stmts_mut<'a>(s: &'a mut Stmt, out: &mut Vec<&'a mut Stmt>) { + match s { + Stmt::If { + then_branch, + else_branch, + .. + } => { + out.extend(then_branch.iter_mut()); + if let Some(e) = else_branch { + out.extend(e.iter_mut()); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => out.extend(body.iter_mut()), + Stmt::For { init, body, .. } => { + if let Some(i) = init { + out.push(i.as_mut()); + } + out.extend(body.iter_mut()); + } + Stmt::Labeled { body, .. } => out.push(body.as_mut()), + Stmt::Try { + body, + catch, + finally, + } => { + out.extend(body.iter_mut()); + if let Some(c) = catch { + out.extend(c.body.iter_mut()); + } + if let Some(f) = finally { + out.extend(f.iter_mut()); + } + } + Stmt::Switch { cases, .. } => { + for c in cases.iter_mut() { + out.extend(c.body.iter_mut()); + } + } + _ => {} + } +} diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 09cf02a9ef..2f9f2f38f8 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -415,3 +415,90 @@ fn a_declining_site_is_not_rewritten() { assert_eq!(segview_rewrite_module(&mut m), 0); assert_eq!(before, render(&m), "a declining site must be untouched"); } + +/// v2: on a site where every use of the segment is view-answerable, nothing is +/// materialised on the accepted path. `N$6` is exactly this shape — one +/// `codePointAt` and two opaque-receiver `.test()` calls. +#[test] +fn v2_answers_every_use_from_the_view_and_materialises_nothing() { + let mut m = module_with(region(cc_body())); + assert_eq!(segview_rewrite_module(&mut m), 1); + let out = render(&m); + + assert!( + out.contains("js_segments_view_code_point_at"), + "the codePointAt use must be answered from the cursor: {out}" + ); + assert!( + out.contains("js_segments_view_regexp_test"), + "the regex test must be answered from the cursor" + ); + assert!( + out.contains("__segview_test_recv"), + "the opaque receiver must be hoisted so it is evaluated exactly once" + ); + + // The decisive property, checked on the tree rather than on its Debug + // rendering: the segment binding's ACCEPTED arm must be `Undefined`. If it + // were `_segment(cursor)` the loop would still allocate a substring per + // grapheme and v2 would buy nothing — and a string-contains assertion + // would not have caught it, because `_segment` legitimately appears inside + // the regexp_test decline arm. + let seg_bind_accept_is_undefined = m.init.iter().any(|s| match s { + Stmt::For { body, .. } => matches!( + body.first(), + Some(Stmt::Let { + init: Some(Expr::Conditional { then_expr, .. }), + .. + }) if matches!(then_expr.as_ref(), Expr::Undefined) + ), + _ => false, + }); + assert!( + seg_bind_accept_is_undefined, + "the segment must NOT be materialised on the accepted path: {out}" + ); +} + +/// The receiver of `.test(O)` is `g54.default()` in cc — an opaque call that +/// must run exactly once per evaluation. It is bound to a temporary and the +/// fallback arm reuses the temporary rather than re-evaluating it. +#[test] +fn v2_evaluates_an_opaque_test_receiver_exactly_once() { + let body = vec![Stmt::Expr(call( + pget(call(pget(Expr::LocalGet(54), "default"), vec![]), "test"), + vec![Expr::LocalGet(SEG)], + ))]; + let mut m = module_with(region(body)); + assert_eq!(segview_rewrite_module(&mut m), 1); + let out = render(&m); + assert_eq!( + out.matches("property: \"default\"").count(), + 2, + "once in the view arm's hoist and once in the decline arm's original — \ + never twice within one arm: {out}" + ); +} + +/// A site with a use the classifier cannot answer keeps v1: materialise once +/// and leave the body alone. Paying the per-use guards on top of a +/// materialisation that happens anyway would be strictly worse. +#[test] +fn a_site_with_an_unanswerable_use_stays_on_v1() { + let mut body = cc_body(); + body.push(Stmt::Expr(call( + Expr::LocalGet(42), + vec![Expr::LocalGet(SEG)], + ))); + let mut m = module_with(region(body)); + assert_eq!(segview_rewrite_module(&mut m), 1); + let out = render(&m); + assert!( + out.contains("js_segments_view_segment"), + "v1 binds the segment by materialising it once" + ); + assert!( + !out.contains("js_segments_view_code_point_at"), + "v1 does not rewrite uses: {out}" + ); +} From 05306026df7a140148fa46dbf8bea6a1057150e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 15:13:02 +0200 Subject: [PATCH 09/11] fix(codegen): declare the segment-view runtime entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lowering emitted the calls and the module never declared them, so the in-process LLVM parse rejected the whole module: perry_llvm_….ll:5172:22: error: use of undefined value '@js_segments_view_next' %r75 = call double @js_segments_view_next(double %r74) Not a degraded build — no build at all. The five entry points are now registered in `runtime_decls/strings.rs` beside `js_for_of_next`, which is where every other runtime native gets its `declare`. Signatures are read from `perry-runtime/src/intl/segments_view.rs`, not assumed: `open(f64,f64)`, `next(f64)`, `code_point_at(f64,f64)`, `segment(f64)`, `regexp_test(f64,f64)`. Note `regexp_test` is (cursor, regex), cursor first; it was relayed the other way round once and the source settled it. Why the tier's twelve HIR tests could not catch this: they assert the rewrite emits `Call(ExternFuncRef "js_segments_view_next", …)`, and it did. The gap was between "the lowering emits the call" and "the module can be parsed", and nothing tested the second. `every_segment_view_entry_point_is_declared` closes it by running the real declare phase over an `LlModule` and checking each of the five by name — remove any one registration and it fails naming that symbol. It also asserts ARITY, which is the sabotage a name-only check would miss: a wrong parameter count parses cleanly and then miscompiles the call, because LLVM will coerce or drop an argument rather than complain. --- crates/perry-codegen/src/runtime_decls/mod.rs | 3 + .../src/runtime_decls/segview_decls_tests.rs | 73 +++++++++++++++++++ .../src/runtime_decls/strings.rs | 11 +++ 3 files changed, 87 insertions(+) create mode 100644 crates/perry-codegen/src/runtime_decls/segview_decls_tests.rs diff --git a/crates/perry-codegen/src/runtime_decls/mod.rs b/crates/perry-codegen/src/runtime_decls/mod.rs index e08c4f6d34..ec14cd207e 100644 --- a/crates/perry-codegen/src/runtime_decls/mod.rs +++ b/crates/perry-codegen/src/runtime_decls/mod.rs @@ -25,6 +25,9 @@ pub use objects::declare_phase_b_objects; pub use stdlib_ffi::declare_stdlib_ffi; pub(crate) use stdlib_ffi_part2::declare_stdlib_ffi_part2; pub use strings::declare_phase_b_strings; + +#[cfg(test)] +mod segview_decls_tests; pub(crate) use strings_part2::declare_phase_b_strings_part2; /// Declare the minimum set of runtime functions needed by Phase 1 diff --git a/crates/perry-codegen/src/runtime_decls/segview_decls_tests.rs b/crates/perry-codegen/src/runtime_decls/segview_decls_tests.rs new file mode 100644 index 0000000000..c76434d3c5 --- /dev/null +++ b/crates/perry-codegen/src/runtime_decls/segview_decls_tests.rs @@ -0,0 +1,73 @@ +//! #9843: the segment-view tier's runtime entry points must be DECLARED, not +//! only called. +//! +//! The tier's HIR-level tests cannot see this. They assert the rewrite emits +//! `Call(ExternFuncRef "js_segments_view_next", …)`, which it did — and the +//! build still failed, because the module carried the call and no `declare`: +//! +//! ```text +//! perry_llvm_….ll:5172:22: error: use of undefined value '@js_segments_view_next' +//! %r75 = call double @js_segments_view_next(double %r74) +//! ``` +//! +//! The in-process LLVM parse rejects the whole module, so this is not a +//! degraded build, it is no build at all. This test closes the gap between +//! "the lowering emits the call" and "the module can be parsed": remove any one +//! of the five registrations in `strings.rs` and it fails by name. + +use super::declare_phase_b_strings; +use crate::module::LlModule; + +/// Every entry point the segment-view lowering can emit, with the signature +/// taken from `perry-runtime/src/intl/segments_view.rs`. `regexp_test` is +/// `(cursor, regex)` — cursor first; it was relayed the other way round once +/// and the source settled it. +const SEGVIEW_DECLS: &[(&str, usize)] = &[ + ("js_segments_view_open", 2), + ("js_segments_view_next", 1), + ("js_segments_view_code_point_at", 2), + ("js_segments_view_segment", 1), + ("js_segments_view_regexp_test", 2), +]; + +#[test] +fn every_segment_view_entry_point_is_declared() { + let mut m = LlModule::new("arm64-apple-macosx"); + declare_phase_b_strings(&mut m); + let declared: Vec<(&str, &str)> = m.declaration_lines().collect(); + + for (name, arity) in SEGVIEW_DECLS { + let line = declared + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| { + panic!( + "`{name}` is never declared, so any module that calls it fails the LLVM \ + parse with \"use of undefined value\". Register it in \ + `runtime_decls/strings.rs` beside `js_for_of_next`." + ) + }) + .1; + // Arity is checked because a wrong one is accepted by the parser and + // then miscompiles the call: LLVM would coerce or drop an argument. + let params = line + .split_once('(') + .and_then(|(_, rest)| rest.split_once(')')) + .map(|(inner, _)| { + if inner.trim().is_empty() { + 0 + } else { + inner.split(',').count() + } + }) + .unwrap_or_else(|| panic!("malformed declare line for `{name}`: {line}")); + assert_eq!( + params, *arity, + "`{name}` is declared with {params} parameters, runtime defines {arity}: {line}" + ); + assert!( + line.contains("double"), + "`{name}` must use the NaN-boxed f64 ABI like every other js_* entry: {line}" + ); + } +} diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 204cdbe4f0..765fe9c8e7 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1568,6 +1568,17 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // Iterator-protocol result validation (for-of lazy loop). module.declare_function("js_iterator_result_validate", DOUBLE, &[DOUBLE]); module.declare_function("js_for_of_next", DOUBLE, &[DOUBLE]); + // #9843: Intl.Segmenter view mode. The segment-view tier emits calls to + // these when it fires; without a `declare` the module references an + // undefined value and the in-process LLVM parse rejects the whole module + // ("use of undefined value '@js_segments_view_next'"). Signatures are + // taken from `perry-runtime/src/intl/segments_view.rs` (#9870) — note that + // `regexp_test` is (cursor, regex), cursor first. + module.declare_function("js_segments_view_open", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function("js_segments_view_next", DOUBLE, &[DOUBLE]); + module.declare_function("js_segments_view_code_point_at", DOUBLE, &[DOUBLE, DOUBLE]); + module.declare_function("js_segments_view_segment", DOUBLE, &[DOUBLE]); + module.declare_function("js_segments_view_regexp_test", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_global_get_or_throw_unresolved", DOUBLE, &[DOUBLE]); // Ambient `require` for compiled external / compilePackages modules (#5373): // bind a bare `require` to a createRequire-backed closure instead of throwing From cafd9e781b634a856054662b83ac2b75ad4daefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 15:13:02 +0200 Subject: [PATCH 10/11] fix(codegen): answer a statically-known RegExpTest from the view too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `v2_answers_every_use_from_the_view_and_materialises_nothing` failed because the v2 rewriter handled only the generic `Call(PropertyGet(recv,"test"), [O])` shape. perry folds a test whose regex is statically known into `Expr::RegExpTest { regex, string }`, which the classifier counts as `regexp_test_static` — so the classification said "answerable" and the emission did not answer it. The pass's own agreement check caught that: emission counts did not match classification counts, so it discarded the rewrite and fell back to v1 rather than emitting a loop that reads an unbound segment on the accepted path. The guard did its job; this teaches the rewriter the shape so the guard stops having to. Unlike the generic form, the static node's regex is a literal or a binding with no side effect worth hoisting, so it can be repeated in the decline arm and needs one temporary rather than two. Also corrects an assertion in that test that could not hold: it required `__segview_test_recv` on a body whose only test is the static node, which has no opaque receiver to hoist. That property belongs to the generic form and is already pinned by `v2_evaluates_an_opaque_test_receiver_exactly_once`. Replaced with the tri-state temporary, which this body does have, and commented so it is not re-added. 16/16 segview tests pass. --- .../perry-codegen/src/collectors/segview.rs | 43 +++++++++++++++++++ .../src/collectors/segview_tests.rs | 11 ++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 172a7d1cd5..90da00a26f 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -1570,6 +1570,49 @@ fn rewrite_uses_in_expr(e: &mut Expr, seg: u32, cur: u32, fresh: &mut u32, out: } } } + // `Expr::RegExpTest { regex, string: O }` — the node perry folds a test to + // when the regex is statically known. The classifier counts it as + // `regexp_test_static`, so the rewriter has to answer it too, or the + // emission/classification agreement check refuses v2 and the site falls + // back to v1. That is exactly what happened on the first version of this + // pass: the check caught it, which is why it fell back instead of emitting + // a loop that read an unbound segment. + if replaced.is_none() { + if let Expr::RegExpTest { regex, string } = e { + if matches!(string.as_ref(), Expr::LocalGet(id) if *id == seg) { + let t_res = *fresh; + *fresh += 1; + out.decls + .push(let_any(t_res, "__segview_test_res", Expr::Undefined)); + // The regex here is an ordinary expression with no side effect + // worth hoisting (a literal or a binding), so unlike the + // generic `recv.test(O)` form it can be repeated in the + // decline arm. + let view_form = Expr::Sequence(vec![ + Expr::LocalSet( + t_res, + Box::new(extern_call( + "js_segments_view_regexp_test", + vec![Expr::LocalGet(cur), regex.as_ref().clone()], + )), + ), + Expr::Conditional { + condition: Box::new(is_undefined_cmp(t_res)), + then_expr: Box::new(Expr::RegExpTest { + regex: regex.clone(), + string: Box::new(extern_call( + "js_segments_view_segment", + vec![Expr::LocalGet(cur)], + )), + }), + else_expr: Box::new(Expr::LocalGet(t_res)), + }, + ]); + replaced = Some(pick(cur, view_form, e_original.clone())); + out.regexp_test += 1; + } + } + } if let Some(new_e) = replaced { *e = new_e; return; diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 2f9f2f38f8..51b7ae71d5 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -433,9 +433,16 @@ fn v2_answers_every_use_from_the_view_and_materialises_nothing() { out.contains("js_segments_view_regexp_test"), "the regex test must be answered from the cursor" ); + // NOT `__segview_test_recv` here: `cc_body()` uses `Expr::RegExpTest`, the + // folded node for a statically-known regex, which has no opaque receiver to + // hoist. The receiver-hoisting property belongs to the generic + // `recv.test(O)` form and is pinned by + // `v2_evaluates_an_opaque_test_receiver_exactly_once`. Asserting it here + // was asserting a property this body does not have. assert!( - out.contains("__segview_test_recv"), - "the opaque receiver must be hoisted so it is evaluated exactly once" + out.contains("__segview_test_res"), + "the tri-state result must be held in a temporary so the decline arm \ + can be selected without calling the runtime twice: {out}" ); // The decisive property, checked on the tree rather than on its Debug From 87b631b0e817956ec992a0509b981361589c00ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 17:34:42 +0200 Subject: [PATCH 11/11] fix(codegen): clear the segment-view cursor at loop exit The cursor local is declared in the ENCLOSING statement list, not inside the loop: let __segview_recv = let __segview_input = let __segview_cursor = js_segments_view_open(recv, inp) let __segview_iter = cur != 0 ? undefined : GetIterator(...) For { ... } <- last read of the cursor so without a clear its slot stays a live GC root until the function returns. The cursor holds the input string in a traced slot, so a cursor promoted during the loop drags that string into the old generation, and leaving the slot rooted afterwards keeps a DEAD cursor doing it for the rest of the function. `string-width` is entered thousands of times per reply. That is a candidate mechanism for the idle behaviour measured on cc: I4 settles 45-65 MB ABOVE I3 at 3300 and 15-20 MB at 400 after 120 s, despite winning 20-50 MB of PEAK RSS in 12/12 paired runs. Lower peak with a higher floor is not "less garbage"; it is something being retained. One unconditional `LocalSet(cursor, undefined)` after the loop covers both paths: on the declined path the local holds `0.0`, a number, so the clear is a no-op. `break` reaches it; `return` inside the body pops the frame, which is equally fine. WHAT THIS DOES NOT DO, stated so the commit is not read as a cure: it does not prevent promotion DURING the loop, and nothing in the compiler can, because the cursor is genuinely live there. It removes only the post-loop rooting of a dead cursor. If the idle delta comes from cursors promoted mid-loop, this will not move it. perrymaster's old-gen census after idle, counting class id 0xFFFF_000E on I5-spec / I5-view / I7-view, decides that independently. The test is structural rather than string-matched: it locates the rewritten `For`, reads the cursor's LocalId out of the loop's own guard, and requires the next statement to be `LocalSet(that id, Undefined)`. It fails if the clear is removed, clears the wrong local, or is emitted before the loop. 17/17 segview tests. --- .../perry-codegen/src/collectors/segview.rs | 21 ++++++++- .../src/collectors/segview_tests.rs | 43 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 90da00a26f..990f31b646 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -1221,7 +1221,26 @@ fn rewrite_site(list: &mut Vec, i: usize, site: &SegmentForOfSite, fresh: pick(cur, Expr::Undefined, decline_iter), ), ); - 3 + // Clear the cursor at loop exit. The cursor local is declared in the + // ENCLOSING statement list, not inside the loop, so without this its slot + // stays a live GC root until the function returns. A cursor that spans a + // minor while the loop runs is promoted, and because it holds the input + // string in a traced slot it drags that string into the old generation + // with it — one per `open`, and `string-width` is entered thousands of + // times per reply. That is a candidate mechanism for I4 settling 45-65 MB + // ABOVE I3 after idle despite winning 20-50 MB of peak. + // + // One unconditional clear covers both paths: on the declined path the + // local holds `0.0`, a number, so clearing it is a no-op. `break` reaches + // this statement; `return` inside the body pops the frame, which is + // equally fine. It does not prevent promotion DURING the loop — nothing + // in the compiler can, since the cursor is genuinely live there — it stops + // the slot from keeping a dead cursor rooted for the rest of the function. + list.insert( + i + 5, + Stmt::Expr(Expr::LocalSet(cur, Box::new(Expr::Undefined))), + ); + 4 } fn unwrap_for_mut(s: &mut Stmt) -> &mut Stmt { diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 51b7ae71d5..9783faf640 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -509,3 +509,46 @@ fn a_site_with_an_unanswerable_use_stays_on_v1() { "v1 does not rewrite uses: {out}" ); } + +/// The cursor local is declared in the enclosing statement list, so its slot is +/// a live GC root until the function returns unless the lowering clears it. A +/// cursor promoted during the loop holds the input string in a traced slot and +/// drags it into the old generation; leaving the slot rooted afterwards keeps a +/// DEAD cursor doing that for the rest of the function. +#[test] +fn the_cursor_is_cleared_at_loop_exit() { + let mut m = module_with(region(cc_body())); + assert_eq!(segview_rewrite_module(&mut m), 1); + + // Structural, not string-matched: the statement AFTER the `For` must be a + // `LocalSet(, Undefined)`, and the cursor is the local the `For`'s + // condition tests against zero. + let for_idx = m + .init + .iter() + .position(|s| matches!(s, Stmt::For { .. })) + .expect("the rewritten loop"); + let cursor_id = match &m.init[for_idx] { + Stmt::For { + condition: Some(Expr::Conditional { condition, .. }), + .. + } => match condition.as_ref() { + Expr::Compare { left, .. } => match left.as_ref() { + Expr::LocalGet(id) => *id, + other => panic!("expected the cursor guard, got {other:?}"), + }, + other => panic!("expected a compare, got {other:?}"), + }, + _ => unreachable!(), + }; + match m.init.get(for_idx + 1) { + Some(Stmt::Expr(Expr::LocalSet(id, v))) => { + assert_eq!(*id, cursor_id, "the cleared local must be the cursor"); + assert!( + matches!(v.as_ref(), Expr::Undefined), + "the cursor slot must be cleared to undefined, got {v:?}" + ); + } + other => panic!("no cursor clear after the loop: {other:?}"), + } +}