Skip to content

Evict from the resolver caches without Map.delete - #185

Merged
jdalton merged 5 commits into
dperini:masterfrom
jdalton:perf/cache-two-generation
Sep 7, 2026
Merged

jdalton merged 5 commits into
dperini:masterfrom
jdalton:perf/cache-two-generation

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

Validation update

Updated to current master. Local checks, 420 Node tests, 46 WPT pages, and 22 package tests pass. Coverage passes increased thresholds: statements 77.60%, branches 65.99%, functions 81.48%, lines 75.57%. Engine coverage comes from WPT; adapter coverage comes from Node.

New unit and WPT regressions cover generation promotion and eviction, capacity, updates, clearing, special keys, and the missing-Map fallback. They also fix a capacity bug: promotions now rotate a full young generation before the next insertion can exceed the limit.

Change

A strict LRU reorders on use and evicts one entry per insertion, both with Map delete, and V8 keeps a deleted entry until the map rehashes — so finding the oldest entry walks the tombstones every earlier eviction left, which profiled at 28% of an eviction-heavy run. Two generations replace it: the young one fills, becomes the old one, and the previous old one is dropped whole, so eviction is a pointer swap.

Detail, and how it was checked

A strict LRU reorders on use and evicts one entry per insertion, both with Map.delete, and V8 keeps a deleted entry in the backing store until the map rehashes — so keys().next(), the way the oldest entry is found, walks the tombstones every earlier eviction left. Profiling 8000 selectors cycling through a 4096-entry cache put Map.set at 28% of total run time.

Entries are now written to a young generation. When it fills, it becomes the old generation and the previous old one is dropped whole: no per-insertion delete, no iteration, and eviction is a pointer swap. A hit in the old generation carries the entry back, so anything still in use survives the next swap. Capacity is unchanged, half the limit per generation.

get() also stops calling has() first. A cached value is never undefined, so one lookup answers both whether the entry exists and what it holds.

Historical measurements from the original proposal, before the current integration and capacity fix. These have not been rerun for this revision:

workload master with this patch
30 selectors, all hit 4.68 us 3.13 us 1.50x
2000 selectors 690 us 732 us 0.94x
3000 selectors 22.72 ms 973 us 23.34x
8000 selectors 67.60 ms 34.86 ms 1.94x

The loss is a working set that straddles a generation: it no longer fits the young one, so a pass takes old-generation hits and pays to carry them across. At 3000 the comparison inverts, because a selector is not one cache entry — a :not() argument takes its own — so 3000 selectors overflow a 4096-entry LRU while the segmented cache degrades instead of thrashing.

Extracted from #167 as a standalone cache change.

References: the spec, the browser source, and what each part was reasoned from

Minimums are now statements 77%, branches 65%, functions 81%, and lines 75%. Added WPT regressions for query-plan eviction across all four caches, callbacks after mutation, clearing, missing WeakMap, and single-entry updates.

@jdalton
jdalton force-pushed the perf/cache-two-generation branch 2 times, most recently from c9b0997 to 33ee8bd Compare September 5, 2026 02:48
@jdalton

jdalton commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

This one sits with #186 and #188, which touch the resolver caches.

They do not depend on each other. All seventeen in the series cherry-pick onto master in any order, and I checked that in both directions, so any one of these can land alone. The order below is the one they read best in:

  • #185 makes eviction cheap, by replacing the LRU that walked tombstones with two generations.
  • #186 then raises the limit to 4096, which is worth doing once eviction no longer costs per insertion.
  • #188 caches the query plan rather than the answer, so a cache entry stops retaining DOM.

A strict LRU reorders on use and evicts one entry per insertion, both with Map.delete, and V8 keeps a deleted entry in the backing store until the map rehashes — so keys().next(), the way the oldest entry is found, walks the tombstones every earlier eviction left. Profiling 8000 selectors cycling through a 4096-entry cache put Map.set at 28% of total run time.
 Entries are now written to a young generation. When it fills, it becomes the old generation and the previous old one is dropped whole: no per-insertion delete, no iteration, and eviction is a pointer swap. A hit in the old generation carries the entry back, so anything still in use survives the next swap. Capacity is unchanged, half the limit per generation.
 get() also stops calling has() first. A cached value is never undefined, so one lookup answers both whether the entry exists and what it holds.
 Measured with both builds in one process, matching a sweep of distinct selectors against one element:
 30 selectors, all hit 3.13us 4.68us 1.50x 2000 selectors 732us 690us 0.94x 3000 selectors 973us 22.72ms 23.34x 8000 selectors 34.86ms 67.60ms 1.94x
 The loss is a working set that straddles a generation: it no longer fits the young one, so a pass takes old-generation hits and pays to carry them across. At 3000 the comparison inverts, because a selector is not one cache entry — a ':not()' argument takes its own — so 3000 selectors overflow a 4096-entry LRU while the segmented cache degrades instead of thrashing.

