Skip to content

perf: cached-window render fast path for wrapped-line navigation (stacked on #2784) - #2813

Open
sinelaw wants to merge 2 commits into
masterfrom
claude/wrapped-line-nav-cache-wqwlh7
Open

perf: cached-window render fast path for wrapped-line navigation (stacked on #2784)#2813
sinelaw wants to merge 2 commits into
masterfrom
claude/wrapped-line-nav-cache-wqwlh7

Conversation

@sinelaw

@sinelaw sinelaw commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Stacked on #2784 (algorithmic fixes). This PR adds the cache layer that takes long-wrapped-line navigation from sluggish to smooth. Merge #2784 first; this diff shows only the cache addition.

What

The renderer re-ran the tokenise-then-wrap pipeline for every visible logical line on every frame, which is O(line length) per keypress inside a long soft-wrapped line. This adds a fast path that assembles the visible window straight from per-line entries in the line-wrap cache:

  • try_cached_window (view_data.rs): walks logical-line boundaries from the viewport top, row-bounded (stops once accumulated entries cover the scroll offset plus two screens), byte-bounded (never reads more than the pipeline would tokenise), validates each entry for completeness exactly per line ending, and falls back to the full pipeline on any miss. The fallback writeback repopulates the cache so the next frame hits. Eligibility: no plugin view transform, folds, virtual text, soft breaks, conceals, terminal grid wrap, binary content, or pending scroll-to-end sync.
  • Cursor-independent cache keys. When no soft breaks or conceals exist, the pipeline never reads cursor positions, so the writeback and layout_for_line key entries with cursor_sig: 0 (layout_depends_on_cursors). The cursor line entry stays valid as the cursor moves within it — previously every cursor move invalidated the very line being navigated.
  • tab_size joins the cache keys (LineWrapKey, VisualRowIndexKey, WrapGeometry). Tab width is a layout input; without it a tab-size change served stale layouts.
  • Generation-aware eviction (begin_frame, get_touch). Entries written or touched in the current frame are spared while the total stays under twice the byte budget, so a huge line entry and its window neighbours stop evicting each other every frame.

Performance

Per keypress at 80x24 (release build; the test harness renders twice per press), on top of the base PR numbers:

Line length Base PR (#2784) This PR
200 KB ~140 ms ~25 ms
50 KB ~37 ms ~13 ms
10 KB ~10 ms ~5 ms
short lines ~2.8 ms ~2.8 ms

Correctness

The design is fail-safe: any doubt (missing, stale, or truncated entry; ineligible feature) falls back to the full pipeline, so a wrong answer requires a keyed entry that is both reachable and wrong. An adversarial review of the cache-invalidation surface was run over this design; all confirmed findings are fixed and pinned by regression tests (two in this PR, two in #2784).

Tests: warm-vs-cold cache equivalence (LF and CRLF, including the trailing-EOF-row key), up/down round-trip stability, edit invalidation, black-box mid-file vs EOF navigation throughput, and the completeness-check contract test — plus the full lib, scroll, and wrap suites.

🤖 Generated with Claude Code

https://claude.ai/code/session_01J3uiv3CFZEAFARmTkqxpaQ

claude added 2 commits July 27, 2026 07:14
…on bump

Navigating a very long soft-wrapped line cost hundreds of milliseconds
per keypress. This lands the algorithmic fixes (a follow-up PR adds a
render-side cache on top):

- Grapheme boundary helpers (next/prev_grapheme_boundary, grapheme_at,
  snap_to_grapheme_boundary) iterated grapheme_indices from the start
  of the string on every call, making callers' walks quadratic — the
  reference-highlight word scan alone cost ~600 ms/keypress inside a
  giant word. Rewritten on GraphemeCursor, which resolves boundaries
  locally; prev_grapheme_boundary now also returns the containing
  cluster's start for a position inside a multi-byte code point
  instead of overshooting one cluster left.

- indent_folding line-boundary scans (find_line_start_byte,
  find_line_end_byte, find_next_line_start_byte) read the buffer one
  byte at a time via slice_bytes; they now scan in 4 KiB blocks.
  These run per rendered frame for the cursor line.

- extend_streaming appends content without bumping buffer.version(),
  leaving version-keyed layout caches (visual-row index) reachable
  with pre-append state. It now bumps the version (without marking
  the buffer dirty — content still matches the on-disk stream).

Measured per keypress at 80x24 (release; the test harness renders
twice per press): 200 KB single wrapped line ~340 ms -> ~140 ms,
50 KB ~168 ms -> ~37 ms, 10 KB ~120 ms -> ~10 ms; short lines
unchanged (~2.8 ms). The follow-up cache PR takes the long-line cases
to ~5-30 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3uiv3CFZEAFARmTkqxpaQ
…igation

Builds on the algorithmic fixes in the base PR. The renderer re-ran
the tokenise -> wrap pipeline for every visible logical line on every
frame — O(line length) per keypress inside a long soft-wrapped line.
This adds a fast path that assembles the visible window straight from
per-line entries in the line-wrap cache:

- try_cached_window (view_data.rs): walks logical-line boundaries from
  the viewport top, row-bounded (stops once accumulated entries cover
  the scroll offset plus two screens), byte-bounded (never reads more
  than the pipeline would tokenise), validates each entry's
  completeness EXACTLY per line ending, and falls back to the full
  pipeline on any miss — whose writeback repopulates the cache for the
  next frame. Eligibility: no view transform, folds, virtual text,
  soft breaks, conceals, grid wrap, binary content, or pending
  scroll-to-end sync.

- Cursor-independent cache keys: when no soft breaks / conceals exist
  the pipeline never reads cursor positions, so the writeback and
  layout_for_line key with cursor_sig 0 (layout_depends_on_cursors),
  keeping the cursor line's entry valid as the cursor moves within it.

- tab_size joins LineWrapKey / VisualRowIndexKey / WrapGeometry: it is
  a layout input; without it a tab-size change served stale layouts.

- Generation-aware eviction (begin_frame / get_touch): entries written
  or touched in the current frame are spared while the total stays
  under 2x the byte budget, so a huge line's entry and its window
  neighbours stop evicting each other every frame.

Measured per keypress at 80x24 (release; the test harness renders
twice per press), on top of the base PR's numbers: 200 KB single
wrapped line ~140 ms -> ~25 ms, 50 KB ~37 ms -> ~13 ms, 10 KB
~10 ms -> ~5 ms; short lines unchanged.

Tests: warm-vs-cold cache equivalence (LF and CRLF, including the
trailing-EOF-row key), up/down round-trip stability, edit
invalidation, black-box mid-file-vs-EOF navigation throughput, and
the completeness-check contract test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3uiv3CFZEAFARmTkqxpaQ
@sinelaw sinelaw changed the title Add cached-window render fast path for wrapped-line navigation perf: cached-window render fast path for wrapped-line navigation (stacked on #2784) Jul 27, 2026
Base automatically changed from claude/wrapped-line-nav-perf-wqwlh7 to master July 27, 2026 14:58
@sinelaw
sinelaw force-pushed the claude/wrapped-line-nav-cache-wqwlh7 branch from e5ca213 to c05d02c Compare July 28, 2026 18:38
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