scripts/bench_plugin_worker_runtime.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Comparative runtime cost bench for the Capgo plugin Cloudflare Worker.
*
* Measures (no guessing):
* 1) Retained minified bundle bytes per package via `wrangler deploy --dry-run --metafile`
* 2) Node import CPU ms + heap/RSS deltas for heavy libraries
* 3) Hot-path microbenches (LIMITED_APPS parse, stats action membership)
*
* For isolate/request CPU ms and validation library CPU comparisons, also run:
* bun run bench:plugin-worker-cpu
* bun run bench:plugin-validation-cpu
*
* Usage:
* bun scripts/bench_plugin_worker_runtime.ts
* bun scripts/bench_plugin_worker_runtime.ts --save scripts/bench/plugin_worker_runtime_results.json
* bun scripts/bench_plugin_worker_runtime.ts --compare scripts/bench/plugin_worker_runtime_baseline.json
* bun run bench:plugin-worker-runtime
*
* The committed baseline JSON is captured from main before optimizations.
* Note: top-level scripts/*.json is gitignored; keep artifacts under scripts/bench/.
*/
import { spawnSync } from 'node:child_process'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
interface PackageBytes {
[key: string]: number
}
interface BundleReport {
uploadBytes: number
gzipBytes: number | null
outputBytes: number
topPackagesBytesInOutput: PackageBytes
focusPackagesBytesInOutput: PackageBytes
}
interface ImportCost {
specifier: string
ms: number
heapUsedDeltaMB: number
rssDeltaMB: number
heapUsedMB: number
rssMB: number
error?: string
}
interface MicroBench {
name: string
iterations: number
ms: number
nsPerOp: number
opsPerSec: number
checksum: number
}
interface BenchReport {
generatedAt: string
gitHead: string
bundle: BundleReport
importCosts: ImportCost[]
microBenches: MicroBench[]
}
const ROOT = resolve(import.meta.dirname, '..')
const FOCUS_PACKAGE_MATCHERS = [
'npm:stripe',
'npm:zod',
'npm:dayjs',
'npm:drizzle-orm',
'npm:cron-schedule',
'npm:pg',
'npm:hono',
'npm:@supabase/supabase-js',
'npm:@supabase/auth-js',
'npm:@supabase/postgrest-js',
'npm:@supabase/realtime-js',
'npm:@supabase/storage-js',
'npm:@jsr/bradenmacdonald__s3-lite-client',
'npm:@jsr/std__semver',
'backend:utils/stripe.ts',
'backend:utils/schema_validation.ts',
'backend:utils/cloudflare.ts',
'backend:utils/supabase.ts',
'backend:utils/pg.ts',
]
const HEAVY_IMPORTS = [
'zod',
'stripe',
'drizzle-orm',
'dayjs',
'@supabase/supabase-js',
'pg',
'cron-schedule',
'hono',
]
function parseArgs(argv: string[]) {
let savePath: string | undefined
let comparePath: string | undefined
let outDir: string | undefined
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--save')
savePath = argv[++i]
else if (arg === '--compare')
comparePath = argv[++i]
else if (arg === '--outdir')
outDir = argv[++i]
else if (arg === '--help' || arg === '-h') {
console.log(`Usage: bun scripts/bench_plugin_worker_runtime.ts [--save path] [--compare path] [--outdir path]`)
process.exit(0)
}
}
return { savePath, comparePath, outDir }
}
function run(cmd: string[], cwd = ROOT) {
const result = spawnSync(cmd[0], cmd.slice(1), {
cwd,
encoding: 'utf8',
env: process.env,
maxBuffer: 32 * 1024 * 1024,
})
if (result.status !== 0) {
throw new Error(`Command failed (${cmd.join(' ')}):\n${result.stdout}\n${result.stderr}`)
}
return result.stdout
}
function gitHead() {
try {
return run(['git', 'rev-parse', 'HEAD']).trim()
}
catch {
return 'unknown'
}
}
function classifyPath(path: string): string {
if (path.includes('node_modules') || path.includes('.bun')) {
const jsr = path.match(/node_modules\/@jsr\/([^/]+)/)
if (jsr)
return `npm:@jsr/${jsr[1]}`
// Bun install cache paths look like:
// .../.bun/<cache-entry>/node_modules/<package>/...
// Prefer the nested package name so we don't collapse everything into npm:.bun.
const bunNested = path.match(/\.bun\/[^/]+\/node_modules\/(@[^/]+\/[^/]+|[^/]+)/)
if (bunNested)
return `npm:${bunNested[1]}`
const bun = path.match(/\.bun\/((?:@[^/]+\/)?[^/@]+)@/)
if (bun)
return `npm:${bun[1]}`
const nm = path.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/)
if (nm)
return `npm:${nm[1]}`
return 'npm:?'
}
if (path.includes('_backend'))
return `backend:${path.replace(/^.*_backend\//, '')}`
return `other:${path.replace(/^.*\//, '')}`
}
function buildPluginBundle(outDir: string): BundleReport {
mkdirSync(outDir, { recursive: true })
const metaPath = join(outDir, 'metafile.json')
const stdout = run([
'bunx',
'wrangler',
'deploy',
'--config',
'cloudflare_workers/plugin/wrangler.jsonc',
'--env=local',
'--dry-run',
'--minify',
`--outdir=${outDir}`,
`--metafile=${metaPath}`,
])
const uploadMatch = stdout.match(/Total Upload:\s*([\d.]+)\s*KiB\s*\/\s*gzip:\s*([\d.]+)\s*KiB/)
const uploadBytes = uploadMatch ? Math.round(Number.parseFloat(uploadMatch[1]) * 1024) : 0
const gzipBytes = uploadMatch ? Math.round(Number.parseFloat(uploadMatch[2]) * 1024) : null
const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as {
outputs: Record<string, { bytes: number, inputs?: Record<string, { bytesInOutput?: number }> }>
}
const jsOutputEntry = Object.entries(meta.outputs).find(([key]) => key.endsWith('index.js') && !key.endsWith('.map'))
if (!jsOutputEntry)
throw new Error('metafile missing index.js output')
const [, jsOutput] = jsOutputEntry
const packagesBytesInOutput: PackageBytes = {}
for (const [path, info] of Object.entries(jsOutput.inputs || {})) {
const key = classifyPath(path)
packagesBytesInOutput[key] = (packagesBytesInOutput[key] || 0) + (info.bytesInOutput || 0)
}
// Also aggregate supabase / zod ecosystems for clearer reporting
let supabaseEcosystem = 0
let zodEcosystem = 0
let stripeEcosystem = 0
for (const [path, info] of Object.entries(jsOutput.inputs || {})) {
const bytes = info.bytesInOutput || 0
if (path.includes('@supabase') || path.includes('supabase-js') || path.includes('iceberg-js'))
supabaseEcosystem += bytes
if (path.includes('/zod') || path.includes('zod@'))
zodEcosystem += bytes
if (path.includes('/stripe') || path.includes('stripe@') || path.endsWith('utils/stripe.ts'))
stripeEcosystem += bytes
}
packagesBytesInOutput['ecosystem:@supabase'] = supabaseEcosystem
packagesBytesInOutput['ecosystem:zod'] = zodEcosystem
packagesBytesInOutput['ecosystem:stripe'] = stripeEcosystem
const focusPackagesBytesInOutput: PackageBytes = {}
for (const key of [
...FOCUS_PACKAGE_MATCHERS,
'ecosystem:@supabase',
'ecosystem:zod',
'ecosystem:stripe',
]) {
if (packagesBytesInOutput[key] != null)
focusPackagesBytesInOutput[key] = packagesBytesInOutput[key]
}
// Exclude synthetic ecosystem:* aggregates — they double-count real packages
// and hide the true largest inputs. Ecosystem totals stay in focusPackagesBytesInOutput.
const topPackagesBytesInOutput = Object.fromEntries(
Object.entries(packagesBytesInOutput)
.filter(([key]) => !key.startsWith('ecosystem:'))
.sort((a, b) => b[1] - a[1])
.slice(0, 40),
)
return {
uploadBytes,
gzipBytes,
outputBytes: jsOutput.bytes,
topPackagesBytesInOutput,
focusPackagesBytesInOutput,
}
}
function measureImport(specifier: string): ImportCost {
// Always use system node so --expose-gc + heap deltas are reliable (bun's execPath differs).
const nodeBin = process.env.CAPGO_BENCH_NODE || 'node'
const code = `
if (global.gc) global.gc();
const { performance } = require('node:perf_hooks');
const beforeMem = process.memoryUsage();
const t0 = performance.now();
try {
require(${JSON.stringify(specifier)});
const t1 = performance.now();
const afterMem = process.memoryUsage();
console.log(JSON.stringify({
specifier: ${JSON.stringify(specifier)},
ms: t1 - t0,
heapUsedDeltaMB: (afterMem.heapUsed - beforeMem.heapUsed) / 1024 / 1024,
rssDeltaMB: (afterMem.rss - beforeMem.rss) / 1024 / 1024,
heapUsedMB: afterMem.heapUsed / 1024 / 1024,
rssMB: afterMem.rss / 1024 / 1024,
}));
} catch (error) {
console.log(JSON.stringify({
specifier: ${JSON.stringify(specifier)},
ms: 0,
heapUsedDeltaMB: 0,
rssDeltaMB: 0,
heapUsedMB: 0,
rssMB: 0,
error: String(error && error.message ? error.message : error),
}));
}
`
const result = spawnSync(nodeBin, ['--expose-gc', '-e', code], {
cwd: ROOT,
encoding: 'utf8',
})
const line = (result.stdout || '').trim().split('\n').pop()
if (!line)
return { specifier, ms: 0, heapUsedDeltaMB: 0, rssDeltaMB: 0, heapUsedMB: 0, rssMB: 0, error: result.stderr || 'no output' }
return JSON.parse(line) as ImportCost
}
function bench(name: string, iterations: number, fn: () => unknown): MicroBench {
// warmup
for (let i = 0; i < Math.min(1000, iterations); i++)
fn()
let checksum = 0
const t0 = performance.now()
for (let i = 0; i < iterations; i++) {
const value = fn()
// Keep results observable so V8 cannot DCE the benchmark body.
if (typeof value === 'boolean')
checksum = (checksum + (value ? 1 : 0)) | 0
else if (typeof value === 'number')
checksum = (checksum + (value | 0)) | 0
else if (typeof value === 'string')
checksum = (checksum + value.length) | 0
else if (value != null)
checksum = (checksum + 1) | 0
}
const ms = performance.now() - t0
return {
name,
iterations,
ms,
nsPerOp: (ms * 1e6) / iterations,
opsPerSec: iterations / (ms / 1000),
checksum,
}
}
function runMicroBenches(): MicroBench[] {
const actions = [
'ping', 'get', 'set', 'delete', 'download_complete', 'needPlanUpgrade',
'disableAutoUpdateToMajor', 'webview_javascript_error', 'native_app_version_changed',
'app_moved_to_foreground', 'checksum_fail', 'NoChannelOrOverride', 'rateLimited',
]
// Expand to ~90 entries like production list shape
while (actions.length < 90)
actions.push(`action_${actions.length}`)
const actionSet = new Set(actions)
const limitsJson = JSON.stringify(Array.from({ length: 50 }, (_, i) => ({
id: `com.example.app${i}`,
ignore: i % 5 === 0 ? 1 : 0.1,
})))
let cachedKey: string | undefined
let cachedMap: Map<string, { id: string, ignore: number }> | undefined
function getCached(limits: string) {
if (limits === cachedKey && cachedMap)
return cachedMap
const apps = JSON.parse(limits) as Array<{ id: string, ignore: number }>
cachedKey = limits
cachedMap = new Map(apps.map(app => [app.id, app]))
return cachedMap
}
const targetAction = 'native_app_version_changed'
const targetApp = 'com.example.app42'
const iterations = 200_000
return [
bench('stats_action_array_includes', iterations, () => actions.includes(targetAction)),
bench('stats_action_set_has', iterations, () => actionSet.has(targetAction)),
bench('limited_apps_json_parse_every_call', iterations, () => {
const apps = JSON.parse(limitsJson) as Array<{ id: string, ignore: number }>
return apps.find(app => app.id === targetApp)?.id ?? ''
}),
bench('limited_apps_cached_map_lookup', iterations, () => {
return getCached(limitsJson).get(targetApp)?.id ?? ''
}),
bench('allowed_actions_join_every_error', 20_000, () => actions.join(', ')),
(() => {
let list: string | undefined
const getList = () => (list ??= actions.join(', '))
return bench('allowed_actions_join_cached', 20_000, () => getList())
})(),
]
}
function kib(bytes: number) {
return `${(bytes / 1024).toFixed(1)} KiB`
}
function pct(before: number, after: number) {
if (before === 0)
return after === 0 ? '0%' : '+inf'
const delta = ((after - before) / before) * 100
const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toFixed(1)}%`
}
function printReport(report: BenchReport) {
console.log('\n=== Plugin worker runtime bench ===')
console.log(`git: ${report.gitHead}`)
console.log(`generatedAt: ${report.generatedAt}`)
console.log('\n-- Bundle --')
console.log(`upload: ${kib(report.bundle.uploadBytes)}` + (report.bundle.gzipBytes != null ? ` / gzip ${kib(report.bundle.gzipBytes)}` : ''))
console.log(`output JS: ${kib(report.bundle.outputBytes)}`)
console.log('\nFocus retained bytes (esbuild bytesInOutput):')
const focus = Object.entries(report.bundle.focusPackagesBytesInOutput).sort((a, b) => b[1] - a[1])
for (const [key, bytes] of focus)
console.log(` ${kib(bytes).padStart(10)} ${key}`)
console.log('\n-- Import CPU / RAM (fresh node process per package) --')
for (const row of report.importCosts) {
if (row.error) {
console.log(` ${row.specifier}: ERROR ${row.error}`)
continue
}
console.log(` ${row.specifier.padEnd(28)} ${row.ms.toFixed(2).padStart(8)} ms | heap Δ ${row.heapUsedDeltaMB.toFixed(2).padStart(6)} MB | rss Δ ${row.rssDeltaMB.toFixed(2).padStart(6)} MB`)
}
console.log('\n-- Hot-path microbenches --')
for (const row of report.microBenches) {
console.log(` ${row.name.padEnd(36)} ${row.ms.toFixed(2).padStart(8)} ms | ${row.nsPerOp.toFixed(1).padStart(8)} ns/op | ${Math.round(row.opsPerSec).toLocaleString()} ops/s | checksum ${row.checksum}`)
}
}
function compareReports(baseline: BenchReport, current: BenchReport) {
console.log('\n=== Comparative results (baseline → current) ===')
console.log(`baseline git: ${baseline.gitHead}`)
console.log(`current git: ${current.gitHead}`)
console.log('\nBundle size:')
console.log(` upload: ${kib(baseline.bundle.uploadBytes)} → ${kib(current.bundle.uploadBytes)} (${pct(baseline.bundle.uploadBytes, current.bundle.uploadBytes)})`)
if (baseline.bundle.gzipBytes != null && current.bundle.gzipBytes != null) {
console.log(` gzip: ${kib(baseline.bundle.gzipBytes)} → ${kib(current.bundle.gzipBytes)} (${pct(baseline.bundle.gzipBytes, current.bundle.gzipBytes)})`)
}
console.log(` output: ${kib(baseline.bundle.outputBytes)} → ${kib(current.bundle.outputBytes)} (${pct(baseline.bundle.outputBytes, current.bundle.outputBytes)})`)
const keys = new Set([
...Object.keys(baseline.bundle.focusPackagesBytesInOutput),
...Object.keys(current.bundle.focusPackagesBytesInOutput),
])
console.log('\nFocus package retained bytes:')
for (const key of [...keys].sort((a, b) => {
const da = (current.bundle.focusPackagesBytesInOutput[a] || 0) - (baseline.bundle.focusPackagesBytesInOutput[a] || 0)
const db = (current.bundle.focusPackagesBytesInOutput[b] || 0) - (baseline.bundle.focusPackagesBytesInOutput[b] || 0)
return da - db
})) {
const before = baseline.bundle.focusPackagesBytesInOutput[key] || 0
const after = current.bundle.focusPackagesBytesInOutput[key] || 0
if (before === 0 && after === 0)
continue
console.log(` ${key.padEnd(42)} ${kib(before).padStart(10)} → ${kib(after).padStart(10)} (${pct(before, after)})`)
}
console.log('\nImport CPU / RAM (machine-local; compare directionally):')
const importKeys = new Set([
...baseline.importCosts.map(row => row.specifier),
...current.importCosts.map(row => row.specifier),
])
const baselineImports = new Map(baseline.importCosts.map(row => [row.specifier, row]))
const currentImports = new Map(current.importCosts.map(row => [row.specifier, row]))
for (const specifier of [...importKeys].sort()) {
const before = baselineImports.get(specifier)
const after = currentImports.get(specifier)
if (!before && !after)
continue
console.log(` ${specifier}`)
const beforeOk = Boolean(before && !before.error)
const afterOk = Boolean(after && !after.error)
if (!beforeOk || !afterOk) {
const beforeLabel = !before ? 'missing' : (before.error ? `error: ${before.error}` : `${before.ms.toFixed(2)} ms`)
const afterLabel = !after ? 'missing' : (after.error ? `error: ${after.error}` : `${after.ms.toFixed(2)} ms`)
console.log(` unavailable for delta (baseline=${beforeLabel}; current=${afterLabel})`)
continue
}
console.log(` cpu ${before!.ms.toFixed(2)} ms → ${after!.ms.toFixed(2)} ms (${pct(before!.ms, after!.ms)})`)
console.log(` heap ${before!.heapUsedDeltaMB.toFixed(2)} MB → ${after!.heapUsedDeltaMB.toFixed(2)} MB (${pct(before!.heapUsedDeltaMB, after!.heapUsedDeltaMB)})`)
console.log(` rss ${before!.rssDeltaMB.toFixed(2)} MB → ${after!.rssDeltaMB.toFixed(2)} MB (${pct(before!.rssDeltaMB, after!.rssDeltaMB)})`)
}
console.log('\nMicrobench method comparison (lower ns/op is better):')
const byName = new Map(current.microBenches.map(row => [row.name, row]))
const pairs: Array<[string, string]> = [
['stats_action_array_includes', 'stats_action_set_has'],
['limited_apps_json_parse_every_call', 'limited_apps_cached_map_lookup'],
['allowed_actions_join_every_error', 'allowed_actions_join_cached'],
]
for (const [oldName, newName] of pairs) {
const oldRow = byName.get(oldName)
const newRow = byName.get(newName)
if (!oldRow || !newRow)
continue
console.log(` ${oldName} → ${newName}`)
console.log(` ${oldRow.nsPerOp.toFixed(1)} ns/op → ${newRow.nsPerOp.toFixed(1)} ns/op (${pct(oldRow.nsPerOp, newRow.nsPerOp)}) | checksum ${oldRow.checksum}/${newRow.checksum}`)
}
}
function main() {
const args = parseArgs(process.argv.slice(2))
const outDir = args.outDir || join(tmpdir(), `capgo-plugin-bench-${Date.now()}`)
console.log(`Building plugin worker into ${outDir} ...`)
const bundle = buildPluginBundle(outDir)
const importCosts = HEAVY_IMPORTS.map(measureImport)
const microBenches = runMicroBenches()
const report: BenchReport = {
generatedAt: new Date().toISOString(),
gitHead: gitHead(),
bundle,
importCosts,
microBenches,
}
printReport(report)
if (args.savePath) {
const abs = resolve(ROOT, args.savePath)
writeFileSync(abs, `${JSON.stringify(report, null, 2)}\n`)
console.log(`\nSaved report to ${abs}`)
}
if (args.comparePath) {
const abs = resolve(ROOT, args.comparePath)
const baseline = JSON.parse(readFileSync(abs, 'utf8')) as BenchReport
compareReports(baseline, report)
}
}
main()