@@ -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