Skip to content

Commit c34363d

Browse files
olwangclaude
andcommitted
selfhost Phase 4 (lexer perf): ~5000x VM slowdown is per-char dispatch, not string O(n^2)
Add lexer_perf_corpus benchmark (rss lexer on reg-VM vs native lex() over the 712KB corpus). Result: 79.5s vs 15.3ms (~5100x). Controlled experiment: switching token/output building from String.concat (O(n^2)) to StringBuilder (O(n)) moved nothing — the cost is ~6 intrinsic dispatches per source char (List.get on List<Char> + Char.* peeks), i.e. VM value-representation + intrinsic-dispatch overhead. Recorded as SH-022; feeds the parked perf roadmap with real-workload evidence. Lexer keeps StringBuilder (correct/idiomatic). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c3625cd commit c34363d

3 files changed

Lines changed: 97 additions & 10 deletions

File tree

crates/rsscript/src/selfhost_parity.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,50 @@ fn lexer_parity_corpus() {
254254
);
255255
}
256256

257+
/// Phase-4 perf probe (ignored; run with `--release -- --ignored --nocapture`):
258+
/// how much slower is the self-hosted rss lexer (on the reg-VM) than the native
259+
/// Rust `lex()` over the whole corpus? This is the "is the self-hosted tool
260+
/// slow?" macro-benchmark — a real workload, not a microkernel. Feeds the parked
261+
/// VM value-representation / intrinsic-dispatch perf work.
262+
#[test]
263+
#[ignore]
264+
fn lexer_perf_corpus() {
265+
use std::time::Instant;
266+
let root = workspace_root();
267+
let files = collect_rss_files(&root);
268+
let exe = compile_lexer().expect("rss lexer should compile");
269+
let mut rust_ns: u128 = 0;
270+
let mut rss_ns: u128 = 0;
271+
let mut bytes: usize = 0;
272+
let mut n_ok = 0usize;
273+
for file in &files {
274+
let rel = file.strip_prefix(&root).unwrap_or(file).display().to_string();
275+
let Ok(source) = std::fs::read_to_string(file) else {
276+
continue;
277+
};
278+
bytes += source.len();
279+
let t0 = Instant::now();
280+
let _ = lex(&rel, &source);
281+
rust_ns += t0.elapsed().as_nanos();
282+
let t1 = Instant::now();
283+
if exe.eval_main_with_args([source.clone()]).is_ok() {
284+
n_ok += 1;
285+
}
286+
rss_ns += t1.elapsed().as_nanos();
287+
}
288+
let rust_ms = rust_ns as f64 / 1e6;
289+
let rss_ms = rss_ns as f64 / 1e6;
290+
eprintln!(
291+
"\n=== lexer_perf_corpus ===\n files: {} (ran {n_ok})\n bytes: {bytes}\n \
292+
Rust lex(): {rust_ms:.1} ms ({:.1} MB/s)\n rss lexer/VM: {rss_ms:.1} ms \
293+
({:.1} MB/s)\n slowdown (rss/Rust): {:.1}x\n",
294+
files.len(),
295+
bytes as f64 / 1e6 / (rust_ms / 1e3),
296+
bytes as f64 / 1e6 / (rss_ms / 1e3),
297+
rss_ms / rust_ms,
298+
);
299+
}
300+
257301
// ---------------------------------------------------------------------------
258302
// Phase 2 — parser recognition parity.
259303
//

