Skip to content

perf(core): reuse per-range decorations on cursor-only updates - #345

Open
YIbaikaishui wants to merge 2 commits into
floatboatai:mainfrom
YIbaikaishui:perf/core-cursor-latency
Open

YIbaikaishui wants to merge 2 commits into
floatboatai:mainfrom
YIbaikaishui:perf/core-cursor-latency

Conversation

@YIbaikaishui

Copy link
Copy Markdown

Summary / 摘要

Selection-only updates (cursor moves) no longer rebuild every live-preview decoration: each range's decorations are cached and reused when the selection cannot have changed them, cutting cursor-move latency on a ~96KB document from 36.2ms to ~12ms (median, p95 42.8ms → 14.8ms) and removing the linear growth with document size. This PR also adds the repo's first benchmark harness (pnpm bench) plus a machine-independent regression budget that pins that invariant.

Motivation / 背景与动机

live-preview.ts already cached two of the three inputs of a decoration build: the mdast tree (astHit) and the code-highlight tokens. The third input, the selection, was handled by rebuilding everything: on a selection-only transaction the StateField calls buildDecorations from scratch, so a cursor move re-allocated ~8400 WidgetType instances and Decoration objects on a ~96KB document even though the cursor was far from almost all of them.

Measured on the bench fixture mix (jsdom, Node 22, median of 30 samples):

Document cursor-move median (before) p95 (before)
~4KB 7.0ms —
~23KB 12.4ms —
~96KB 36.2ms 42.8ms

The [perf] instrumentation already in the file showed the split: parse: 0 and collectRanges: 0.7ms (both cached), buildWidgets: 24ms — i.e. the entire cost was re-creating decorations that were about to be thrown away again.

This is the per-keystroke path (every arrow key, every click repositions the selection), on document sizes a notes app hits routinely — the README positions Nexus for PKM / Markdown-native note apps.

Changes / 变更内容

  • packages/core:
    • live-preview.ts:
      • buildDecorations caches the decorations each AST range contributed, keyed by range identity plus the exact selection-dependent inputs its builders consume:
        • most node types: the three range-level predicates their builders read — selectionIntersects(…, true), selectionIntersects(…), selectionOnSameLine(…);
        • list ranges: the selection's overlap interval clamped to the range, because buildListDecorations evaluates the intersect predicate per list item (a sub-interval), which one range-level bit cannot distinguish.
      • The cache lives in the createLivePreviewExtension closure next to the existing lastBuilt AST cache and is dropped whenever the document or the composition state changes — so a reused entry is always built from identical inputs, and CM6 sees the same widget instances it already reconciled DOM for via the existing eq() keys (no new widget identity semantics, nothing about the 12 table rules changes).
      • Reused ranges push the same Decoration objects, so CM6's diff sees an unchanged set.
  • bench/ (new, zero dependencies):
    • harness.mjs (warm-up + 30 samples, median/p95), run.mjs (table on stdout, bench/results/latest.json for run-to-run diffing), scenarios/ (cursor-move latency at 4KB/23KB/96KB, full setDocument, editor mount, exportHTML, getAst, getDocumentStats).
    • Scenarios are black-box: they drive createEditor and EditorAPI against packages/core/dist, never internals.
    • bench/README.md documents how to run, what each scenario answers, and how to add one.
  • packages/core/test/live-preview-cursor-budget.test.ts (new): the invariant this change establishes, as a ratio — cursor-move latency on ~96KB must stay below 3.5× the ~4KB latency. Ratios, not absolute milliseconds, survive slower CI runners; the pre-change ratio measured ~5.2×, the post-change one ~2.5×, so the gate fails on the old code and passes with margin on the new.
  • docs/ROADMAP.md: new row feat(react,vue): add controlled document mode to Editor #30 (per the maintenance rules: appended with the next number, mentioned here).
  • openspec/changes/add-missing-features/tasks.md: ticks 4.2 Performance benchmarks.
  • package.json: pnpm bench script.

Testing / 测试

  • pnpm test — 908 passed / 12 failed on this Windows host; the 12 are pre-existing and environmental (10 × EPERM ... symlink in apps/electron-demo/test/plugin-host-broker.test.ts — symlinks need Developer Mode/admin — and 2 in app-runtime-integration.test.ts). Unmodified main on the same host: 12 failed / 886 passed — identical failure set, 22 more passing tests from the new budget test and this PR's suite. All 112 live-preview / mermaid / widget / accessibility / wikilinks tests pass unchanged.
  • Affected packages build (pnpm build) / pnpm typecheck clean.
  • New vitest cases: the cursor-latency budget described above (ratio-based, warm-up included so the highlight LRU and range cache are hot, as in a running editor).
  • Measured results (pnpm bench, same machine, same run):
