Skip to content

Commit b07b2fb

Browse files
olwangclaude
andcommitted
perf(reg-vm): elide DeepCopy of read List<Char> params (SH-022) — 5100x -> 45.6x
Root cause (corrected): a read List<Char> param got an eager prologue DeepCopy that the elision pass KEPT, because the taint pass propagated through ListGet to the extracted Char and the Char.* intrinsics were classified Keep — so every per-char lexer helper call deep-copied the whole char list = O(n^2). Measured: helper-per-char O(n^2) (4x/doubling) vs inline O(n); AOT ~1ms (borrows). Fix: classify the 12 pure scalar Char.* intrinsics as PureFreshReader in deepcopy_intrinsic_class (they take Char/Int by value, return fresh scalar/Bool/ String, never mutate/store/alias — verified in intrinsics/char.rs). The existing elision pass then drops the redundant copy → O(n^2)->O(n). One match arm, VM-only, no new intrinsic/spec/AOT change. This is the perf-refactor Phase-2 elision 'v2 classifier' follow-up (v1 was sound-but-no-win: intrinsic reads forced keep). Result (lexer_perf_corpus, release): rss lexer/VM 79.5s -> 732ms (~108x speedup); vs native lex() 5100x -> 45.6x. Parity-safe (elision removes a provably-redundant copy; native treats DeepCopyElided==DeepCopy; AOT borrows). Full suite + differential + compiled-parity green; added regression guard deepcopy_elision_fires_for_char_list_read_param. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8c066be commit b07b2fb

3 files changed

Lines changed: 110 additions & 31 deletions

File tree

