Repository navigation
Evict from the resolver caches without Map.delete - #185
Merged
Merged
Conversation
jdalton
force-pushed
the
perf/cache-two-generation
branch
2 times, most recently
from
September 5, 2026 02:48
c9b0997 to
33ee8bd
Compare
Collaborator
Author
This was referenced Sep 5, 2026
jdalton
force-pushed
the
perf/cache-two-generation
branch
from
September 6, 2026 02:30
33ee8bd to
e81ca56
Compare
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
force-pushed
the
perf/cache-two-generation
branch
from
September 6, 2026 11:40
e81ca56 to
b5c4e3d
Compare
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
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>
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.
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 callinghas()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:
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
Map— the delete semantics this replaces.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.