diff --git a/.gitignore b/.gitignore index 7b4783aa..790f08a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ .idea node_modules/* dist +tmp/ .yarn/install-state.gz diff --git a/scripts/analyze-cpuprofile.js b/scripts/analyze-cpuprofile.js new file mode 100644 index 00000000..4717c730 --- /dev/null +++ b/scripts/analyze-cpuprofile.js @@ -0,0 +1,71 @@ +/* eslint-env node */ +// +// analyze-cpuprofile.js — print top frames by self-time from a .cpuprofile. +// +// Usage: node scripts/analyze-cpuprofile.js path/to/file.cpuprofile [topN] +// +// .cpuprofile format (Chrome DevTools): +// { nodes: [{id, callFrame:{functionName,url,lineNumber}, hitCount?, children?}], +// samples: [nodeId,...], timeDeltas: [µs,...] } +// Self-time per node = sum of timeDeltas for samples whose node === id. +// We bucket by (functionName, url) so anonymous closures inside the same file +// merge sensibly. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const file = process.argv[2]; +const topN = Number(process.argv[3] || 20); +if (!file) { + console.error('usage: analyze-cpuprofile.js [topN]'); + process.exit(2); +} + +const profile = JSON.parse(fs.readFileSync(file, 'utf8')); +const { nodes, samples, timeDeltas } = profile; + +const nodeById = new Map(); +for (const n of nodes) nodeById.set(n.id, n); + +const selfByNode = new Map(); +for (let i = 0; i < samples.length; i++) { + const id = samples[i]; + const dt = timeDeltas[i] || 0; + selfByNode.set(id, (selfByNode.get(id) || 0) + dt); +} + +const totalUs = timeDeltas.reduce((a, b) => a + b, 0); + +const buckets = new Map(); +for (const [id, us] of selfByNode) { + const node = nodeById.get(id); + const cf = node.callFrame || {}; + const fn = cf.functionName || '(anonymous)'; + const url = cf.url || ''; + const key = `${fn}\t${url}`; + buckets.set(key, (buckets.get(key) || 0) + us); +} + +const ranked = [...buckets.entries()] + .map(([k, us]) => { + const [fn, url] = k.split('\t'); + return { fn, url, us }; + }) + .sort((a, b) => b.us - a.us); + +console.log(`# ${path.basename(file)}`); +console.log(`# total profile time: ${(totalUs / 1000).toFixed(1)} ms across ${samples.length} samples`); +console.log(''); +console.log(' self% self_ms function (file)'); +console.log(' ------ ------- -----------------------------------------------------'); +for (const row of ranked.slice(0, topN)) { + const pct = (row.us / totalUs) * 100; + const ms = row.us / 1000; + const tag = row.url + ? path.basename(row.url.replace(/^file:\/\//, '')) + : '(no-file)'; + const fn = row.fn || '(anonymous)'; + console.log(` ${pct.toFixed(1).padStart(5)}% ${ms.toFixed(1).padStart(6)} ${fn} (${tag})`); +} diff --git a/scripts/analyze-heapprofile.js b/scripts/analyze-heapprofile.js new file mode 100644 index 00000000..8a038ce2 --- /dev/null +++ b/scripts/analyze-heapprofile.js @@ -0,0 +1,59 @@ +/* eslint-env node */ +// +// analyze-heapprofile.js — top frames by self-allocation bytes from +// node --heap-prof output. Format: { head: { id, callFrame, selfSize, +// children: [...] } }, recursive. selfSize is sampled bytes attributed to +// allocations done directly in that frame. +// +// Usage: node scripts/analyze-heapprofile.js path/to/file.heapprofile [topN] + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const file = process.argv[2]; +const topN = Number(process.argv[3] || 20); +if (!file) { + console.error('usage: analyze-heapprofile.js [topN]'); + process.exit(2); +} + +const profile = JSON.parse(fs.readFileSync(file, 'utf8')); + +const buckets = new Map(); +let total = 0; + +(function walk(node) { + if (node.selfSize > 0) { + const cf = node.callFrame || {}; + const fn = cf.functionName || '(anonymous)'; + const url = cf.url || ''; + const key = `${fn}\t${url}`; + buckets.set(key, (buckets.get(key) || 0) + node.selfSize); + total += node.selfSize; + } + if (node.children) for (const c of node.children) walk(c); +})(profile.head); + +const ranked = [...buckets.entries()] + .map(([k, b]) => { + const [fn, url] = k.split('\t'); + return { fn, url, bytes: b }; + }) + .sort((a, b) => b.bytes - a.bytes); + +console.log(`# ${path.basename(file)}`); +console.log(`# total sampled allocations: ${(total / 1024 / 1024).toFixed(1)} MB`); +console.log(''); +console.log(' alloc% bytes(KB) function (file)'); +console.log(' ------ --------- -----------------------------------------------------'); +for (const row of ranked.slice(0, topN)) { + const pct = (row.bytes / total) * 100; + const kb = row.bytes / 1024; + const tag = row.url + ? path.basename(row.url.replace(/^file:\/\//, '')) + : '(no-file)'; + const fn = row.fn || '(anonymous)'; + console.log(` ${pct.toFixed(1).padStart(5)}% ${kb.toFixed(1).padStart(8)} ${fn} (${tag})`); +} diff --git a/scripts/profile-driver.js b/scripts/profile-driver.js new file mode 100644 index 00000000..4a8fc978 --- /dev/null +++ b/scripts/profile-driver.js @@ -0,0 +1,171 @@ +/* eslint-env node */ +// +// profile-driver.js — runs ONE scenario against ONE fixture, sized so a +// single node invocation under --cpu-prof / --heap-prof / --prof produces a +// useful profile. Pair with scripts/profile.sh, which wraps node with the +// right flags. +// +// Env vars: +// SCENARIO init | opf | gpf | eachmap-gen | eachmap-orig (default: opf) +// FIXTURE path under benchmarks/jridgewell/fixtures (default: babel.min.js.map) +// ITERS override per-scenario iteration count (optional) +// WARMUP warmup iters before the measured loop (default: scenario-specific) +// +// The driver intentionally does *only* the scenario inside the measured loop. +// Fixture read + JSON.parse happen up front so disk/JSON cost doesn't pollute +// the profile. The Consumer is also re-used across iterations for opf / gpf / +// eachmap scenarios so we measure the steady-state hot path, not init. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { performance } = require('perf_hooks'); + +const { SourceMapConsumer } = require('../source-map.js'); + +const SCENARIO = process.env.SCENARIO || 'opf'; +const FIXTURE_NAME = process.env.FIXTURE || 'babel.min.js.map'; +const FIXTURE_PATH = path.isAbsolute(FIXTURE_NAME) + ? FIXTURE_NAME + : path.join(__dirname, '..', 'benchmarks', 'jridgewell', 'fixtures', FIXTURE_NAME); + +const raw = fs.readFileSync(FIXTURE_PATH, 'utf8'); +const json = JSON.parse(raw); + +// Build a deterministic set of (line, column) probes drawn from the decoded +// mapping table itself. Random probes that miss every mapping line would skew +// the profile toward "binary-search-the-empty-line" code paths. +function buildOpfProbes(consumer, count) { + const probes = []; + consumer.eachMapping((m) => { + probes.push({ line: m.generatedLine, column: m.generatedColumn }); + }); + if (probes.length === 0) return probes; + const stride = Math.max(1, Math.floor(probes.length / count)); + const out = []; + for (let i = 0; i < probes.length && out.length < count; i += stride) { + out.push(probes[i]); + } + return out; +} + +function buildGpfProbes(consumer, count) { + const probes = []; + consumer.eachMapping((m) => { + if (m.source) { + probes.push({ source: m.source, line: m.originalLine, column: m.originalColumn }); + } + }); + if (probes.length === 0) return probes; + const stride = Math.max(1, Math.floor(probes.length / count)); + const out = []; + for (let i = 0; i < probes.length && out.length < count; i += stride) { + out.push(probes[i]); + } + return out; +} + +const scenarios = { + init({ iters }) { + iters = iters || 30; + // No warmup: every iteration is a fresh init, that's the whole point. + let sink = 0; + const t0 = performance.now(); + for (let i = 0; i < iters; i++) { + const c = new SourceMapConsumer(json); + // Touch one position so the lazy parts that the constructor schedules + // actually run (e.g. the originalPositionFor warmup that triggers + // _buildOriginalMappings on first call is excluded — see gpf scenario). + const r = c.originalPositionFor({ line: 1, column: 0 }); + sink += r.line || 0; + } + return { iters, ms: performance.now() - t0, sink }; + }, + + opf({ iters, warmup }) { + iters = iters || 5_000_000; + warmup = warmup == null ? 5000 : warmup; + const c = new SourceMapConsumer(json); + const probes = buildOpfProbes(c, 5000); + if (probes.length === 0) throw new Error('no probes built'); + // Warmup: triggers JIT tier-up so the profile reflects optimized code. + for (let i = 0; i < warmup; i++) { + const p = probes[i % probes.length]; + c.originalPositionFor(p); + } + let sink = 0; + const t0 = performance.now(); + for (let i = 0; i < iters; i++) { + const p = probes[i % probes.length]; + const r = c.originalPositionFor(p); + sink += r.line || 0; + } + return { iters, ms: performance.now() - t0, sink }; + }, + + gpf({ iters, warmup }) { + iters = iters || 3_000_000; + warmup = warmup == null ? 5000 : warmup; + const c = new SourceMapConsumer(json); + const probes = buildGpfProbes(c, 5000); + if (probes.length === 0) throw new Error('no probes built'); + for (let i = 0; i < warmup; i++) { + const p = probes[i % probes.length]; + c.generatedPositionFor(p); + } + let sink = 0; + const t0 = performance.now(); + for (let i = 0; i < iters; i++) { + const p = probes[i % probes.length]; + const r = c.generatedPositionFor(p); + sink += r.line || 0; + } + return { iters, ms: performance.now() - t0, sink }; + }, + + 'eachmap-gen': function ({ iters, warmup }) { + iters = iters || 300; + warmup = warmup == null ? 3 : warmup; + const c = new SourceMapConsumer(json); + let sink = 0; + for (let i = 0; i < warmup; i++) c.eachMapping(() => {}); + const t0 = performance.now(); + for (let i = 0; i < iters; i++) { + c.eachMapping((m) => { + sink += m.generatedLine | 0; + }); + } + return { iters, ms: performance.now() - t0, sink }; + }, + + 'eachmap-orig': function ({ iters, warmup }) { + iters = iters || 300; + warmup = warmup == null ? 3 : warmup; + const c = new SourceMapConsumer(json); + const ORIG = SourceMapConsumer.ORIGINAL_ORDER; + let sink = 0; + for (let i = 0; i < warmup; i++) c.eachMapping(() => {}, null, ORIG); + const t0 = performance.now(); + for (let i = 0; i < iters; i++) { + c.eachMapping((m) => { + sink += m.generatedLine | 0; + }, null, ORIG); + } + return { iters, ms: performance.now() - t0, sink }; + }, +}; + +if (!scenarios[SCENARIO]) { + console.error(`unknown SCENARIO=${SCENARIO}; expected one of: ${Object.keys(scenarios).join(', ')}`); + process.exit(2); +} + +const iters = process.env.ITERS ? Number(process.env.ITERS) : undefined; +const warmup = process.env.WARMUP != null ? Number(process.env.WARMUP) : undefined; + +console.log(`scenario=${SCENARIO} fixture=${path.basename(FIXTURE_PATH)} node=${process.version}`); +const result = scenarios[SCENARIO]({ iters, warmup }); +console.log( + ` iters=${result.iters} total=${result.ms.toFixed(1)}ms per-iter=${(result.ms / result.iters).toFixed(4)}ms sink=${result.sink}`, +); diff --git a/scripts/profile.sh b/scripts/profile.sh new file mode 100755 index 00000000..4d1f9c10 --- /dev/null +++ b/scripts/profile.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# profile.sh — run scripts/profile-driver.js under V8 profilers and emit: +# tmp/profiles/$SCENARIO.cpuprofile load in Chrome DevTools → Performance +# tmp/profiles/$SCENARIO.heapprofile load in Chrome DevTools → Memory → Allocation profile +# tmp/profiles/$SCENARIO.prof.txt node --prof text summary (V8 ticks) +# +# Usage: +# scripts/profile.sh # SCENARIO=opf, FIXTURE=babel.min.js.map +# SCENARIO=init scripts/profile.sh +# SCENARIO=eachmap-gen FIXTURE=vscode.map scripts/profile.sh +# SCENARIO=opf MODE=cpu scripts/profile.sh # cpu profile only (skip heap + prof) +# +# MODE values: all (default), cpu, heap, prof + +set -euo pipefail + +SCENARIO="${SCENARIO:-opf}" +FIXTURE="${FIXTURE:-babel.min.js.map}" +MODE="${MODE:-all}" + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +OUT_DIR="$ROOT/tmp/profiles" +mkdir -p "$OUT_DIR" + +# Skip the per-call sandbox/permission overhead; --no-warnings keeps the +# output clean. --max-old-space-size matches the bench scripts so vscode.map +# doesn't OOM. +NODE_FLAGS=(--no-warnings --max-old-space-size=8192) + +DRIVER="$ROOT/scripts/profile-driver.js" + +export SCENARIO FIXTURE + +run_cpu() { + echo "--- CPU profile (SCENARIO=$SCENARIO FIXTURE=$FIXTURE) ---" + rm -f "$OUT_DIR/$SCENARIO.cpuprofile" + node "${NODE_FLAGS[@]}" \ + --cpu-prof \ + --cpu-prof-dir="$OUT_DIR" \ + --cpu-prof-name="$SCENARIO.cpuprofile" \ + --cpu-prof-interval=100 \ + "$DRIVER" + echo " → $OUT_DIR/$SCENARIO.cpuprofile" +} + +run_heap() { + echo "--- Heap allocation profile (SCENARIO=$SCENARIO FIXTURE=$FIXTURE) ---" + rm -f "$OUT_DIR/$SCENARIO.heapprofile" + node "${NODE_FLAGS[@]}" \ + --heap-prof \ + --heap-prof-dir="$OUT_DIR" \ + --heap-prof-name="$SCENARIO.heapprofile" \ + "$DRIVER" + echo " → $OUT_DIR/$SCENARIO.heapprofile" +} + +run_prof() { + echo "--- V8 tick profile (SCENARIO=$SCENARIO FIXTURE=$FIXTURE) ---" + local work + work="$(mktemp -d)" + ( + cd "$work" + node "${NODE_FLAGS[@]}" --prof "$DRIVER" + local log + log="$(ls isolate-*.log | head -n 1)" + node --prof-process "$log" > "$OUT_DIR/$SCENARIO.prof.txt" + ) + rm -rf "$work" + echo " → $OUT_DIR/$SCENARIO.prof.txt" +} + +case "$MODE" in + cpu) run_cpu ;; + heap) run_heap ;; + prof) run_prof ;; + all) run_cpu; run_heap; run_prof ;; + *) echo "ERROR: unknown MODE='$MODE' (expected: all, cpu, heap, prof)" >&2; exit 1 ;; +esac