References:

- MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map — the delete semantics this replaces
- Reading: https://zod.dev/blog/reducing-memory-footprint — the same measurement discipline applied to a library that caches heavily
@jdalton
jdalton force-pushed the perf/cache-two-generation branch from e81ca56 to b5c4e3d Compare September 6, 2026 11:40
@jdalton

jdalton commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed in f22b2fa. Updated to master and moved the legacy cache tests into the active test layout. New unit and WPT regressions cover cache misses, updates, promotion, eviction, clearing, special keys, and the missing-Map fallback. They exposed and now protect a capacity bug: promoting old entries could let the next insertion exceed 1,000 entries. Promotions now rotate a full young generation.

Local checks and all 22 package tests pass. All 417 Node tests and 45 WPT pages pass. Coverage passes unchanged thresholds: statements 77.22%, branches 64.87%, functions 79.85%, lines 75.20%. Engine coverage still comes exclusively from the WPT runner. CI is running.

@jdalton
jdalton merged commit 4ee01f1 into dperini:master Sep 7, 2026
jdalton added a commit that referenced this pull request Sep 17, 2026
* Evict from the resolver caches without Map.delete

A strict LRU reorders on use and evicts one entry per insertion, both with Map.delete, and V8 keeps a deleted entry in the backing store until the map rehashes — so keys().next(), the way the oldest entry is found, walks the tombstones every earlier eviction left. Profiling 8000 selectors cycling through a 4096-entry cache put Map.set at 28% of total run time.
 Entries are now written to a young generation. When it fills, it becomes the old generation and the previous old one is dropped whole: no per-insertion delete, no iteration, and eviction is a pointer swap. A hit in the old generation carries the entry back, so anything still in use survives the next swap. Capacity is unchanged, half the limit per generation.
 get() also stops calling has() first. A cached value is never undefined, so one lookup answers both whether the entry exists and what it holds.
 Measured with both builds in one process, matching a sweep of distinct selectors against one element:
 30 selectors, all hit 3.13us 4.68us 1.50x 2000 selectors 732us 690us 0.94x 3000 selectors 973us 22.72ms 23.34x 8000 selectors 34.86ms 67.60ms 1.94x
 The loss is a working set that straddles a generation: it no longer fits the young one, so a pass takes old-generation hits and pays to carry them across. At 3000 the comparison inverts, because a selector is not one cache entry — a ':not()' argument takes its own — so 3000 selectors overflow a 4096-entry LRU while the segmented cache degrades instead of thrashing.

References:

- MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map — the delete semantics this replaces
- Reading: https://zod.dev/blog/reducing-memory-footprint — the same measurement discipline applied to a library that caches heavily

* Expand cache WPT coverage and raise coverage minimums

* Assert missing-WeakMap matcher execution and raise function coverage
jdalton added a commit that referenced this pull request Sep 17, 2026
Consolidated commits:
- adba43d Evict from the resolver caches without Map.delete (#185)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- 17a7467 Add opt-in legacy DOM reads and regression coverage (#203)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- 47bad6d perf: streamline resolver execution, query plans and DOM reads
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- 341d50a Validate logical selector arguments before candidate matching
  Original author: jdalton <john.david.dalton@gmail.com>
- f74aaaa fix(selectors): defer hover tracking and integrate selector tests (#209)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- 9dcf18c perf(selectors): route simple descendant chains through scoped lookups (#206)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- 131347f fix(selectors): isolate lazy hover state by document (#207)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
- abdbc83 perf(selectors): filter candidates before ancestor traversal (#189)
  Original author: John-David Dalton <jdalton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant