Skip to content

perf(zerolag): rebuild only the changed overlay line, one paint layer per line - #713

Merged
Ark0N merged 1 commit into
masterfrom
perf/zerolag-overlay-render
Oct 27, 2026
Merged

Ark0N merged 1 commit into
masterfrom
perf/zerolag-overlay-render

Conversation

@Ark0N

@Ark0N Ark0N commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

xterm-zerolag-input is the package behind Codeman's local echo: it draws what you type on top of the terminal immediately, before the session echoes it back, which is what makes typing on a phone over a slow link feel instant. Its renderer rebuilt the whole prompt on every keystroke, one absolutely positioned <span> per character, so each key cost more the longer the prompt was. On a phone-class CPU it already drops below 60 fps at a few hundred characters (46 to 54 ms frames across two runs while the prompt grows from 200 to 400), and at about 4,000 characters a keystroke takes most of a second to appear.

This PR changes only that renderer (packages/xterm-zerolag-input/src/overlay-renderer.ts), its tests, the READMEs that describe it and a changeset. No server, API or app.js changes, and PredictiveEchoAddon (the Codex echo) is untouched: its renderer already adds and removes one span per unconfirmed keystroke.

What changes

  1. Only the lines that changed are rebuilt. renderOverlay() keeps a record (a WeakMap keyed by the overlay element) of the line elements it drew and the inputs each depended on, and skips any line whose text, position and composition range are unchanged. Typing a character changes just the last line. It starts over when the font, colour, cell size or the terminal's Unicode version changes, or when something else emptied or edited the overlay element (the addon's hide path sets innerHTML = '').
  2. One paint layer per line instead of one per character. In makeLine() the spans are no longer position:absolute. Chromium gives every positioned element its own paint layer, so even with (1) alone the browser still re-layerized and repainted thousands of them per frame. The spans now sit in a flex row (flex:none, explicit width), and the font, colour, line height, centring and ligature settings are set once on the line div and inherited.

The flex row is what normally makes DOM text drift off a canvas grid, so the widths are snapped: every cell edge is round(col * cellW * 4) / 4, and each span is the difference between two edges. A quarter pixel is stored exactly by Blink and WebKit (1/64 px layout units) and Gecko (1/60 px app units), so the widths add up to the snapped edge with no accumulated drift, and every edge stays within 1/8 px of col * cellW.

Why I believe this was the cost

Headless Chromium, real xterm 6 with the WebGL renderer, a buffer with 3,000 lines of scrollback and a ❯ prompt, the bundle Codeman ships (LocalEchoOverlay). "Work" is the median script plus style and layout per keystroke (forced synchronously); "frame" is the median frame time with one keystroke per frame (16.7 ms = 60 fps). The phone-class rows use CDP CPU throttling at 6x. The table is from a run on the pushed commit; an earlier run (the one quoted in the package README) differed by up to about 15%, master at ~4,100 characters measuring 813 ms there.

Prompt length master work / frame this PR work / frame
phone-class, ~100 chars 14.1 ms / 16.9 ms 6.4 ms / 16.6 ms
phone-class, ~300 chars 34.4 ms / 46 ms 7.3 ms / 16.7 ms
phone-class, ~1,100 chars 116 ms / 177 ms 8.1 ms / 17.5 ms
phone-class, ~4,100 chars 491 ms / 713 ms 10 ms / 18 ms
desktop, ~1,100 chars 17.6 ms / 28.2 ms 1.1 ms / 16.7 ms
desktop, ~4,100 chars 62 ms / 104 ms 0.9 ms / 16.7 ms

The same holds in the real app. I ran this branch and master (from a clean detached worktree of origin/master) as isolated Codeman instances (own CODEMAN_INSTANCE, so their own data dir and tmux socket), opened a shell session in headless Chromium, typed echo xxx… END (about 1,500 characters) through the page's own keystroke path with local echo on, and timed frames over the last 300 keys: master a median 58.8 ms per frame (p95 103) unthrottled and 423 ms (p95 567) at 6x, this branch 16.7 ms (p95 18.8) and 20.4 ms (p95 36.5). Enter then sent the whole line, the overlay cleared, and the pane printed the command's output in both.

