Skip to content

[lexical] Perf: Adopt GenMap copy-on-write for NodeMap and reconciler keyToDOMMap#8481

Merged
etrepum merged 4 commits into
facebook:mainfrom
mayrang:perf/issue-7422-genmap-nodemap
May 8, 2026
Merged

[lexical] Perf: Adopt GenMap copy-on-write for NodeMap and reconciler keyToDOMMap#8481
etrepum merged 4 commits into
facebook:mainfrom
mayrang:perf/issue-7422-genmap-nodemap

Conversation

@mayrang
Copy link
Copy Markdown
Contributor

@mayrang mayrang commented May 8, 2026

Description

The cloneEditorState clone of _nodeMap and the reconciler's _keyToDOMMap clone (LexicalReconciler.ts:854) are the two new Map(prev) calls that scale O(N) per editor.update cycle, 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. GenMap is a CoW Map — clone shares _old and _nursery references between sibling clones; first write to either side triggers a copy of the nursery before mutating; periodic compact() folds nursery into _old once 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.
  • cloneEditorState and the reconciler's activePrevKeyToDOMMap now go through cloneMap.
  • editor._keyToDOMMap is 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 GenMap itself and applied it to _nodeMap. The integration here adds:

  • cloneMap() helper with a 1000-entry threshold so small documents stay on the plain Map fast path and don't pay GenMap overhead.
  • _keyToDOMMap adoption in the reconciler — the second new Map(prev) on the hot path that [WIP][lexical] Feature: GenMap - A generational copy-on-write NodeMap implementation #7674 didn't cover.
  • editor._keyToDOMMap as GenMap from construction, so each reconcile cycle hits the O(1) clone path instead of re-paying the O(N) plain-Map → GenMap conversion.
  • cloneMap short-circuits a GenMap source before the threshold check, so a small _keyToDOMMap still takes clone() 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 the new Map(prev) baseline at four sizes:

size new Map(prev) clone GenMap.clone() speedup
100 0.0021ms ~28ns 78x
1k 0.021ms ~28ns 750x
10k 0.31ms ~28ns 11,000x
100k 2.77ms ~28ns 98,000x

GenMap.clone() is O(1) regardless of source size — the absolute number stays ~28ns whether _old holds 100 or 100,000 entries.

End-to-end (pnpm vitest bench --project bench-dom, editorCycle.bench.ts) — full editor.update cycle, jsdom, typing one character per cycle:

size Baseline (main) + GenMap reduction
1,000 paragraphs 0.294ms (p99 0.553ms) 0.250ms (p99 0.460ms) -15%
5,000 paragraphs 1.558ms (p99 2.836ms) 1.047ms (p99 1.676ms) -33%

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 _old and _nursery separately). All _nodeMap / _keyToDOMMap iteration 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:

  • New __bench__/ directory layout with shared helpers (_utils.ts for microbench, dom/_utils.ts for jsdom).
  • Two vitest projects: bench (node env, microbenches) and bench-dom (jsdom env, real-editor benches). pnpm bench runs both.
  • README in __bench__/ documents conventions: when to add a bench, file naming, comparison pattern, DCE prevention, how to read the summary.
  • File selection uses test.benchmark.include (not test.include) — vitest reads only the former in bench mode.

Backwards compatibility

_keyToDOMMap and _nodeMap are typed Map<...>; GenMap implements that interface. No instanceof Map checks anywhere in the repo, no public exports change, Flow types unchanged.

What's deferred

Instrumentation showed $reconcileRoot is ~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 in LexicalGenMap.test.ts covering CoW isolation, size accounting, iteration order, tombstone resurrection, compact, clear).
  • pnpm tsc --noEmit -p tsconfig.json clean.
  • pnpm bench clean — both bench and bench-dom projects produce sensible numbers.
  • pnpm test-e2e-ci-chromium on a sample of categories that exercise NodeMap/keyToDOMMap heavily: TextEntry, History, Focus, Mutations, Selection, CopyAndPaste, DateTime, Tables, TextDragDrop. 220+ tests passed with --retries=0.
  • Manual playground sanity in chromium and Safari: typing, undo/redo, paste, format toggles, large-doc typing latency — no regressions.

mayrang added 4 commits May 8, 2026 12:39
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.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 8, 2026 2:37pm
lexical-playground Ready Ready Preview, Comment May 8, 2026 2:37pm

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 8, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 8, 2026
@etrepum
Copy link
Copy Markdown
Collaborator

etrepum commented May 8, 2026

For posterity here are some results from my laptop (on battery, with other stuff running, etc.)

`pnpm vitest bench --project bench`
BENCH  Summary

   bench  GenMap: clone() - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: clone
    125.26x faster than Map: new Map(prev)

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: clone + 1 set (typing 1 char)
    41.26x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: 50 sustained cycles (typing)
    3.86x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: paste 100 nodes (1 cycle, 100 mutations)
    1.39x faster than Map

   bench  Map - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: get
    1.15x faster than GenMap

   bench  Map - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100 :: full iteration
    6.66x faster than GenMap

   bench  GenMap: clone() - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: clone
    995.16x faster than Map: new Map(prev)

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: clone + 1 set (typing 1 char)
    350.31x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: 50 sustained cycles (typing)
    31.98x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: paste 100 nodes (1 cycle, 100 mutations)
    4.81x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: get
    1.74x faster than Map

   bench  Map - packages/lexical/src/__bench__/nodeMap.bench.ts > size=1000 :: full iteration
    8.94x faster than GenMap

   bench  GenMap: clone() - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: clone
    14232.79x faster than Map: new Map(prev)

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: clone + 1 set (typing 1 char)
    5855.53x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: 50 sustained cycles (typing)
    386.59x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: paste 100 nodes (1 cycle, 100 mutations)
    47.44x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: get
    1.06x faster than Map

   bench  Map - packages/lexical/src/__bench__/nodeMap.bench.ts > size=10000 :: full iteration
    7.94x faster than GenMap

   bench  GenMap: clone() - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: clone
    135227.90x faster than Map: new Map(prev)

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: clone + 1 set (typing 1 char)
    57178.07x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: 50 sustained cycles (typing)
    4137.83x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: paste 100 nodes (1 cycle, 100 mutations)
    467.24x faster than Map

   bench  GenMap - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: get
    1.13x faster than Map

   bench  Map - packages/lexical/src/__bench__/nodeMap.bench.ts > size=100000 :: full iteration
    8.12x faster than GenMap

The DOM benchmark output is less interesting on its own because it doesn't compare implementations

@mayrang
Copy link
Copy Markdown
Contributor Author

mayrang commented May 8, 2026

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.

@etrepum etrepum added this pull request to the merge queue May 8, 2026
Merged via the queue into facebook:main with commit 43aea29 May 8, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants