Skip to content

perf(input): cache per-line tree-sitter matches across frames#2462

Open
dux-web wants to merge 1 commit into
longbridge:mainfrom
dux-web:codux-perf-pr
Open

perf(input): cache per-line tree-sitter matches across frames#2462
dux-web wants to merge 1 commit into
longbridge:mainfrom
dux-web:codux-perf-pr

Conversation

@dux-web

@dux-web dux-web commented Jun 13, 2026

Copy link
Copy Markdown

Problem

When scrolling a code_editor, SyntaxHighlighter::styles() re-runs the tree-sitter
match query (match_styles) over the entire visible byte range on every frame, even
when nothing changed. For large/complex files this dominates the editor's paint cost.

Change

Add a per-line match cache to SyntaxHighlighter:

  • A monotonically increasing generation counter is bumped whenever the document text
    changes (update()) and whenever a background parse swaps in a newer tree
    (apply_background_tree()).
  • match_styles_cached() splits the requested range into buffer lines (via the same
    ropey LineType::LF API already used here), and caches each line's match_styles
    result keyed by line-start byte. The whole cache is invalidated in O(1) when
    generation changes, so an edit or a completed background parse transparently
    refreshes it.
  • A MATCH_CACHE_MAX_LINES cap bounds memory on very large files.

styles() now calls match_styles_cached() instead of match_styles(). Output is
identical; only repeated work across frames is removed.

Bumping generation inside apply_background_tree() is important: the first paint may
use a partial tree (2ms sync-parse timeout); when the full background tree lands, the
generation bump invalidates the stale partial-tree entries so highlighting is correct.

Result

Profiling a code-editor scroll (macOS sample): the editor's Window::draw cost
dropped ~40% (8709 → 5220 samples) with highlighting visually unchanged, including
correct first-screen highlighting after the background parse completes.

Notes

  • Single file, +90/-2. No public API change.
  • Verified in a downstream app (scroll, edit, syntax-highlight correctness).

…ames

`SyntaxHighlighter::styles()` re-ran a tree-sitter query for the visible
range on every frame, so scrolling a code editor re-highlighted unchanged
lines continuously. Cache `match_styles` results per line, keyed by a new
`generation` counter that is bumped whenever the syntax tree changes (edits
and background-parse completion). While `generation` is stable (e.g. during
scrolling) cached line matches are reused; an edit invalidates them.

Profiling a code-editor scroll: Window::draw dropped ~40% (8709 -> 5220
samples) with highlighting unchanged, including correct first-screen
highlighting after a background parse completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@huacnlee

Copy link
Copy Markdown
Member

On the highlight cache: I don't think it's worth it.

I benchmarked styles() on a large markdown file (~11k lines, with code-block injections), 50-line viewport, comparing main (no cache) vs this cache version:

scenario no cache with cache
static repaint (no scroll, no edit) 47µs 5µs
scroll 1 line / frame 53µs 14µs
page jump (new viewport) 55µs 153µs (slower)

Two things stand out:

  1. match_styles is already fast enough. It's ~50µs per call, and the cost stays flat no matter how big the file is (the query seeks into the tree, so it's viewport-bound, not file-bound). At 120fps that's under 1% of one frame. It is not a bottleneck.

  2. The cache only helps when nothing changes — same viewport, no edit. Any scroll or edit invalidates it, and the per-line variant is actually ~3x slower when jumping to a new viewport.

So we'd be trading real added complexity (a generation counter, per-frame cache, invalidation) for a speedup on a path that already costs <1% of a frame. I don't think that trade is worth it — I'd rather drop the cache and keep styles() calling match_styles directly.

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