The real-app run and the traces below used the commit before a last, render-neutral tweak (the record's validity check now also compares child order); the table and the geometry check were rerun on the pushed commit.

A Chromium trace over 60 frames at ~4,100 characters (phone-class) shows where it went: Paint 28.6 s on master vs 0.47 s here, Layout 16.2 s vs 0.74 s, script 15.6 s vs 0.34 s, style 9.2 s vs 0.18 s. A first version with only change (1), still with absolute spans, sat at 66 ms frames, and its trace was dominated by Paint (1.7 s) and Layerize (1.2 s): that is why change (2) is in here too.

Is the rendering the same?

Measured, not assumed: I rendered the same sequences with master's bundle and this one (mixed ASCII and Japanese wrapped over several lines, 40 backspaces, an IME composition, a 130-character paste, and a 9,000-character prompt taller than the viewport) and compared every glyph span's bounding box and computed style (colour, font family, size and weight, line height, alignment, underline, font features, pointer events) plus each line div's box and background.

  • At device pixel ratios 1, 1.5 and 2 (where xterm's cell width is a whole or half pixel), every box and style is identical.
  • At fractional cell widths the snapping shows: max horizontal shift 0.125 CSS px (0.33 device px) at ratio 2.625 (cell 8.381 px) and 0.094 CSS px (0.28 device px) at ratio 3 (cell 8.333 px). Rows and styles are identical.

Reviewing the diff

  • src/overlay-renderer.ts is the real change: cellEdge(), the RenderedLines record and its validity check, the reuse loop in renderOverlay(), and the flex layout in makeLine().
  • In the existing tests, almost every edit is the same mechanical one: an assertion on span.style.left (which no longer exists) now reads the left edge from the new lineSpans() helper in test/helpers.ts, which adds up the widths before it the way the browser does, and assertions on styles that moved to the line now check the line. A few cases that used a cell width of 8.4 px now use a whole-pixel width so the expected numbers stay exact, and the fractional case is covered by its own drift test.
  • New tests: the flex layout, drift-free snapping across 80 columns with a wide character, and a rebuilding only the lines that changed block (append keeps earlier line elements, cursor stays last, backspace across a wrap, cursor removal, font/colour/cell-size/Unicode-version rebuilds, an emptied, reordered or edited container, a composition on the last line, two containers). Forcing a full rebuild on every render (making the reset condition always true) fails appending to the last line keeps every earlier line element and a composition growing on the last line leaves the lines it does not reach alone, so they do test the reuse.
  • README: the rendering section describes the new layout and its cost, plus corrected size (5.7 kB minified and gzipped, measured: 5,374 B before, 5,738 B after) and test counts. The root and zh-CN READMEs carried the same numbers.

Risk

  • The 1/8 px snapping. At fractional cell widths (most phones) a glyph can sit up to 1/8 CSS px from where master put it. I judged that below what the eye can see, especially since the handoff to xterm's own WebGL glyphs already differs more than that, but it is a judgment worth a second look on a real phone.
  • The reuse record must see every input a line depends on. A future parameter that changes how a line looks has to go into styleKey or the per-line key, or a reused line keeps the old look. The keys are built in one place, next to makeLine()'s inputs. Likewise, anything else that writes into the overlay element must empty it (as _hide() and the render error path do) or go through renderOverlay(); a container whose children no longer match the record (count, order or parent) is rebuilt from scratch.
  • Not measured: Gecko, WebKit. WebKit could not start on the box I tested on, and Firefox is not installed there. Gecko's 1/60 px units are covered by the quarter-pixel choice on paper only. The phone and desktop check under Testing was by hand, with no numbers.

Testing

  • packages/xterm-zerolag-input: 276 tests pass (261 before, all still passing after the assertion updates; 15 new). Typecheck clean.
  • Root: npm run typecheck, npm run lint, npm run format:check and npm test pass.
  • npm run test:browser -- test/mobile-ime-preview.browser.test.ts test/capture-geometry-retry.browser.test.ts: 16 of 16 pass.
  • npm run test:mobile -- test/mobile/keyboard.test.ts: 47 of 52 pass. The same 5 fail on master (layout and tap-focus tests; the one about CJK local echo swaps the overlay for a mock), so nothing new.
  • Real devices: I tried this branch on a phone and in a desktop browser, typing into Claude sessions on an isolated instance, and found it working on both.

Related

#711 (hold the PTY rows while the on-screen keyboard is up) and #608 (Codex compose) also touch this package, in zerolag-input-addon.ts, predictive-echo-addon.ts and other README sections. None of their hunks overlap this one, and the renderer takes whatever row count the addon passes it.

… per line

The local echo overlay rebuilt every character of the prompt on each
keystroke, one absolutely positioned span per character, so a keystroke
cost O(prompt length) in DOM work, style, layout and paint. Every
positioned span was also its own paint layer, so the browser re-layerized
and repainted thousands of them per frame. In headless Chromium with a 6x
CPU slowdown, a ~4,100-character prompt ran at a median 813 ms per frame,
and ~300 characters already missed 60 fps (54 ms).

renderOverlay() now keeps a per-container record of the line elements it
drew and rebuilds only lines whose text, position or composition range
changed. It starts over when the font, colour, cell size or Unicode
version changes, or when something else emptied or edited the container.

makeLine() lays the spans out in a flex row instead of positioning each
one, and sets the shared font styles once on the line. Cell edges are
snapped to a quarter pixel, which Blink, WebKit and Gecko all store
exactly, so the widths add up to the grid without drift and every edge
stays within 1/8 px of col * cellW. Same prompt: 17 ms per frame.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N
Ark0N marked this pull request as ready for review October 25, 2026 14:49
@Ark0N
Ark0N merged commit 97d2998 into master Oct 27, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 27, 2026
Replaces the six contributor changesets (#694, #707, #708, #709, #718,
#713) with one consolidated minor changeset covering all 14 PRs, with the
Thanks section first and the release-review fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants