diff --git a/changelog.d/9843-segment-view-for-of-matcher.md b/changelog.d/9843-segment-view-for-of-matcher.md new file mode 100644 index 0000000000..4ca62c52bd --- /dev/null +++ b/changelog.d/9843-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. diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 0257f43348..5a65978bb1 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>, + /// #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 + /// 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, ); + // #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); 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..990f31b646 --- /dev/null +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -0,0 +1,1698 @@ +//! 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. +//! +//! 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` +//! 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; + } + } + } + // `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; + 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(), + } +} + +// ── 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 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, + }; + + // 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, + ); + } + } + } + } + } + + // 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), + ), + ); + // 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 { + 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 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 +} + +// ── 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; + } + } + } + // `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; + } + 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 new file mode 100644 index 0000000000..9783faf640 --- /dev/null +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -0,0 +1,554 @@ +//! #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))`. +//! +//! 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()); +} + +/// 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" + ); +} + +// ── 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"); +} + +/// 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" + ); + // 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_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 + // 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}" + ); +} + +/// 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:?}"), + } +} diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 3a676a2d0b..3e3ea4fd81 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -80,6 +80,13 @@ pub use codegen::{ NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; +// #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. +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-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 diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index f2eb61c696..948648f4cf 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -856,6 +856,15 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if std::env::var("PERRY_NATIVEINST_DIAG").is_ok() { return Err("nativeinst-diag".to_string()); } + // #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 + // 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..cc75c585e5 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1024,6 +1024,32 @@ pub fn run_with_parse_cache( perry_transform::module_const_fold::run(hir_module); } + // #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 + // `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 { + diag.scan_module(&path.display().to_string(), hir_module); + } + diag.report(); + } + if trace_hir { dump_hir_for_debug(&ctx, args.focus.as_deref()); }