ADR 0072: Incremental highlighting, and a measured hot path¶
Status¶
Accepted
Context¶
"Fast as hell" is the first claim the project makes about itself. ADR 0053 measured and fixed one hot path (22% idle CPU → 0.0%) and left "incremental Tree-sitter reparse" as the largest remaining gap, with an estimate of ~12.7ms per keystroke on an 8400-line file.
The estimate was optimistic by an order of magnitude. Measured on a generated 10,803-line C file (276KB), with the editor instrumented and driven through real X11 keystrokes:
| per keystroke | ms |
|---|---|
| full Tree-sitter reparse | 107 |
| query over the whole tree — 14,402 spans, to draw ~45 lines | 64 |
| tree delete | 3 |
| rebuilding line starts | 4.4 |
| flattening captures | 2 |
total refreshCache() |
~177 |
Every keystroke. On a file a tenth the size of the kernel's larger sources.
Decision¶
The parse is incremental, and the edit is derived, not threaded¶
AseSyntax keeps its tree and a copy of the text it was parsed from.
On the next call it derives the edit — common prefix, common suffix,
everything between them is what changed — builds a TSInputEdit, calls
ts_tree_edit(), and hands the old tree back to the parser.
The alternative was an API that takes the edit, which would mean every
one of the ~15 mutation sites in EditorViewport computing byte offsets
and row/column points and getting them right. Deriving it from two
buffers costs one pass and keeps ase_syntax_highlight()'s "here is the
whole text" contract, so no caller changes at all.
This is deliberately not an exact edit list: several disjoint changes
(a multi-cursor edit, an undo restoring scattered text) collapse into
one span covering all of them. That is correct — a TSInputEdit may
describe more than actually changed, it only costs a larger reparse —
and it is the reason the simple version is also the safe one.
The three points a TSInputEdit needs are computed in one pass rather
than three: find the start point from the beginning of the file, then
advance from there to each end point, since both are at or after it.
Any path that cannot prove the stored text matches the stored tree throws both away and parses from scratch. A wrong incremental parse corrupts highlighting in ways that look like a Tree-sitter bug.
The query runs over a window, not the file¶
A syntax tree of half a file is not a syntax tree, so the parse still
covers everything. But the query that turns the tree into spans only
has to answer for what is on screen. ase_syntax_highlight_range()
takes a byte range; the viewport keeps a capture window padded a
screenful either side, recomputes it when an edit invalidates it, and —
this is the part that is easy to get wrong — re-checks it in
paintEvent(), so scrolling into unqueried text cannot draw unstyled.
14,402 spans per keystroke became 272.
The line scan was slower than the parser it sat next to¶
Rebuilding m_lineStarts with a byte loop over a QByteArray measured
8.2ms for 276KB — 34 MB/s, and more than the incremental parse it
runs beside. memchr plus resize(0) (which keeps the vector's
capacity, unlike clear()) took it to 1.7ms.
Consequences¶
Same file, same measurement method (process CPU time across 57 real keystrokes):
| ms/keystroke | |
|---|---|
| before | 52.1 |
| after | 11.2 |
and refreshCache() itself went from ~177ms to ~7ms: 1.7ms line scan,
~3.4ms incremental parse, ~1.3ms deriving the edit, ~2ms query and
flatten.
Steady-state idle, animations on, same file: 9% of one core, and paint accounts for all of it (2.4ms × 36fps). With animations off: 1%.
Correctness checked live rather than assumed: typing at the end of a
10,803-line file highlights correctly; jumping to line 5000 and back to
line 1 highlights correctly (the window follows); inserting unsigned
into a declaration and then undoing it leaves every keyword, type,
string and comment correctly coloured — insertion and deletion both
exercise the derived edit.
An honest note on a number I could not pin down. Early samples on a freshly-opened large file showed ~90% CPU at idle, both before and after these changes. Once the file had been opened a few times it settled to the 9% above and never returned, so the likeliest explanation is first-open work — the initial full parse, the language server starting and its first diagnostics — rather than a steady-state leak. It is recorded here because it was measured, not because it is understood.
Two cleanups that came out of the survey¶
-Wall -Wextrais now enforced. The project builds clean at those flags and always has; nothing was asking for them, so nothing would have stopped the next warning. They are carried by anase_warningsINTERFACE target linked into the three first-party targets, so the Tree-sitter and Lua sources FetchContent pulls in — which are not ours to keep clean — are unaffected.- Panel text-field theming is one helper. Four panels each set the
same five palette roles by hand, and the placeholder fix from ADR 0055
was present in two of them and missing from the other two — which is
what a copied idiom eventually looks like.
SmoothLineEdit::applyPanelTheme()now owns it, and every field gains the next fix at the same time.
ctest 9/9, clean build, zero warnings.
What is next, in measured order¶
- Repaint the caret's rect, not the whole viewport. Idle cost is now entirely a full-window repaint 36 times a second to animate one blinking caret. Rect-limited updates would take that 9% to nearly nothing.
- Incremental line starts. 1.7ms per keystroke is a whole-buffer scan to learn something an edit already knows: lines before the edit are unchanged, and the rest shift by a known delta.
- The
ase_syntaxtext copy doubles the buffer's memory. Fine at 276KB, worth revisiting alongside a real large-file pass.