perf(core): reuse per-range decorations on cursor-only updates - #345
Open
YIbaikaishui wants to merge 2 commits into
Open
YIbaikaishui wants to merge 2 commits into
YIbaikaishui wants to merge 2 commits into
Conversation
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.
|
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 / 背景与动机
openspec/changes/add-missing-features/tasks.md(registered as feat(react,vue): add controlled document mode to Editor #30); relates to feat(search): 增加整词匹配支持 #26 (missing perf gate).live-preview.tsalready 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 callsbuildDecorationsfrom scratch, so a cursor move re-allocated ~8400WidgetTypeinstances andDecorationobjects on a ~96KB document even though the cursor was far from almost all of them.Measured on the
benchfixture mix (jsdom, Node 22, median of 30 samples):The
[perf]instrumentation already in the file showed the split:parse: 0andcollectRanges: 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:buildDecorationscaches the decorations each AST range contributed, keyed by range identity plus the exact selection-dependent inputs its builders consume:selectionIntersects(…, true),selectionIntersects(…),selectionOnSameLine(…);listranges: the selection's overlap interval clamped to the range, becausebuildListDecorationsevaluates the intersect predicate per list item (a sub-interval), which one range-level bit cannot distinguish.createLivePreviewExtensionclosure next to the existinglastBuiltAST 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 existingeq()keys (no new widget identity semantics, nothing about the 12 table rules changes).Decorationobjects, 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.jsonfor run-to-run diffing),scenarios/(cursor-move latency at 4KB/23KB/96KB, fullsetDocument, editor mount,exportHTML,getAst,getDocumentStats).createEditorandEditorAPIagainstpackages/core/dist, never internals.bench/README.mddocuments 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 benchscript.Testing / 测试
pnpm test— 908 passed / 12 failed on this Windows host; the 12 are pre-existing and environmental (10 ×EPERM ... symlinkinapps/electron-demo/test/plugin-host-broker.test.ts— symlinks need Developer Mode/admin — and 2 inapp-runtime-integration.test.ts). Unmodifiedmainon 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.pnpm build) /pnpm typecheckclean.pnpm bench, same machine, same run):buildWidgetsbucketCompliance / 合规自检
[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.vitest benchwas avoided because it would require addingtinybench.dist/,dist-electron/,bench/results/are ignored)..env/ personal vault data committed.Checklist / 自检清单
perf(core),bench)live-preview-table.tsnot touched (n/a)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 benchreproduces it.