Skip to content

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

Open
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/cache-two-generation
Open

Evict from the resolver caches without Map.delete#185
jdalton wants to merge 1 commit into
dperini:masterfrom
jdalton:perf/cache-two-generation

Conversation

@jdalton

@jdalton jdalton commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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.

Measured with both builds in one process, matching a sweep of distinct selectors against one element:

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 change: one file, applies to master on its own, and checked against the benchmark fixture to confirm every selector still agrees with the native engine.

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

This patch applies to master on its own. The sixteen in this series were checked by cherry-picking them onto master one after another, in this order and in reverse, and all sixteen land without a conflict.

@jdalton
jdalton force-pushed the perf/cache-two-generation branch from d18e3f8 to c9b0997 Compare September 4, 2026 18:00
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 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant