benchforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +432 -0
  2. package/bin/benchforge +3 -0
  3. package/dist/bin/benchforge.mjs +9 -0
  4. package/dist/bin/benchforge.mjs.map +1 -0
  5. package/dist/browser/index.js +914 -0
  6. package/dist/index.mjs +3 -0
  7. package/dist/src-CGuaC3Wo.mjs +3676 -0
  8. package/dist/src-CGuaC3Wo.mjs.map +1 -0
  9. package/package.json +49 -0
  10. package/src/BenchMatrix.ts +380 -0
  11. package/src/Benchmark.ts +33 -0
  12. package/src/BenchmarkReport.ts +156 -0
  13. package/src/GitUtils.ts +79 -0
  14. package/src/HtmlDataPrep.ts +148 -0
  15. package/src/MeasuredResults.ts +127 -0
  16. package/src/NodeGC.ts +48 -0
  17. package/src/PermutationTest.ts +115 -0
  18. package/src/StandardSections.ts +268 -0
  19. package/src/StatisticalUtils.ts +176 -0
  20. package/src/TypeUtil.ts +8 -0
  21. package/src/bin/benchforge.ts +4 -0
  22. package/src/browser/BrowserGcStats.ts +44 -0
  23. package/src/browser/BrowserHeapSampler.ts +248 -0
  24. package/src/cli/CliArgs.ts +64 -0
  25. package/src/cli/FilterBenchmarks.ts +68 -0
  26. package/src/cli/RunBenchCLI.ts +856 -0
  27. package/src/export/JsonExport.ts +103 -0
  28. package/src/export/JsonFormat.ts +91 -0
  29. package/src/export/PerfettoExport.ts +203 -0
  30. package/src/heap-sample/HeapSampleReport.ts +196 -0
  31. package/src/heap-sample/HeapSampler.ts +78 -0
  32. package/src/html/HtmlReport.ts +131 -0
  33. package/src/html/HtmlTemplate.ts +284 -0
  34. package/src/html/Types.ts +88 -0
  35. package/src/html/browser/CIPlot.ts +287 -0
  36. package/src/html/browser/HistogramKde.ts +118 -0
  37. package/src/html/browser/LegendUtils.ts +163 -0
  38. package/src/html/browser/RenderPlots.ts +263 -0
  39. package/src/html/browser/SampleTimeSeries.ts +389 -0
  40. package/src/html/browser/Types.ts +96 -0
  41. package/src/html/browser/index.ts +1 -0
  42. package/src/html/index.ts +17 -0
  43. package/src/index.ts +92 -0
  44. package/src/matrix/CaseLoader.ts +36 -0
  45. package/src/matrix/MatrixFilter.ts +103 -0
  46. package/src/matrix/MatrixReport.ts +290 -0
  47. package/src/matrix/VariantLoader.ts +46 -0
  48. package/src/runners/AdaptiveWrapper.ts +391 -0
  49. package/src/runners/BasicRunner.ts +368 -0
  50. package/src/runners/BenchRunner.ts +60 -0
  51. package/src/runners/CreateRunner.ts +11 -0
  52. package/src/runners/GcStats.ts +107 -0
  53. package/src/runners/RunnerOrchestrator.ts +374 -0
  54. package/src/runners/RunnerUtils.ts +2 -0
  55. package/src/runners/TimingUtils.ts +13 -0
  56. package/src/runners/WorkerScript.ts +256 -0
  57. package/src/table-util/ConvergenceFormatters.ts +19 -0
  58. package/src/table-util/Formatters.ts +152 -0
  59. package/src/table-util/README.md +70 -0
  60. package/src/table-util/TableReport.ts +293 -0
  61. package/src/table-util/test/TableReport.test.ts +105 -0
  62. package/src/table-util/test/TableValueExtractor.test.ts +41 -0
  63. package/src/table-util/test/TableValueExtractor.ts +100 -0
  64. package/src/test/AdaptiveRunner.test.ts +185 -0
  65. package/src/test/AdaptiveStatistics.integration.ts +119 -0
  66. package/src/test/BenchmarkReport.test.ts +82 -0
  67. package/src/test/BrowserBench.e2e.test.ts +44 -0
  68. package/src/test/BrowserBench.test.ts +79 -0
  69. package/src/test/GcStats.test.ts +94 -0
  70. package/src/test/PermutationTest.test.ts +121 -0
  71. package/src/test/RunBenchCLI.test.ts +166 -0
  72. package/src/test/RunnerOrchestrator.test.ts +102 -0
  73. package/src/test/StatisticalUtils.test.ts +112 -0
  74. package/src/test/TestUtils.ts +93 -0
  75. package/src/test/fixtures/test-bench-script.ts +30 -0
  76. package/src/tests/AdaptiveConvergence.test.ts +177 -0
  77. package/src/tests/AdaptiveSampling.test.ts +240 -0
  78. package/src/tests/BenchMatrix.test.ts +366 -0
  79. package/src/tests/MatrixFilter.test.ts +117 -0
  80. package/src/tests/MatrixReport.test.ts +139 -0
  81. package/src/tests/RealDataValidation.test.ts +177 -0
  82. package/src/tests/fixtures/baseline/impl.ts +4 -0
  83. package/src/tests/fixtures/bevy30-samples.ts +158 -0
  84. package/src/tests/fixtures/cases/asyncCases.ts +7 -0
  85. package/src/tests/fixtures/cases/cases.ts +8 -0
  86. package/src/tests/fixtures/cases/variants/product.ts +2 -0
  87. package/src/tests/fixtures/cases/variants/sum.ts +2 -0
  88. package/src/tests/fixtures/discover/fast.ts +1 -0
  89. package/src/tests/fixtures/discover/slow.ts +4 -0
  90. package/src/tests/fixtures/invalid/bad.ts +1 -0
  91. package/src/tests/fixtures/loader/fast.ts +1 -0
  92. package/src/tests/fixtures/loader/slow.ts +4 -0
  93. package/src/tests/fixtures/loader/stateful.ts +2 -0
  94. package/src/tests/fixtures/stateful/stateful.ts +2 -0
  95. package/src/tests/fixtures/variants/extra.ts +1 -0
  96. package/src/tests/fixtures/variants/impl.ts +1 -0
  97. package/src/tests/fixtures/worker/fast.ts +1 -0
  98. package/src/tests/fixtures/worker/slow.ts +4 -0