scenario ~4KB ~23KB ~96KB
cursor-move median 7.03ms → 4.41ms 12.41ms → 8.24ms 36.16ms → 12.18ms
cursor-move p95 — — 42.75ms → 14.84ms
large-doc buildWidgets bucket — — 24ms → ~5ms
  • Manual UI check in electron-demo — not run: this branch was written in a headless environment. The behaviour contract is covered by the 112 unchanged live-preview tests (including click-drift, IME-composition and table-interaction regressions), and the cache is keyed so that any input a builder reads invalidates it. If you would rather see demo screenshots before merge, say so and I will produce them.

Compliance / 合规自检

  • CLA signed — first-time contributor; I will sign as soon as the bot prompts.
  • AI disclosure: the functional code in this PR was written with an AI coding agent (CodeBuddy) under my direction. I chose the target (cursor latency), built the measurement first, located the hot path through the [perf] buckets plus targeted experiments, decided the cache key granularity (per-range predicates, lists via clamped overlap) after two incorrect designs were caught by the existing tests, and reviewed the final diff. I can explain and defend any part of it.
  • New dependencies: none — the harness deliberately uses plain Node + the jsdom already present, and vitest bench was avoided because it would require adding tinybench.
  • No build artifacts committed (dist/, dist-electron/, bench/results/ are ignored).
  • No secrets / .env / personal vault data committed.

Checklist / 自检清单

  • Title follows Conventional Commits (two commits: perf(core), bench)
  • Public API unchanged — no exports, types or config surface touched
  • live-preview-table.ts not touched (n/a)
  • No OpenSpec proposal needed — no new capability or breaking change; the measurement infra fills an existing OpenSpec task
  • Change aligns with project scope (GOVERNANCE.md §4)

Screenshots / Recordings · 截图或录屏 (UI changes)

No UI change and no screenshots available from this environment — the before/after table under Testing is the measured contract, and pnpm bench reproduces it.

A selection-only transaction rebuilt every live-preview decoration in the
document, although a range's output only depends on the selection through a
handful of predicates bounded by that range: cursor moves paid O(document)
allocation even though all but one or two ranges rendered identically.
Measured on the electron-demo fixture mix (jsdom): cursor-move latency grew
from 7.0ms at 4KB to 36.2ms at 96KB, with the buildWidgets bucket at 24ms for
8402 decorations on the large document.

The rebuild now caches each range's decorations, keyed by the range identity
plus the exact selection-dependent inputs its builders consume: the intersect
(inclusive and exclusive variants) and same-line predicates for most node
types, and the selection's overlap with the range for lists, whose builder
evaluates the intersect predicate per list item. The cache is dropped when
the document or the composition state changes, so a reused entry is always
built from identical inputs, and the objects CM6 sees are the same instances
it already reconciled DOM for via the existing eq() keys.

Cursor-move latency is now 4.4 / 8.2 / 12.2ms (median) at 4KB / 23KB / 96KB
and the buildWidgets bucket on the large document drops from 24ms to ~5ms —
latency no longer scales linearly with document size. All 112 live-preview,
mermaid, widget, accessibility and wikilinks tests pass unchanged.
`pnpm bench` now runs reproducible scenarios (cursor-move latency across
document sizes, full document replace, editor mount, exportHTML, AST and
stats access) against the built packages, reporting median and p95 per
scenario. Zero new dependencies: the harness is plain Node plus the jsdom
that is already a devDependency, and scenarios stay black-box — they drive
`createEditor` and `EditorAPI`, so they measure what consumers pay. Results
are written to bench/results/latest.json for run-to-run diffing; see
bench/README.md.

The invariant that motivated the per-range decoration reuse is now enforced
by packages/core/test/live-preview-cursor-budget.test.ts: cursor-move
latency on a ~96KB document must stay below 3.5x the latency on a ~4KB one.
The gate is expressed as a ratio, not an absolute time, so it survives
slower CI runners; the pre-optimization ratio measured ~5.2x and the current
one ~2.5x. This also fills the still-open "Performance benchmarks" task in
openspec/changes/add-missing-features and is registered as ROADMAP floatboatai#30.
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

This branch has not been deployed

No deployments
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