Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/store-getter-memoization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@geajs/core": patch
---

### @geajs/core (patch)

- **Store getter memoization**: Prototype-level getters on Store subclasses are now memoized with reactive dependency tracking. Cached results are invalidated when tracked fields change. Getters reading `_`-prefixed internal fields or function-valued fields are marked uncacheable. Chained getters propagate dependencies to parent getters.
105 changes: 105 additions & 0 deletions packages/gea/benchmarks/getter-memo.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Benchmark: store getter memoization with reactive dependency tracking
* PR #41: Cache prototype getter results; invalidate on observed field changes
*
* Run: npx tsx --conditions source packages/gea/benchmarks/getter-memo.bench.ts
*/
import { Store } from '../src/lib/store.ts'

function heapMB() {
return process.memoryUsage().heapUsed / 1024 / 1024
}

function bench(fn: () => void, iters: number): number {
for (let i = 0; i < 20; i++) fn() // warm-up
const t0 = performance.now()
for (let i = 0; i < iters; i++) fn()
return performance.now() - t0
}

// ---------- Store with computed getters ----------
class ProductStore extends Store {
price = 100
tax = 0.18
discount = 0
quantity = 5
name = 'Widget'

get subtotal() {
return this.price * this.quantity
}

get taxAmount() {
return this.subtotal * this.tax
}

get discountAmount() {
return this.subtotal * this.discount
}

get total() {
return this.subtotal + this.taxAmount - this.discountAmount
}

get summary() {
return `${this.name}: $${this.total.toFixed(2)} (qty: ${this.quantity})`
}
}

const ITERS = 100_000

console.log('\n=== store getter memoization benchmark ===')
console.log('Store: ProductStore with 5 chained computed getters\n')

const store = new ProductStore()

// --- Scenario 1: Warm reads (no state change — pure cache hits) ---
if (global.gc) global.gc()
const h0 = heapMB()

const warmMs = bench(() => {
void store.total
void store.summary
void store.subtotal
}, ITERS)

if (global.gc) global.gc()
const h1 = heapMB()

// --- Scenario 2: Mixed reads + invalidation ---
let counter = 0
const invalidMs = bench(() => {
void store.total
if (counter++ % 100 === 0) {
store.price = 100 + (counter % 10) // invalidate every 100 reads
}
void store.summary
}, ITERS)

if (global.gc) global.gc()
const h2 = heapMB()

// --- Per-getter timing ---
const singleGetterMs = bench(() => {
void store.total
}, ITERS)

console.log(`${'Scenario'.padEnd(36)} ${'Time (ms)'.padStart(10)} ${'Per-call (ns)'.padStart(14)}`)
console.log('-'.repeat(62))

function row(label: string, ms: number) {
const perCallNs = ((ms / ITERS) * 1_000_000).toFixed(0)
console.log(`${label.padEnd(36)} ${ms.toFixed(2).padStart(10)} ${perCallNs.padStart(14)}`)
}

row(`Warm reads (${ITERS.toLocaleString()} × 3 getters)`, warmMs)
row(`Single getter (${ITERS.toLocaleString()} reads)`, singleGetterMs)
row(`Mixed read+invalidate (1% churn)`, invalidMs)

console.log()
console.log(`Heap after warm reads: ${h1.toFixed(2)} MB (delta: ${(h1-h0).toFixed(3)} MB)`)
console.log(`Heap after invalidation mix: ${h2.toFixed(2)} MB (delta: ${(h2-h1).toFixed(3)} MB)`)
console.log()
console.log('Memoized getters: repeated reads hit the cache (no recomputation).')
console.log('Invalidation: changing a dependency clears only the affected getters.')
console.log('Memory overhead: one WeakMap entry + dep set per getter instance.\n')
102 changes: 102 additions & 0 deletions packages/gea/benchmarks/sort-permutation.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Benchmark: sort/reverse permutation O(n²) → O(n)
* PR #38: Replace indexOf-in-loop with Map-based bucket lookup
*
* Run: npx tsx packages/gea/benchmarks/sort-permutation.bench.ts
*/

function heapMB() {
return process.memoryUsage().heapUsed / 1024 / 1024
}

// ---------- OLD implementation (O(n²)) ----------
function computePermutationOld(prev: any[], next: any[]): number[] {
return next.map((v) => {
const idx = prev.indexOf(v)
prev[idx] = undefined // mark consumed
return idx
})
}

// ---------- NEW implementation (O(n)) ----------
function computePermutationNew(prev: any[], next: any[]): number[] {
const idxMap = new Map<any, number[]>()
for (let i = 0; i < prev.length; i++) {
const a = idxMap.get(prev[i])
a ? a.push(i) : idxMap.set(prev[i], [i])
}
const cursors = new Map<any, number>()
return next.map((v) => {
const bucket = idxMap.get(v)!
const cursor = cursors.get(v) ?? 0
cursors.set(v, cursor + 1)
return bucket[cursor]
})
}

// ---------- Benchmark harness ----------
function bench(label: string, fn: () => void, iters: number): number {
// warm-up
for (let i = 0; i < 5; i++) fn()
const t0 = performance.now()
for (let i = 0; i < iters; i++) fn()
return performance.now() - t0
}

function makeArray(size: number): number[] {
return Array.from({ length: size }, (_, i) => i)
}

function shuffleArray(arr: number[]): number[] {
const a = arr.slice()
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}

const SIZES = [100, 1000, 5000, 10000]
const ITERS = 200

console.log('\n=== sort/reverse permutation benchmark ===')
console.log(`${'Size'.padEnd(8)} ${'Old (ms)'.padStart(10)} ${'New (ms)'.padStart(10)} ${'Speedup'.padStart(10)} ${'Heap Δ (MB)'.padStart(12)}`)
console.log('-'.repeat(56))

for (const size of SIZES) {
const base = makeArray(size)
const shuffled = shuffleArray(base)

const h0 = heapMB()

const oldMs = bench(
'old',
() => {
const prev = base.slice()
computePermutationOld(prev, shuffled)
},
ITERS,
)

const h1 = heapMB()

const newMs = bench(
'new',
() => {
const prev = base.slice()
computePermutationNew(prev, shuffled)
},
ITERS,
)

const h2 = heapMB()
const heapDelta = (h2 - h1).toFixed(3)
const speedup = (oldMs / newMs).toFixed(1)

console.log(
`${String(size).padEnd(8)} ${oldMs.toFixed(2).padStart(10)} ${newMs.toFixed(2).padStart(10)} ${(speedup + 'x').padStart(10)} ${heapDelta.padStart(12)}`,
)
}

console.log('\nAll sizes: new implementation is O(n) vs O(n²) old.')
console.log('Speedup scales with array size. Heap delta is minimal.\n')
Loading