v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "imhotep-bench",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/anomalyco/imhotep.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --test dist/**/*.test.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"imhotep-core": "^1.0.0",
|
||||
"imhotep-solver": "^1.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { createEmptyWorld, adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
console.log('=== World Adaptation Benchmark ===\n')
|
||||
|
||||
for (const count of [10, 50, 100, 500, 1000, 5000]) {
|
||||
const canonical = createEmptyWorld('scene', 'snap')
|
||||
canonical.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
canonical.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
canonical.subjects.subjectKind = new Uint8Array(Array.from({length: count}, () => 1))
|
||||
canonical.subjects.primaryBoxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
canonical.subjects.firstFragmentId = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.subjects.fragmentCount = new Uint8Array(Array.from({length: count}, () => 1))
|
||||
|
||||
canonical.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
canonical.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
canonical.boxes.frameId = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
|
||||
canonical.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
|
||||
canonical.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
|
||||
canonical.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
|
||||
canonical.boxes.paddingLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 2))
|
||||
canonical.boxes.paddingTop = new Float64Array(Array.from({length: count}, () => 2))
|
||||
canonical.boxes.paddingRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 48))
|
||||
canonical.boxes.paddingBottom = new Float64Array(Array.from({length: count}, () => 48))
|
||||
canonical.boxes.contentLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 5))
|
||||
canonical.boxes.contentTop = new Float64Array(Array.from({length: count}, () => 5))
|
||||
canonical.boxes.contentRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 45))
|
||||
canonical.boxes.contentBottom = new Float64Array(Array.from({length: count}, () => 45))
|
||||
|
||||
canonical.dom.nodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
canonical.dom.parentNodeId = new Uint32Array(Array.from({length: count}, () => 1))
|
||||
canonical.dom.childCount = new Uint8Array(Array.from({length: count}, () => 0))
|
||||
canonical.dom.tagNameStringId = new Uint16Array(Array.from({length: count}, () => 0))
|
||||
|
||||
canonical.topology.containingBlockOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.nearestPositionedAncestorOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.scrollContainerOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.stackingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.formattingContextOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.clippingRootOf = new Uint32Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.paintOrderBucket = new Uint8Array(Array.from({length: count}, () => 0))
|
||||
canonical.topology.paintOrderIndex = new Uint32Array(Array.from({length: count}, (_, i) => i))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
adaptCanonicalWorldToSolver(canonical)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} elements: ${mean.toFixed(2)}ms (avg of 100 runs)`)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// bench.test.ts - Tests for imhotep-bench harness
|
||||
// Validates benchmark execution and cache correctness.
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import {
|
||||
MemoryCache,
|
||||
CompilationCache,
|
||||
ExtractionCache,
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
profileRun,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
} from './index.js'
|
||||
|
||||
describe('Cache correctness', () => {
|
||||
it('MemoryCache stores and retrieves values', () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
const key = { hash: 'abc', version: '1' }
|
||||
cache.set(key, 'hello')
|
||||
assert.strictEqual(cache.get(key), 'hello')
|
||||
assert.strictEqual(cache.size(), 1)
|
||||
})
|
||||
|
||||
it('MemoryCache returns undefined for missing keys', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
assert.strictEqual(cache.get({ hash: 'missing', version: '1' }), undefined)
|
||||
})
|
||||
|
||||
it('MemoryCache invalidates entries', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
const key = { hash: 'del', version: '1' }
|
||||
cache.set(key, 42)
|
||||
assert.strictEqual(cache.invalidate(key), true)
|
||||
assert.strictEqual(cache.get(key), undefined)
|
||||
assert.strictEqual(cache.invalidate(key), false)
|
||||
})
|
||||
|
||||
it('MemoryCache respects TTL expiration', async () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
const key = { hash: 'ttl', version: '1' }
|
||||
cache.set(key, 'temp', 10)
|
||||
assert.strictEqual(cache.get(key), 'temp')
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
assert.strictEqual(cache.get(key), undefined)
|
||||
assert.strictEqual(cache.size(), 0)
|
||||
})
|
||||
|
||||
it('MemoryCache clear removes all entries', () => {
|
||||
const cache = new MemoryCache<number>()
|
||||
cache.set({ hash: 'a', version: '1' }, 1)
|
||||
cache.set({ hash: 'b', version: '1' }, 2)
|
||||
cache.clear()
|
||||
assert.strictEqual(cache.size(), 0)
|
||||
})
|
||||
|
||||
it('CompilationCache caches compile results by source', () => {
|
||||
const inner = new MemoryCache<{ code: string }>()
|
||||
const cache = new CompilationCache(inner, 'v1')
|
||||
const result = { code: 'compiled' }
|
||||
cache.set('source-a', result)
|
||||
assert.deepStrictEqual(cache.get('source-a'), result)
|
||||
assert.strictEqual(cache.get('source-b'), undefined)
|
||||
})
|
||||
|
||||
it('CompilationCache invalidation targets specific source', () => {
|
||||
const inner = new MemoryCache<number>()
|
||||
const cache = new CompilationCache(inner, 'v1')
|
||||
cache.set('src1', 1)
|
||||
cache.set('src2', 2)
|
||||
cache.invalidate('src1')
|
||||
assert.strictEqual(cache.get('src1'), undefined)
|
||||
assert.strictEqual(cache.get('src2'), 2)
|
||||
})
|
||||
|
||||
it('ExtractionCache caches by selector and facts', () => {
|
||||
const inner = new MemoryCache<{ rects: number[] }>()
|
||||
const cache = new ExtractionCache(inner, 'v1')
|
||||
const result = { rects: [0, 0, 10, 10] }
|
||||
cache.set('#app', ['box', 'style'], result)
|
||||
assert.deepStrictEqual(cache.get('#app', ['box', 'style']), result)
|
||||
assert.strictEqual(cache.get('#app', ['box']), undefined)
|
||||
})
|
||||
|
||||
it('ExtractionCache invalidation targets specific selector+facts', () => {
|
||||
const inner = new MemoryCache<number>()
|
||||
const cache = new ExtractionCache(inner, 'v1')
|
||||
cache.set('sel1', ['a'], 1)
|
||||
cache.set('sel1', ['b'], 2)
|
||||
cache.set('sel2', ['a'], 3)
|
||||
cache.invalidate('sel1', ['a'])
|
||||
assert.strictEqual(cache.get('sel1', ['a']), undefined)
|
||||
assert.strictEqual(cache.get('sel1', ['b']), 2)
|
||||
assert.strictEqual(cache.get('sel2', ['a']), 3)
|
||||
})
|
||||
|
||||
it('Cache version isolates entries', () => {
|
||||
const cache = new MemoryCache<string>()
|
||||
cache.set({ hash: 'x', version: 'v1' }, 'old')
|
||||
cache.set({ hash: 'x', version: 'v2' }, 'new')
|
||||
assert.strictEqual(cache.get({ hash: 'x', version: 'v1' }), 'old')
|
||||
assert.strictEqual(cache.get({ hash: 'x', version: 'v2' }), 'new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Benchmark execution', () => {
|
||||
it('profileRun measures duration and memory', async () => {
|
||||
const { durationMs, memoryDeltaBytes, result } = await profileRun(() => {
|
||||
const arr = new Array(1000).fill(0)
|
||||
return arr.length
|
||||
})
|
||||
assert.strictEqual(typeof durationMs, 'number')
|
||||
assert.strictEqual(durationMs >= 0, true)
|
||||
assert.strictEqual(typeof memoryDeltaBytes, 'number')
|
||||
assert.strictEqual(result, 1000)
|
||||
})
|
||||
|
||||
it('runBenchmark returns aggregated stats', async () => {
|
||||
let counter = 0
|
||||
const result = await runBenchmark('inc', () => {
|
||||
counter++
|
||||
return counter
|
||||
})
|
||||
assert.strictEqual(result.name, 'inc')
|
||||
assert.strictEqual(typeof result.meanDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.minDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.maxDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.stdDevDurationMs, 'number')
|
||||
assert.strictEqual(typeof result.meanMemoryDeltaBytes, 'number')
|
||||
assert.strictEqual(result.runs.length, 5)
|
||||
assert.strictEqual(result.minDurationMs <= result.meanDurationMs, true)
|
||||
assert.strictEqual(result.meanDurationMs <= result.maxDurationMs, true)
|
||||
})
|
||||
|
||||
it('runBenchmark applies budget check', async () => {
|
||||
const result = await runBenchmark('slow', () => {}, {
|
||||
budgetName: 'compile',
|
||||
profile: DEFAULT_PROFILES.benchmark,
|
||||
})
|
||||
assert.ok(result.budgetCheck)
|
||||
assert.strictEqual(result.budgetCheck!.budgetName, 'compile')
|
||||
assert.strictEqual(typeof result.budgetCheck!.passed, 'boolean')
|
||||
})
|
||||
|
||||
it('runBenchmark uses custom run counts', async () => {
|
||||
let calls = 0
|
||||
await runBenchmark(
|
||||
'count',
|
||||
() => {
|
||||
calls++
|
||||
},
|
||||
{ warmupRuns: 2, measurementRuns: 3 }
|
||||
)
|
||||
assert.strictEqual(calls, 5)
|
||||
})
|
||||
|
||||
it('BenchmarkSuite runs sequentially', async () => {
|
||||
const suite = new BenchmarkSuite('seq')
|
||||
const order: number[] = []
|
||||
suite.add('a', () => order.push(1), { warmupRuns: 0, measurementRuns: 1 })
|
||||
suite.add('b', () => order.push(2), { warmupRuns: 0, measurementRuns: 1 })
|
||||
const result = await suite.runSequential()
|
||||
assert.strictEqual(result.suiteName, 'seq')
|
||||
assert.strictEqual(result.results.length, 2)
|
||||
assert.deepStrictEqual(order, [1, 2])
|
||||
assert.strictEqual(typeof result.allBudgetsPassed, 'boolean')
|
||||
assert.strictEqual(typeof result.totalDurationMs, 'number')
|
||||
})
|
||||
|
||||
it('BenchmarkSuite runs in parallel', async () => {
|
||||
const suite = new BenchmarkSuite('par', {
|
||||
poolOptions: { maxConcurrency: 2, taskTimeoutMs: 5000 },
|
||||
})
|
||||
suite.add('x', async () => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}, { warmupRuns: 0, measurementRuns: 1 })
|
||||
suite.add('y', async () => {
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
}, { warmupRuns: 0, measurementRuns: 1 })
|
||||
const result = await suite.runParallel()
|
||||
assert.strictEqual(result.results.length, 2)
|
||||
assert.strictEqual(typeof result.totalDurationMs, 'number')
|
||||
// Parallel total should be less than sequential sum of sleeps (20ms) plus generous overhead
|
||||
assert.ok(result.totalDurationMs < 150)
|
||||
})
|
||||
|
||||
it('Budget check passes when under budget', () => {
|
||||
const check = checkBudget('compile', 10, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, true)
|
||||
assert.strictEqual(check.maxDurationMs, 50)
|
||||
})
|
||||
|
||||
it('Budget check fails when over budget', () => {
|
||||
const check = checkBudget('compile', 1000, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, false)
|
||||
})
|
||||
|
||||
it('Missing budget returns passed=true with Infinity', () => {
|
||||
const check = checkBudget('unknown', 99999, undefined, DEFAULT_PROFILES.dev)
|
||||
assert.strictEqual(check.passed, true)
|
||||
assert.strictEqual(check.maxDurationMs, Infinity)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
// benchmark.ts - Benchmark runner for Imhotep bench harness
|
||||
// Measures execution time, memory, and CPU for extraction and solving paths.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
type PerformanceProfile,
|
||||
type BudgetCheck,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
} from './profiles.js'
|
||||
import { type CompilationCache, type ExtractionCache } from './cache.js'
|
||||
import { PromisePool, type PromisePoolOptions } from './parallel.js'
|
||||
|
||||
export interface BenchmarkOptions {
|
||||
/** Number of warm-up runs before measurement */
|
||||
warmupRuns?: number
|
||||
/** Number of measured runs */
|
||||
measurementRuns?: number
|
||||
/** Performance profile to check budgets against */
|
||||
profile?: PerformanceProfile
|
||||
/** Budget category name (compile | extract | solve | e2e) */
|
||||
budgetName?: string
|
||||
/** Optional compilation cache injected by caller */
|
||||
compilationCache?: CompilationCache<unknown>
|
||||
/** Optional extraction cache injected by caller */
|
||||
extractionCache?: ExtractionCache<unknown>
|
||||
}
|
||||
|
||||
export interface BenchmarkResult {
|
||||
/** Benchmark name */
|
||||
name: string
|
||||
/** Mean duration across measured runs in milliseconds */
|
||||
meanDurationMs: number
|
||||
/** Minimum duration in milliseconds */
|
||||
minDurationMs: number
|
||||
/** Maximum duration in milliseconds */
|
||||
maxDurationMs: number
|
||||
/** Standard deviation of duration */
|
||||
stdDevDurationMs: number
|
||||
/** Mean memory delta in bytes */
|
||||
meanMemoryDeltaBytes: number
|
||||
/** Budget check result, if a budget name was provided */
|
||||
budgetCheck?: BudgetCheck
|
||||
/** Per-run raw measurements */
|
||||
runs: RunMeasurement[]
|
||||
}
|
||||
|
||||
export interface RunMeasurement {
|
||||
durationMs: number
|
||||
memoryDeltaBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile a single function execution.
|
||||
* Returns duration and memory delta.
|
||||
*/
|
||||
export async function profileRun<T>(
|
||||
fn: () => T | Promise<T>
|
||||
): Promise<{ durationMs: number; memoryDeltaBytes: number; result: T }> {
|
||||
const memBefore = process.memoryUsage().heapUsed
|
||||
const start = performance.now()
|
||||
const result = await fn()
|
||||
const end = performance.now()
|
||||
const memAfter = process.memoryUsage().heapUsed
|
||||
|
||||
return {
|
||||
durationMs: end - start,
|
||||
memoryDeltaBytes: memAfter - memBefore,
|
||||
result,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a benchmark with warm-up and measurement phases.
|
||||
*/
|
||||
export async function runBenchmark<T>(
|
||||
name: string,
|
||||
fn: () => T | Promise<T>,
|
||||
options: BenchmarkOptions = {}
|
||||
): Promise<BenchmarkResult> {
|
||||
const warmupRuns = options.warmupRuns ?? 1
|
||||
const measurementRuns = options.measurementRuns ?? 5
|
||||
const profile = options.profile ?? DEFAULT_PROFILES.dev
|
||||
const budgetName = options.budgetName ?? 'e2e'
|
||||
|
||||
// Warm-up phase: discard results
|
||||
for (let i = 0; i < warmupRuns; i++) {
|
||||
await fn()
|
||||
}
|
||||
|
||||
// Measurement phase
|
||||
const runs: RunMeasurement[] = []
|
||||
for (let i = 0; i < measurementRuns; i++) {
|
||||
const { durationMs, memoryDeltaBytes } = await profileRun(fn)
|
||||
runs.push({ durationMs, memoryDeltaBytes })
|
||||
}
|
||||
|
||||
const durations = runs.map(r => r.durationMs)
|
||||
const meanDurationMs =
|
||||
durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
const minDurationMs = Math.min(...durations)
|
||||
const maxDurationMs = Math.max(...durations)
|
||||
const variance =
|
||||
durations.reduce((sum, d) => sum + Math.pow(d - meanDurationMs, 2), 0) /
|
||||
durations.length
|
||||
const stdDevDurationMs = Math.sqrt(variance)
|
||||
const meanMemoryDeltaBytes =
|
||||
runs.reduce((sum, r) => sum + r.memoryDeltaBytes, 0) / runs.length
|
||||
|
||||
const budgetCheck = checkBudget(
|
||||
budgetName,
|
||||
meanDurationMs,
|
||||
meanMemoryDeltaBytes,
|
||||
profile
|
||||
)
|
||||
|
||||
return {
|
||||
name,
|
||||
meanDurationMs,
|
||||
minDurationMs,
|
||||
maxDurationMs,
|
||||
stdDevDurationMs,
|
||||
meanMemoryDeltaBytes,
|
||||
budgetCheck,
|
||||
runs,
|
||||
}
|
||||
}
|
||||
|
||||
export interface BenchmarkSuiteOptions {
|
||||
/** Global benchmark options applied to every benchmark */
|
||||
defaults?: BenchmarkOptions
|
||||
/** Promise pool options for parallel execution */
|
||||
poolOptions?: PromisePoolOptions
|
||||
}
|
||||
|
||||
export interface BenchmarkSuiteResult {
|
||||
suiteName: string
|
||||
results: BenchmarkResult[]
|
||||
totalDurationMs: number
|
||||
allBudgetsPassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* BenchmarkSuite collects and runs multiple named benchmarks.
|
||||
* Supports sequential and parallel execution.
|
||||
*/
|
||||
export class BenchmarkSuite {
|
||||
private benchmarks: Array<{
|
||||
name: string
|
||||
fn: () => unknown | Promise<unknown>
|
||||
options?: BenchmarkOptions
|
||||
}> = []
|
||||
|
||||
constructor(private suiteName: string, private options: BenchmarkSuiteOptions = {}) {}
|
||||
|
||||
/** Register a benchmark function */
|
||||
add<T>(
|
||||
name: string,
|
||||
fn: () => T | Promise<T>,
|
||||
options?: BenchmarkOptions
|
||||
): this {
|
||||
this.benchmarks.push({ name, fn, options })
|
||||
return this
|
||||
}
|
||||
|
||||
/** Run all benchmarks sequentially */
|
||||
async runSequential(): Promise<BenchmarkSuiteResult> {
|
||||
const results: BenchmarkResult[] = []
|
||||
const suiteStart = performance.now()
|
||||
|
||||
for (const { name, fn, options } of this.benchmarks) {
|
||||
const merged: BenchmarkOptions = {
|
||||
...this.options.defaults,
|
||||
...options,
|
||||
}
|
||||
const result = await runBenchmark(name, fn, merged)
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const suiteEnd = performance.now()
|
||||
|
||||
return {
|
||||
suiteName: this.suiteName,
|
||||
results,
|
||||
totalDurationMs: suiteEnd - suiteStart,
|
||||
allBudgetsPassed: results.every(
|
||||
r => !r.budgetCheck || r.budgetCheck.passed
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Run all benchmarks in parallel with controlled concurrency */
|
||||
async runParallel(): Promise<BenchmarkSuiteResult> {
|
||||
const poolOptions: PromisePoolOptions = {
|
||||
maxConcurrency: this.options.poolOptions?.maxConcurrency ?? 4,
|
||||
taskTimeoutMs: this.options.poolOptions?.taskTimeoutMs ?? 30000,
|
||||
}
|
||||
const pool = new PromisePool(poolOptions)
|
||||
const suiteStart = performance.now()
|
||||
|
||||
try {
|
||||
const tasks = this.benchmarks.map(({ name, fn, options }) => ({
|
||||
id: name,
|
||||
input: undefined,
|
||||
fn: async () => {
|
||||
const merged: BenchmarkOptions = {
|
||||
...this.options.defaults,
|
||||
...options,
|
||||
}
|
||||
return runBenchmark(name, fn, merged)
|
||||
},
|
||||
}))
|
||||
|
||||
const results = (await pool.executeAll(tasks)) as BenchmarkResult[]
|
||||
const suiteEnd = performance.now()
|
||||
|
||||
return {
|
||||
suiteName: this.suiteName,
|
||||
results,
|
||||
totalDurationMs: suiteEnd - suiteStart,
|
||||
allBudgetsPassed: results.every(
|
||||
r => !r.budgetCheck || r.budgetCheck.passed
|
||||
),
|
||||
}
|
||||
} finally {
|
||||
await pool.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// benchmarks.test.ts - Validation tests for benchmark suites.
|
||||
// Runs each benchmark with reduced parameters to verify correctness.
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { runFolBenchmarks } from './fol-benchmark.js'
|
||||
import { runPropertyBenchmarks } from './property-benchmark.js'
|
||||
import { runRenderTargetBenchmarks } from './render-target-benchmark.js'
|
||||
import { runPublicApiBenchmarks } from './public-api-benchmark.js'
|
||||
|
||||
describe('FOL benchmarks', () => {
|
||||
it('runs forall benchmarks without error', async () => {
|
||||
const results = await runFolBenchmarks();
|
||||
assert.ok(results.length >= 4);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
assert.ok(r.runs.length > 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Property benchmarks', () => {
|
||||
it('runs enumerated and sampled benchmarks', async () => {
|
||||
const results = await runPropertyBenchmarks();
|
||||
assert.ok(results.length >= 2);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render target benchmarks', () => {
|
||||
it('runs renderer mount benchmarks', async () => {
|
||||
const results = await runRenderTargetBenchmarks();
|
||||
assert.ok(results.length >= 4);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public API benchmarks', () => {
|
||||
it('runs leftOf relation benchmark under 100ms', async () => {
|
||||
const results = await runPublicApiBenchmarks();
|
||||
assert.ok(results.length >= 1);
|
||||
for (const r of results) {
|
||||
assert.strictEqual(typeof r.meanDurationMs, 'number');
|
||||
assert.ok(r.meanDurationMs >= 0);
|
||||
assert.ok(r.meanDurationMs < 100, `Expected public API benchmark to complete in under 100ms, but took ${r.meanDurationMs}ms`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cache-hit-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
// Warmup: run once to populate cache
|
||||
const ui1 = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui1.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui1.checkAll()
|
||||
|
||||
console.log('=== Cache Hit Performance ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(selectors[i]).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors (cache hit): ${mean.toFixed(2)}ms (avg of 10 runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,171 @@
|
||||
// cache.ts - Compilation and extraction cache implementations for Imhotep bench harness
|
||||
// Provides in-memory caching with TTL, versioning, and explicit invalidation.
|
||||
|
||||
export interface CacheKey {
|
||||
/** Content hash or deterministic identifier */
|
||||
hash: string
|
||||
/** Cache schema version for invalidation on format changes */
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
/** Stored value */
|
||||
value: T
|
||||
/** Unix timestamp when the entry was created */
|
||||
createdAt: number
|
||||
/** Optional Unix timestamp when the entry expires */
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export interface Cache<T> {
|
||||
get(key: CacheKey): T | undefined
|
||||
set(key: CacheKey, value: T, ttlMs?: number): void
|
||||
invalidate(key: CacheKey): boolean
|
||||
clear(): void
|
||||
size(): number
|
||||
}
|
||||
|
||||
/** Simple in-memory cache backed by a Map. */
|
||||
export class MemoryCache<T> implements Cache<T> {
|
||||
private store = new Map<string, CacheEntry<T>>()
|
||||
|
||||
private makeKey(key: CacheKey): string {
|
||||
return `${key.version}:${key.hash}`
|
||||
}
|
||||
|
||||
get(key: CacheKey): T | undefined {
|
||||
const k = this.makeKey(key)
|
||||
const entry = this.store.get(k)
|
||||
if (!entry) return undefined
|
||||
if (entry.expiresAt !== undefined && Date.now() > entry.expiresAt) {
|
||||
this.store.delete(k)
|
||||
return undefined
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
set(key: CacheKey, value: T, ttlMs?: number): void {
|
||||
const k = this.makeKey(key)
|
||||
const now = Date.now()
|
||||
this.store.set(k, {
|
||||
value,
|
||||
createdAt: now,
|
||||
expiresAt: ttlMs !== undefined ? now + ttlMs : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
invalidate(key: CacheKey): boolean {
|
||||
return this.store.delete(this.makeKey(key))
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear()
|
||||
}
|
||||
|
||||
size(): number {
|
||||
// Prune expired entries before reporting size for accuracy
|
||||
const now = Date.now()
|
||||
for (const [k, entry] of this.store) {
|
||||
if (entry.expiresAt !== undefined && now > entry.expiresAt) {
|
||||
this.store.delete(k)
|
||||
}
|
||||
}
|
||||
return this.store.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Cache key factory for compilation inputs. */
|
||||
export function makeCompilationCacheKey(
|
||||
source: string,
|
||||
version: string
|
||||
): CacheKey {
|
||||
// Simple hash: in production this should be a real hash function
|
||||
let hash = 0
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const char = source.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0
|
||||
}
|
||||
return { hash: String(hash), version }
|
||||
}
|
||||
|
||||
/** Cache key factory for extraction plans. */
|
||||
export function makeExtractionCacheKey(
|
||||
selector: string,
|
||||
facts: string[],
|
||||
version: string
|
||||
): CacheKey {
|
||||
const combined = `${selector}::${facts.sort().join(',')}`
|
||||
let hash = 0
|
||||
for (let i = 0; i < combined.length; i++) {
|
||||
const char = combined.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash |= 0
|
||||
}
|
||||
return { hash: String(hash), version }
|
||||
}
|
||||
|
||||
/**
|
||||
* CompilationCache wraps a generic Cache for CompileResult-like objects.
|
||||
* Accepts the underlying Cache via constructor injection.
|
||||
*/
|
||||
export class CompilationCache<TCompileResult> {
|
||||
constructor(
|
||||
private cache: Cache<TCompileResult>,
|
||||
private version: string
|
||||
) {}
|
||||
|
||||
get(source: string): TCompileResult | undefined {
|
||||
return this.cache.get(makeCompilationCacheKey(source, this.version))
|
||||
}
|
||||
|
||||
set(source: string, result: TCompileResult, ttlMs?: number): void {
|
||||
this.cache.set(makeCompilationCacheKey(source, this.version), result, ttlMs)
|
||||
}
|
||||
|
||||
invalidate(source: string): boolean {
|
||||
return this.cache.invalidate(makeCompilationCacheKey(source, this.version))
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractionCache wraps a generic Cache for extraction payloads.
|
||||
* Accepts the underlying Cache via constructor injection.
|
||||
*/
|
||||
export class ExtractionCache<TExtractResult> {
|
||||
constructor(
|
||||
private cache: Cache<TExtractResult>,
|
||||
private version: string
|
||||
) {}
|
||||
|
||||
get(selector: string, facts: string[]): TExtractResult | undefined {
|
||||
return this.cache.get(makeExtractionCacheKey(selector, facts, this.version))
|
||||
}
|
||||
|
||||
set(
|
||||
selector: string,
|
||||
facts: string[],
|
||||
result: TExtractResult,
|
||||
ttlMs?: number
|
||||
): void {
|
||||
this.cache.set(
|
||||
makeExtractionCacheKey(selector, facts, this.version),
|
||||
result,
|
||||
ttlMs
|
||||
)
|
||||
}
|
||||
|
||||
invalidate(selector: string, facts: string[]): boolean {
|
||||
return this.cache.invalidate(
|
||||
makeExtractionCacheKey(selector, facts, this.version)
|
||||
)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Contention analysis benchmark.
|
||||
*
|
||||
* Tests parallel execution at different granularities to identify
|
||||
* where contention occurs: browser, context, or page level.
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-contention-'))
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="app">
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'fixture.html'), html)
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
async function benchmark() {
|
||||
console.log('========================================')
|
||||
console.log(' Contention Analysis Benchmark')
|
||||
console.log('========================================\n')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const fixtureUrl = `file:${join(tmpdir2, 'fixture.html')}`
|
||||
|
||||
// ============================================================
|
||||
// Test 1: Baseline sequential page operations
|
||||
// ============================================================
|
||||
console.log('--- Test 1: Sequential Baseline ---')
|
||||
const seqTimes = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
await ctx.close()
|
||||
}
|
||||
seqTimes.push(performance.now() - start)
|
||||
}
|
||||
const seqMean = mean(seqTimes)
|
||||
console.log(` 20 ops (new context each): ${seqMean.toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Test 2: Parallel with varying concurrency
|
||||
// ============================================================
|
||||
console.log('--- Test 2: Parallel Execution Sweep ---')
|
||||
console.log(' Using isolated contexts (no sharing)')
|
||||
|
||||
for (const concurrency of [1, 2, 4, 6, 8, 12, 16]) {
|
||||
const sem = new Semaphore(concurrency)
|
||||
const times = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
sem.run(async () => {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
await ctx.close()
|
||||
})
|
||||
)
|
||||
)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const t = mean(times)
|
||||
const speedup = seqMean / t
|
||||
const efficiency = (speedup / concurrency) * 100
|
||||
console.log(` concurrency=${concurrency.toString().padStart(2)}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1).padStart(4)}x efficiency: ${efficiency.toFixed(0).padStart(3)}%`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 3: Page-level parallelism within single context
|
||||
// ============================================================
|
||||
console.log('--- Test 3: Pages in Single Context ---')
|
||||
|
||||
for (const concurrency of [1, 2, 4, 6, 8]) {
|
||||
const sem = new Semaphore(concurrency)
|
||||
const times = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const ctx = await browser.newContext()
|
||||
const start = performance.now()
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
sem.run(async () => {
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => document.getElementById('result').textContent = 'done')
|
||||
await page.close()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
times.push(performance.now() - start)
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
const t = mean(times)
|
||||
const speedup = seqMean / t
|
||||
console.log(` concurrency=${concurrency}: ${t.toFixed(1).padStart(7)}ms speedup: ${speedup.toFixed(1)}x`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 4: I/O vs CPU operation types
|
||||
// ============================================================
|
||||
console.log('--- Test 4: Operation Type Comparison ---')
|
||||
|
||||
async function ioOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
async function mixedOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = 'test'
|
||||
document.body.appendChild(div)
|
||||
return div.getBoundingClientRect()
|
||||
})
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
async function cpuOp() {
|
||||
const ctx = await browser.newContext()
|
||||
const page = await ctx.newPage()
|
||||
await page.goto(fixtureUrl)
|
||||
await page.evaluate(() => {
|
||||
let a = 0, b = 1
|
||||
for (let i = 0; i < 1000000; i++) {
|
||||
const temp = a + b; a = b; b = temp
|
||||
}
|
||||
return b
|
||||
})
|
||||
await ctx.close()
|
||||
}
|
||||
|
||||
for (const [name, op] of [['I/O', ioOp], ['Mixed', mixedOp], ['CPU', cpuOp]]) {
|
||||
const sem = new Semaphore(4)
|
||||
const seqT = []
|
||||
const parT = []
|
||||
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const s0 = performance.now()
|
||||
for (let i = 0; i < 4; i++) await op()
|
||||
seqT.push(performance.now() - s0)
|
||||
|
||||
const p0 = performance.now()
|
||||
await Promise.all(Array.from({ length: 4 }, () => sem.run(op)))
|
||||
parT.push(performance.now() - p0)
|
||||
}
|
||||
|
||||
const speedup = mean(seqT) / mean(parT)
|
||||
console.log(` ${name.padEnd(5)}: seq=${mean(seqT).toFixed(1).padStart(6)}ms par=${mean(parT).toFixed(1).padStart(6)}ms speedup: ${speedup.toFixed(1)}x`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Test 5: Resource degradation with many pages
|
||||
// ============================================================
|
||||
console.log('--- Test 5: Resource Saturation ---')
|
||||
|
||||
const ctx = await browser.newContext()
|
||||
const pageCreationTimes = []
|
||||
const pages = []
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const t0 = performance.now()
|
||||
const p = await ctx.newPage()
|
||||
pageCreationTimes.push(performance.now() - t0)
|
||||
pages.push(p)
|
||||
}
|
||||
|
||||
console.log(` Page creation (1st): ${pageCreationTimes[0].toFixed(1)}ms`)
|
||||
console.log(` Page creation (10th): ${pageCreationTimes[9].toFixed(1)}ms`)
|
||||
console.log(` Page creation (20th): ${pageCreationTimes[19].toFixed(1)}ms`)
|
||||
|
||||
// Now navigate all simultaneously
|
||||
const navStart = performance.now()
|
||||
await Promise.all(pages.map(p => p.goto(fixtureUrl)))
|
||||
const navTime = performance.now() - navStart
|
||||
console.log(` Simultaneous navigation (20 pages): ${navTime.toFixed(1)}ms`)
|
||||
|
||||
await Promise.all(pages.map(p => p.close()))
|
||||
await ctx.close()
|
||||
console.log('')
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' Contention Analysis Complete')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
benchmark().catch(err => {
|
||||
console.error('Benchmark failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-e2e-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 100}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Profile each phase separately
|
||||
console.log('=== End-to-End Pipeline Profile ===\n')
|
||||
|
||||
// Phase 1: Page creation + imhotep setup
|
||||
const setupTimes = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const start = performance.now()
|
||||
const ui = await imhotep(page)
|
||||
setupTimes.push(performance.now() - start)
|
||||
}
|
||||
console.log(`imhotep() setup: ${(setupTimes.reduce((a,b) => a+b, 0) / setupTimes.length).toFixed(1)}ms`)
|
||||
|
||||
// Phase 2: Assertion accumulation
|
||||
const ui = await imhotep(page)
|
||||
const accStart = performance.now()
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
console.log(`Assertion accumulation (50): ${(performance.now() - accStart).toFixed(2)}ms`)
|
||||
|
||||
// Phase 3: First extraction (cold, no cache)
|
||||
const coldStart = performance.now()
|
||||
await ui.checkAll()
|
||||
const coldDur = performance.now() - coldStart
|
||||
console.log(`First extraction (cold): ${coldDur.toFixed(1)}ms`)
|
||||
|
||||
// Phase 4: Second extraction (warm, with cache)
|
||||
const ui2 = await imhotep(page, { cacheDir: join(tmpdir2, 'cache') })
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui2.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
const warmStart = performance.now()
|
||||
await ui2.checkAll()
|
||||
const warmDur = performance.now() - warmStart
|
||||
console.log(`First check with cache dir: ${warmDur.toFixed(1)}ms`)
|
||||
|
||||
// Phase 5: Cached run
|
||||
const warmStart2 = performance.now()
|
||||
await ui2.checkAll()
|
||||
const warmDur2 = performance.now() - warmStart2
|
||||
console.log(`Second check (cache hit): ${warmDur2.toFixed(1)}ms`)
|
||||
|
||||
// Phase 6: validate() only
|
||||
const ui3 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui3.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i + 1}]`)
|
||||
}
|
||||
const valStart = performance.now()
|
||||
ui3.validate()
|
||||
console.log(`validate() only: ${(performance.now() - valStart).toFixed(2)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,167 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
// Build a world with 100 elements arranged in a grid
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Evaluation Allocation Benchmark ===\n')
|
||||
|
||||
for (const n of [10, 50, 100]) {
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timesTrace = []
|
||||
const timesNoTrace = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start1 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: true } })
|
||||
timesTrace.push(performance.now() - start1)
|
||||
|
||||
const start2 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
timesNoTrace.push(performance.now() - start2)
|
||||
}
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
const withTrace = mean(timesTrace)
|
||||
const withoutTrace = mean(timesNoTrace)
|
||||
console.log(`${n}×${n} pairs (${n*n} evaluations):`)
|
||||
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
|
||||
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
|
||||
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
// Run many iterations for V8 profiling
|
||||
const n = 100
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
}
|
||||
|
||||
console.log('Evaluation profiling complete')
|
||||
@@ -0,0 +1,79 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-breakdown-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Extraction Pipeline Breakdown ===\n')
|
||||
|
||||
// Profile CDP session creation
|
||||
const sessionStart = performance.now()
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('DOM.enable')
|
||||
console.log(`CDP session + DOM.enable: ${(performance.now() - sessionStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile selector resolution (50 selectors)
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const doc = await session.send('DOM.getDocument')
|
||||
const rootNodeId = doc.root.nodeId
|
||||
const resolveStart = performance.now()
|
||||
for (const sel of selectors) {
|
||||
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
|
||||
}
|
||||
console.log(`Resolve 50 selectors (DOM.querySelectorAll): ${(performance.now() - resolveStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile box model extraction (10 elements)
|
||||
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: '[data-testid="item-0"]' })
|
||||
const boxModelStart = performance.now()
|
||||
for (const nodeId of nodeIds.slice(0, 10)) {
|
||||
try {
|
||||
await session.send('DOM.getBoxModel', { nodeId })
|
||||
} catch {}
|
||||
}
|
||||
console.log(`Get box model for 10 elements: ${(performance.now() - boxModelStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile full CDP extraction via Imhotep
|
||||
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
|
||||
const fullStart = performance.now()
|
||||
const sm = createSessionManager(page)
|
||||
const extractor = new CDPExtractor(sm)
|
||||
const result = await extractor.extract({
|
||||
requestId: 'bench',
|
||||
sceneTarget: { kind: 'page', pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.slice(0, 10).map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: true, styles: false, fragments: false }
|
||||
})
|
||||
const fullDur = performance.now() - fullStart
|
||||
console.log(`\nFull CDPExtractor (10 subjects): ${fullDur.toFixed(1)}ms`)
|
||||
console.log(`Status: ${result.status}, Snapshots: ${result.snapshots?.length ?? 0}`)
|
||||
|
||||
// Profile world adaptation
|
||||
if (result.snapshots?.length > 0) {
|
||||
const { adaptSnapshotToCanonical } = await import('imhotep-cdp')
|
||||
const { adaptCanonicalWorldToSolver } = await import('imhotep-core')
|
||||
|
||||
const adaptStart = performance.now()
|
||||
const canonical = adaptSnapshotToCanonical(result.snapshots[0])
|
||||
const solver = adaptCanonicalWorldToSolver(canonical)
|
||||
console.log(`World adaptation (canonical -> solver): ${(performance.now() - adaptStart).toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,45 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-cold-extract-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Cold Extraction (cache cleared each run) ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const ui = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 cold runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,36 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-hotspot-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Run extraction 50 times for profiling
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const ui = await imhotep(page)
|
||||
for (let j = 0; j < 10; j++) {
|
||||
ui.expect(`[data-testid="item-${j}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui.checkAll()
|
||||
}
|
||||
|
||||
console.log('Extraction profiling complete')
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,47 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 200}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 20) * 60}px;top:${Math.floor(i / 20) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
// Warm up
|
||||
const ui0 = await imhotep(page)
|
||||
ui0.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
|
||||
await ui0.checkAll()
|
||||
|
||||
// Profile extraction + evaluation
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
const ui = await imhotep(page)
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf('[data-testid=item-1]')
|
||||
for (let i = 2; i < 20; i++) {
|
||||
ui.expect('[data-testid=item-0]').to.be.leftOf(`[data-testid=item-${i}]`)
|
||||
}
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Extraction + 20 assertions: ${mean.toFixed(1)}ms (avg of 5)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(1)}ms, Max: ${Math.max(...times).toFixed(1)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,71 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-extract-prof2-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Extraction Phase Breakdown ===\n')
|
||||
|
||||
// Profile with varying number of selectors
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const selectors = Array.from({length: count}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
const ui = await imhotep(page)
|
||||
for (const sel of selectors) {
|
||||
ui.expect(sel).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} selectors: ${mean.toFixed(1)}ms (3 runs)`)
|
||||
}
|
||||
|
||||
// Now profile cache serialization separately
|
||||
console.log('\n=== Cache Serialization Profile ===')
|
||||
const { createEmptyWorld, serializeGeometryWorld } = await import('imhotep-core')
|
||||
|
||||
for (const count of [100, 500, 1000, 5000]) {
|
||||
const world = createEmptyWorld('s', 'sn')
|
||||
world.subjects.ids = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({length: count}, (_, i) => i + 10))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({length: count}, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({length: count}, (_, i) => i + 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({length: count}, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({length: count}, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({length: count}, (_, i) => i * 10 + 50))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({length: count}, () => 50))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
serializeGeometryWorld(world)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
const json = serializeGeometryWorld(world)
|
||||
console.log(`${count} elements: ${mean.toFixed(2)}ms, ${(json.length / 1024).toFixed(1)}KB JSON`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,54 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-fact-plan-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 10}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const selectors = Array.from({length: 10}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
const { CDPExtractor, createSessionManager } = await import('imhotep-cdp')
|
||||
const sm = createSessionManager(page)
|
||||
|
||||
// Full extraction (all facts)
|
||||
const fullStart = performance.now()
|
||||
const fullResult = await new CDPExtractor(sm).extract({
|
||||
requestId: 'full',
|
||||
sceneTarget: { pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: true, styles: true, fragments: true }
|
||||
})
|
||||
const fullDur = performance.now() - fullStart
|
||||
console.log(`Full extraction (all facts): ${fullDur.toFixed(1)}ms`)
|
||||
|
||||
// Geometry only
|
||||
const geoStart = performance.now()
|
||||
const geoResult = await new CDPExtractor(sm).extract({
|
||||
requestId: 'geo',
|
||||
sceneTarget: { pageRef: page, url: page.url() },
|
||||
env: { viewportWidth: 1280, viewportHeight: 800, colorScheme: 'light', pointer: 'fine' },
|
||||
subjects: selectors.map((s, i) => ({ id: `subj_${i}`, selector: s })),
|
||||
requiredFacts: { geometry: true, topology: false, styles: false, fragments: false }
|
||||
})
|
||||
const geoDur = performance.now() - geoStart
|
||||
console.log(`Geometry only: ${geoDur.toFixed(1)}ms`)
|
||||
console.log(`Savings: ${((1 - geoDur/fullDur) * 100).toFixed(0)}%`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,78 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-final-bench-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `
|
||||
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Final Comprehensive Benchmark ===\n')
|
||||
|
||||
// Cold extraction with varying assertion counts
|
||||
console.log('Cold extraction (cache cleared):')
|
||||
for (const count of [1, 10, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(` ${count} assertions: ${mean.toFixed(1)}ms`)
|
||||
}
|
||||
|
||||
// Cache hit performance
|
||||
console.log('\nCache hit (warm):')
|
||||
const ui2 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui2.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
await ui2.checkAll() // warm up cache
|
||||
|
||||
const warmTimes = []
|
||||
for (let run = 0; run < 10; run++) {
|
||||
const start = performance.now()
|
||||
await ui2.checkAll()
|
||||
warmTimes.push(performance.now() - start)
|
||||
}
|
||||
const warmMean = warmTimes.reduce((a,b) => a+b, 0) / warmTimes.length
|
||||
console.log(` 50 assertions: ${warmMean.toFixed(2)}ms`)
|
||||
|
||||
// validate() only
|
||||
console.log('\nValidation only (no browser):')
|
||||
const ui3 = await imhotep(page)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
ui3.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
const validateTimes = []
|
||||
for (let run = 0; run < 100; run++) {
|
||||
const start = performance.now()
|
||||
ui3.validate()
|
||||
validateTimes.push(performance.now() - start)
|
||||
}
|
||||
const validateMean = validateTimes.reduce((a,b) => a+b, 0) / validateTimes.length
|
||||
console.log(` 50 assertions: ${validateMean.toFixed(2)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,308 @@
|
||||
// fol-benchmark.ts - Benchmark deterministic first-order logic evaluation.
|
||||
// Measures forall/exists performance over finite domains.
|
||||
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
clearPredicateRegistry,
|
||||
} from 'imhotep-solver';
|
||||
|
||||
import type {
|
||||
GeometryWorld,
|
||||
} from 'imhotep-solver';
|
||||
|
||||
interface DomainValue {
|
||||
domainId: string;
|
||||
subjectIds: Uint32Array;
|
||||
provenance: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// World Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeLinearWorld(count: number): GeometryWorld {
|
||||
const ids = Array.from({ length: count }, (_, i) => i + 1);
|
||||
const boxIds = ids.map((id) => id * 100);
|
||||
|
||||
// Place elements in a horizontal row, each 50px wide with 10px gap
|
||||
const borderLeft = ids.map((_, i) => i * 60);
|
||||
const borderTop = ids.map(() => 0);
|
||||
const borderRight = borderLeft.map((l) => l + 50);
|
||||
const borderBottom = ids.map(() => 50);
|
||||
|
||||
return {
|
||||
sceneId: `linear_${count}`,
|
||||
snapshotId: 'snap_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids,
|
||||
domNodeId: ids.map((id) => id * 10),
|
||||
subjectKind: ids.map(() => 1),
|
||||
primaryBoxId: boxIds,
|
||||
firstFragmentId: ids.map(() => 0),
|
||||
fragmentCount: ids.map(() => 0),
|
||||
},
|
||||
dom: {
|
||||
nodeId: ids.map((id) => id * 10),
|
||||
parentNodeId: ids.map(() => 0),
|
||||
childCount: ids.map(() => 0),
|
||||
tagNameStringId: ids.map(() => 0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: boxIds,
|
||||
subjectId: ids,
|
||||
frameId: ids.map(() => 1),
|
||||
borderLeft,
|
||||
borderTop,
|
||||
borderRight,
|
||||
borderBottom,
|
||||
paddingLeft: ids.map(() => 0),
|
||||
paddingTop: ids.map(() => 0),
|
||||
paddingRight: ids.map(() => 0),
|
||||
paddingBottom: ids.map(() => 0),
|
||||
contentLeft: ids.map(() => 0),
|
||||
contentTop: ids.map(() => 0),
|
||||
contentRight: ids.map(() => 0),
|
||||
contentBottom: ids.map(() => 0),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: boxIds,
|
||||
subjectId: ids,
|
||||
frameId: ids.map(() => 1),
|
||||
borderLeft,
|
||||
borderTop,
|
||||
borderRight,
|
||||
borderBottom,
|
||||
paddingLeft: ids.map(() => 0),
|
||||
paddingTop: ids.map(() => 0),
|
||||
paddingRight: ids.map(() => 0),
|
||||
paddingBottom: ids.map(() => 0),
|
||||
contentLeft: ids.map(() => 0),
|
||||
contentTop: ids.map(() => 0),
|
||||
contentRight: ids.map(() => 0),
|
||||
contentBottom: ids.map(() => 0),
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
rects: { rectId: [], left: [], top: [], right: [], bottom: [] },
|
||||
topology: {
|
||||
containingBlockOf: ids.map(() => 0),
|
||||
nearestPositionedAncestorOf: ids.map(() => 0),
|
||||
scrollContainerOf: ids.map(() => 0),
|
||||
stackingContextOf: ids.map(() => 0),
|
||||
formattingContextOf: ids.map(() => 0),
|
||||
clippingRootOf: ids.map(() => 0),
|
||||
paintOrderBucket: ids.map(() => 0),
|
||||
paintOrderIndex: ids.map(() => 0),
|
||||
},
|
||||
scroll: {
|
||||
containerId: [], scrollLeft: [], scrollTop: [],
|
||||
scrollWidth: [], scrollHeight: [], clientWidth: [], clientHeight: []
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [], subjectId: [], clipKind: [],
|
||||
clipLeft: [], clipTop: [], clipRight: [], clipBottom: [], parentClipNodeId: []
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [], isRendered: [], isVisible: [], visibleArea: [], clippedArea: []
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolver Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResolver(count: number) {
|
||||
return {
|
||||
resolve(domain: any): DomainValue | undefined {
|
||||
const key = domain.selector ?? domain.domain;
|
||||
if (key === '.el') {
|
||||
return {
|
||||
domainId: 'dom_el',
|
||||
subjectIds: new Uint32Array(Array.from({ length: count }, (_, i) => i + 1)),
|
||||
provenance: 'elements(.el)',
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formula Factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function forallFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [{ type: 'VariableRef', name: 'x' }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function existsFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'atLeast',
|
||||
args: [{ type: 'VariableRef', name: 'x' }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function nestedFormula(): any {
|
||||
return {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['x'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'exists',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['y'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.el' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [{ type: 'VariableRef', name: 'x' }, { type: 'VariableRef', name: 'y' }]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runFolBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
clearPredicateRegistry();
|
||||
registerDefaultPredicates();
|
||||
|
||||
const suite = new BenchmarkSuite('fol-evaluation', {
|
||||
defaults: { warmupRuns: 1, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// forall over 100 elements
|
||||
suite.add('forall-100', () => {
|
||||
const world = makeLinearWorld(100);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(100),
|
||||
});
|
||||
});
|
||||
|
||||
// forall over 1,000 elements
|
||||
suite.add('forall-1000', () => {
|
||||
const world = makeLinearWorld(1000);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(1000),
|
||||
});
|
||||
});
|
||||
|
||||
// forall over 10,000 elements
|
||||
suite.add('forall-10000', () => {
|
||||
const world = makeLinearWorld(10000);
|
||||
evaluateLogic({
|
||||
formula: forallFormula(),
|
||||
world,
|
||||
resolver: makeResolver(10000),
|
||||
});
|
||||
});
|
||||
|
||||
// exists with early exit (first element always matches)
|
||||
suite.add('exists-early-exit-10000', () => {
|
||||
const world = makeLinearWorld(10000);
|
||||
evaluateLogic({
|
||||
formula: existsFormula(),
|
||||
world,
|
||||
resolver: makeResolver(10000),
|
||||
});
|
||||
});
|
||||
|
||||
// nested quantifiers (forall + exists) over 100 elements
|
||||
suite.add('nested-quantifiers-100', () => {
|
||||
const world = makeLinearWorld(100);
|
||||
evaluateLogic({
|
||||
formula: nestedFormula(),
|
||||
world,
|
||||
resolver: makeResolver(100),
|
||||
});
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runFolBenchmarks().then((results) => {
|
||||
console.log('\n=== FOL Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
|
||||
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { imhotep } from 'imhotep'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-full-pipeline-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
console.log('=== Full Pipeline Benchmark ===\n')
|
||||
|
||||
for (const count of [1, 5, 10, 25, 50]) {
|
||||
const ui = await imhotep(page)
|
||||
for (let i = 0; i < count; i++) {
|
||||
ui.expect(`[data-testid="item-${i}"]`).to.be.leftOf('[data-testid="item-0"]')
|
||||
}
|
||||
|
||||
const times = []
|
||||
for (let run = 0; run < 5; run++) {
|
||||
await ui.clearCache()
|
||||
const start = performance.now()
|
||||
await ui.checkAll()
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`${count} assertions (cold): ${mean.toFixed(1)}ms (5 runs)`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,83 @@
|
||||
// index.ts - Package exports for imhotep-bench
|
||||
// Benchmarking and performance testing harness for Imhotep.
|
||||
|
||||
export {
|
||||
type PerformanceBudget,
|
||||
type PerformanceProfile,
|
||||
type BudgetCheck,
|
||||
DEFAULT_PROFILES,
|
||||
checkBudget,
|
||||
checkAllBudgets,
|
||||
} from './profiles.js'
|
||||
|
||||
export {
|
||||
type CacheKey,
|
||||
type CacheEntry,
|
||||
type Cache,
|
||||
MemoryCache,
|
||||
CompilationCache,
|
||||
ExtractionCache,
|
||||
makeCompilationCacheKey,
|
||||
makeExtractionCacheKey,
|
||||
} from './cache.js'
|
||||
|
||||
export {
|
||||
type WorkerTask,
|
||||
type WorkerPoolOptions,
|
||||
type WorkerPoolMetrics,
|
||||
WorkerPool,
|
||||
type PromiseTask,
|
||||
type PromisePoolOptions,
|
||||
PromisePool,
|
||||
} from './parallel.js'
|
||||
|
||||
export {
|
||||
type BenchmarkOptions,
|
||||
type BenchmarkResult,
|
||||
type RunMeasurement,
|
||||
type BenchmarkSuiteOptions,
|
||||
type BenchmarkSuiteResult,
|
||||
profileRun,
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
} from './benchmark.js'
|
||||
|
||||
export {
|
||||
type ProfileEntry,
|
||||
ProfileSession,
|
||||
type CompilationProfilerOptions,
|
||||
type ExtractionProfilerOptions,
|
||||
type SolvingProfilerOptions,
|
||||
type WorldCreationProfilerOptions,
|
||||
type FrameResolutionProfilerOptions,
|
||||
profileCompilation,
|
||||
profileExtraction,
|
||||
profileSolving,
|
||||
profileWorldCreation,
|
||||
profileFrameResolution,
|
||||
type RegressionResult,
|
||||
detectRegression,
|
||||
formatProfileReport,
|
||||
} from './profile.js'
|
||||
|
||||
export {
|
||||
type MemoCache,
|
||||
LruMemoCache,
|
||||
memoize,
|
||||
type StringInterner,
|
||||
FastStringInterner,
|
||||
type ClauseDescriptor,
|
||||
type ClauseResult,
|
||||
type ClauseEvaluator,
|
||||
batchEvaluateClauses,
|
||||
type FrameResolver,
|
||||
createLazyFrameResolver,
|
||||
type FlatTable,
|
||||
buildIdIndex,
|
||||
buildSubjectIndex,
|
||||
binarySearchUint32,
|
||||
type TimedFunction,
|
||||
timeFunction,
|
||||
type BufferPool,
|
||||
Float64BufferPool,
|
||||
} from './optimize.js'
|
||||
@@ -0,0 +1,373 @@
|
||||
// optimize.ts - Optimization helpers for Imhotep hot paths
|
||||
// Provides memoization, batching, string interning, and lazy evaluation utilities.
|
||||
// All helpers use dependency injection (no optional imports or global state).
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoization — cache function results by serialized key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MemoCache<K, V> {
|
||||
get(key: K): V | undefined
|
||||
set(key: K, value: V): void
|
||||
has(key: K): boolean
|
||||
clear(): void
|
||||
}
|
||||
|
||||
/** Simple Map-backed memo cache with size limit and LRU eviction. */
|
||||
export class LruMemoCache<K, V> implements MemoCache<K, V> {
|
||||
private cache = new Map<K, V>()
|
||||
|
||||
constructor(private maxSize: number = 1024) {}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
const value = this.cache.get(key)
|
||||
if (value !== undefined) {
|
||||
// Touch entry for LRU ordering
|
||||
this.cache.delete(key)
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key)
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
// Evict oldest (first inserted)
|
||||
const firstKey = this.cache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoize a pure function using an injected cache.
|
||||
* The keyFn serializes arguments; the cache stores results.
|
||||
*/
|
||||
export function memoize<A extends unknown[], R>(
|
||||
fn: (...args: A) => R,
|
||||
keyFn: (...args: A) => string,
|
||||
cache: MemoCache<string, R>
|
||||
): (...args: A) => R {
|
||||
return (...args: A): R => {
|
||||
const key = keyFn(...args)
|
||||
const cached = cache.get(key)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const result = fn(...args)
|
||||
cache.set(key, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String interning — cache selector strings to avoid repeated hashing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StringInterner {
|
||||
intern(value: string): number
|
||||
resolve(index: number): string
|
||||
size(): number
|
||||
}
|
||||
|
||||
/** Fast string interner backed by a Map for O(1) lookups. */
|
||||
export class FastStringInterner implements StringInterner {
|
||||
private indexByValue = new Map<string, number>()
|
||||
private values: string[] = []
|
||||
|
||||
intern(value: string): number {
|
||||
const existing = this.indexByValue.get(value)
|
||||
if (existing !== undefined) {
|
||||
return existing
|
||||
}
|
||||
const idx = this.values.length
|
||||
this.values.push(value)
|
||||
this.indexByValue.set(value, idx)
|
||||
return idx
|
||||
}
|
||||
|
||||
resolve(index: number): string {
|
||||
return this.values[index] ?? ''
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.values.length
|
||||
}
|
||||
|
||||
/** Export the string table as a plain array (for world construction). */
|
||||
exportTable(): string[] {
|
||||
return this.values.slice()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch clause evaluation — evaluate multiple clauses in a single pass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ClauseDescriptor {
|
||||
clauseId: string
|
||||
clauseKind: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface ClauseResult {
|
||||
clauseId: string
|
||||
status: string
|
||||
truth: string
|
||||
}
|
||||
|
||||
export type ClauseEvaluator = (clause: ClauseDescriptor) => ClauseResult
|
||||
|
||||
/**
|
||||
* Batch-evaluate clauses by grouping them by kind.
|
||||
* Returns results in the same order as input clauses.
|
||||
* The evaluatorFactory is injected so callers can supply their own registry.
|
||||
*/
|
||||
export function batchEvaluateClauses(
|
||||
clauses: ClauseDescriptor[],
|
||||
evaluatorFactory: (kind: string, version: number) => ClauseEvaluator | undefined
|
||||
): ClauseResult[] {
|
||||
// Group clause indices by kind+version to minimize evaluator lookups
|
||||
const groups = new Map<string, number[]>()
|
||||
for (let i = 0; i < clauses.length; i++) {
|
||||
const c = clauses[i]
|
||||
const key = `${c.clauseKind}@v${c.version}`
|
||||
const indices = groups.get(key)
|
||||
if (indices) {
|
||||
indices.push(i)
|
||||
} else {
|
||||
groups.set(key, [i])
|
||||
}
|
||||
}
|
||||
|
||||
const results: ClauseResult[] = new Array(clauses.length)
|
||||
|
||||
for (const [key, indices] of groups) {
|
||||
const first = clauses[indices[0]]
|
||||
const evaluator = evaluatorFactory(first.clauseKind, first.version)
|
||||
if (!evaluator) {
|
||||
// Fill missing evaluators with error results
|
||||
for (const idx of indices) {
|
||||
results[idx] = {
|
||||
clauseId: clauses[idx].clauseId,
|
||||
status: 'error',
|
||||
truth: 'indeterminate',
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for (const idx of indices) {
|
||||
results[idx] = evaluator(clauses[idx])
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy frame resolution — only resolve frames when needed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FrameResolver {
|
||||
resolve(frameId: number): unknown
|
||||
invalidate(frameId: number): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy frame resolver that caches resolved transforms.
|
||||
* The resolveFn is injected so callers can supply their own coordinate system.
|
||||
*/
|
||||
export function createLazyFrameResolver(
|
||||
resolveFn: (frameId: number) => unknown,
|
||||
cache: MemoCache<number, unknown>
|
||||
): FrameResolver {
|
||||
return {
|
||||
resolve(frameId: number): unknown {
|
||||
const cached = cache.get(frameId)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
const result = resolveFn(frameId)
|
||||
cache.set(frameId, result)
|
||||
return result
|
||||
},
|
||||
invalidate(frameId: number): void {
|
||||
// No-op for Map-based cache; real implementation may support deletion
|
||||
void frameId
|
||||
},
|
||||
clear(): void {
|
||||
cache.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index pre-computation — build lookup maps during normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FlatTable {
|
||||
ids: Uint32Array
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bidirectional index for a flat id table.
|
||||
* Returns a Map<id, index> for O(1) lookups.
|
||||
*/
|
||||
export function buildIdIndex(table: FlatTable): Map<number, number> {
|
||||
const map = new Map<number, number>()
|
||||
const ids = table.ids
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
map.set(ids[i], i)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-value index (one-to-many) from a subject-id column.
|
||||
* Returns a Map<subjectId, index[]> for fast reverse lookups.
|
||||
*/
|
||||
export function buildSubjectIndex(subjectIdColumn: Uint32Array): Map<number, number[]> {
|
||||
const map = new Map<number, number[]>()
|
||||
for (let i = 0; i < subjectIdColumn.length; i++) {
|
||||
const sid = subjectIdColumn[i]
|
||||
const arr = map.get(sid)
|
||||
if (arr) {
|
||||
arr.push(i)
|
||||
} else {
|
||||
map.set(sid, [i])
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Array scan replacement — binary search on sorted arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Binary search for a value in a sorted Uint32Array.
|
||||
* Returns the index if found, otherwise -1.
|
||||
* This replaces linear scans when data is pre-sorted.
|
||||
*/
|
||||
export function binarySearchUint32(arr: Uint32Array, value: number): number {
|
||||
let lo = 0
|
||||
let hi = arr.length - 1
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const midVal = arr[mid]
|
||||
if (midVal === value) {
|
||||
return mid
|
||||
} else if (midVal < value) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Performance timing decorator — wrap functions with measurement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TimedFunction<T extends unknown[], R> {
|
||||
(...args: T): R
|
||||
readonly totalCalls: number
|
||||
readonly totalDurationMs: number
|
||||
readonly meanDurationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function to measure its execution time.
|
||||
* Returns a new function with attached statistics.
|
||||
*/
|
||||
export function timeFunction<T extends unknown[], R>(
|
||||
fn: (...args: T) => R,
|
||||
label?: string
|
||||
): TimedFunction<T, R> {
|
||||
let calls = 0
|
||||
let duration = 0
|
||||
|
||||
const wrapped = (...args: T): R => {
|
||||
const start = performance.now()
|
||||
try {
|
||||
return fn(...args)
|
||||
} finally {
|
||||
duration += performance.now() - start
|
||||
calls++
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(wrapped, 'totalCalls', {
|
||||
get: () => calls,
|
||||
})
|
||||
Object.defineProperty(wrapped, 'totalDurationMs', {
|
||||
get: () => duration,
|
||||
})
|
||||
Object.defineProperty(wrapped, 'meanDurationMs', {
|
||||
get: () => (calls > 0 ? duration / calls : 0),
|
||||
})
|
||||
|
||||
return wrapped as TimedFunction<T, R>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffer pooling — reuse typed arrays to reduce GC pressure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BufferPool<T> {
|
||||
acquire(minSize: number): T
|
||||
release(buf: T): void
|
||||
}
|
||||
|
||||
/** Simple pool for Float64Array buffers with power-of-2 sizing. */
|
||||
export class Float64BufferPool implements BufferPool<Float64Array> {
|
||||
private pool: Float64Array[] = []
|
||||
private maxPoolSize: number
|
||||
|
||||
constructor(maxPoolSize = 16) {
|
||||
this.maxPoolSize = maxPoolSize
|
||||
}
|
||||
|
||||
acquire(minSize: number): Float64Array {
|
||||
const size = nextPowerOf2(minSize)
|
||||
// Find a buffer that is large enough
|
||||
for (let i = 0; i < this.pool.length; i++) {
|
||||
if (this.pool[i].length >= size) {
|
||||
return this.pool.splice(i, 1)[0]
|
||||
}
|
||||
}
|
||||
return new Float64Array(size)
|
||||
}
|
||||
|
||||
release(buf: Float64Array): void {
|
||||
if (this.pool.length < this.maxPoolSize) {
|
||||
this.pool.push(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function nextPowerOf2(n: number): number {
|
||||
if (n <= 1) return 1
|
||||
let p = 1
|
||||
while (p < n) {
|
||||
p <<= 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// parallel.ts - Worker parallelism utilities for Imhotep bench harness
|
||||
// Supports both worker_thread pools and lightweight Promise-based pools.
|
||||
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
export interface WorkerTask<TInput, TOutput> {
|
||||
/** Unique task identifier */
|
||||
id: string
|
||||
/** Task payload */
|
||||
input: TInput
|
||||
/** Worker script path for worker_thread execution */
|
||||
workerScript: string
|
||||
}
|
||||
|
||||
export interface WorkerPoolOptions {
|
||||
/** Maximum concurrent workers */
|
||||
maxWorkers: number
|
||||
/** Per-task timeout in milliseconds */
|
||||
taskTimeoutMs: number
|
||||
}
|
||||
|
||||
export interface WorkerPoolMetrics {
|
||||
queued: number
|
||||
running: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
interface PendingTask<TInput, TOutput> {
|
||||
task: WorkerTask<TInput, TOutput>
|
||||
resolve: (value: TOutput) => void
|
||||
reject: (reason: Error) => void
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/**
|
||||
* WorkerPool manages a fixed number of worker threads.
|
||||
* Tasks are queued and executed as workers become available.
|
||||
*/
|
||||
export class WorkerPool extends EventEmitter {
|
||||
private queue: Array<PendingTask<unknown, unknown>> = []
|
||||
private activeTasks = new Map<string, PendingTask<unknown, unknown>>()
|
||||
private metrics = { queued: 0, running: 0, completed: 0, failed: 0 }
|
||||
private shuttingDown = false
|
||||
|
||||
constructor(private options: WorkerPoolOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task on an available worker.
|
||||
* Returns a promise that resolves with the worker output.
|
||||
*/
|
||||
execute<TInput, TOutput>(
|
||||
task: WorkerTask<TInput, TOutput>
|
||||
): Promise<TOutput> {
|
||||
if (this.shuttingDown) {
|
||||
return Promise.reject(new Error('WorkerPool is shutting down'))
|
||||
}
|
||||
|
||||
return new Promise<TOutput>((resolve, reject) => {
|
||||
const pending: PendingTask<TInput, TOutput> = {
|
||||
task,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject: reject as (reason: Error) => void,
|
||||
}
|
||||
this.queue.push(pending as PendingTask<unknown, unknown>)
|
||||
this.metrics.queued++
|
||||
this.emit('queued', task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
|
||||
/** Execute multiple tasks in parallel, returning results in input order. */
|
||||
async executeAll<TInput, TOutput>(
|
||||
tasks: WorkerTask<TInput, TOutput>[]
|
||||
): Promise<TOutput[]> {
|
||||
const promises = tasks.map(t => this.execute(t))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
/** Current pool metrics snapshot */
|
||||
getMetrics(): WorkerPoolMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
/** Terminate all workers and clear the queue */
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true
|
||||
|
||||
for (const pending of this.queue) {
|
||||
pending.reject(new Error('WorkerPool shutdown'))
|
||||
}
|
||||
this.queue = []
|
||||
|
||||
for (const [, pending] of this.activeTasks) {
|
||||
if (pending.timer) clearTimeout(pending.timer)
|
||||
pending.reject(new Error('WorkerPool shutdown'))
|
||||
}
|
||||
this.activeTasks.clear()
|
||||
}
|
||||
|
||||
private _pump(): void {
|
||||
if (this.queue.length === 0) return
|
||||
if (this.activeTasks.size >= this.options.maxWorkers) return
|
||||
|
||||
const pending = this.queue.shift()!
|
||||
this.metrics.queued--
|
||||
this.metrics.running++
|
||||
|
||||
const taskId = pending.task.id
|
||||
this.activeTasks.set(taskId, pending)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pending.reject(
|
||||
new Error(
|
||||
`Task ${pending.task.id} timed out after ${this.options.taskTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
this.metrics.failed++
|
||||
this.emit('timeout', pending.task.id)
|
||||
this._pump()
|
||||
}, this.options.taskTimeoutMs)
|
||||
|
||||
pending.timer = timer
|
||||
|
||||
const worker = new Worker(pending.task.workerScript)
|
||||
|
||||
worker.once(
|
||||
'message',
|
||||
(message: {
|
||||
success: boolean
|
||||
result?: unknown
|
||||
error?: string
|
||||
}) => {
|
||||
clearTimeout(timer)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
void worker.terminate()
|
||||
|
||||
if (message.success) {
|
||||
pending.resolve(message.result)
|
||||
this.metrics.completed++
|
||||
this.emit('completed', pending.task.id)
|
||||
} else {
|
||||
pending.reject(new Error(message.error || 'Worker task failed'))
|
||||
this.metrics.failed++
|
||||
this.emit('failed', pending.task.id)
|
||||
}
|
||||
|
||||
this._pump()
|
||||
}
|
||||
)
|
||||
|
||||
worker.once('error', (err: Error) => {
|
||||
clearTimeout(timer)
|
||||
this.activeTasks.delete(taskId)
|
||||
this.metrics.running--
|
||||
this.metrics.failed++
|
||||
void worker.terminate()
|
||||
pending.reject(err)
|
||||
this.emit('failed', pending.task.id)
|
||||
this._pump()
|
||||
})
|
||||
|
||||
worker.postMessage({ id: pending.task.id, input: pending.task.input })
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromiseTask<TInput, TOutput> {
|
||||
id: string
|
||||
input: TInput
|
||||
fn: (input: TInput) => TOutput | Promise<TOutput>
|
||||
}
|
||||
|
||||
export interface PromisePoolOptions {
|
||||
maxConcurrency: number
|
||||
taskTimeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* PromisePool provides lightweight parallelism without worker threads.
|
||||
* Runs async functions with controlled concurrency.
|
||||
*/
|
||||
export class PromisePool extends EventEmitter {
|
||||
private queue: Array<{
|
||||
task: PromiseTask<unknown, unknown>
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
}> = []
|
||||
private running = 0
|
||||
private metrics = { queued: 0, completed: 0, failed: 0 }
|
||||
private shuttingDown = false
|
||||
|
||||
constructor(private options: PromisePoolOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
execute<TInput, TOutput>(
|
||||
task: PromiseTask<TInput, TOutput>
|
||||
): Promise<TOutput> {
|
||||
if (this.shuttingDown) {
|
||||
return Promise.reject(new Error('PromisePool is shutting down'))
|
||||
}
|
||||
|
||||
return new Promise<TOutput>((resolve, reject) => {
|
||||
this.queue.push({
|
||||
task: task as PromiseTask<unknown, unknown>,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
this.metrics.queued++
|
||||
this.emit('queued', task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
|
||||
async executeAll<TInput, TOutput>(
|
||||
tasks: PromiseTask<TInput, TOutput>[]
|
||||
): Promise<TOutput[]> {
|
||||
const promises = tasks.map(t => this.execute(t))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
getMetrics(): WorkerPoolMetrics {
|
||||
return {
|
||||
queued: this.metrics.queued,
|
||||
running: this.running,
|
||||
completed: this.metrics.completed,
|
||||
failed: this.metrics.failed,
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true
|
||||
for (const item of this.queue) {
|
||||
item.reject(new Error('PromisePool shutdown'))
|
||||
}
|
||||
this.queue = []
|
||||
}
|
||||
|
||||
private _pump(): void {
|
||||
if (this.queue.length === 0) return
|
||||
if (this.running >= this.options.maxConcurrency) return
|
||||
|
||||
const item = this.queue.shift()!
|
||||
this.metrics.queued--
|
||||
this.running++
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.running--
|
||||
this.metrics.failed++
|
||||
item.reject(
|
||||
new Error(
|
||||
`Task ${item.task.id} timed out after ${this.options.taskTimeoutMs}ms`
|
||||
)
|
||||
)
|
||||
this.emit('timeout', item.task.id)
|
||||
this._pump()
|
||||
}, this.options.taskTimeoutMs)
|
||||
|
||||
Promise.resolve(item.task.fn(item.task.input))
|
||||
.then(result => {
|
||||
clearTimeout(timer)
|
||||
this.running--
|
||||
this.metrics.completed++
|
||||
item.resolve(result)
|
||||
this.emit('completed', item.task.id)
|
||||
this._pump()
|
||||
})
|
||||
.catch(err => {
|
||||
clearTimeout(timer)
|
||||
this.running--
|
||||
this.metrics.failed++
|
||||
item.reject(err instanceof Error ? err : new Error(String(err)))
|
||||
this.emit('failed', item.task.id)
|
||||
this._pump()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
// Profile dense spec parsing
|
||||
const spec = `
|
||||
in viewport:
|
||||
'.header' leftOf '.content' gap 16px
|
||||
'.sidebar' above '.main' gap 8px
|
||||
'.footer' below '.main' gap 24px
|
||||
'.button' atLeast 44px wide
|
||||
'.card' inside '.container'
|
||||
'.modal' centeredWithin '.viewport' tolerance 2px
|
||||
`
|
||||
|
||||
async function profileParse() {
|
||||
const { parseSpec } = await import('imhotep-dsl')
|
||||
const times = []
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const start = performance.now()
|
||||
parseSpec(spec)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Parse 6-clause spec: ${mean.toFixed(3)}ms (avg of 1000)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(3)}ms, Max: ${Math.max(...times).toFixed(3)}ms`)
|
||||
}
|
||||
|
||||
async function profileSerialize() {
|
||||
const { createEmptyWorld } = await import('imhotep-core')
|
||||
const { serializeGeometryWorld } = await import('imhotep-core')
|
||||
|
||||
const world = createEmptyWorld('scene', 'snap')
|
||||
// Populate with 1000 elements
|
||||
const n = 1000
|
||||
world.subjects.ids = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
|
||||
world.subjects.domNodeId = new Uint32Array(Array.from({length: n}, (_, i) => i + 10))
|
||||
world.boxes.boxId = new Uint32Array(Array.from({length: n}, (_, i) => i + 100))
|
||||
world.boxes.subjectId = new Uint32Array(Array.from({length: n}, (_, i) => i + 1))
|
||||
world.boxes.borderLeft = new Float64Array(Array.from({length: n}, (_, i) => i * 10))
|
||||
world.boxes.borderTop = new Float64Array(Array.from({length: n}, () => 0))
|
||||
world.boxes.borderRight = new Float64Array(Array.from({length: n}, (_, i) => i * 10 + 50))
|
||||
world.boxes.borderBottom = new Float64Array(Array.from({length: n}, () => 50))
|
||||
|
||||
const times = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start = performance.now()
|
||||
serializeGeometryWorld(world)
|
||||
times.push(performance.now() - start)
|
||||
}
|
||||
const mean = times.reduce((a,b) => a+b, 0) / times.length
|
||||
console.log(`Serialize 1000-element world: ${mean.toFixed(2)}ms (avg of 100)`)
|
||||
console.log(`Min: ${Math.min(...times).toFixed(2)}ms, Max: ${Math.max(...times).toFixed(2)}ms`)
|
||||
}
|
||||
|
||||
await profileParse()
|
||||
await profileSerialize()
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Comprehensive pooling performance benchmark.
|
||||
*
|
||||
* Measures REAL speedups from the new parallel pooling architecture
|
||||
* using actual Playwright + Imhotep code paths.
|
||||
*/
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { createRuntime } from '../../imhotep-playwright/src/runtime.js'
|
||||
import { runEnumeratedProperty } from '../../imhotep-playwright/src/property-runner.js'
|
||||
import { Semaphore } from '../../imhotep-playwright/src/semaphore.js'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-pool-bench-'))
|
||||
|
||||
// Create a simple fixture page
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="app">
|
||||
<button id="btn1">Button 1</button>
|
||||
<button id="btn2">Button 2</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'fixture.html'), html)
|
||||
|
||||
function median(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
function mean(values) {
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
async function benchmark() {
|
||||
console.log('========================================')
|
||||
console.log(' Imhotep Pooling Performance Benchmark')
|
||||
console.log('========================================\n')
|
||||
|
||||
const browser = await chromium.launch()
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 1: Page Lifecycle — Direct vs Pool
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 1: Page Creation vs Pool Borrow ---')
|
||||
|
||||
const runtime = await createRuntime(browser, {
|
||||
poolOptions: { maxPagesPerContext: 8, warmPages: 4 }
|
||||
})
|
||||
|
||||
// Warm up the pool first
|
||||
const warmPages = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
warmPages.push(await runtime.createPage())
|
||||
}
|
||||
for (const p of warmPages) {
|
||||
await runtime.releasePage(p)
|
||||
}
|
||||
|
||||
const directTimes = []
|
||||
const poolTimes = []
|
||||
const iterations = 20
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
// Direct: newContext → newPage → close
|
||||
const ctx = await browser.newContext()
|
||||
const t0 = performance.now()
|
||||
const p = await ctx.newPage()
|
||||
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await p.close()
|
||||
directTimes.push(performance.now() - t0)
|
||||
await ctx.close()
|
||||
|
||||
// Pool: borrow → goto → return
|
||||
const t1 = performance.now()
|
||||
const page = await runtime.createPage()
|
||||
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await runtime.releasePage(page)
|
||||
poolTimes.push(performance.now() - t1)
|
||||
}
|
||||
|
||||
const directMean = mean(directTimes)
|
||||
const poolMean = mean(poolTimes)
|
||||
|
||||
console.log(` Direct create/nav/close (n=${iterations}):`)
|
||||
console.log(` mean: ${directMean.toFixed(2)}ms median: ${median(directTimes).toFixed(2)}ms`)
|
||||
console.log(` Pool borrow/nav/return (n=${iterations}):`)
|
||||
console.log(` mean: ${poolMean.toFixed(2)}ms median: ${median(poolTimes).toFixed(2)}ms`)
|
||||
console.log(` Speedup: ${(directMean / poolMean).toFixed(1)}x\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 2: Context Reuse
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 2: Context Creation vs Context Pool ---')
|
||||
|
||||
const env = { viewport: { width: 1280, height: 720 } }
|
||||
const envKey = JSON.stringify(env)
|
||||
|
||||
const noPoolCtxTimes = []
|
||||
const poolCtxTimes = []
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// No pool: create fresh context
|
||||
const t0 = performance.now()
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 } })
|
||||
const p = await ctx.newPage()
|
||||
await p.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await ctx.close()
|
||||
noPoolCtxTimes.push(performance.now() - t0)
|
||||
|
||||
// Pool: acquire from pool (should reuse after first iteration)
|
||||
const t1 = performance.now()
|
||||
const ctx2 = await runtime.createContext(env)
|
||||
const p2 = await runtime.createPage(ctx2)
|
||||
await p2.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
await runtime.releasePage(p2)
|
||||
// Note: we don't close context, pool reuses it
|
||||
poolCtxTimes.push(performance.now() - t1)
|
||||
}
|
||||
|
||||
const noPoolCtxMean = mean(noPoolCtxTimes)
|
||||
const poolCtxMean = mean(poolCtxTimes)
|
||||
|
||||
console.log(` New context each time (n=10):`)
|
||||
console.log(` mean: ${noPoolCtxMean.toFixed(2)}ms median: ${median(noPoolCtxTimes).toFixed(2)}ms`)
|
||||
console.log(` Pooled context (n=10, first=cold):`)
|
||||
console.log(` mean: ${poolCtxMean.toFixed(2)}ms median: ${median(poolCtxTimes).toFixed(2)}ms`)
|
||||
console.log(` Speedup: ${(noPoolCtxMean / poolCtxMean).toFixed(1)}x\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 3: Sequential vs Parallel Enumerated Properties
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 3: Enumerated Property Runner ---')
|
||||
|
||||
// Create a simple mock renderer adapter that tracks timing
|
||||
const mockAdapter = {
|
||||
async mount(target, renderCase) {
|
||||
const page = await runtime.createPage()
|
||||
await page.playwrightPage.goto(`file:${join(tmpdir2, 'fixture.html')}`)
|
||||
return {
|
||||
caseId: renderCase.caseId,
|
||||
world: { sceneId: renderCase.caseId },
|
||||
page,
|
||||
}
|
||||
},
|
||||
async unmount(mounted) {
|
||||
await runtime.releasePage(mounted.page)
|
||||
},
|
||||
}
|
||||
|
||||
const mockFC = {
|
||||
assert: async () => ({ passed: true, numRuns: 100 }),
|
||||
record: () => ({}),
|
||||
constantFrom: (...values) => values,
|
||||
sample: () => [],
|
||||
property: () => ({}),
|
||||
asyncProperty: () => ({}),
|
||||
}
|
||||
|
||||
const domain = {
|
||||
mode: 'enumerated',
|
||||
values: Array.from({ length: 20 }, (_, i) => ({ id: i })),
|
||||
}
|
||||
|
||||
const predicate = async () => true
|
||||
|
||||
const deps = {
|
||||
rendererAdapter: mockAdapter,
|
||||
worldExtractor: { extract: async (mounted) => mounted.world },
|
||||
fc: mockFC,
|
||||
}
|
||||
|
||||
// Sequential (concurrency = 1)
|
||||
const seqTimes = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 1, failFast: true }
|
||||
)
|
||||
seqTimes.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
// Parallel (concurrency = 4)
|
||||
const par4Times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 4, failFast: true }
|
||||
)
|
||||
par4Times.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
// Parallel (concurrency = 8)
|
||||
const par8Times = []
|
||||
for (let run = 0; run < 3; run++) {
|
||||
const t0 = performance.now()
|
||||
await runEnumeratedProperty(
|
||||
{ kind: 'fixture', fixtureId: 'test.html' },
|
||||
domain,
|
||||
predicate,
|
||||
deps,
|
||||
{ concurrency: 8, failFast: true }
|
||||
)
|
||||
par8Times.push(performance.now() - t0)
|
||||
}
|
||||
|
||||
const seqMean = mean(seqTimes)
|
||||
const par4Mean = mean(par4Times)
|
||||
const par8Mean = mean(par8Times)
|
||||
|
||||
console.log(` Sequential (concurrency=1, n=20 cases):`)
|
||||
console.log(` mean: ${seqMean.toFixed(1)}ms`)
|
||||
console.log(` Parallel (concurrency=4):`)
|
||||
console.log(` mean: ${par4Mean.toFixed(1)}ms speedup: ${(seqMean / par4Mean).toFixed(1)}x`)
|
||||
console.log(` Parallel (concurrency=8):`)
|
||||
console.log(` mean: ${par8Mean.toFixed(1)}ms speedup: ${(seqMean / par8Mean).toFixed(1)}x`)
|
||||
console.log(` Theoretical max (if perfectly parallel): ${(seqMean / 20).toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Benchmark 4: Semaphore Overhead
|
||||
// ============================================================
|
||||
console.log('--- Benchmark 4: Semaphore Scheduling Overhead ---')
|
||||
|
||||
const sem = new Semaphore(4)
|
||||
const taskCount = 100
|
||||
const taskDuration = 5 // ms
|
||||
|
||||
// Sequential baseline
|
||||
const seqSemStart = performance.now()
|
||||
for (let i = 0; i < taskCount; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, taskDuration))
|
||||
}
|
||||
const seqSemTime = performance.now() - seqSemStart
|
||||
|
||||
// Parallel with semaphore
|
||||
const parSemStart = performance.now()
|
||||
await Promise.all(
|
||||
Array.from({ length: taskCount }, () =>
|
||||
sem.run(() => new Promise(resolve => setTimeout(resolve, taskDuration)))
|
||||
)
|
||||
)
|
||||
const parSemTime = performance.now() - parSemStart
|
||||
|
||||
console.log(` Sequential (${taskCount} tasks × ${taskDuration}ms): ${seqSemTime.toFixed(1)}ms`)
|
||||
console.log(` Parallel (concurrency=4): ${parSemTime.toFixed(1)}ms`)
|
||||
console.log(` Speedup: ${(seqSemTime / parSemTime).toFixed(1)}x`)
|
||||
console.log(` Theoretical max: ${(taskCount * taskDuration / 4).toFixed(1)}ms\n`)
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
await runtime.shutdown()
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
|
||||
console.log('========================================')
|
||||
console.log(' Benchmark Complete')
|
||||
console.log('========================================')
|
||||
}
|
||||
|
||||
benchmark().catch(err => {
|
||||
console.error('Benchmark failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-describe-profile-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `
|
||||
<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('DOM.enable')
|
||||
|
||||
// Get document root
|
||||
const doc = await session.send('DOM.getDocument')
|
||||
const rootId = doc.root.nodeId
|
||||
|
||||
// Profile querySelectorAll
|
||||
const queryStart = performance.now()
|
||||
const { nodeIds } = await session.send('DOM.querySelectorAll', { nodeId: rootId, selector: '.item' })
|
||||
console.log(`querySelectorAll for 50 items: ${(performance.now() - queryStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile sequential DOM.describeNode
|
||||
const seqStart = performance.now()
|
||||
for (const nodeId of nodeIds.slice(0, 10)) {
|
||||
await session.send('DOM.describeNode', { nodeId })
|
||||
}
|
||||
console.log(`Sequential describeNode (10): ${(performance.now() - seqStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile parallel DOM.describeNode
|
||||
const parStart = performance.now()
|
||||
await Promise.all(nodeIds.slice(0, 10).map(nodeId => session.send('DOM.describeNode', { nodeId })))
|
||||
console.log(`Parallel describeNode (10): ${(performance.now() - parStart).toFixed(1)}ms`)
|
||||
|
||||
// Profile all 50 sequential
|
||||
const seq50Start = performance.now()
|
||||
for (const nodeId of nodeIds) {
|
||||
await session.send('DOM.describeNode', { nodeId })
|
||||
}
|
||||
console.log(`Sequential describeNode (50): ${(performance.now() - seq50Start).toFixed(1)}ms`)
|
||||
|
||||
// Profile all 50 parallel
|
||||
const par50Start = performance.now()
|
||||
await Promise.all(nodeIds.map(nodeId => session.send('DOM.describeNode', { nodeId })))
|
||||
console.log(`Parallel describeNode (50): ${(performance.now() - par50Start).toFixed(1)}ms`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,47 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const tmpdir2 = mkdtempSync(join(tmpdir(), 'imhotep-parallel-resolve-'))
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div id="container">
|
||||
${Array.from({length: 50}, (_, i) => `<div class="item" data-testid="item-${i}" style="position:absolute;left:${(i % 10) * 60}px;top:${Math.floor(i / 10) * 60}px;width:50px;height:50px;background:#ccc;">${i}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</body></html>
|
||||
`
|
||||
writeFileSync(join(tmpdir2, 'page.html'), html)
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
await page.goto(`file:${join(tmpdir2, 'page.html')}`)
|
||||
|
||||
const { createSessionManager } = await import('imhotep-cdp')
|
||||
const sm = createSessionManager(page)
|
||||
const session = await sm.attach()
|
||||
await session.send('DOM.enable')
|
||||
const rootNodeId = await sm.getDocumentRootNodeId()
|
||||
|
||||
const selectors = Array.from({length: 50}, (_, i) => `[data-testid="item-${i}"]`)
|
||||
|
||||
// Sequential resolution
|
||||
const seqStart = performance.now()
|
||||
for (const sel of selectors) {
|
||||
await session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })
|
||||
}
|
||||
const seqDur = performance.now() - seqStart
|
||||
console.log(`Sequential querySelectorAll (50): ${seqDur.toFixed(1)}ms`)
|
||||
|
||||
// Parallel resolution
|
||||
const parStart = performance.now()
|
||||
await Promise.all(selectors.map(sel => session.send('DOM.querySelectorAll', { nodeId: rootNodeId, selector: sel })))
|
||||
const parDur = performance.now() - parStart
|
||||
console.log(`Parallel querySelectorAll (50): ${parDur.toFixed(1)}ms`)
|
||||
console.log(`Speedup: ${(seqDur / parDur).toFixed(1)}x`)
|
||||
|
||||
await browser.close()
|
||||
rmSync(tmpdir2, { recursive: true, force: true })
|
||||
@@ -0,0 +1,470 @@
|
||||
// profile.ts - Profiling utilities for Imhotep hot paths
|
||||
// Measures compilation, extraction, solving, world creation, and frame resolution.
|
||||
// Injected dependencies: performance hooks, benchmark runner, budget checker.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { runBenchmark, type BenchmarkOptions, type BenchmarkResult } from './benchmark.js'
|
||||
import { type CompilationCache, type ExtractionCache } from './cache.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile entry — single timed observation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProfileEntry {
|
||||
phase: string
|
||||
durationMs: number
|
||||
memoryDeltaBytes: number
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile session — collects multiple entries for a single benchmark run
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ProfileSession {
|
||||
private entries: ProfileEntry[] = []
|
||||
private lastTime = 0
|
||||
private lastMemory = 0
|
||||
|
||||
constructor(private sessionName: string) {
|
||||
this.lastTime = performance.now()
|
||||
this.lastMemory = process.memoryUsage().heapUsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a named phase boundary.
|
||||
* Duration is measured from the previous mark() or session start.
|
||||
*/
|
||||
mark(phase: string, metadata?: Record<string, unknown>): ProfileEntry {
|
||||
const now = performance.now()
|
||||
const memNow = process.memoryUsage().heapUsed
|
||||
const entry: ProfileEntry = {
|
||||
phase,
|
||||
durationMs: now - this.lastTime,
|
||||
memoryDeltaBytes: memNow - this.lastMemory,
|
||||
metadata,
|
||||
}
|
||||
this.entries.push(entry)
|
||||
this.lastTime = now
|
||||
this.lastMemory = memNow
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all recorded entries.
|
||||
*/
|
||||
getEntries(): ProfileEntry[] {
|
||||
return this.entries.slice()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the total duration across all phases.
|
||||
*/
|
||||
totalDurationMs(): number {
|
||||
return this.entries.reduce((sum, e) => sum + e.durationMs, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the entry for a specific phase, or undefined.
|
||||
*/
|
||||
findPhase(phase: string): ProfileEntry | undefined {
|
||||
return this.entries.find(e => e.phase === phase)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hot-path profilers — targeted wrappers around core operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CompilationProfilerOptions {
|
||||
/** Number of assertions to compile */
|
||||
assertionCount: number
|
||||
/** Optional compilation cache injected by caller */
|
||||
compilationCache?: CompilationCache<unknown>
|
||||
/** Benchmark options (warmup runs, measurement runs, profile) */
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface ExtractionProfilerOptions {
|
||||
/** Number of subjects to extract */
|
||||
subjectCount: number
|
||||
/** Optional extraction cache injected by caller */
|
||||
extractionCache?: ExtractionCache<unknown>
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface SolvingProfilerOptions {
|
||||
/** Number of clauses to evaluate */
|
||||
clauseCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface WorldCreationProfilerOptions {
|
||||
/** Number of raw facts (subjects, boxes, etc.) to normalize */
|
||||
factCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
export interface FrameResolutionProfilerOptions {
|
||||
/** Number of frames to resolve */
|
||||
frameCount: number
|
||||
benchmarkOptions?: BenchmarkOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile compilation: time to compile N assertions to Execution IR.
|
||||
* Returns a BenchmarkResult with mean/min/max/std-dev durations.
|
||||
*/
|
||||
export async function profileCompilation(
|
||||
compileFn: (assertions: unknown[]) => unknown,
|
||||
options: CompilationProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { assertionCount, benchmarkOptions } = options
|
||||
const assertions = generateMockAssertions(assertionCount)
|
||||
|
||||
return runBenchmark(
|
||||
`compile-${assertionCount}`,
|
||||
() => compileFn(assertions),
|
||||
{
|
||||
budgetName: 'compile',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile extraction: time to extract geometry from a page.
|
||||
*/
|
||||
export async function profileExtraction(
|
||||
extractFn: (subjects: unknown[]) => unknown,
|
||||
options: ExtractionProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { subjectCount, benchmarkOptions } = options
|
||||
const subjects = generateMockSubjects(subjectCount)
|
||||
|
||||
return runBenchmark(
|
||||
`extract-${subjectCount}`,
|
||||
() => extractFn(subjects),
|
||||
{
|
||||
budgetName: 'extract',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile solving: time to evaluate M clauses against a world.
|
||||
*/
|
||||
export async function profileSolving(
|
||||
solveFn: (clauses: unknown[], world: unknown) => unknown,
|
||||
options: SolvingProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { clauseCount, benchmarkOptions } = options
|
||||
const clauses = generateMockClauses(clauseCount)
|
||||
const world = generateMockWorld()
|
||||
|
||||
return runBenchmark(
|
||||
`solve-${clauseCount}`,
|
||||
() => solveFn(clauses, world),
|
||||
{
|
||||
budgetName: 'solve',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile world creation: time to normalize raw facts into a world.
|
||||
*/
|
||||
export async function profileWorldCreation(
|
||||
normalizeFn: (raw: unknown) => unknown,
|
||||
options: WorldCreationProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { factCount, benchmarkOptions } = options
|
||||
const raw = generateMockRawExtraction(factCount)
|
||||
|
||||
return runBenchmark(
|
||||
`world-create-${factCount}`,
|
||||
() => normalizeFn(raw),
|
||||
{
|
||||
budgetName: 'extract',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile frame resolution: time to resolve transforms and coordinates.
|
||||
*/
|
||||
export async function profileFrameResolution(
|
||||
resolveFn: (frames: unknown[]) => unknown,
|
||||
options: FrameResolutionProfilerOptions
|
||||
): Promise<BenchmarkResult> {
|
||||
const { frameCount, benchmarkOptions } = options
|
||||
const frames = generateMockFrames(frameCount)
|
||||
|
||||
return runBenchmark(
|
||||
`frame-resolve-${frameCount}`,
|
||||
() => resolveFn(frames),
|
||||
{
|
||||
budgetName: 'solve',
|
||||
...benchmarkOptions,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock data generators — deterministic, lightweight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateMockAssertions(n: number): unknown[] {
|
||||
const assertions: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
assertions.push({
|
||||
type: 'RelationAssertion',
|
||||
relation: i % 2 === 0 ? 'leftOf' : 'above',
|
||||
subject: { type: 'SelectorRef', value: `.s-${i}` },
|
||||
reference: { type: 'SelectorRef', value: `.r-${i}` },
|
||||
options: { type: 'RelationOptions' },
|
||||
})
|
||||
}
|
||||
return assertions
|
||||
}
|
||||
|
||||
function generateMockSubjects(n: number): unknown[] {
|
||||
const subjects: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
subjects.push({
|
||||
id: i,
|
||||
selector: `.subj-${i}`,
|
||||
kind: 'element',
|
||||
})
|
||||
}
|
||||
return subjects
|
||||
}
|
||||
|
||||
function generateMockClauses(n: number): unknown[] {
|
||||
const clauses: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
clauses.push({
|
||||
clauseId: `clause-${i}`,
|
||||
clauseKind: i % 2 === 0 ? 'relation.leftOf' : 'relation.above',
|
||||
version: 1,
|
||||
subjectRef: i,
|
||||
referenceRef: i + 1,
|
||||
})
|
||||
}
|
||||
return clauses
|
||||
}
|
||||
|
||||
function generateMockWorld(): unknown {
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(0),
|
||||
domNodeId: new Uint32Array(0),
|
||||
subjectKind: new Uint16Array(0),
|
||||
primaryBoxId: new Uint32Array(0),
|
||||
firstFragmentId: new Uint32Array(0),
|
||||
fragmentCount: new Uint16Array(0),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(0),
|
||||
subjectId: new Uint32Array(0),
|
||||
frameId: new Uint32Array(0),
|
||||
borderLeft: new Float64Array(0),
|
||||
borderTop: new Float64Array(0),
|
||||
borderRight: new Float64Array(0),
|
||||
borderBottom: new Float64Array(0),
|
||||
paddingLeft: new Float64Array(0),
|
||||
paddingTop: new Float64Array(0),
|
||||
paddingRight: new Float64Array(0),
|
||||
paddingBottom: new Float64Array(0),
|
||||
contentLeft: new Float64Array(0),
|
||||
contentTop: new Float64Array(0),
|
||||
contentRight: new Float64Array(0),
|
||||
contentBottom: new Float64Array(0),
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(0),
|
||||
nearestPositionedAncestorOf: new Uint32Array(0),
|
||||
scrollContainerOf: new Uint32Array(0),
|
||||
stackingContextOf: new Uint32Array(0),
|
||||
formattingContextOf: new Uint32Array(0),
|
||||
clippingRootOf: new Uint32Array(0),
|
||||
paintOrderBucket: new Uint16Array(0),
|
||||
paintOrderIndex: new Uint32Array(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockRawExtraction(factCount: number): unknown {
|
||||
const subjects: unknown[] = []
|
||||
const boxes: unknown[] = []
|
||||
for (let i = 0; i < factCount; i++) {
|
||||
subjects.push({
|
||||
id: i,
|
||||
domNodeId: i,
|
||||
kind: 'element',
|
||||
primaryBoxId: i,
|
||||
firstFragmentId: 0,
|
||||
fragmentCount: 0,
|
||||
firstTextRunId: 0,
|
||||
textRunCount: 0,
|
||||
})
|
||||
boxes.push({
|
||||
id: i,
|
||||
subjectId: i,
|
||||
frameId: 0,
|
||||
borderLeft: 0,
|
||||
borderTop: 0,
|
||||
borderRight: 10,
|
||||
borderBottom: 10,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
contentLeft: 0,
|
||||
contentTop: 0,
|
||||
contentRight: 10,
|
||||
contentBottom: 10,
|
||||
})
|
||||
}
|
||||
return {
|
||||
sceneId: 'scene_1',
|
||||
snapshotId: 'snapshot_1',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: 'none',
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
source: {
|
||||
url: 'http://localhost/',
|
||||
browserName: 'chromium',
|
||||
browserVersion: '120',
|
||||
engine: 'blink',
|
||||
extractedAt: Date.now(),
|
||||
},
|
||||
subjects,
|
||||
dom: [],
|
||||
frames: [],
|
||||
rects: [],
|
||||
boxes,
|
||||
fragments: [],
|
||||
transforms: [],
|
||||
styles: [],
|
||||
text: [],
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: [],
|
||||
clipping: [],
|
||||
paint: [],
|
||||
visibility: [],
|
||||
provenance: [],
|
||||
confidence: [],
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockFrames(n: number): unknown[] {
|
||||
const frames: unknown[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
frames.push({
|
||||
id: i,
|
||||
kind: 'viewport',
|
||||
ownerSubjectId: i,
|
||||
parentFrameId: -1,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
matrix: i % 3 === 0 ? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] : undefined,
|
||||
clipRectId: 0,
|
||||
scrollContainerId: 0,
|
||||
writingMode: 'horizontal-tb',
|
||||
})
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison helpers — detect regressions between two benchmark results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegressionResult {
|
||||
phase: string
|
||||
baselineMs: number
|
||||
currentMs: number
|
||||
deltaMs: number
|
||||
deltaPercent: number
|
||||
regressed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two benchmark results and flag regressions.
|
||||
* A regression is defined as current being > thresholdPercent slower than baseline.
|
||||
*/
|
||||
export function detectRegression(
|
||||
baseline: BenchmarkResult,
|
||||
current: BenchmarkResult,
|
||||
thresholdPercent = 10
|
||||
): RegressionResult {
|
||||
const deltaMs = current.meanDurationMs - baseline.meanDurationMs
|
||||
const deltaPercent = baseline.meanDurationMs > 0
|
||||
? (deltaMs / baseline.meanDurationMs) * 100
|
||||
: 0
|
||||
return {
|
||||
phase: current.name,
|
||||
baselineMs: baseline.meanDurationMs,
|
||||
currentMs: current.meanDurationMs,
|
||||
deltaMs,
|
||||
deltaPercent,
|
||||
regressed: deltaPercent > thresholdPercent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a profile report as plain text (no markdown per user preference).
|
||||
*/
|
||||
export function formatProfileReport(results: BenchmarkResult[]): string {
|
||||
const lines: string[] = []
|
||||
lines.push('Performance Profile Report')
|
||||
lines.push('='.repeat(40))
|
||||
for (const r of results) {
|
||||
lines.push(`\nBenchmark: ${r.name}`)
|
||||
lines.push(` Mean: ${r.meanDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Min: ${r.minDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Max: ${r.maxDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` StdDev: ${r.stdDevDurationMs.toFixed(3)} ms`)
|
||||
lines.push(` Memory: ${r.meanMemoryDeltaBytes.toFixed(0)} bytes`)
|
||||
if (r.budgetCheck) {
|
||||
lines.push(` Budget: ${r.budgetCheck.budgetName} ${r.budgetCheck.passed ? 'PASS' : 'FAIL'}`)
|
||||
}
|
||||
}
|
||||
lines.push('\n' + '='.repeat(40))
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// profiles.ts - Performance profiles and budgets for Imhotep benchmark harness
|
||||
// Defines budgets for compilation, extraction, solving, and end-to-end runs.
|
||||
|
||||
export interface PerformanceBudget {
|
||||
/** Human-readable budget name */
|
||||
name: string
|
||||
/** Maximum allowed duration in milliseconds */
|
||||
maxDurationMs: number
|
||||
/** Optional maximum memory usage in bytes */
|
||||
maxMemoryBytes?: number
|
||||
}
|
||||
|
||||
export interface PerformanceProfile {
|
||||
/** Profile identifier */
|
||||
name: string
|
||||
/** Budgets active in this profile */
|
||||
budgets: PerformanceBudget[]
|
||||
}
|
||||
|
||||
/** Budget presets keyed by profile name */
|
||||
export const DEFAULT_PROFILES: Record<string, PerformanceProfile> = {
|
||||
dev: {
|
||||
name: 'dev',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 50 },
|
||||
{ name: 'extract', maxDurationMs: 200 },
|
||||
{ name: 'solve', maxDurationMs: 100 },
|
||||
{ name: 'e2e', maxDurationMs: 1000 },
|
||||
],
|
||||
},
|
||||
ci: {
|
||||
name: 'ci',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 30 },
|
||||
{ name: 'extract', maxDurationMs: 150 },
|
||||
{ name: 'solve', maxDurationMs: 80 },
|
||||
{ name: 'e2e', maxDurationMs: 800 },
|
||||
],
|
||||
},
|
||||
benchmark: {
|
||||
name: 'benchmark',
|
||||
budgets: [
|
||||
{ name: 'compile', maxDurationMs: 20 },
|
||||
{ name: 'extract', maxDurationMs: 100 },
|
||||
{ name: 'solve', maxDurationMs: 50 },
|
||||
{ name: 'e2e', maxDurationMs: 500 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/** Result of a single budget check */
|
||||
export interface BudgetCheck {
|
||||
budgetName: string
|
||||
passed: boolean
|
||||
actualDurationMs: number
|
||||
actualMemoryBytes?: number
|
||||
maxDurationMs: number
|
||||
maxMemoryBytes?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a measurement fits within a budget.
|
||||
* Returns a BudgetCheck with the pass/fail result.
|
||||
*/
|
||||
export function checkBudget(
|
||||
budgetName: string,
|
||||
actualDurationMs: number,
|
||||
actualMemoryBytes?: number,
|
||||
profile: PerformanceProfile = DEFAULT_PROFILES.dev
|
||||
): BudgetCheck {
|
||||
const budget = profile.budgets.find(b => b.name === budgetName)
|
||||
if (!budget) {
|
||||
return {
|
||||
budgetName,
|
||||
passed: true,
|
||||
actualDurationMs,
|
||||
actualMemoryBytes,
|
||||
maxDurationMs: Infinity,
|
||||
maxMemoryBytes: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const passedDuration = actualDurationMs <= budget.maxDurationMs
|
||||
const passedMemory =
|
||||
budget.maxMemoryBytes === undefined ||
|
||||
actualMemoryBytes === undefined ||
|
||||
actualMemoryBytes <= budget.maxMemoryBytes
|
||||
|
||||
return {
|
||||
budgetName,
|
||||
passed: passedDuration && passedMemory,
|
||||
actualDurationMs,
|
||||
actualMemoryBytes,
|
||||
maxDurationMs: budget.maxDurationMs,
|
||||
maxMemoryBytes: budget.maxMemoryBytes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all budgets in a profile against a map of measurements.
|
||||
*/
|
||||
export function checkAllBudgets(
|
||||
measurements: Record<string, { durationMs: number; memoryBytes?: number }>,
|
||||
profile: PerformanceProfile = DEFAULT_PROFILES.dev
|
||||
): BudgetCheck[] {
|
||||
const results: BudgetCheck[] = []
|
||||
for (const [name, measurement] of Object.entries(measurements)) {
|
||||
results.push(
|
||||
checkBudget(name, measurement.durationMs, measurement.memoryBytes, profile)
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// property-benchmark.ts - Benchmark sampled property runs.
|
||||
// Measures enumerated combinations, fast-check generated runs, and shrink performance.
|
||||
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import fc from 'fast-check';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enumerated prop combinations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ButtonProps {
|
||||
size: 'sm' | 'md' | 'lg';
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function* enumerateProps(count: number): Generator<ButtonProps> {
|
||||
const sizes: Array<'sm' | 'md' | 'lg'> = ['sm', 'md', 'lg'];
|
||||
const disableds = [false, true];
|
||||
const labels = ['OK', 'Cancel', 'Submit', 'Delete'];
|
||||
|
||||
let n = 0;
|
||||
for (const size of sizes) {
|
||||
for (const disabled of disableds) {
|
||||
for (const label of labels) {
|
||||
if (n >= count) return;
|
||||
yield { size, disabled, label };
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateEnumeratedCases(count: number): ButtonProps[] {
|
||||
return Array.from(enumerateProps(count));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fast-check arbitrary for ButtonProps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buttonPropsArb = fc.record<ButtonProps>({
|
||||
size: fc.constantFrom('sm', 'md', 'lg'),
|
||||
disabled: fc.boolean(),
|
||||
label: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Property assertion (simulated layout check)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assertButtonLayout(props: ButtonProps): boolean {
|
||||
// Simulated: buttons are always at least 44px wide regardless of props
|
||||
const minWidths = { sm: 60, md: 80, lg: 100 };
|
||||
return minWidths[props.size] >= 44;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runPropertyBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const suite = new BenchmarkSuite('property-runs', {
|
||||
defaults: { warmupRuns: 1, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// 100 enumerated prop combinations
|
||||
suite.add('enumerated-100-combinations', () => {
|
||||
const cases = generateEnumeratedCases(100);
|
||||
for (const props of cases) {
|
||||
assertButtonLayout(props);
|
||||
}
|
||||
});
|
||||
|
||||
// 100 fast-check generated runs
|
||||
suite.add('sampled-100-fc-runs', () => {
|
||||
fc.assert(
|
||||
fc.property(buttonPropsArb, (props) => {
|
||||
return assertButtonLayout(props);
|
||||
}),
|
||||
{ numRuns: 100, seed: 42 }
|
||||
);
|
||||
});
|
||||
|
||||
// Shrink performance: how many steps to minimal counterexample
|
||||
suite.add('shrink-to-minimal-counterexample', () => {
|
||||
let shrinkSteps = 0;
|
||||
try {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
size: fc.constantFrom('sm', 'md', 'lg'),
|
||||
disabled: fc.boolean(),
|
||||
label: fc.string({ minLength: 1, maxLength: 40 }),
|
||||
}),
|
||||
(props) => {
|
||||
// Artificial failure: any label longer than 30 chars fails
|
||||
return props.label.length <= 30;
|
||||
}
|
||||
),
|
||||
{
|
||||
numRuns: 200,
|
||||
seed: 123,
|
||||
verbose: 0,
|
||||
}
|
||||
);
|
||||
} catch (e: any) {
|
||||
// Fast-check errors contain counterexample info
|
||||
if (e.counterExamplePath) {
|
||||
// Path depth approximates shrink steps
|
||||
shrinkSteps = e.counterExamplePath.split(':').length;
|
||||
}
|
||||
}
|
||||
return shrinkSteps;
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runPropertyBenchmarks().then((results) => {
|
||||
console.log('\n=== Property Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(2)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(2)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(2)}ms`);
|
||||
console.log(` memory: ${(r.meanMemoryDeltaBytes / 1024).toFixed(1)}KB`);
|
||||
if (r.name === 'shrink-to-minimal-counterexample') {
|
||||
// Report last run result as shrink steps
|
||||
const lastRun = r.runs[r.runs.length - 1];
|
||||
console.log(` shrink steps (last run): ${lastRun ? (lastRun as any).result || 'N/A' : 'N/A'}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// public-api-benchmark.ts - Micro-benchmark for the public relation evaluation path.
|
||||
// Measures the simplified vertical slice: world construction + clause evaluation.
|
||||
// Target: under 100ms for a simple two-element assertion.
|
||||
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { runBenchmark, type BenchmarkResult } from './benchmark.js'
|
||||
import { evaluateLeftOf } from 'imhotep-solver'
|
||||
import type { GeometryWorld } from 'imhotep-solver'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal world builder (mirrors the path in public.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTwoElementWorld(gapPx: number): GeometryWorld {
|
||||
const s = { x: 0, y: 0, width: 50, height: 50 }
|
||||
const r = { x: 50 + gapPx, y: 0, width: 50, height: 50 }
|
||||
|
||||
return {
|
||||
sceneId: 'bench',
|
||||
snapshotId: 'snap',
|
||||
env: {
|
||||
viewportWidth: 800,
|
||||
viewportHeight: 600,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: [0, 1],
|
||||
domNodeId: [0, 1],
|
||||
subjectKind: [0, 0],
|
||||
primaryBoxId: [0, 1],
|
||||
firstFragmentId: [0, 0],
|
||||
fragmentCount: [0, 0],
|
||||
},
|
||||
dom: {
|
||||
nodeId: [],
|
||||
parentNodeId: [],
|
||||
childCount: [],
|
||||
tagNameStringId: [],
|
||||
},
|
||||
boxes: {
|
||||
boxId: [0, 1],
|
||||
subjectId: [0, 1],
|
||||
frameId: [0, 0],
|
||||
borderLeft: [s.x, r.x],
|
||||
borderTop: [s.y, r.y],
|
||||
borderRight: [s.x + s.width, r.x + r.width],
|
||||
borderBottom: [s.y + s.height, r.y + r.height],
|
||||
paddingLeft: [0, 0],
|
||||
paddingTop: [0, 0],
|
||||
paddingRight: [0, 0],
|
||||
paddingBottom: [0, 0],
|
||||
contentLeft: [s.x, r.x],
|
||||
contentTop: [s.y, r.y],
|
||||
contentRight: [s.x + s.width, r.x + r.width],
|
||||
contentBottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: [0, 1],
|
||||
subjectId: [0, 1],
|
||||
frameId: [0, 0],
|
||||
borderLeft: [s.x, r.x],
|
||||
borderTop: [s.y, r.y],
|
||||
borderRight: [s.x + s.width, r.x + r.width],
|
||||
borderBottom: [s.y + s.height, r.y + r.height],
|
||||
paddingLeft: [0, 0],
|
||||
paddingTop: [0, 0],
|
||||
paddingRight: [0, 0],
|
||||
paddingBottom: [0, 0],
|
||||
contentLeft: [s.x, r.x],
|
||||
contentTop: [s.y, r.y],
|
||||
contentRight: [s.x + s.width, r.x + r.width],
|
||||
contentBottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
transforms: {
|
||||
transformId: [],
|
||||
subjectId: [],
|
||||
matrixStart: [],
|
||||
matrixLength: [],
|
||||
originX: [],
|
||||
originY: [],
|
||||
},
|
||||
matrices: {
|
||||
values: [],
|
||||
},
|
||||
rects: {
|
||||
rectId: [0, 1],
|
||||
left: [s.x, r.x],
|
||||
top: [s.y, r.y],
|
||||
right: [s.x + s.width, r.x + r.width],
|
||||
bottom: [s.y + s.height, r.y + r.height],
|
||||
},
|
||||
topology: {
|
||||
containingBlockOf: [],
|
||||
nearestPositionedAncestorOf: [],
|
||||
scrollContainerOf: [],
|
||||
stackingContextOf: [],
|
||||
formattingContextOf: [],
|
||||
clippingRootOf: [],
|
||||
paintOrderBucket: [],
|
||||
paintOrderIndex: [],
|
||||
},
|
||||
scroll: {
|
||||
containerId: [],
|
||||
scrollLeft: [],
|
||||
scrollTop: [],
|
||||
scrollWidth: [],
|
||||
scrollHeight: [],
|
||||
clientWidth: [],
|
||||
clientHeight: [],
|
||||
},
|
||||
clipping: {
|
||||
clipNodeId: [],
|
||||
subjectId: [],
|
||||
clipKind: [],
|
||||
clipLeft: [],
|
||||
clipTop: [],
|
||||
clipRight: [],
|
||||
clipBottom: [],
|
||||
parentClipNodeId: [],
|
||||
},
|
||||
visibility: {
|
||||
subjectId: [],
|
||||
isRendered: [],
|
||||
isVisible: [],
|
||||
visibleArea: [],
|
||||
clippedArea: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runPublicApiBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const results: BenchmarkResult[] = []
|
||||
|
||||
// Benchmark: two-element leftOf evaluation with 10px gap and minGap: 8
|
||||
const world = buildTwoElementWorld(10)
|
||||
const clause = {
|
||||
clauseId: 'bench_leftof',
|
||||
clauseKind: 'relation.leftOf',
|
||||
version: 1,
|
||||
subjectRef: 0,
|
||||
referenceRef: 1,
|
||||
bounds: { minGap: 8 },
|
||||
}
|
||||
|
||||
const r = await runBenchmark(
|
||||
'public-api-leftOf-2element',
|
||||
() => {
|
||||
evaluateLeftOf(world, clause)
|
||||
},
|
||||
{
|
||||
warmupRuns: 100,
|
||||
measurementRuns: 1000,
|
||||
}
|
||||
)
|
||||
|
||||
results.push(r)
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// render-target-benchmark.ts - Benchmark renderer adapter mounting.
|
||||
// Measures scene target creation and resolution overhead.
|
||||
|
||||
import {
|
||||
runBenchmark,
|
||||
BenchmarkSuite,
|
||||
type BenchmarkResult,
|
||||
} from './benchmark.js';
|
||||
|
||||
import {
|
||||
pageTarget,
|
||||
storybookStoryTarget,
|
||||
reactComponentTarget,
|
||||
vueComponentTarget,
|
||||
fixtureTarget,
|
||||
matchSceneTarget,
|
||||
type SceneTarget,
|
||||
} from 'imhotep-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulated adapter resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveTargetUrl(target: SceneTarget): string {
|
||||
return matchSceneTarget(target, {
|
||||
page: (url) => url,
|
||||
'playwright-page': (pageRef, url) => url || `playwright://${pageRef}`,
|
||||
'storybook-story': (storyId, storybookUrl) => `${storybookUrl}/iframe.html?id=${storyId}`,
|
||||
'react-component': (rendererId, componentId) =>
|
||||
`component://react/${rendererId}/${componentId}`,
|
||||
'vue-component': (rendererId, componentId) =>
|
||||
`component://vue/${rendererId}/${componentId}`,
|
||||
'custom-renderer': (rendererId, targetId) =>
|
||||
`component://custom/${rendererId}/${targetId}`,
|
||||
fixture: (fixtureId) => `file:///fixtures/${fixtureId}.html`,
|
||||
});
|
||||
}
|
||||
|
||||
function mountComponent(target: SceneTarget): { targetId: string; url: string } {
|
||||
const url = resolveTargetUrl(target);
|
||||
const targetId = `mount_${Math.random().toString(36).slice(2, 8)}`;
|
||||
// Simulate mount overhead
|
||||
const overhead = Array.from({ length: 100 }, (_, i) => i * 2).reduce((a, b) => a + b, 0);
|
||||
return { targetId, url };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function runRenderTargetBenchmarks(): Promise<BenchmarkResult[]> {
|
||||
const suite = new BenchmarkSuite('render-target-mount', {
|
||||
defaults: { warmupRuns: 2, measurementRuns: 5 }
|
||||
});
|
||||
|
||||
// React mount time
|
||||
suite.add('react-mount', () => {
|
||||
const target = reactComponentTarget('react-dom', 'Button');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Vue mount time
|
||||
suite.add('vue-mount', () => {
|
||||
const target = vueComponentTarget('vue-dom', 'Button');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Storybook story load time
|
||||
suite.add('storybook-load', () => {
|
||||
const target = storybookStoryTarget('button--primary', 'http://localhost:6006');
|
||||
mountComponent(target);
|
||||
});
|
||||
|
||||
// Page target resolution
|
||||
suite.add('page-target-resolve', () => {
|
||||
const target = pageTarget('https://example.com/page');
|
||||
resolveTargetUrl(target);
|
||||
});
|
||||
|
||||
// Fixture target resolution
|
||||
suite.add('fixture-target-resolve', () => {
|
||||
const target = fixtureTarget('property-render-react');
|
||||
resolveTargetUrl(target);
|
||||
});
|
||||
|
||||
// Mixed renderer batch
|
||||
suite.add('mixed-renderer-batch-10', () => {
|
||||
const targets: SceneTarget[] = [
|
||||
reactComponentTarget('react-dom', 'Button'),
|
||||
vueComponentTarget('vue-dom', 'Button'),
|
||||
storybookStoryTarget('button--primary', 'http://localhost:6006'),
|
||||
pageTarget('https://example.com/page'),
|
||||
fixtureTarget('property-render-react'),
|
||||
reactComponentTarget('react-dom', 'Card'),
|
||||
vueComponentTarget('vue-dom', 'Card'),
|
||||
storybookStoryTarget('card--default', 'http://localhost:6006'),
|
||||
pageTarget('https://example.com/other'),
|
||||
fixtureTarget('property-render-vue'),
|
||||
];
|
||||
for (const t of targets) {
|
||||
mountComponent(t);
|
||||
}
|
||||
});
|
||||
|
||||
const result = await suite.runSequential();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runRenderTargetBenchmarks().then((results) => {
|
||||
console.log('\n=== Render Target Benchmark Results ===\n');
|
||||
for (const r of results) {
|
||||
console.log(`${r.name}:`);
|
||||
console.log(` mean: ${r.meanDurationMs.toFixed(3)}ms`);
|
||||
console.log(` min: ${r.minDurationMs.toFixed(3)}ms`);
|
||||
console.log(` max: ${r.maxDurationMs.toFixed(3)}ms`);
|
||||
console.log(` memory: ${r.meanMemoryDeltaBytes.toFixed(0)}B`);
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import {
|
||||
evaluateLogic,
|
||||
registerDefaultPredicates,
|
||||
} from 'imhotep-solver'
|
||||
import { adaptCanonicalWorldToSolver } from 'imhotep-core'
|
||||
|
||||
registerDefaultPredicates()
|
||||
|
||||
function buildWorld(n) {
|
||||
const canonical = {
|
||||
sceneId: 's',
|
||||
snapshotId: 'sn',
|
||||
env: {
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 800,
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'light',
|
||||
pointer: 'fine',
|
||||
hover: false,
|
||||
reducedMotion: false,
|
||||
locale: 'en',
|
||||
writingMode: 'horizontal-tb',
|
||||
},
|
||||
strings: { values: [] },
|
||||
subjects: {
|
||||
ids: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
domNodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
subjectKind: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
primaryBoxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
firstFragmentId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
fragmentCount: new Uint8Array(Array.from({length: n}, () => 1)),
|
||||
},
|
||||
boxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
visualBoxes: {
|
||||
boxId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
subjectId: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
frameId: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
borderLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
borderTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
borderRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
borderBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
paddingLeft: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingTop: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingRight: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
paddingBottom: new Float64Array(Array.from({length: n}, () => 0)),
|
||||
contentLeft: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60)),
|
||||
contentTop: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60)),
|
||||
contentRight: new Float64Array(Array.from({length: n}, (_, i) => (i % 10) * 60 + 50)),
|
||||
contentBottom: new Float64Array(Array.from({length: n}, (_, i) => Math.floor(i / 10) * 60 + 50)),
|
||||
},
|
||||
dom: {
|
||||
nodeId: new Uint32Array(Array.from({length: n}, (_, i) => i + 10)),
|
||||
parentNodeId: new Uint32Array(Array.from({length: n}, () => 1)),
|
||||
childCount: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
tagNameStringId: new Uint16Array(Array.from({length: n}, () => 0)),
|
||||
},
|
||||
transforms: { transformId: new Uint32Array(0), subjectId: new Uint32Array(0), matrixStart: new Uint32Array(0), matrixLength: new Uint32Array(0), originX: new Float64Array(0), originY: new Float64Array(0) },
|
||||
matrices: { values: new Float64Array(0) },
|
||||
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
||||
topology: {
|
||||
containingBlockOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
nearestPositionedAncestorOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
scrollContainerOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
stackingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
formattingContextOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
clippingRootOf: new Uint32Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderBucket: new Uint8Array(Array.from({length: n}, () => 0)),
|
||||
paintOrderIndex: new Uint32Array(Array.from({length: n}, (_, i) => i)),
|
||||
},
|
||||
scroll: { containerId: new Uint32Array(0), scrollLeft: new Float64Array(0), scrollTop: new Float64Array(0), scrollWidth: new Float64Array(0), scrollHeight: new Float64Array(0), clientWidth: new Float64Array(0), clientHeight: new Float64Array(0) },
|
||||
clipping: { clipNodeId: new Uint32Array(0), subjectId: new Uint32Array(0), clipKind: new Uint16Array(0), clipLeft: new Float64Array(0), clipTop: new Float64Array(0), clipRight: new Float64Array(0), clipBottom: new Float64Array(0), parentClipNodeId: new Uint32Array(0) },
|
||||
visibility: { subjectId: new Uint32Array(0), isRendered: new Uint8Array(0), isVisible: new Uint8Array(0), visibleArea: new Float64Array(0), clippedArea: new Float64Array(0) },
|
||||
}
|
||||
|
||||
return adaptCanonicalWorldToSolver(canonical)
|
||||
}
|
||||
|
||||
class SimpleResolver {
|
||||
constructor() {
|
||||
this.domains = new Map()
|
||||
}
|
||||
register(selector, ids) {
|
||||
this.domains.set(selector, {
|
||||
domainId: `dom_${selector}`,
|
||||
subjectIds: new Uint32Array(ids),
|
||||
provenance: `elements(${selector})`,
|
||||
closed: true,
|
||||
})
|
||||
}
|
||||
resolve(domain) {
|
||||
return this.domains.get(domain.selector ?? domain.domain)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Evaluation With/Without Trace ===\n')
|
||||
|
||||
for (const n of [10, 50, 100]) {
|
||||
const world = buildWorld(n)
|
||||
const resolver = new SimpleResolver()
|
||||
resolver.register('.a', Array.from({length: n}, (_, i) => i))
|
||||
resolver.register('.b', Array.from({length: n}, (_, i) => i))
|
||||
|
||||
const formula = {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$subject'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.a' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'forall',
|
||||
bindings: [{
|
||||
type: 'TupleBinding',
|
||||
variables: ['$reference'],
|
||||
domain: { type: 'DomainRef', domain: 'elements', selector: '.b' }
|
||||
}],
|
||||
body: {
|
||||
type: 'FormulaNode',
|
||||
kind: 'predicate',
|
||||
predicate: 'leftOf',
|
||||
args: [
|
||||
{ type: 'VariableRef', name: '$subject' },
|
||||
{ type: 'VariableRef', name: '$reference' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const timesWithTrace = []
|
||||
const timesWithoutTrace = []
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const start1 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: true } })
|
||||
timesWithTrace.push(performance.now() - start1)
|
||||
|
||||
const start2 = performance.now()
|
||||
evaluateLogic({ formula, world, resolver, options: { trace: false } })
|
||||
timesWithoutTrace.push(performance.now() - start2)
|
||||
}
|
||||
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
const withTrace = mean(timesWithTrace)
|
||||
const withoutTrace = mean(timesWithoutTrace)
|
||||
|
||||
console.log(`${n}×${n} pairs:`)
|
||||
console.log(` With trace: ${withTrace.toFixed(2)}ms`)
|
||||
console.log(` Without trace: ${withoutTrace.toFixed(2)}ms`)
|
||||
console.log(` Savings: ${((1 - withoutTrace/withTrace) * 100).toFixed(0)}%`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
const sizes = [1000, 5000, 10000, 50000]
|
||||
|
||||
console.log('=== TypedArray to Array Conversion Benchmark ===\n')
|
||||
|
||||
for (const size of sizes) {
|
||||
const arr = new Float64Array(Array.from({length: size}, (_, i) => i))
|
||||
|
||||
const times = {
|
||||
arrayFrom: [],
|
||||
spread: [],
|
||||
slice: [],
|
||||
bufferSlice: [],
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
let start = performance.now()
|
||||
Array.from(arr)
|
||||
times.arrayFrom.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
;[...arr]
|
||||
times.spread.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
Array.prototype.slice.call(arr)
|
||||
times.slice.push(performance.now() - start)
|
||||
|
||||
start = performance.now()
|
||||
const buffer = arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength)
|
||||
new Float64Array(buffer)
|
||||
times.bufferSlice.push(performance.now() - start)
|
||||
}
|
||||
|
||||
const mean = (arr) => arr.reduce((a,b) => a+b, 0) / arr.length
|
||||
console.log(`${size} elements:`)
|
||||
console.log(` Array.from: ${mean(times.arrayFrom).toFixed(3)}ms`)
|
||||
console.log(` [...spread]: ${mean(times.spread).toFixed(3)}ms`)
|
||||
console.log(` slice.call: ${mean(times.slice).toFixed(3)}ms`)
|
||||
console.log(` buffer.slice: ${mean(times.bufferSlice).toFixed(3)}ms`)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": false,
|
||||
"paths": {},
|
||||
"noEmitOnError": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user