From 1cd3483a4a789620e2c8eee30903dc701b0fa5df Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 00:16:16 +0200 Subject: [PATCH] perf: optimize lru-cache.ts # LRU Cache Optimization Results ## BEFORE State: test/benchmark support changes applied, implementation stashed to baseline. ### Tests - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:unit --outputStyle=stream --skipRemoteCache -- tests/lru.test.ts`: failed, 1 failed / 13 total. - Failure: `updates existing entries without evicting when full`, expected `cache.get('a')` to be `1`, received `undefined`. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:unit --outputStyle=stream --skipRemoteCache`: failed, 1 failed / 1104 total. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:types --outputStyle=stream --skipRemoteCache`: passed. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:eslint --outputStyle=stream --skipRemoteCache`: passed with 20 existing warnings. ### Performance Command: `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:perf --outputStyle=stream --skipRemoteCache -- tests/lru-cache.bench.ts` | Case | hz | mean | rme | samples | | --- | ---: | ---: | ---: | ---: | | newest hit | 18,518.89 | 0.0540 | +/-0.58% | 9,260 | | rotating hit | 13,741.92 | 0.0728 | +/-3.08% | 6,871 | | update newest while full | 17,309.32 | 0.0578 | +/-0.67% | 8,655 | | update oldest while full | 17,448.76 | 0.0573 | +/-1.65% | 8,725 | | update rotating entries while full | 9,113.98 | 0.1097 | +/-0.50% | 4,557 | | miss-heavy get | 78,647.37 | 0.0127 | +/-0.73% | 39,324 | | insert churn | 14,705.09 | 0.0680 | +/-3.13% | 7,353 | | mixed workload | 41,227.14 | 0.0243 | +/-0.25% | 20,614 | ### Bundle Size Command: `CI=1 NX_DAEMON=false pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache --skipNxCache -- --scenario react-router.minimal >/tmp/lru-before-targeted-bundle.log 2>&1 && pnpm benchmark:bundle-size:query --id react-router.minimal` - `react-router.minimal gzip=89421 initial=89281 raw=280786 brotli=77765` ## AFTER State: test/benchmark support changes plus optimized LRU implementation applied. ### Tests - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:unit --outputStyle=stream --skipRemoteCache -- tests/lru.test.ts`: passed, 13 passed / 13 total. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:unit --outputStyle=stream --skipRemoteCache`: passed, 1101 passed / 3 expected fail / 1104 total. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:types --outputStyle=stream --skipRemoteCache`: passed. - `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:eslint --outputStyle=stream --skipRemoteCache`: passed with 20 existing warnings. ### Performance Command: `CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:perf --outputStyle=stream --skipRemoteCache -- tests/lru-cache.bench.ts` | Case | hz | mean | rme | samples | | --- | ---: | ---: | ---: | ---: | | newest hit | 19,796.85 | 0.0505 | +/-0.43% | 9,899 | | rotating hit | 14,971.94 | 0.0668 | +/-0.44% | 7,486 | | update newest while full | 19,253.35 | 0.0519 | +/-1.03% | 9,627 | | update oldest while full | 18,284.35 | 0.0547 | +/-3.18% | 9,143 | | update rotating entries while full | 14,969.14 | 0.0668 | +/-0.92% | 7,485 | | miss-heavy get | 78,455.30 | 0.0127 | +/-0.26% | 39,228 | | insert churn | 14,950.34 | 0.0669 | +/-4.60% | 7,476 | | mixed workload | 38,459.24 | 0.0260 | +/-2.41% | 19,230 | ### Performance Comparison | Case | BEFORE hz | AFTER hz | hz delta | BEFORE mean | AFTER mean | mean delta | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | newest hit | 18,518.89 | 19,796.85 | +6.90% | 0.0540 | 0.0505 | -6.48% | | rotating hit | 13,741.92 | 14,971.94 | +8.95% | 0.0728 | 0.0668 | -8.24% | | update newest while full | 17,309.32 | 19,253.35 | +11.23% | 0.0578 | 0.0519 | -10.21% | | update oldest while full | 17,448.76 | 18,284.35 | +4.79% | 0.0573 | 0.0547 | -4.54% | | update rotating entries while full | 9,113.98 | 14,969.14 | +64.24% | 0.1097 | 0.0668 | -39.11% | | miss-heavy get | 78,647.37 | 78,455.30 | -0.24% | 0.0127 | 0.0127 | 0.00% | | insert churn | 14,705.09 | 14,950.34 | +1.67% | 0.0680 | 0.0669 | -1.62% | | mixed workload | 41,227.14 | 38,459.24 | -6.71% | 0.0243 | 0.0260 | +7.00% | Notes: `update rotating entries while full` improves materially because the old implementation evicted before checking existing entries. `mixed workload` regressed in this run and has moderate noise (`AFTER rme +/-2.41%`), so treat as directional. ### Bundle Size Command: `CI=1 NX_DAEMON=false pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache --skipNxCache -- --scenario react-router.minimal >/tmp/lru-after-targeted-bundle.log 2>&1 && pnpm benchmark:bundle-size:query --id react-router.minimal && pnpm benchmark:bundle-size:diff --baseline /var/folders/6f/2t42ntqs4yv4h6qwzbh5pmcm0000gn/T/opencode/lru-base-current.json --id react-router.minimal` - `react-router.minimal gzip=89407 initial=89268 raw=280685 brotli=77653` - Diff vs BEFORE: `gzip -14`, `initial -13`, `raw -101`, `brotli -112`. --- packages/router-core/src/lru-cache.ts | 65 +++++----- packages/router-core/tests/lru-cache.bench.ts | 87 +++++++++++++ packages/router-core/tests/lru.test.ts | 117 ++++++++++++++++++ 3 files changed, 236 insertions(+), 33 deletions(-) create mode 100644 packages/router-core/tests/lru-cache.bench.ts diff --git a/packages/router-core/src/lru-cache.ts b/packages/router-core/src/lru-cache.ts index ee3ec7d7a0..6eadee26f0 100644 --- a/packages/router-core/src/lru-cache.ts +++ b/packages/router-core/src/lru-cache.ts @@ -13,57 +13,56 @@ export function createLRUCache( let newest: Node | undefined const touch = (entry: Node) => { - if (!entry.next) return - if (!entry.prev) { - entry.next.prev = undefined - oldest = entry.next - entry.next = undefined - if (newest) { - entry.prev = newest - newest.next = entry - } + const next = entry.next + if (!next) { + return + } + const prev = entry.prev + if (prev) { + prev.next = next } else { - entry.prev.next = entry.next - entry.next.prev = entry.prev - entry.next = undefined - if (newest) { - newest.next = entry - entry.prev = newest - } + oldest = next } + next.prev = prev + entry.prev = newest + entry.next = undefined + newest!.next = entry newest = entry } return { get(key) { const entry = cache.get(key) - if (!entry) return undefined + if (!entry) { + return undefined + } touch(entry) return entry.value }, set(key, value) { + const entry = cache.get(key) + if (entry) { + entry.value = value + touch(entry) + return + } if (cache.size >= max && oldest) { - const toDelete = oldest - cache.delete(toDelete.key) - if (toDelete.next) { - oldest = toDelete.next - toDelete.next.prev = undefined - } - if (toDelete === newest) { + cache.delete(oldest.key) + oldest = oldest.next + if (oldest) { + oldest.prev = undefined + } else { newest = undefined } } - const existing = cache.get(key) - if (existing) { - existing.value = value - touch(existing) + const newEntry: Node = { key, value, prev: newest } + if (newest) { + newest.next = newEntry } else { - const entry: Node = { key, value, prev: newest } - if (newest) newest.next = entry - newest = entry - if (!oldest) oldest = entry - cache.set(key, entry) + oldest = newEntry } + newest = newEntry + cache.set(key, newEntry) }, clear() { cache.clear() diff --git a/packages/router-core/tests/lru-cache.bench.ts b/packages/router-core/tests/lru-cache.bench.ts new file mode 100644 index 0000000000..e875cce3a9 --- /dev/null +++ b/packages/router-core/tests/lru-cache.bench.ts @@ -0,0 +1,87 @@ +import { bench, describe } from 'vitest' +import { createLRUCache } from '../src/lru-cache' + +const keys1000 = Array.from({ length: 1000 }, (_, i) => `key-${i}`) +const missing1000 = Array.from({ length: 1000 }, (_, i) => `missing-${i}`) +const new1000 = Array.from({ length: 1000 }, (_, i) => `new-${i}`) + +function fillCache( + cache: { set: (key: string, value: number) => void }, + count: number, +) { + for (let i = 0; i < count; i++) { + cache.set(keys1000[i]!, i) + } +} + +describe('LRU cache', () => { + bench('newest hit', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[999]!) + } + }) + + bench('rotating hit', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[i]!) + } + }) + + bench('update newest while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[999]!, i) + } + }) + + bench('update oldest while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[0]!, i) + } + }) + + bench('update rotating entries while full', () => { + const cache = createLRUCache(1000) + fillCache(cache, 1000) + for (let i = 0; i < 1000; i++) { + cache.set(keys1000[i]!, i + 1) + } + }) + + bench('miss-heavy get', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.get(missing1000[i]!) + } + }) + + bench('insert churn', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.set(new1000[i]!, i) + } + }) + + bench('mixed workload', () => { + const cache = createLRUCache(64) + fillCache(cache, 64) + for (let i = 0; i < 1000; i++) { + cache.get(keys1000[i % 8]!) + if (i % 10 === 0) { + cache.get(missing1000[i]!) + } + if (i % 20 === 0) { + cache.set(new1000[i]!, i) + } + } + }) +}) diff --git a/packages/router-core/tests/lru.test.ts b/packages/router-core/tests/lru.test.ts index f601816596..d7ee74a52d 100644 --- a/packages/router-core/tests/lru.test.ts +++ b/packages/router-core/tests/lru.test.ts @@ -21,4 +21,121 @@ describe('LRU Cache', () => { expect(cache.get('b')).toBeUndefined() expect(cache.get('a')).toBe(1) }) + it('does not change order on a missing get', () => { + const cache = createLRUCache(2) + cache.set('a', 1) + cache.set('b', 2) + expect(cache.get('x')).toBeUndefined() + cache.set('c', 3) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(2) + }) + it('moves updated entries to most recently used before capacity is reached', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('a', 3) + cache.set('c', 4) + cache.set('d', 5) + expect(cache.get('b')).toBeUndefined() + expect(cache.get('a')).toBe(3) + }) + it('updates existing entries without evicting when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('b', 4) + expect(cache.get('a')).toBe(1) + expect(cache.get('b')).toBe(4) + }) + it('moves updated oldest entries to most recently used when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('a', 4) + cache.set('d', 5) + expect(cache.get('b')).toBeUndefined() + expect(cache.get('a')).toBe(4) + }) + it('keeps updated newest entries most recently used when full', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('c', 4) + cache.set('d', 5) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('c')).toBe(4) + }) + it('handles repeated updates without duplicating entries', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + cache.set('a', 4) + cache.set('a', 5) + cache.set('b', 6) + cache.set('d', 7) + expect(cache.get('c')).toBeUndefined() + expect(cache.get('a')).toBe(5) + expect(cache.get('b')).toBe(6) + }) + it('moves middle entries to most recently used on get', () => { + const cache = createLRUCache(3) + cache.set('a', 1) + cache.set('b', 2) + cache.set('c', 3) + expect(cache.get('b')).toBe(2) + cache.set('d', 4) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('c')).toBe(3) + expect(cache.get('b')).toBe(2) + }) + it('clears entries and reuses the cache', () => { + const cache = createLRUCache(2) + cache.clear() + cache.set('a', 1) + cache.set('b', 2) + cache.get('a') + cache.clear() + cache.clear() + expect(cache.get('a')).toBeUndefined() + cache.set('c', 3) + cache.set('d', 4) + cache.set('e', 5) + expect(cache.get('c')).toBeUndefined() + expect(cache.get('d')).toBe(4) + }) + it('handles a max size of one', () => { + const cache = createLRUCache(1) + cache.set('a', undefined) + expect(cache.get('a')).toBeUndefined() + cache.set('a', 2) + expect(cache.get('a')).toBe(2) + cache.set('b', 3) + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe(3) + }) + it('caches undefined values', () => { + const cache = createLRUCache(2) + cache.set('a', undefined) + cache.set('b', 2) + expect(cache.get('a')).toBeUndefined() + cache.set('c', 3) + expect(cache.get('b')).toBeUndefined() + }) + it('uses non-string keys by identity', () => { + const cache = createLRUCache(2) + const a = { id: 'a' } + const b = { id: 'b' } + const aLike = { id: 'a' } + cache.set(a, 1) + cache.set(b, 2) + expect(cache.get(aLike)).toBeUndefined() + expect(cache.get(a)).toBe(1) + cache.set({ id: 'c' }, 3) + expect(cache.get(b)).toBeUndefined() + }) })