overtake 1.0.5 → 1.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 (51) hide show
  1. package/README.md +25 -29
  2. package/build/cli.cjs +43 -33
  3. package/build/cli.cjs.map +1 -1
  4. package/build/cli.js +42 -32
  5. package/build/cli.js.map +1 -1
  6. package/build/executor.cjs +6 -3
  7. package/build/executor.cjs.map +1 -1
  8. package/build/executor.d.ts +3 -2
  9. package/build/executor.js +6 -3
  10. package/build/executor.js.map +1 -1
  11. package/build/gc-watcher.cjs +31 -0
  12. package/build/gc-watcher.cjs.map +1 -0
  13. package/build/gc-watcher.d.ts +9 -0
  14. package/build/gc-watcher.js +21 -0
  15. package/build/gc-watcher.js.map +1 -0
  16. package/build/index.cjs +9 -1
  17. package/build/index.cjs.map +1 -1
  18. package/build/index.d.ts +1 -1
  19. package/build/index.js +9 -1
  20. package/build/index.js.map +1 -1
  21. package/build/runner.cjs +226 -24
  22. package/build/runner.cjs.map +1 -1
  23. package/build/runner.d.ts +1 -1
  24. package/build/runner.js +226 -24
  25. package/build/runner.js.map +1 -1
  26. package/build/types.cjs.map +1 -1
  27. package/build/types.d.ts +4 -0
  28. package/build/types.js.map +1 -1
  29. package/build/utils.cjs +21 -0
  30. package/build/utils.cjs.map +1 -1
  31. package/build/utils.d.ts +1 -0
  32. package/build/utils.js +18 -0
  33. package/build/utils.js.map +1 -1
  34. package/build/worker.cjs +95 -14
  35. package/build/worker.cjs.map +1 -1
  36. package/build/worker.d.ts +1 -1
  37. package/build/worker.js +54 -8
  38. package/build/worker.js.map +1 -1
  39. package/examples/accuracy.ts +54 -0
  40. package/examples/custom-reports.ts +0 -1
  41. package/examples/imports.ts +3 -7
  42. package/examples/quick-start.ts +2 -0
  43. package/package.json +10 -9
  44. package/src/cli.ts +42 -29
  45. package/src/executor.ts +8 -2
  46. package/src/gc-watcher.ts +23 -0
  47. package/src/index.ts +11 -0
  48. package/src/runner.ts +265 -23
  49. package/src/types.ts +4 -0
  50. package/src/utils.ts +20 -0
  51. package/src/worker.ts +59 -9
package/src/runner.ts CHANGED
@@ -1,23 +1,177 @@
1
+ import { performance, PerformanceObserver } from 'node:perf_hooks';
1
2
  import { Options, Control } from './types.js';
3
+ import { GCWatcher } from './gc-watcher.js';
4
+ import { StepFn, MaybePromise } from './types.js';
2
5
 
3
6
  const COMPLETE_VALUE = 100_00;
4
7
 
8
+ const hr = process.hrtime.bigint.bind(process.hrtime);
9
+
5
10
  const runSync = (run: Function) => {
6
11
  return (...args: unknown[]) => {
7
- const start = process.hrtime.bigint();
12
+ const start = hr();
8
13
  run(...args);
9
- return process.hrtime.bigint() - start;
14
+ return hr() - start;
10
15
  };
11
16
  };
12
17
 
13
18
  const runAsync = (run: Function) => {
14
19
  return async (...args: unknown[]) => {
15
- const start = process.hrtime.bigint();
20
+ const start = hr();
16
21
  await run(...args);
17
- return process.hrtime.bigint() - start;
22
+ return hr() - start;
18
23
  };
19
24
  };
20
25
 