crates/rsscript/src/reg_vm/model.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2292,7 +2292,26 @@ fn deepcopy_intrinsic_class(intrinsic: RegIntrinsic) -> IntrinsicTaintClass {
22922292
// Deque
22932293
| RegIntrinsic::DequeIsEmpty
22942294
| RegIntrinsic::DequeLen
2295-
| RegIntrinsic::DequeNew => PureFreshReader,
2295+
| RegIntrinsic::DequeNew
2296+
// Char — pure scalar readers: each takes `Char`/`Int` by value and returns
2297+
// a fresh `Int`/`Bool`/`Char`/`String`; none borrow_mut, store into
2298+
// streams/channels/resources, or alias an arg (verified in
2299+
// `intrinsics/char.rs`). Classifying them PureFreshReader lets the elision
2300+
// pass drop a redundant `read List<Char>` prologue DeepCopy when the only
2301+
// keep-forcing use of a `ListGet`-extracted `Char` is one of these — the
2302+
// SH-022 O(n^2) fix (a `read List<Char>` param was deep-copied per call).
2303+
| RegIntrinsic::CharToCode
2304+
| RegIntrinsic::CharFromCode
2305+
| RegIntrinsic::CharToString
2306+
| RegIntrinsic::CharToLower
2307+
| RegIntrinsic::CharToUpper
2308+
| RegIntrinsic::CharIsDigit
2309+
| RegIntrinsic::CharIsAlpha
2310+
| RegIntrinsic::CharIsAlphanumeric
2311+
| RegIntrinsic::CharIsLower
2312+
| RegIntrinsic::CharIsUpper
2313+
| RegIntrinsic::CharIsWhitespace
2314+
| RegIntrinsic::CharCompare => PureFreshReader,
22962315

22972316
// ---- AliasReturner: result shares an arg's inner `Rc`; propagate taint arg→dst. ----
22982317
// List

crates/rsscript/src/reg_vm/tests.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,57 @@ fn main() -> Unit {
658658
assert_eq!(output.stdout, "12\n");
659659
}
660660

661+
/// SH-022 regression: a `read List<Char>` param whose only keep-forcing use is
662+
/// a pure scalar `Char.*` intrinsic (here `Char.to_code` on a `ListGet`-extracted
663+
/// element) must have its prologue `DeepCopy` ELIDED. Before the `Char.*`
664+
/// intrinsics were classified `PureFreshReader`, `Char.to_code(c)` pinned the
665+
/// copy, so every per-char lexer helper call deep-copied the whole char list —
666+
/// a genuine O(n^2) that made the self-hosted lexer ~5000x slower than native.
667+
#[test]
668+
fn deepcopy_elision_fires_for_char_list_read_param() {
669+
let source = r#"
670+
fn scan(chars: read List<Char>, i: Int) -> Int {
671+
let c = List.get<Char>(list: read chars, index: i)
672+
return Char.to_code(value: read c)
673+
}
674+
675+
fn main() -> Unit {
676+
let chars = String.chars(value: read "abc")
677+
Log.write(message: read String.from_int(value: scan(chars: read chars, i: 1)))
678+
return Unit
679+
}
680+
"#;
681+
let executable =
682+
reg_vm_compile_source("deepcopy-elision-char.rss", source).expect("lowering succeeds");
683+
684+
let scan_id = executable.unit.function_ids["scan"];
685+
let scan = executable.unit.functions[scan_id].as_ref();
686+
let elided = scan
687+
.code
688+
.iter()
689+
.filter(|instr| matches!(instr, RegInstr::DeepCopyElided { .. }))
690+
.count();
691+
let eager = scan
692+
.code
693+
.iter()
694+
.filter(|instr| matches!(instr, RegInstr::DeepCopy { .. }))
695+
.count();
696+
if crate::reg_vm::model::elide_deepcopy_enabled_for_test() {
697+
assert!(
698+
elided >= 1 && eager == 0,
699+
"elision ON: `read List<Char>` param whose only use is ListGet + \
700+
Char.to_code must be elided, got {elided} elided / {eager} eager: {:#?}",
701+
scan.code,
702+
);
703+
}
704+
705+
// Elision must not change behavior: scan("abc"[1]) == code of 'b' == 98.
706+
let output = executable
707+
.eval_main_with_args(Vec::<String>::new())
708+
.expect("program should still run");
709+
assert_eq!(output.stdout, "98\n");
710+
}
711+
661712
#[test]
662713
fn jit_runs_scalar_self_recursion_on_flat_executor() {
663714
let source = r#"

docs/ledgers/rss-selfhost-ledger.md

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -605,7 +605,7 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
605605
- **Tests:** `crate::selfhost_parity::parser_parity_corpus`.
606606
- **Status:** decided.
607607

608-
### SH-022 — self-hosted lexer is ~5100× slower on the VM; cost is per-char intrinsic/collection dispatch, NOT string building
608+
### SH-022 — self-hosted lexer was ~5100× slower on the VM: O(n²) DeepCopy of a `read List<Char>` param per helper call (FIXED → 45.6×)
609609

610610
- **Tool:** self-hosted lexer (`selfhost/lexer.rss`) run on the reg-VM vs native
611611
`crate::lexer::lex`, over the whole 545-file corpus (712 KB).
@@ -619,36 +619,45 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
619619
`String.concat` (O(n²)) to `StringBuilder` (O(n)). **No measurable change**
620620
(5140× → 5195×, within noise), parity still 544/544. So string-building is NOT
621621
the bottleneck (most tokens are short, so the quadratic term never dominates).
622-
- **Backend:** vm (also relevant to tier-0/native — this code is intrinsic-bound,
623-
not native-eligible, cf. SH-001/SH-004).
624-
- **Root cause:** the main loop is O(n) (single `String.chars` + one pass), but does
625-
~6 intrinsic dispatches PER CHARACTER — `List.get` on a `List<Char>` (VmValue
626-
boxing + refcount) plus `Char.to_code`/`is_whitespace`/`is_digit`/… and the
627-
`code_at` peeks (c1,c2). The dominant cost is the VM's per-op value-representation
628-
+ intrinsic-dispatch overhead over ~712 K chars, exactly the lever flagged by
629-
SH-001/SH-004/SH-011 and the parked perf roadmap. This is the first REAL-workload
630-
profile that is unambiguously VM-dispatch-bound.
631-
- **Classification:** VM (value representation + intrinsic dispatch) + stdlib
632-
(no char-cursor / native String-iteration intrinsic; `String.chars → List<Char>`
633-
forces per-char boxed access).
634-
- **Decision:** the real lever is cheaper per-char access, NOT string building:
635-
e.g. a native string byte/char cursor intrinsic (iterate without materializing a
636-
boxed `List<Char>`), and lower per-intrinsic dispatch overhead. Feeds
637-
[[perf-refactor-roadmap]] / [[jit-collection-perf-measurement]] with real-workload
638-
evidence (the trigger that work was waiting for).
639-
- **Measured vs. extrapolated (be honest):** only two things here are *measured*
640-
(a) the VM-vs-native table above, and (b) the `String.concat``StringBuilder`
641-
control. The VM-vs-**AOT** split is **NOT measured**: SH-006 measured AOT ~144×
642-
faster than the VM on comparable tool code, which *would* put an AOT-compiled
643-
self-hosted lexer near ~0.5 s (~30× native Rust), but this lexer has not actually
644-
been run under AOT. That AOT number is the piece that would separate *fixable VM
645-
per-op overhead* from the *inherent per-char intrinsic count* (which AOT also
646-
pays) — worth measuring as a follow-up (needs a file-reading lexer variant so a
647-
700 KB input isn't passed via `argv`).
622+
- **Backend:** vm.
623+
- **ROOT CAUSE (CORRECTED 2026-07-01 — earlier "per-char dispatch" was WRONG):** a
624+
genuine **O(n²)**. Every lexer helper takes `chars: read List<Char>`; a `read`
625+
non-Copy param gets an eager prologue `DeepCopy`. The DeepCopy-elision pass
626+
*should* drop that copy (the list is never mutated), but it was KEPT: the taint
627+
pass propagates through `ListGet` to the extracted scalar `Char`, and the
628+
`Char.*` intrinsics were classified `Keep`, so `Char.to_code(c)` pinned the copy.
629+
Result: every per-char helper call (`code_at`, `slice`, `scan_*` — called O(n)
630+
times) deep-copied the whole O(n) char list ⇒ **O(n²)**. Measured attribution:
631+
a helper taking `read List<Char>` per char is O(n²) (10k→588ms, 20k→2319ms,
632+
40k→9127ms, 80k→37099ms, ~4×/doubling); the same work inlined (no per-call copy)
633+
is flat O(n) (~15–20ms). `RSS_VM_ELIDE_DEEPCOPY=0` vs on = identical (the copy was
634+
kept either way). AOT of the same source = ~1ms (borrows `read` params). So
635+
~9000× of the gap was VM-specific redundant DeepCopy — NOT dispatch, NOT boxing
636+
(dispatch measured ~60ns/char), NOT string building (the StringBuilder control
637+
correctly ruled that out; its "so it's dispatch" inference was the mistake).
638+
- **Classification:** VM (DeepCopy-elision classifier) — exactly the
639+
[[perf-refactor-phase2-deepcopy-elision]] "v2 classifier" follow-up (v1 was
640+
sound-but-no-win because "intrinsic reads force keep").
641+
- **FIX (landed):** classify the 12 pure scalar `Char.*` intrinsics
642+
(`CharToCode`, `CharFromCode`, `CharToString`, `CharToLower`, `CharToUpper`,
643+
`CharIsDigit`, `CharIsAlpha`, `CharIsAlphanumeric`, `CharIsLower`, `CharIsUpper`,
644+
`CharIsWhitespace`, `CharCompare`) as `PureFreshReader` in
645+
`deepcopy_intrinsic_class` (`reg_vm/model.rs`) — they take `Char`/`Int` by value
646+
and return a fresh scalar/Bool/String, never mutate/store/alias (verified in
647+
`intrinsics/char.rs`). The existing elision pass then proves the `read List<Char>`
648+
copy redundant and drops it → O(n²)→O(n). ONE match arm; VM-only; no new
649+
intrinsic, no spec, no AOT/native change. Parity-safe: elision only removes a
650+
provably-redundant copy (native treats `DeepCopyElided` == `DeepCopy`; AOT borrows).
651+
- **RESULT (measured, release, `lexer_perf_corpus`, 556 files / 724 KB):** rss
652+
lexer/VM **79.5 s → 732.7 ms** (~**108× speedup**); slowdown vs native
653+
`lex()` **5100× → 45.6×**. The ~46× residual is the real VM per-op tax over native
654+
Rust (AOT would remove most of it); cutting that further = the parked
655+
[[perf-refactor-roadmap]] collection-rep work, not this bounded fix.
648656
- **Tests / bench:** `crate::selfhost_parity::lexer_perf_corpus`
649-
(`--release -- --ignored`).
650-
- **Status:** open (VM/stdlib lever identified; feeds perf roadmap; AOT split
651-
still to be measured).
657+
(`--release -- --ignored`); `reg_vm::tests::…::deepcopy_elision_fires_for_char_list_read_param`
658+
(regression guard). Full differential + compiled-parity green (elision soundness).
659+
- **Status:** fixed (O(n²) removed; residual ~46× is the general VM per-op tax,
660+
tracked by the parked perf roadmap).
652661

653662
### SH-023 — self-hosted checker reaches RS0005 parity at declaration level; the merged callable namespace is the load-bearing rule
654663

0 commit comments

Comments
 (0)