136 lines
7.7 KiB
Markdown
136 lines
7.7 KiB
Markdown
# slim-forge: xterm.js parser perf
|
||
|
||
An experiment to autonomously speed up xterm.js's `EscapeSequenceParser`, the terminal escape sequence parser inside VS Code, GitHub Codespaces, and most web-based terminals.
|
||
|
||
## Setup
|
||
|
||
1. **Confirm the workspace**: fresh clone of `xtermjs/xterm.js`. Branch should be `autoresearch/xterm-<date>`. Verify: `git status`, `git log --oneline -5`.
|
||
2. **Read the in-scope files**:
|
||
* `src/common/parser/EscapeSequenceParser.ts` — ~845 LOC. **THIS is what you optimize.** The main `parse()` method is the hot loop. Read the whole file.
|
||
* `src/common/parser/Params.ts` — parameter accumulation. Called from the PARAM action in the main loop. Secondary surface.
|
||
* `src/common/parser/Constants.ts` — `ParserState` and `ParserAction` enums.
|
||
* `src/common/parser/EscapeSequenceParser.test.ts` — ~2236 LOC of tests. Read before making changes.
|
||
* `bench/workload.js` — the benchmark. **Do NOT modify.**
|
||
* `run.py` — the harness. **Do NOT modify.**
|
||
* `program.md` — this file.
|
||
3. **Verify baseline exists**: `cat results.tsv`. One row, `baseline`, with a `mbps` value and `tests_pass: 1`.
|
||
4. **Confirm and go.**
|
||
|
||
## Mission
|
||
|
||
Primary metric: **`mbps`** — weighted throughput in MB/s across 5 representative terminal workloads (plain ASCII print, SGR-heavy colored output, cursor movement sequences, parameter-heavy truecolor, mixed realistic git-log-style output). Higher is better.
|
||
|
||
Hard gates:
|
||
1. `npm run test-unit` passes (2261 mocha tests, ~6s).
|
||
2. The harness enforces `mbps > best_so_far * 1.05` for a row to be labeled `keep`.
|
||
|
||
## Important: build pipeline
|
||
|
||
xterm.js is TypeScript. Your edits go in `src/`. The build pipeline is:
|
||
|
||
```
|
||
npm run build # tsc: src/**/*.ts -> out/**/*.js
|
||
npm run esbuild # esbuild: out/ -> out-esbuild/ (what tests and bench consume)
|
||
```
|
||
|
||
`run.py` handles this automatically. But if you want to test manually:
|
||
```
|
||
npm run build && npm run esbuild && npm run test-unit && node bench/workload.js
|
||
```
|
||
|
||
The agent edits `.ts` source. The bench runs against compiled `.js` in `out-esbuild/`. If your edit has a TS type error, `npm run build` fails and the experiment is `discard`.
|
||
|
||
## What you CAN do
|
||
|
||
* Edit `src/common/parser/EscapeSequenceParser.ts` — the main target.
|
||
* Edit `src/common/parser/Params.ts` — if parameter handling is the bottleneck.
|
||
* Edit `src/common/parser/OscParser.ts`, `DcsParser.ts`, `ApcParser.ts` — if those sub-parsers show up in the bench.
|
||
* Add internal helper functions or restructure the parse loop.
|
||
|
||
## What you CANNOT do
|
||
|
||
* Modify `src/common/parser/EscapeSequenceParser.test.ts` or any test file.
|
||
* Modify `bench/workload.js` or `run.py`.
|
||
* Modify `program.md`.
|
||
* Modify the `TransitionTable` initialization — the VT500 table is the spec-correct state machine. Changing transitions changes which sequences are recognized, which is a correctness violation even if tests pass.
|
||
* Cache parse results across calls. The parser is stateful (it maintains `currentState`), but each `parse()` call must process every byte of input. No memoization.
|
||
* Remove or weaken handler dispatch. The bench registers real handlers; skipping dispatch would be a benchmark exploit.
|
||
|
||
## What the parser already does well
|
||
|
||
Before you start, understand what's already optimized so you don't waste time re-discovering it:
|
||
|
||
* **Transition table**: `Uint16Array` lookup — state × charCode → action + nextState. One array access per byte. This is already near-optimal for the state machine itself.
|
||
* **PRINT action loop unrolling**: 4× unrolled inner loop for consecutive printable characters. This is why `print_ascii` hits ~2500 MB/s.
|
||
* **PARAM inner loop**: do-while that stays in the PARAM action for consecutive digit/semicolon/colon bytes without re-entering the main switch.
|
||
|
||
## Where to look for wins
|
||
|
||
The bench breakdown shows where time is actually spent:
|
||
|
||
* **`sgr_heavy`: ~84 MB/s** — 30× slower than print. Every SGR sequence goes through: state transitions for `ESC [ params m`, param parsing, CSI handler dispatch. The handler dispatch involves array lookups and function calls per sequence.
|
||
* **`params_heavy`: ~92 MB/s** — similar. Truecolor sequences have many params (e.g. `38;2;0;255;128`).
|
||
* **`cursor_move`: ~127 MB/s** — CSI sequences with fewer params but more state transitions per byte.
|
||
* **`mixed_realistic`: ~158 MB/s** — blend of print runs and SGR.
|
||
|
||
Likely sources of real speedups:
|
||
|
||
* **Reduce handler dispatch overhead.** The CSI dispatch does `this._csiHandlers[this._collect << 8 | code]` then iterates the handler array in reverse. For the common case of a single registered handler, the array iteration + `instanceof Promise` check is overhead. A fast-path for single-handler case could help.
|
||
* **Inline the PARAM parsing more aggressively.** The current do-while re-enters `this._params.addDigit()` / `addParam()` / `addSubParam()` per byte. If `Params` methods are not inlined by V8, each is a virtual dispatch.
|
||
* **Reduce property access in the hot loop.** `this._transitions`, `this.currentState`, `this._collect`, `this._params` — each `this.` access goes through the object's hidden class. Caching in locals at the top of `parse()` and writing back at the end could help.
|
||
* **Specialize the common case in the main switch.** PRINT and PARAM are the most common actions. If the switch has many cases, V8 may generate a jump table; if it has few hot cases, a nested if-else could be faster.
|
||
* **The `code < 0xa0 ? code : NON_ASCII_PRINTABLE` ternary** runs per byte. For ASCII-only input (the overwhelming common case), the branch is always taken. Consider a fast-path that skips the check when all input is < 0x80.
|
||
|
||
Anti-patterns:
|
||
* Don't try to change the transition table layout (e.g. making it a 2D array). The `Uint16Array` with bit shifting is already the most cache-friendly layout.
|
||
* Don't try to replace the state machine with regex. The state machine handles streaming input correctly; regex can't.
|
||
* Don't optimize the async handler resume path (top of `parse()`). It's cold code.
|
||
|
||
## Output format
|
||
|
||
`run.py` prints:
|
||
|
||
```
|
||
---
|
||
mbps: 693.3
|
||
tests_passed: 1
|
||
build_ok: 1
|
||
install_seconds: 5.0
|
||
build_seconds: 15.0
|
||
test_seconds: 6.0
|
||
best_so_far: 693.3
|
||
threshold: 728.0 (best_so_far * 1.05)
|
||
auto-status: keep
|
||
```
|
||
|
||
Read the headline: `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log`
|
||
|
||
Per-case breakdown is on stderr from the bench: `grep "per-case" -A 10 run.log` (or check the bench stderr output).
|
||
|
||
## Logging results
|
||
|
||
`python run.py --record --description "<one line>"` appends a row. The harness auto-labels status.
|
||
|
||
Schema: `commit mbps tests_pass build_ok status description`
|
||
|
||
## The experiment loop
|
||
|
||
LOOP FOREVER:
|
||
|
||
1. `git log --oneline -10` and `tail -10 results.tsv`. What's the current best? What has been tried?
|
||
2. Pick one optimization idea. Be specific.
|
||
3. Edit `src/common/parser/EscapeSequenceParser.ts` (or Params.ts). One change per experiment.
|
||
4. `git add -A && git commit -m "try: <description>"` (commit BEFORE the record run).
|
||
5. `python run.py --record --description "<description>" > run.log 2>&1`
|
||
6. `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log`.
|
||
7. Decision:
|
||
* `auto-status: keep` → the branch advances.
|
||
* `auto-status: discard` → `git reset --hard HEAD~1`.
|
||
8. Goto 1.
|
||
|
||
**Build failures**: if `npm run build` fails, it's a TS type error from your edit. Read the tsc output in run.log, fix the type error or revert.
|
||
|
||
**Verification for large wins**: if `auto-status: keep` on a >15% improvement, run `python run.py > /tmp/verify.log 2>&1` (no --record). If verify mbps is also clearly above threshold, it's real. If verify is close to baseline, it was noise — `git reset --hard HEAD~1`.
|
||
|
||
**NEVER STOP**: once the loop has begun, do NOT pause. Run indefinitely until manually stopped.
|