docs/ledgers/rss-selfhost-ledger.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,3 +536,41 @@ gap is VM value-representation / intrinsic-dispatch cost (the next big lever).
536536
the writeup does not overclaim — Phase 2 delivered a self-hosted *recognizer*.
537537
- **Tests:** `crate::selfhost_parity::parser_parity_corpus`.
538538
- **Status:** decided.
539+
540+
### SH-022 — self-hosted lexer is ~5000× slower on the VM; cost is per-char intrinsic/collection dispatch, NOT string building
541+
542+
- **Tool:** self-hosted lexer (`selfhost/lexer.rss`) run on the reg-VM vs native
543+
`crate::lexer::lex`, over the whole 545-file corpus (712 KB).
544+
- **Symptom (measured, release):**
545+
| lexer | time | throughput |
546+
|-------|------|-----------|
547+
| native Rust `lex()` | 15.3 ms | 46.5 MB/s |
548+
| rss lexer on reg-VM | **79.5 s** | ~0.009 MB/s |
549+
**~5100× slowdown** (~112 µs per source char).
550+
- **Controlled experiment:** rewrote the token/output string building from repeated
551+
`String.concat` (O(n²)) to `StringBuilder` (O(n)). **No measurable change**
552+
(5140× → 5195×, within noise), parity still 544/544. So string-building is NOT
553+
the bottleneck (most tokens are short, so the quadratic term never dominates).
554+
- **Backend:** vm (also relevant to tier-0/native — this code is intrinsic-bound,
555+
not native-eligible, cf. SH-001/SH-004).
556+
- **Root cause:** the main loop is O(n) (single `String.chars` + one pass), but does
557+
~6 intrinsic dispatches PER CHARACTER — `List.get` on a `List<Char>` (VmValue
558+
boxing + refcount) plus `Char.to_code`/`is_whitespace`/`is_digit`/… and the
559+
`code_at` peeks (c1,c2). The dominant cost is the VM's per-op value-representation
560+
+ intrinsic-dispatch overhead over ~712 K chars, exactly the lever flagged by
561+
SH-001/SH-004/SH-011 and the parked perf roadmap. This is the first REAL-workload
562+
profile that is unambiguously VM-dispatch-bound.
563+
- **Classification:** VM (value representation + intrinsic dispatch) + stdlib
564+
(no char-cursor / native String-iteration intrinsic; `String.chars → List<Char>`
565+
forces per-char boxed access).
566+
- **Decision:** the real lever is cheaper per-char access, NOT string building:
567+
e.g. a native string byte/char cursor intrinsic (iterate without materializing a
568+
boxed `List<Char>`), and lower per-intrinsic dispatch overhead. Cross-ref SH-006:
569+
AOT is ~144× faster than the VM on such code, so an AOT-compiled self-hosted lexer
570+
would land ~0.5 s (~30× native Rust) — the residual being the intrinsic *count*
571+
per char, which the char-cursor intrinsic would also cut. Feeds
572+
[[perf-refactor-roadmap]] / [[jit-collection-perf-measurement]] with real-workload
573+
evidence (the trigger that work was waiting for).
574+
- **Tests / bench:** `crate::selfhost_parity::lexer_perf_corpus`
575+
(`--release -- --ignored`).
576+
- **Status:** open (VM/stdlib lever identified; feeds perf roadmap).

selfhost/lexer.rss

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
// rss constraints honored throughout (see ledger SH-016/SH-017):
99
// - no character literals: compare code points via Char.to_code.
1010
// - statement-level expressions stay on ONE line.
11+
//
12+
// SH-022: string building uses StringBuilder (O(n)) not repeated String.concat
13+
// (O(n^2)) — `local` bindings require `features: local`.
14+
15+
features: local
1116

1217
// The 30 reserved keywords (crate::lexer::KEYWORDS). Contextual words
1318
// (await, native) and builtin constants (true false Ok Err Some None Unit)
@@ -41,39 +46,39 @@ fn code_at(chars: read List<Char>, n: read Int, i: read Int) -> Int {
4146

4247
// Raw substring chars[from..to].
4348
fn slice(chars: read List<Char>, from: read Int, to: read Int) -> String {
44-
let mut out = ""
49+
local out = StringBuilder.new()
4550
let mut k = from
4651
while k < to {
4752
let c = List.get(list: read chars, index: read k)
48-
out = String.concat(left: read out, right: read Char.to_string(value: read c))
53+
StringBuilder.push(builder: mut out, value: read Char.to_string(value: read c))
4954
k = k + 1
5055
}
51-
return out
56+
return StringBuilder.finish(builder: take out)
5257
}
5358

5459
// Escape a payload exactly like the harness: backslash->\\, \n, \t, \r.
5560
fn escape(s: read String) -> String {
5661
let cs = String.chars(value: read s)
5762
let m = List.len(list: read cs)
58-
let mut out = ""
63+
local out = StringBuilder.new()
5964
let mut k = 0
6065
while k < m {
6166
let c = List.get(list: read cs, index: read k)
6267
let code = Char.to_code(value: read c)
6368
if code == 92 {
64-
out = String.concat(left: read out, right: read "\\\\")
69+
StringBuilder.push(builder: mut out, value: read "\\\\")
6570
} else if code == 10 {
66-
out = String.concat(left: read out, right: read "\\n")
71+
StringBuilder.push(builder: mut out, value: read "\\n")
6772
} else if code == 9 {
68-
out = String.concat(left: read out, right: read "\\t")
73+
StringBuilder.push(builder: mut out, value: read "\\t")
6974
} else if code == 13 {
70-
out = String.concat(left: read out, right: read "\\r")
75+
StringBuilder.push(builder: mut out, value: read "\\r")
7176
} else {
72-
out = String.concat(left: read out, right: read Char.to_string(value: read c))
77+
StringBuilder.push(builder: mut out, value: read Char.to_string(value: read c))
7378
}
7479
k = k + 1
7580
}
76-
return out
81+
return StringBuilder.finish(builder: take out)
7782
}
7883

7984
fn emit(kind: read String, payload: read String) -> Unit {

0 commit comments

Comments
 (0)