26
+ const TARGET_SAMPLE_NS = 1_000_000n; // aim for ~1ms per measured sample
27
+ const MAX_BATCH = 1_048_576;
28
+ const PROGRESS_STRIDE = 16;
29
+ const GC_STRIDE = 32;
30
+ const OUTLIER_MULTIPLIER = 4;
31
+ const OUTLIER_IQR_MULTIPLIER = 3;
32
+ const OUTLIER_WINDOW = 64;
33
+
34
+ type GCEvent = { start: number; end: number };
35
+
36
+ const collectSample = async <TContext, TInput>(
37
+ batchSize: number,
38
+ run: (ctx: TContext, data: TInput) => MaybePromise<bigint>,
39
+ pre: StepFn<TContext, TInput> | undefined,
40
+ post: StepFn<TContext, TInput> | undefined,
41
+ context: TContext,
42
+ data: TInput,
43
+ ) => {
44
+ let sampleDuration = 0n;
45
+ for (let b = 0; b < batchSize; b++) {
46
+ await pre?.(context, data);
47
+ sampleDuration += await run(context, data);
48
+ await post?.(context, data);
49
+ }
50
+ return sampleDuration / BigInt(batchSize);
51
+ };
52
+
53
+ const tuneParameters = async <TContext, TInput>({
54
+ initialBatch,
55
+ run,
56
+ pre,
57
+ post,
58
+ context,
59
+ data,
60
+ minCycles,
61
+ relThreshold,
62
+ maxCycles,
63
+ }: {
64
+ initialBatch: number;
65
+ run: (ctx: TContext, data: TInput) => MaybePromise<bigint>;
66
+ pre?: StepFn<TContext, TInput>;
67
+ post?: StepFn<TContext, TInput>;
68
+ context: TContext;
69
+ data: TInput;
70
+ minCycles: number;
71
+ relThreshold: number;
72
+ maxCycles: number;
73
+ }) => {
74
+ let batchSize = initialBatch;
75
+ let bestCv = Number.POSITIVE_INFINITY;
76
+ let bestBatch = batchSize;
77
+
78
+ for (let attempt = 0; attempt < 3; attempt++) {
79
+ const samples: number[] = [];
80
+ const sampleCount = Math.min(8, maxCycles);
81
+ for (let s = 0; s < sampleCount; s++) {
82
+ const duration = await collectSample(batchSize, run, pre, post, context, data);
83
+ samples.push(Number(duration));
84
+ }
85
+ const mean = samples.reduce((acc, v) => acc + v, 0) / samples.length;
86
+ const variance = samples.reduce((acc, v) => acc + (v - mean) * (v - mean), 0) / Math.max(1, samples.length - 1);
87
+ const stddev = Math.sqrt(variance);
88
+ const cv = mean === 0 ? Number.POSITIVE_INFINITY : stddev / mean;
89
+
90
+ if (cv < bestCv) {
91
+ bestCv = cv;
92
+ bestBatch = batchSize;
93
+ }
94
+
95
+ if (cv <= relThreshold || batchSize >= MAX_BATCH) {
96
+ break;
97
+ }
98
+ batchSize = Math.min(MAX_BATCH, batchSize * 2);
99
+ }
100
+
101
+ const tunedRel = bestCv < relThreshold ? Math.max(bestCv * 1.5, relThreshold * 0.5) : relThreshold;
102
+ const tunedMin = Math.min(maxCycles, Math.max(minCycles, Math.ceil(minCycles * Math.max(1, bestCv / (relThreshold || 1e-6)))));
103
+
104
+ return { batchSize: bestBatch, relThreshold: tunedRel, minCycles: tunedMin };
105
+ };
106
+
107
+ const createGCTracker = () => {
108
+ if (process.env.OVERTAKE_GC_OBSERVER !== '1') {
109
+ return null;
110
+ }
111
+ if (typeof PerformanceObserver === 'undefined') {
112
+ return null;
113
+ }
114
+
115
+ const events: GCEvent[] = [];
116
+ const observer = new PerformanceObserver((list) => {
117
+ for (const entry of list.getEntries()) {
118
+ events.push({ start: entry.startTime, end: entry.startTime + entry.duration });
119
+ }
120
+ });
121
+
122
+ try {
123
+ observer.observe({ entryTypes: ['gc'] });
124
+ } catch {
125
+ return null;
126
+ }
127
+
128
+ const overlaps = (start: number, end: number) => {
129
+ let noisy = false;
130
+ for (let i = events.length - 1; i >= 0; i--) {
131
+ const event = events[i];
132
+ if (event.end < start - 5_000) {
133
+ events.splice(i, 1);
134
+ continue;
135
+ }
136
+ if (event.start <= end && event.end >= start) {
137
+ noisy = true;
138
+ }
139
+ }
140
+ return noisy;
141
+ };
142
+
143
+ const dispose = () => observer.disconnect();
144
+
145
+ return { overlaps, dispose };
146
+ };
147
+
148
+ const pushWindow = (arr: number[], value: number, cap: number) => {
149
+ if (arr.length === cap) {
150
+ arr.shift();
151
+ }
152
+ arr.push(value);
153
+ };
154
+
155
+ const medianAndIqr = (arr: number[]) => {
156
+ if (arr.length === 0) return { median: 0, iqr: 0 };
157
+ const sorted = [...arr].sort((a, b) => a - b);
158
+ const mid = Math.floor(sorted.length / 2);
159
+ const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
160
+ const q1Idx = Math.floor(sorted.length * 0.25);
161
+ const q3Idx = Math.floor(sorted.length * 0.75);
162
+ const q1 = sorted[q1Idx];
163
+ const q3 = sorted[q3Idx];
164
+ return { median, iqr: q3 - q1 };
165
+ };
166
+
167
+ const windowCv = (arr: number[]) => {
168
+ if (arr.length < 2) return Number.POSITIVE_INFINITY;
169
+ const mean = arr.reduce((a, v) => a + v, 0) / arr.length;
170
+ const variance = arr.reduce((a, v) => a + (v - mean) * (v - mean), 0) / (arr.length - 1);
171
+ const stddev = Math.sqrt(variance);
172
+ return mean === 0 ? Number.POSITIVE_INFINITY : stddev / mean;
173
+ };
174
+
21
175
  export const benchmark = async <TContext, TInput>({
22
176
  setup,
23
177
  teardown,
@@ -30,6 +184,7 @@ export const benchmark = async <TContext, TInput>({
30
184
  minCycles,
31
185
  absThreshold,
32
186
  relThreshold,
187
+ gcObserver = false,
33
188
 
34
189
  durationsSAB,
35
190
  controlSAB,
@@ -43,47 +198,133 @@ export const benchmark = async <TContext, TInput>({
43
198
 
44
199
  const context = (await setup?.()) as TContext;
45
200
  const maxCycles = durations.length;
201
+ const gcWatcher = new GCWatcher();
202
+ const gcTracker = gcObserver ? createGCTracker() : null;
46
203
 
47
204
  try {
205
+ // classify sync/async and capture initial duration
48
206
  await pre?.(context, data!);
49
- const result = runRaw(context, data!);
207
+ const probeStart = hr();
208
+ const probeResult = runRaw(context, data!);
209
+ const isAsync = probeResult instanceof Promise;
210
+ if (isAsync) {
211
+ await probeResult;
212
+ }
213
+ const durationProbe = hr() - probeStart;
50
214
  await post?.(context, data!);
51
- global.gc?.();
52
- global.gc?.();
53
215
 
54
- const run = result instanceof Promise ? runAsync(runRaw) : runSync(runRaw);
55
- const start = Date.now();
56
- while (Date.now() - start < 1_000) {
57
- Math.sqrt(Math.random());
216
+ const run = isAsync ? runAsync(runRaw) : runSync(runRaw);
217
+
218
+ // choose batch size to amortize timer overhead
219
+ const durationPerRun = durationProbe === 0n ? 1n : durationProbe;
220
+ const suggestedBatch = Number(TARGET_SAMPLE_NS / durationPerRun);
221
+ const initialBatchSize = Math.min(MAX_BATCH, Math.max(1, suggestedBatch));
222
+
223
+ // auto-tune based on warmup samples
224
+ const tuned = await tuneParameters({
225
+ initialBatch: initialBatchSize,
226
+ run,
227
+ pre,
228
+ post,
229
+ context,
230
+ data: data as TInput,
231
+ minCycles,
232
+ relThreshold,
233
+ maxCycles,
234
+ });
235
+ let batchSize = tuned.batchSize;
236
+ minCycles = tuned.minCycles;
237
+ relThreshold = tuned.relThreshold;
238
+
239
+ // warmup: run until requested cycles, adapt if unstable
240
+ const warmupStart = Date.now();
241
+ let warmupRemaining = warmupCycles;
242
+ const warmupWindow: number[] = [];
243
+ const warmupCap = Math.max(warmupCycles, Math.min(maxCycles, warmupCycles * 4 || 1000));
244
+
245
+ while (Date.now() - warmupStart < 1_000 && warmupRemaining > 0) {
246
+ const start = hr();
247
+ await pre?.(context, data!);
248
+ await run(context, data);
249
+ await post?.(context, data!);
250
+ pushWindow(warmupWindow, Number(hr() - start), warmupCap);
251
+ warmupRemaining--;
58
252
  }
59
- for (let i = 0; i < warmupCycles; i++) {
253
+ let warmupDone = 0;
254
+ while (warmupDone < warmupRemaining) {
255
+ const start = hr();
60
256
  await pre?.(context, data!);
61
257
  await run(context, data);
62
258
  await post?.(context, data!);
63
- global.gc?.();
64
- global.gc?.();
259
+ pushWindow(warmupWindow, Number(hr() - start), warmupCap);
260
+ warmupDone++;
261
+ if (global.gc && warmupDone % GC_STRIDE === 0) {
262
+ global.gc();
263
+ }
264
+ }
265
+ while (warmupWindow.length >= 8 && warmupWindow.length < warmupCap) {
266
+ const cv = windowCv(warmupWindow);
267
+ if (cv <= relThreshold * 2) {
268
+ break;
269
+ }
270
+ const start = hr();
271
+ await pre?.(context, data!);
272
+ await run(context, data);
273
+ await post?.(context, data!);
274
+ pushWindow(warmupWindow, Number(hr() - start), warmupCap);
65
275
  }
66
276
 
67
277
  let i = 0;
68
278
  let mean = 0n;
69
279
  let m2 = 0n;
280
+ const outlierWindow: number[] = [];
70
281
 
71
282
  while (true) {
72
283
  if (i >= maxCycles) break;
73
284
 
74
- await pre?.(context, data!);
75
- const duration = await run(context, data);
76
- await post?.(context, data!);
77
- global.gc?.();
78
- global.gc?.();
285
+ const gcMarker = gcWatcher.start();
286
+ const sampleStart = performance.now();
287
+ let sampleDuration = 0n;
288
+ for (let b = 0; b < batchSize; b++) {
289
+ await pre?.(context, data!);
290
+ sampleDuration += await run(context, data);
291
+ await post?.(context, data!);
292
+ if (global.gc && (i + b) % GC_STRIDE === 0) {
293
+ global.gc();
294
+ }
295
+ }
79
296
 
80
- durations[i++] = duration;
81
- const delta = duration - mean;
297
+ // normalize by batch size
298
+ sampleDuration /= BigInt(batchSize);
299
+
300
+ const sampleEnd = performance.now();
301
+ const gcNoise = gcWatcher.seen(gcMarker) || (gcTracker?.overlaps(sampleStart, sampleEnd) ?? false);
302
+ if (gcNoise) {
303
+ continue;
304
+ }
305
+
306
+ const durationNumber = Number(sampleDuration);
307
+ pushWindow(outlierWindow, durationNumber, OUTLIER_WINDOW);
308
+ const { median, iqr } = medianAndIqr(outlierWindow);
309
+ const maxAllowed = median + OUTLIER_IQR_MULTIPLIER * iqr || Number.POSITIVE_INFINITY;
310
+ if (outlierWindow.length >= 8 && durationNumber > maxAllowed) {
311
+ continue;
312
+ }
313
+
314
+ const meanNumber = Number(mean);
315
+ if (i >= 8 && meanNumber > 0 && durationNumber > OUTLIER_MULTIPLIER * meanNumber) {
316
+ continue;
317
+ }
318
+
319
+ durations[i++] = sampleDuration;
320
+ const delta = sampleDuration - mean;
82
321
  mean += delta / BigInt(i);
83
- m2 += delta * (duration - mean);
322
+ m2 += delta * (sampleDuration - mean);
84
323
 
85
324
  const progress = Math.max(i / maxCycles) * COMPLETE_VALUE;
86
- control[Control.PROGRESS] = progress;
325
+ if (i % PROGRESS_STRIDE === 0) {
326
+ control[Control.PROGRESS] = progress;
327
+ }
87
328
 
88
329
  if (i >= minCycles) {
89
330
  const variance = Number(m2) / (i - 1);
@@ -106,6 +347,7 @@ export const benchmark = async <TContext, TInput>({
106
347
  console.error(e && typeof e === 'object' && 'stack' in e ? e.stack : e);
107
348
  control[Control.COMPLETE] = 1;
108
349
  } finally {
350
+ gcTracker?.dispose?.();
109
351
  try {
110
352
  await teardown?.(context);
111
353
  } catch (e) {
package/src/types.ts CHANGED
@@ -33,9 +33,12 @@ export interface BenchmarkOptions {
33
33
  minCycles?: number;
34
34
  absThreshold?: number; // ns
35
35
  relThreshold?: number; // %
36
+ gcObserver?: boolean;
37
+ baseUrl?: string;
36
38
  }
37
39
 
38
40
  export interface RunOptions<TContext, TInput> {
41
+ baseUrl?: string;
39
42
  setup?: SetupFn<TContext>;
40
43
  teardown?: TeardownFn<TContext>;
41
44
  pre?: StepFn<TContext, TInput>;
@@ -45,6 +48,7 @@ export interface RunOptions<TContext, TInput> {
45
48
  }
46
49
 
47
50
  export interface WorkerOptions extends Required<BenchmarkOptions> {
51
+ baseUrl: string;
48
52
  setupCode?: string;
49
53
  teardownCode?: string;
50
54
  preCode?: string;
package/src/utils.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { transform } from '@swc/core';
2
+
1
3
  export const abs = (value: bigint) => {
2
4
  if (value < 0n) {
3
5
  return -value;
@@ -63,3 +65,21 @@ export class ScaledBigInt {
63
65
  return Number(div(this.value, this.scale));
64
66
  }
65
67
  }
68
+
69
+ export const transpile = async (code: string): Promise<string> => {
70
+ const output = await transform(code, {
71
+ filename: 'benchmark.ts',
72
+ jsc: {
73
+ parser: {
74
+ syntax: 'typescript',
75
+ tsx: false,
76
+ dynamicImport: true,
77
+ },
78
+ target: 'esnext',
79
+ },
80
+ module: {
81
+ type: 'es6',
82
+ },
83
+ });
84
+ return output.code;
85
+ };
package/src/worker.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { workerData } from 'node:worker_threads';
2
+ import { SourceTextModule, SyntheticModule, createContext } from 'node:vm';
3
+ import { createRequire } from 'node:module';
4
+ import { fileURLToPath } from 'node:url';
2
5
  import { benchmark } from './runner.js';
3
- import { SetupFn, TeardownFn, StepFn, WorkerOptions } from './types.js';
6
+ import { WorkerOptions } from './types.js';
4
7
 
5
8
  const {
9
+ baseUrl,
6
10
  setupCode,
7
11
  teardownCode,
8
12
  preCode,
@@ -14,19 +18,66 @@ const {
14
18
  minCycles,
15
19
  absThreshold,
16
20
  relThreshold,
21
+ gcObserver = true,
17
22
 
18
23
  durationsSAB,
19
24
  controlSAB,
20
25
  }: WorkerOptions = workerData;
21
26
 
22
- const setup: SetupFn<unknown> = setupCode && Function(`return ${setupCode};`)();
23
- const teardown: TeardownFn<unknown> = teardownCode && Function(`return ${teardownCode};`)();
27
+ const serialize = (code?: string) => (code ? code : '() => {}');
24
28
 
25
- const pre: StepFn<unknown, unknown> = preCode && Function(`return ${preCode};`)();
26
- const run: StepFn<unknown, unknown> = runCode && Function(`return ${runCode};`)();
27
- const post: StepFn<unknown, unknown> = postCode && Function(`return ${postCode};`)();
29
+ const source = `
30
+ export const setup = ${serialize(setupCode)};
31
+ export const teardown = ${serialize(teardownCode)};
32
+ export const pre = ${serialize(preCode)};
33
+ export const run = ${serialize(runCode)};
34
+ export const post = ${serialize(postCode)};
35
+ `;
28
36
 
29
- export const exitCode = await benchmark({
37
+ const context = createContext({ console, Buffer });
38
+ const imports = new Map<string, SyntheticModule>();
39
+ const mod = new SourceTextModule(source, {
40
+ identifier: baseUrl,
41
+ context,
42
+ initializeImportMeta(meta) {
43
+ meta.url = baseUrl;
44
+ },
45
+ importModuleDynamically(specifier, referencingModule) {
46
+ const base = referencingModule.identifier ?? baseUrl;
47
+ const resolveFrom = createRequire(fileURLToPath(base));
48
+ return import(resolveFrom.resolve(specifier));
49
+ },
50
+ });
51
+
52
+ await mod.link(async (specifier, referencingModule) => {
53
+ const base = referencingModule.identifier ?? baseUrl;
54
+ const resolveFrom = createRequire(fileURLToPath(base));
55
+ const target = resolveFrom.resolve(specifier);
56
+ const cached = imports.get(target);
57
+ if (cached) return cached;
58
+
59
+ const importedModule = await import(target);
60
+ const exportNames = Object.keys(importedModule);
61
+ const imported = new SyntheticModule(
62
+ exportNames,
63
+ () => {
64
+ exportNames.forEach((key) => imported.setExport(key, importedModule[key]));
65
+ },
66
+ { identifier: target, context: referencingModule.context },
67
+ );
68
+ imports.set(target, imported);
69
+ return imported;
70
+ });
71
+
72
+ await mod.evaluate();
73
+ const { setup, teardown, pre, run, post } = mod.namespace as any;
74
+
75
+ if (!run) {
76
+ throw new Error('Benchmark run function is required');
77
+ }
78
+
79
+ process.exitCode = await benchmark({
80
+ baseUrl,
30
81
  setup,
31
82
  teardown,
32
83
  pre,
@@ -38,9 +89,8 @@ export const exitCode = await benchmark({
38
89
  minCycles,
39
90
  absThreshold,
40
91
  relThreshold,
92
+ gcObserver,
41
93
 
42
94
  durationsSAB,
43
95
  controlSAB,
44
96
  });
45
-
46
- process.exit(exitCode);