Skip to content

Optimize forEachDecorationAtCell by maintaining cache against buffer line index#5902

Merged
Tyriar merged 3 commits into
xtermjs:masterfrom
Tyriar:tyriar/cell_opt
May 26, 2026
Merged

Optimize forEachDecorationAtCell by maintaining cache against buffer line index#5902
Tyriar merged 3 commits into
xtermjs:masterfrom
Tyriar:tyriar/cell_opt

Conversation

@Tyriar

@Tyriar Tyriar commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

Speeds up DecorationService.forEachDecorationAtCell and getDecorationsAtCell by indexing decorations per logical buffer line (via marker coordinates), so render-time lookup scales with decorations on the queried line instead of the total decoration count. Addresses renderer blocking when search (and similar features) register very large numbers of highlights.

Resolves #5176

Refs #3809, #5443, microsoft/vscode#230365

Problem

forEachDecorationAtCell previously iterated every registered decoration on each call. The DOM renderer invokes it per cell during row refresh, so cost is roughly:

O(viewport cells × total decorations)

With thousands of search highlights (and higher highlight limits in clients like VS Code), a single viewport refresh can scan the full decoration set tens of thousands of times and noticeably block the main thread. This is the bottleneck called out in #5176.

Prior approaches (e.g. #5886) used a fast path only when no multi-line decorations exist; a single multi-line decoration forced a full scan again.

Related issues this does not fully resolve:

  • #5177 — incremental search
  • #4902 — slow search with wrapped lines (scan cost)
  • #4911 — mass decoration registration/disposal (SortedList / GC)

Solution

Per-line index (DecorationLineCache)

  • Exported DecorationLineCache in src/common/services/DecorationService.ts maintains Map<logicalLine, decorations[]>.
  • Keys use marker.line (stable logical buffer coordinates), not CircularList ring slots.
  • Multi-line decorations (height > 1) are stored in every line bucket they span, so mixed single-line + multi-line workloads stay on the fast path.
  • DecorationService delegates add/remove/lookup to the cache; the existing SortedList remains for sorted iteration elsewhere.

Buffer line mutations

The cache listens to active buffer trim / insert / delete events (via IBufferService) and keeps indexes aligned with marker movement:

Event Strategy
Trim Shift all map keys down synchronously; update _indexedStartLine
Insert After markers update (microtask): shift keys at/above insert index; sync start lines; full re-index only for rare multi-line decorations that span the insert point
Delete After markers update (microtask): drop deleted line keys, shift keys below; full re-index only for rare multi-line decorations that span the deleted range but survive

Insert/delete maintenance is O(unique indexed lines) for key shifts plus a light pass over decorations, not a full remove/re-add per decoration.

MicrotaskTimer

  • New MicrotaskTimer in src/common/Async.ts mirrors TimeoutTimer ergonomics (set, cancel, dispose) but schedules via queueMicrotask.
  • DecorationLineCache uses it to batch multiple insert/delete sync callbacks into one microtask after marker line updates.

Internal API

  • IInternalDecoration._indexedStartLine supports bucket removal when marker.line is cleared on dispose.

Benchmarks

New test/benchmark/DecorationService.benchmark.ts:

  • Single-line dense / sparse hit — 20k decorations, repeated lookup on line 0
  • Mixed single-line + multi-line — 19,999 single-line + one 2-line decoration
  • Viewport grid scan — 30×80 grid × 20 iterations with 20k decorations

Run benchmarks:

npm run benchmark -- -s "out-test/benchmark/DecorationService.benchmark.js|DecorationService.forEachDecorationAtCell" out-test/benchmark/DecorationService.benchmark.js

Results:

Benchmark case Before (ms) After (ms) Delta (ms) Delta (%)
Single-line dense / sparse line hit 7.37 0.02 −7.35 −99.7%
Mixed single-line + multi-line / sparse line hit 3.49 0.02 −3.47 −99.4%
Viewport grid scan (30×80 × 20 iter, 20k decorations) 6385.83 1.80 −6384.03 −100.0%

Manual perf profile

Test case:

  1. Set scrollback to 100000
  2. Enable overview ruler (probably doesn't make a difference)
  3. Run ls -lR . 4 times to fill scrollback
  4. Find next "d" (daniel is username so there are many results)

Before:

Screenshot 2026-05-26 at 10 47 53 AM

After:

Screenshot 2026-05-26 at 10 45 34 AM

Tests

Extended src/common/services/DecorationService.test.ts:

  • Existing single/multi-line and x-range behavior
  • Dense single-line set with a multi-line decoration on the same line
  • Line index maintenance: buffer trim, trim-dispose, line insert (splice)
  • Direct DecorationLineCache smoke test

Commits on this branch (vs master)

  1. Speed up decoration cell lookup with a per-line index — core index, IBufferService wiring, tests, benchmark
  2. Refactor decoration line index into DecorationLineCache — extract cache class; optimized insert/delete key shifting
  3. Add MicrotaskTimer and use it for decoration line index sync — shared async helper; cache uses it for deferred sync

Test plan

  • npm run build && npm run esbuild
  • npm run test-unit -- out-esbuild/common/services/DecorationService.test.js
  • npm run benchmark -- -s "out-test/benchmark/DecorationService.benchmark.js|DecorationService.forEachDecorationAtCell" out-test/benchmark/DecorationService.benchmark.js
  • npm run lint-changes
  • Manual: search addon with decorations enabled on a large buffer; verify highlights render correctly while scrolling and after buffer reflow
  • Manual: decoration with height > 1 still highlights all spanned rows

Notes

  • Complements broader marker/buffer work in #5853; scoped to the current forEachDecorationAtCell API used by DOM/WebGL render paths today.
  • Makes higher highlight limits (see #3809) practical for the renderer; registration/disposal performance (#4911) is a separate concern.

Tyriar and others added 3 commits May 26, 2026 09:30
Refs xtermjs#5176.

forEachDecorationAtCell and getDecorationsAtCell previously scanned every
registered decoration on each call. With thousands of search highlights this
becomes O(total decorations) per cell and blocks rendering during viewport
refreshes.

Maintain a Map of buffer line -> decorations that cover that line. Multi-line
decorations are indexed on every line in their span, so mixed single-line and
multi-line workloads stay fast without falling back to a full scan.

Keep the index aligned with buffer mutations:
- Register and unregister decorations in the line buckets on add/dispose.
- On trim, shift line keys synchronously (before marker handlers run).
- On insert/delete, re-index affected decorations in a microtask after marker
  line updates are applied.

Inject IBufferService so line listeners follow the active buffer. Add unit
tests for trim, dispose-on-trim, insert, and dense single-line plus multi-line
lookup. Add DecorationService.benchmark.ts to measure sparse hits and
viewport-sized grid scans with 20k decorations.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extract the per-line lookup index from DecorationService into an exported
DecorationLineCache class in the same module, with DecorationService listed
first for readability.

Optimize buffer insert/delete maintenance to shift map keys in O(unique
indexed lines) instead of full re-indexing every affected decoration. Only
multi-line decorations that span the mutation point get a full re-index.

Add a direct DecorationLineCache unit test in DecorationService.test.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce MicrotaskTimer as a TimeoutTimer-style helper that schedules a
single runner via queueMicrotask, with set and cancel but no cancelAndSet.

Use it in DecorationLineCache to batch insert/delete line-index updates after
marker line adjustments, replacing ad-hoc queueMicrotask scheduling.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Tyriar Tyriar self-assigned this May 26, 2026
@Tyriar Tyriar added this to the 7.0.0 milestone May 26, 2026
@Tyriar Tyriar marked this pull request as ready for review May 26, 2026 17:48
@Tyriar Tyriar merged commit 4e5994d into xtermjs:master May 26, 2026
12 checks passed
@Tyriar

Tyriar commented May 27, 2026

Copy link
Copy Markdown
Member Author

@anthonykim1 big improvement to a long standing issue btw

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.

Search is too slow

1 participant