[lexical] Perf: Adopt GenMap copy-on-write for NodeMap and reconciler keyToDOMMap#8481
Merged
Merged
Conversation
Integrates etrepum's GenMap design (facebook#7674) for copy-on-write cloning of NodeMap. Adds an empty-nursery skip optimization and applies the same strategy to the reconciler's _keyToDOMMap snapshot. Also lands benchmark infrastructure (vitest bench projects + Lexical's first __bench__ directory + README) so future perf work has a baseline. See facebook#7422 for context. Reconciler-side optimizations beyond keyToDOMMap (e.g. avoiding the prev/next children array rebuild in $reconcileChildren on append-only mutations) are deferred to a follow-up issue.
- Drop empty-nursery skip in getNursery (dead in practice; unsafe if reached via direct field assignment). - Restore the init() helper from etrepum's facebook#7674 to keep field initialization encapsulated. - Reorder cloneMap so a GenMap source always takes the O(1) clone path, including when its size is below the threshold (avoids the 8x iteration penalty on small-doc keyToDOMMap snapshots). - Mark GenMap and cloneMap as @internal. - Fix the dead `from '.'` import in dom/editorCycle.bench.ts. - Exclude __bench__ from tsconfig.build.json (was retype-checking on releases). - Exclude __bench__/dom/** from the node-env bench project glob (was attempting to load the jsdom bench file in node). - Add LexicalGenMap unit tests covering CoW isolation, size accounting, iteration order, tombstone resurrection, compact, and clear.
- Move shared bench helpers to `_utils.ts` (microbench) and `dom/_utils.ts` (real-editor) so each bench file can stay focused on the workload. - Switch the project file selection from `test.include` to `test.benchmark.include`. In bench mode vitest reads `benchmark.include`; the previous config let vitest fall back to its default test glob and pick up files outside the project's intended directory. - Expand the README to cover: why we have two projects (node vs jsdom), filtering with `--project`, the comparison-pattern conventions, DCE prevention, and how to read the summary block.
…ention Replaces the `_count++` anti-DCE pattern (which violated playbook §2.2 — `_var` prefix is reserved for unused variables) with a module-level `_benchSink` that's assigned from each bench body. The local counter is plain `count`; `_benchSink` is the intentionally-unused sink. Updates the README's DCE-prevention section to document the pattern so future contributors follow it.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
etrepum
approved these changes
May 8, 2026
Collaborator
|
For posterity here are some results from my laptop (on battery, with other stuff running, etc.) `pnpm vitest bench --project bench`The DOM benchmark output is less interesting on its own because it doesn't compare implementations |
Contributor
Author
|
Good observation — I'll restructure the DOM bench in the follow-up PR to do a head-to-head comparison (legacy walk vs the new fast path), since that PR introduces a natural A/B target. |
Merged
5 tasks
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.
Description
The
cloneEditorStateclone of_nodeMapand the reconciler's_keyToDOMMapclone (LexicalReconciler.ts:854) are the twonew Map(prev)calls that scale O(N) pereditor.updatecycle, which #7422 identified as the dominant cost on large documents. This PR replaces both with a GenMap-based copy-on-write strategy, picking up @etrepum's design from #7674 and finishing the integration he had started.What this changes
LexicalGenMap.ts: new file.GenMapis a CoW Map — clone shares_oldand_nurseryreferences between sibling clones; first write to either side triggers a copy of the nursery before mutating; periodiccompact()folds nursery into_oldonce it grows past half the size.cloneMap(map, threshold)is the entry point. Source is a GenMap →map.clone()(O(1)). Source is a small plain Map →new Map(map)to avoid GenMap overhead. Source is a large plain Map → wrap in a fresh GenMap.cloneEditorStateand the reconciler'sactivePrevKeyToDOMMapnow go throughcloneMap.editor._keyToDOMMapis now a GenMap from construction so the reconciler's snapshot hits the O(1) clone path; without this, each cycle would re-pay an O(N) plain-Map → GenMap conversion.Adapted from #7674
@etrepum's original prototype landed
GenMapitself and applied it to_nodeMap. The integration here adds:cloneMap()helper with a 1000-entry threshold so small documents stay on the plainMapfast path and don't pay GenMap overhead._keyToDOMMapadoption in the reconciler — the secondnew Map(prev)on the hot path that [WIP][lexical] Feature: GenMap - A generational copy-on-write NodeMap implementation #7674 didn't cover.editor._keyToDOMMapas GenMap from construction, so each reconcile cycle hits the O(1) clone path instead of re-paying the O(N) plain-Map → GenMap conversion.cloneMapshort-circuits a GenMap source before the threshold check, so a small_keyToDOMMapstill takesclone()and doesn't get downgraded to a plain Map (which would lose the structural sharing on the very next cycle).Bench numbers
Microbench (
pnpm vitest bench --project bench,nodeMap.bench.ts) —GenMap.clone()against thenew Map(prev)baseline at four sizes:new Map(prev)cloneGenMap.clone()GenMap.clone()is O(1) regardless of source size — the absolute number stays ~28ns whether_oldholds 100 or 100,000 entries.End-to-end (
pnpm vitest bench --project bench-dom,editorCycle.bench.ts) — fulleditor.updatecycle, jsdom, typing one character per cycle:The end-to-end reduction is smaller than the microbench because the clone is one of several costs in
editor.update(transform, reconcile, commit). The bigger the document, the more the clone dominates and the more the user observes — the trend continues past 5k paragraphs.Iteration is ~8x slower under GenMap (it walks
_oldand_nurseryseparately). All_nodeMap/_keyToDOMMapiteration sites are cold paths —$restoreEditorState,getCachedTypeToNodeMap, mutation-listener init, devtools serialization. The hot typing path doesn't iterate.Bench infrastructure
This is the first benchmark setup in the repo, so the goal was to make it usable for future perf work, not just this PR's measurements:
__bench__/directory layout with shared helpers (_utils.tsfor microbench,dom/_utils.tsfor jsdom).bench(node env, microbenches) andbench-dom(jsdom env, real-editor benches).pnpm benchruns both.__bench__/documents conventions: when to add a bench, file naming, comparison pattern, DCE prevention, how to read the summary.test.benchmark.include(nottest.include) — vitest reads only the former in bench mode.Backwards compatibility
_keyToDOMMapand_nodeMapare typedMap<...>; GenMap implements that interface. Noinstanceof Mapchecks anywhere in the repo, no public exports change, Flow types unchanged.What's deferred
Instrumentation showed
$reconcileRootis ~74% of cycle time on a 5k-paragraph doc (NodeMap clone is 23%). I've already prototyped that follow-up — for a 5k-paragraph typing cycle: 1.558ms (main) → 1.047ms (this PR) → 0.132ms (with the follow-up applied, -91% from main total). The follow-up PR is ready and I'll open it as soon as this one lands.Addresses #7422. Builds on #7674.
Test plan
pnpm test-unit— 2463 passed (12 new inLexicalGenMap.test.tscovering CoW isolation, size accounting, iteration order, tombstone resurrection, compact, clear).pnpm tsc --noEmit -p tsconfig.jsonclean.pnpm benchclean — bothbenchandbench-domprojects produce sensible numbers.pnpm test-e2e-ci-chromiumon a sample of categories that exercise NodeMap/keyToDOMMap heavily: TextEntry, History, Focus, Mutations, Selection, CopyAndPaste, DateTime, Tables, TextDragDrop. 220+ tests passed with--retries=0.