@@ -0,0 +1,368 @@
1
+ import { getHeapStatistics } from "node:v8";
2
+ import type { BenchmarkSpec } from "../Benchmark.ts";
3
+ import type {
4
+ MeasuredResults,
5
+ OptStatusInfo,
6
+ PausePoint,
7
+ } from "../MeasuredResults.ts";
8
+ import { checkConvergence } from "./AdaptiveWrapper.ts";
9
+ import type { BenchRunner, RunnerOptions } from "./BenchRunner.ts";
10
+ import { executeBenchmark } from "./BenchRunner.ts";
11
+ import { msToNs } from "./RunnerUtils.ts";
12
+
13
+ /**
14
+ * Wait time after gc() for V8 to stabilize (ms).
15
+ *
16
+ * V8 has 4 compilation tiers: Ignition (interpreter) -> Sparkplug (baseline) ->
17
+ * Maglev (mid-tier optimizer) -> TurboFan (full optimizer). Tiering thresholds:
18
+ * - Ignition -> Sparkplug: 8 invocations
19
+ * - Sparkplug -> Maglev: 500 invocations
20
+ * - Maglev -> TurboFan: 6000 invocations
21
+ *
22
+ * Optimization compilation happens on background threads and requires idle time
23
+ * on the main thread to complete. Without sufficient warmup + settle time,
24
+ * benchmarks exhibit bimodal timing: slow Sparkplug samples (~30% slower) mixed
25
+ * with fast optimized samples.
26
+ *
27
+ * The warmup iterations trigger the optimization decision, then gcSettleTime
28
+ * provides idle time for background compilation to finish before measurement.
29
+ *
30
+ * @see https://v8.dev/blog/sparkplug
31
+ * @see https://v8.dev/blog/maglev
32
+ * @see https://v8.dev/blog/background-compilation
33
+ */
34
+ const gcSettleTime = 1000;
35
+
36
+ type CollectParams<T = unknown> = {
37
+ benchmark: BenchmarkSpec<T>;
38
+ maxTime: number;
39
+ maxIterations: number;
40
+ warmup: number;
41
+ params?: T;
42
+ skipWarmup?: boolean;
43
+ traceOpt?: boolean;
44
+ noSettle?: boolean;
45
+ pauseFirst?: number;
46
+ pauseInterval?: number;
47
+ pauseDuration?: number;
48
+ };
49
+
50
+ type CollectResult = {
51
+ samples: number[];
52
+ warmupSamples: number[]; // timing of warmup iterations
53
+ heapGrowth: number; // amortized KB per sample
54
+ heapSamples?: number[]; // heap size per sample (bytes)
55
+ timestamps?: number[]; // wall-clock μs per sample for Perfetto
56
+ optStatus?: OptStatusInfo;
57
+ optSamples?: number[]; // per-sample V8 opt status codes
58
+ pausePoints: PausePoint[]; // where pauses occurred
59
+ };
60
+
61
+ export type SampleTimeStats = {
62
+ min: number;
63
+ max: number;
64
+ avg: number;
65
+ p50: number;
66
+ p75: number;
67
+ p99: number;
68
+ p999: number;
69
+ };
70
+
71
+ /** @return runner with time and iteration limits */
72
+ export class BasicRunner implements BenchRunner {
73
+ async runBench<T = unknown>(
74
+ benchmark: BenchmarkSpec<T>,
75
+ options: RunnerOptions,
76
+ params?: T,
77
+ ): Promise<MeasuredResults[]> {
78
+ const opts = { ...defaultCollectOptions, ...(options as any) };
79
+ const collected = await collectSamples({ benchmark, params, ...opts });
80
+ return [buildMeasuredResults(benchmark.name, collected)];
81
+ }
82
+ }
83
+
84
+ const defaultCollectOptions = {
85
+ maxTime: 5000,
86
+ maxIterations: 1000000,
87
+ warmup: 0,
88
+ traceOpt: false,
89
+ noSettle: false,
90
+ };
91
+
92
+ function buildMeasuredResults(name: string, c: CollectResult): MeasuredResults {
93
+ const time = computeStats(c.samples);
94
+ const convergence = checkConvergence(c.samples.map(s => s * msToNs));
95
+ return {
96
+ name,
97
+ samples: c.samples,
98
+ warmupSamples: c.warmupSamples,
99
+ heapSamples: c.heapSamples,
100
+ timestamps: c.timestamps,
101
+ time,
102
+ heapSize: { avg: c.heapGrowth, min: c.heapGrowth, max: c.heapGrowth },
103
+ convergence,
104
+ optStatus: c.optStatus,
105
+ optSamples: c.optSamples,
106
+ pausePoints: c.pausePoints,
107
+ };
108
+ }
109
+
110
+ /** @return timing samples and amortized allocation from benchmark execution */
111
+ async function collectSamples<T>(p: CollectParams<T>): Promise<CollectResult> {
112
+ if (!p.maxIterations && !p.maxTime) {
113
+ throw new Error(`At least one of maxIterations or maxTime must be set`);
114
+ }
115
+ const warmupSamples = p.skipWarmup ? [] : await runWarmup(p);
116
+ const heapBefore = process.memoryUsage().heapUsed;
117
+ const { samples, heapSamples, timestamps, optStatuses, pausePoints } =
118
+ await runSampleLoop(p);
119
+ const heapGrowth =
120
+ Math.max(0, process.memoryUsage().heapUsed - heapBefore) /
121
+ 1024 /
122
+ samples.length;
123
+ if (samples.length === 0) {
124
+ throw new Error(`No samples collected for benchmark: ${p.benchmark.name}`);
125
+ }
126
+ const optStatus = p.traceOpt
127
+ ? analyzeOptStatus(samples, optStatuses)
128
+ : undefined;
129
+ const optSamples =
130
+ p.traceOpt && optStatuses.length > 0 ? optStatuses : undefined;
131
+ return {
132
+ samples,
133
+ warmupSamples,
134
+ heapGrowth,
135
+ heapSamples,
136
+ timestamps,
137
+ optStatus,
138
+ optSamples,
139
+ pausePoints,
140
+ };
141
+ }
142
+
143
+ /** Run warmup iterations with gc + settle time for V8 optimization */
144
+ async function runWarmup<T>(p: CollectParams<T>): Promise<number[]> {
145
+ const gc = gcFunction();
146
+ const samples = new Array<number>(p.warmup);
147
+ for (let i = 0; i < p.warmup; i++) {
148
+ const start = performance.now();
149
+ executeBenchmark(p.benchmark, p.params);
150
+ samples[i] = performance.now() - start;
151
+ }
152
+ gc();
153
+ if (!p.noSettle) {
154
+ await new Promise(r => setTimeout(r, gcSettleTime));
155
+ gc();
156
+ }
157
+ return samples;
158
+ }
159
+
160
+ type SampleLoopResult = {
161
+ samples: number[];
162
+ heapSamples?: number[];
163
+ timestamps?: number[];
164
+ optStatuses: number[];
165
+ pausePoints: PausePoint[];
166
+ };
167
+
168
+ /** Estimate sample count for pre-allocation */
169
+ function estimateSampleCount(maxTime: number, maxIterations: number): number {
170
+ return maxIterations || Math.ceil(maxTime / 0.1); // assume 0.1ms per iteration minimum
171
+ }
172
+
173
+ type SampleArrays = {
174
+ samples: number[];
175
+ timestamps: number[];
176
+ heapSamples: number[];
177
+ optStatuses: number[];
178
+ pausePoints: PausePoint[];
179
+ };
180
+
181
+ /** Pre-allocate arrays to reduce GC pressure during measurement */
182
+ function createSampleArrays(
183
+ n: number,
184
+ trackHeap: boolean,
185
+ trackOpt: boolean,
186
+ ): SampleArrays {
187
+ const arr = (track: boolean) => (track ? new Array<number>(n) : []);
188
+ return {
189
+ samples: new Array<number>(n),
190
+ timestamps: new Array<number>(n),
191
+ heapSamples: arr(trackHeap),
192
+ optStatuses: arr(trackOpt),
193
+ pausePoints: [],
194
+ };
195
+ }
196
+
197
+ /** Trim arrays to actual sample count */
198
+ function trimArrays(
199
+ a: SampleArrays,
200
+ count: number,
201
+ trackHeap: boolean,
202
+ trackOpt: boolean,
203
+ ): void {
204
+ a.samples.length = a.timestamps.length = count;
205
+ if (trackHeap) a.heapSamples.length = count;
206
+ if (trackOpt) a.optStatuses.length = count;
207
+ }
208
+
209
+ /** Collect timing samples with periodic pauses for V8 optimization */
210
+ async function runSampleLoop<T>(
211
+ p: CollectParams<T>,
212
+ ): Promise<SampleLoopResult> {
213
+ const {
214
+ maxTime,
215
+ maxIterations,
216
+ pauseFirst,
217
+ pauseInterval = 0,
218
+ pauseDuration = 100,
219
+ } = p;
220
+ const trackHeap = true; // Always track heap for charts
221
+ const getOptStatus = p.traceOpt ? createOptStatusGetter() : undefined;
222
+ const estimated = estimateSampleCount(maxTime, maxIterations);
223
+ const a = createSampleArrays(estimated, trackHeap, !!getOptStatus);
224
+
225
+ let count = 0;
226
+ let elapsed = 0;
227
+ let totalPauseTime = 0;
228
+ const loopStart = performance.now();
229
+
230
+ while (
231
+ (!maxIterations || count < maxIterations) &&
232
+ (!maxTime || elapsed < maxTime)
233
+ ) {
234
+ const start = performance.now();
235
+ executeBenchmark(p.benchmark, p.params);
236
+ const end = performance.now();
237
+ a.samples[count] = end - start;
238
+ a.timestamps[count] = Number(process.hrtime.bigint() / 1000n);
239
+ if (trackHeap) a.heapSamples[count] = getHeapStatistics().used_heap_size;
240
+ if (getOptStatus) a.optStatuses[count] = getOptStatus(p.benchmark.fn);
241
+ count++;
242
+
243
+ if (shouldPause(count, pauseFirst, pauseInterval)) {
244
+ a.pausePoints.push({ sampleIndex: count - 1, durationMs: pauseDuration });
245
+ const pauseStart = performance.now();
246
+ await new Promise(r => setTimeout(r, pauseDuration));
247
+ totalPauseTime += performance.now() - pauseStart;
248
+ }
249
+ elapsed = performance.now() - loopStart - totalPauseTime;
250
+ }
251
+
252
+ trimArrays(a, count, trackHeap, !!getOptStatus);
253
+ return {
254
+ samples: a.samples,
255
+ heapSamples: trackHeap ? a.heapSamples : undefined,
256
+ timestamps: a.timestamps,
257
+ optStatuses: a.optStatuses,
258
+ pausePoints: a.pausePoints,
259
+ };
260
+ }
261
+
262
+ /** Check if we should pause at this iteration for V8 optimization */
263
+ function shouldPause(
264
+ iter: number,
265
+ first: number | undefined,
266
+ interval: number,
267
+ ): boolean {
268
+ if (first !== undefined && iter === first) return true;
269
+ if (interval <= 0) return false;
270
+ if (first === undefined) return iter % interval === 0;
271
+ return (iter - first) % interval === 0;
272
+ }
273
+
274
+ /** @return percentiles and basic statistics */
275
+ export function computeStats(samples: number[]): SampleTimeStats {
276
+ const sorted = [...samples].sort((a, b) => a - b);
277
+ const avg = samples.reduce((sum, s) => sum + s, 0) / samples.length;
278
+ return {
279
+ min: sorted[0],
280
+ max: sorted[sorted.length - 1],
281
+ avg,
282
+ p50: percentile(sorted, 0.5),
283
+ p75: percentile(sorted, 0.75),
284
+ p99: percentile(sorted, 0.99),
285
+ p999: percentile(sorted, 0.999),
286
+ };
287
+ }
288
+
289
+ /** @return percentile value with linear interpolation */
290
+ function percentile(sortedArray: number[], p: number): number {
291
+ const index = (sortedArray.length - 1) * p;
292
+ const lower = Math.floor(index);
293
+ const upper = Math.ceil(index);
294
+ const weight = index % 1;
295
+
296
+ if (upper >= sortedArray.length) return sortedArray[sortedArray.length - 1];
297
+
298
+ return sortedArray[lower] * (1 - weight) + sortedArray[upper] * weight;
299
+ }
300
+
301
+ /** @return runtime gc() function, or no-op if unavailable */
302
+ function gcFunction(): () => void {
303
+ const gc = globalThis.gc || (globalThis as any).__gc;
304
+ if (gc) return gc;
305
+ console.warn("gc() not available, run node/bun with --expose-gc");
306
+ return () => {};
307
+ }
308
+
309
+ /** @return function to get V8 optimization status (requires --allow-natives-syntax) */
310
+ function createOptStatusGetter(): ((fn: unknown) => number) | undefined {
311
+ try {
312
+ // %GetOptimizationStatus returns a bitmask
313
+ const getter = new Function("f", "return %GetOptimizationStatus(f)");
314
+ getter(() => {});
315
+ return getter as (fn: unknown) => number;
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * V8 optimization status bit meanings:
323
+ * Bit 0 (1): is_function
324
+ * Bit 4 (16): is_optimized (TurboFan)
325
+ * Bit 5 (32): is_optimized (Maglev)
326
+ * Bit 7 (128): is_baseline (Sparkplug)
327
+ * Bit 3 (8): maybe_deoptimized
328
+ */
329
+ const statusNames: Record<number, string> = {
330
+ 1: "interpreted",
331
+ 129: "sparkplug", // 1 + 128
332
+ 17: "turbofan", // 1 + 16
333
+ 33: "maglev", // 1 + 32
334
+ 49: "turbofan+maglev", // 1 + 16 + 32
335
+ 32769: "optimized", // common optimized status
336
+ };
337
+
338
+ /** @return analysis of V8 optimization status per sample */
339
+ function analyzeOptStatus(
340
+ samples: number[],
341
+ statuses: number[],
342
+ ): OptStatusInfo | undefined {
343
+ if (statuses.length === 0 || statuses[0] === undefined) return undefined;
344
+
345
+ const byStatusCode = new Map<number, number[]>();
346
+ let deoptCount = 0;
347
+
348
+ for (let i = 0; i < samples.length; i++) {
349
+ const status = statuses[i];
350
+ if (status === undefined) continue;
351
+
352
+ // Check deopt flag (bit 3)
353
+ if (status & 8) deoptCount++;
354
+
355
+ if (!byStatusCode.has(status)) byStatusCode.set(status, []);
356
+ byStatusCode.get(status)!.push(samples[i]);
357
+ }
358
+
359
+ const byTier: Record<string, { count: number; medianMs: number }> = {};
360
+ for (const [status, times] of byStatusCode) {
361
+ const name = statusNames[status] || `status=${status}`;
362
+ const sorted = [...times].sort((a, b) => a - b);
363
+ const median = sorted[Math.floor(sorted.length / 2)];
364
+ byTier[name] = { count: times.length, medianMs: median };
365
+ }
366
+
367
+ return { byTier, deoptCount };
368
+ }
@@ -0,0 +1,60 @@
1
+ import type { BenchmarkSpec } from "../Benchmark.ts";
2
+ import type { MeasuredResults } from "../MeasuredResults.ts";
3
+
4
+ /** Execute benchmark with optional parameters */
5
+ export function executeBenchmark<T>(
6
+ benchmark: BenchmarkSpec<T>,
7
+ params?: T,
8
+ ): void {
9
+ (benchmark.fn as (params?: T) => void)(params);
10
+ }
11
+
12
+ /** Interface for benchmark execution libraries */
13
+ export interface BenchRunner {
14
+ runBench<T = unknown>(
15
+ benchmark: BenchmarkSpec<T>,
16
+ options: RunnerOptions,
17
+ params?: T,
18
+ ): Promise<MeasuredResults[]>;
19
+ }
20
+
21
+ export interface RunnerOptions {
22
+ /** Minimum time to run each benchmark (milliseconds) */
23
+ minTime?: number;
24
+ /** Maximum time to run each benchmark - ignored by mitata (milliseconds) */
25
+ maxTime?: number;
26
+ /** Maximum iterations per benchmark - ignored by TinyBench */
27
+ maxIterations?: number;
28
+ /** Warmup iterations before measurement (default: 0) */
29
+ warmup?: number;
30
+ /** Warmup time before measurement (milliseconds) */
31
+ warmupTime?: number;
32
+ /** Warmup samples - mitata only, for reducing test time */
33
+ warmupSamples?: number;
34
+ /** Warmup threshold - mitata only (nanoseconds) */
35
+ warmupThreshold?: number;
36
+ /** Minimum samples required - mitata only */
37
+ minSamples?: number;
38
+ /** Force GC after each iteration (requires --expose-gc) */
39
+ collect?: boolean;
40
+ /** Enable CPU performance counters (requires root access) */
41
+ cpuCounters?: boolean;
42
+ /** Trace V8 optimization tiers (requires --allow-natives-syntax) */
43
+ traceOpt?: boolean;
44
+ /** Skip post-warmup settle time (default: false) */
45
+ noSettle?: boolean;
46
+ /** Iterations before first pause (then pauseInterval applies) */
47
+ pauseFirst?: number;
48
+ /** Iterations between pauses for V8 optimization (0 to disable) */
49
+ pauseInterval?: number;
50
+ /** Pause duration in ms for V8 optimization */
51
+ pauseDuration?: number;
52
+ /** Collect GC stats via --trace-gc-nvp (requires worker mode) */
53
+ gcStats?: boolean;
54
+ /** Heap sampling allocation attribution */
55
+ heapSample?: boolean;
56
+ /** Heap sampling interval in bytes */
57
+ heapInterval?: number;
58
+ /** Heap sampling stack depth */
59
+ heapDepth?: number;
60
+ }
@@ -0,0 +1,11 @@
1
+ import { BasicRunner } from "./BasicRunner.ts";
2
+ import type { BenchRunner } from "./BenchRunner.ts";
3
+
4
+ export type KnownRunner = "basic";
5
+
6
+ /** @return benchmark runner */
7
+ export async function createRunner(
8
+ _runnerName: KnownRunner,
9
+ ): Promise<BenchRunner> {
10
+ return new BasicRunner();
11
+ }
@@ -0,0 +1,107 @@
1
+ /** GC statistics aggregated from V8 trace events.
2
+ * Node (--trace-gc-nvp) provides all fields.
3
+ * Browser (CDP Tracing) provides counts, collected, and pause only. */
4
+ export interface GcStats {
5
+ scavenges: number;
6
+ markCompacts: number;
7
+ totalCollected: number; // bytes freed
8
+ gcPauseTime: number; // total pause time (ms)
9
+ totalAllocated?: number; // bytes allocated (Node only)
10
+ totalPromoted?: number; // bytes promoted to old gen (Node only)
11
+ totalSurvived?: number; // bytes survived in young gen (Node only)
12
+ }
13
+
14
+ /** Single GC event. Node provides all fields; browser provides type, pauseMs, collected. */
15
+ export interface GcEvent {
16
+ type: "scavenge" | "mark-compact" | "minor-ms" | "unknown";
17
+ pauseMs: number;
18
+ collected: number;
19
+ allocated?: number; // Node only
20
+ promoted?: number; // Node only
21
+ survived?: number; // Node only
22
+ }
23
+
24
+ /** Parse a single --trace-gc-nvp stderr line */
25
+ export function parseGcLine(line: string): GcEvent | undefined {
26
+ // V8 format: [pid:addr:gen] N ms: pause=X gc=s ... allocated=N promoted=N ...
27
+ if (!line.includes("pause=")) return undefined;
28
+
29
+ const fields = parseNvpFields(line);
30
+ if (!fields.gc) return undefined;
31
+
32
+ const int = (k: string) => Number.parseInt(fields[k] || "0", 10);
33
+ const type = parseGcType(fields.gc);
34
+ const pauseMs = Number.parseFloat(fields.pause || "0");
35
+ const allocated = int("allocated");
36
+ const promoted = int("promoted");
37
+ // V8 uses "new_space_survived" not "survived"
38
+ const survived = int("new_space_survived") || int("survived");
39
+ // Calculate collected from start/end object size if available
40
+ const startSize = int("start_object_size");
41
+ const endSize = int("end_object_size");
42
+ const collected = startSize > endSize ? startSize - endSize : 0;
43
+
44
+ if (Number.isNaN(pauseMs)) return undefined;
45
+
46
+ return { type, pauseMs, allocated, collected, promoted, survived };
47
+ }
48
+
49
+ /** Parse name=value pairs from trace-gc-nvp line */
50
+ function parseNvpFields(line: string): Record<string, string> {
51
+ const fields: Record<string, string> = {};
52
+ // Format: "key=value, key=value, ..." or "key=value key=value"
53
+ const matches = line.matchAll(/(\w+)=([^\s,]+)/g);
54
+ for (const [, key, value] of matches) {
55
+ fields[key] = value;
56
+ }
57
+ return fields;
58
+ }
59
+
60
+ /** Map V8 gc type codes to our types */
61
+ function parseGcType(gcField: string): GcEvent["type"] {
62
+ // V8 uses: s=scavenge, mc=mark-compact, mmc=minor-mc (young gen mark-compact)
63
+ if (gcField === "s" || gcField === "scavenge") return "scavenge";
64
+ if (gcField === "mc" || gcField === "ms" || gcField === "mark-compact")
65
+ return "mark-compact";
66
+ if (gcField === "mmc" || gcField === "minor-mc" || gcField === "minor-ms")
67
+ return "minor-ms";
68
+ return "unknown";
69
+ }
70
+
71
+ /** Aggregate GC events into summary stats */
72
+ export function aggregateGcStats(events: GcEvent[]): GcStats {
73
+ let scavenges = 0;
74
+ let markCompacts = 0;
75
+ let gcPauseTime = 0;
76
+ let totalCollected = 0;
77
+ let hasNodeFields = false;
78
+ let totalAllocated = 0;
79
+ let totalPromoted = 0;
80
+ let totalSurvived = 0;
81
+
82
+ for (const e of events) {
83
+ if (e.type === "scavenge" || e.type === "minor-ms") scavenges++;
84
+ else if (e.type === "mark-compact") markCompacts++;
85
+ gcPauseTime += e.pauseMs;
86
+ totalCollected += e.collected;
87
+ if (e.allocated != null) {
88
+ hasNodeFields = true;
89
+ totalAllocated += e.allocated;
90
+ totalPromoted += e.promoted ?? 0;
91
+ totalSurvived += e.survived ?? 0;
92
+ }
93
+ }
94
+
95
+ return {
96
+ scavenges,
97
+ markCompacts,
98
+ totalCollected,
99
+ gcPauseTime,
100
+ ...(hasNodeFields && { totalAllocated, totalPromoted, totalSurvived }),
101
+ };
102
+ }
103
+
104
+ /** @return GcStats with all counters zeroed */
105
+ export function emptyGcStats(): GcStats {
106
+ return { scavenges: 0, markCompacts: 0, totalCollected: 0, gcPauseTime: 0 };
107
+ }