@cosyte/bench 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * The benchmark constants, frozen.
3
+ *
4
+ * Every value here is fixed by the performance standard,
5
+ * `documentation/decisions/0005-the-performance-standard.md` in the `config` repo, which says for
6
+ * each one whether it was measured or chosen, and on what basis. The warmup rule is not here: it
7
+ * is `PERF_CONTRACT` in `@cosyte/test-utils/perf` (ADR 0001 section 2), which `measure.ts` imports
8
+ * and the build bundles, so the scaling gate and the benchmarks warm up by one rule kept in one
9
+ * place.
10
+ *
11
+ * They are not tuning knobs and cannot be overridden per package. A comparison's false alarm rate
12
+ * was measured with these values; change one and the measurement no longer describes the tool.
13
+ *
14
+ * @packageDocumentation
15
+ */
16
+ /** The benchmark constants. See {@link BENCH_CONTRACT}. */
17
+ interface BenchContractShape {
18
+ /** Timed samples per case per process. Each sample is whole passes over the corpus. */
19
+ readonly SAMPLES: number;
20
+ /** Minimum timed length of one sample, in milliseconds. A sample runs whole passes until it is reached. */
21
+ readonly SAMPLE_MIN_MS: number;
22
+ /** Rounds in one base against head comparison. Each round runs both sides once, in fresh processes. */
23
+ readonly ROUNDS: number;
24
+ /** One-sided error allowed at each end of the comparison's interval on the median ratio. */
25
+ readonly ALPHA: number;
26
+ /** The smallest slowdown a comparison will call a regression, as a fraction: 0.03 is 3%. */
27
+ readonly THRESHOLD: number;
28
+ /** Time budget for the latency calls of one case, in milliseconds. */
29
+ readonly LATENCY_BUDGET_MS: number;
30
+ /** Most latency calls taken for one case. */
31
+ readonly LATENCY_MAX_CALLS: number;
32
+ /** Fewest latency calls taken for one case, whatever the budget says. */
33
+ readonly LATENCY_MIN_CALLS: number;
34
+ /** A percentile q is reported only when at least this many calls lie above it: n(1 - q) >= 10. */
35
+ readonly LATENCY_TAIL_CALLS: number;
36
+ /** The median call must be at least this many timer reads long, or latency is skipped. */
37
+ readonly LATENCY_TIMER_MULTIPLE: number;
38
+ /** Most operations in the first allocation window. Halved while a garbage collection lands in a window. */
39
+ readonly ALLOC_WINDOW_OPS: number;
40
+ /** The first allocation window is cut to about this many milliseconds of operations, from the warmup's pace. */
41
+ readonly ALLOC_WINDOW_MS: number;
42
+ /** The young generation size the allocation process runs with, in MiB, so a window rarely collects. */
43
+ readonly ALLOC_SEMI_SPACE_MIB: number;
44
+ /** Calls measured one by one for the peak heap. */
45
+ readonly PEAK_CALLS: number;
46
+ /** Rounds in the long run that checks the heap stays flat. */
47
+ readonly LONG_RUN_ROUNDS: number;
48
+ /** Rounds at the start of the long run left out of the slope, while caches and compiled code settle. */
49
+ readonly LONG_RUN_SETTLE_ROUNDS: number;
50
+ /** Minimum length of one long run round, in milliseconds, fixed as a number of operations by the first. */
51
+ readonly LONG_RUN_ROUND_MS: number;
52
+ /** A long run whose fitted growth over the measured rounds reaches this many bytes is reported as growing. */
53
+ readonly LONG_RUN_GROWTH_BYTES: number;
54
+ /** A garbage collection round that reclaims less than this, in bytes, counts as settled (ADR 0001 section 8). */
55
+ readonly GC_SETTLE_BYTES: number;
56
+ /** Consecutive settled rounds needed (ADR 0001 section 8). */
57
+ readonly GC_SETTLE_ROUNDS: number;
58
+ /** Most garbage collection rounds before giving up (ADR 0001 section 8). */
59
+ readonly GC_MAX_ROUNDS: number;
60
+ /** Fresh processes timed for the cold import, for the target and for an empty module each. */
61
+ readonly IMPORT_RUNS: number;
62
+ /**
63
+ * A case whose last three warmup batches each took at least this many milliseconds a pass runs on
64
+ * the large-case plan: every batch is one pass, so the warmup rule's aggregation cannot happen.
65
+ */
66
+ readonly LARGE_PASS_MS: number;
67
+ /** Passes in each of the two windows whose medians the large-case warmup compares. */
68
+ readonly LARGE_WARMUP_WINDOW: number;
69
+ /** Hard cap on the large-case warmup, in milliseconds, from the start of the warmup. */
70
+ readonly LARGE_WARMUP_MAX_MS: number;
71
+ /**
72
+ * The long run also runs on a benchmark's largest size whose rounds fit in this many milliseconds,
73
+ * a round being at least one pass.
74
+ */
75
+ readonly LONG_RUN_MAX_MS: number;
76
+ /** The timing comparison may become a required check when A/A false alarms are under this rate. */
77
+ readonly REQUIRED_FALSE_ALARM_RATE: number;
78
+ /** The confidence that the false alarm rate is under the line, for the rule above. */
79
+ readonly REQUIRED_CONFIDENCE: number;
80
+ }
81
+ /**
82
+ * The frozen benchmark constants.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * import { BENCH_CONTRACT } from "@cosyte/bench";
87
+ *
88
+ * BENCH_CONTRACT.ROUNDS; // => 12
89
+ * BENCH_CONTRACT.THRESHOLD; // => 0.03
90
+ * ```
91
+ */
92
+ declare const BENCH_CONTRACT: BenchContractShape;
93
+
94
+ /**
95
+ * How a package describes its benchmarks.
96
+ *
97
+ * A suite is a module whose default export is {@link defineSuite}'s result. It is handed the module
98
+ * under test (`bind`) rather than importing it, so one suite file measures the base build and the
99
+ * head build of a comparison. Every corpus is built in code (`build`): the runner never takes a data
100
+ * file, which is how its inputs stay synthetic by construction.
101
+ *
102
+ * @packageDocumentation
103
+ */
104
+ /** One corpus: the inputs one pass runs over, built outside every timed region. */
105
+ interface BenchCorpus<TInput> {
106
+ /** The inputs. One pass calls the measured function once on each. */
107
+ readonly inputs: readonly TInput[];
108
+ /** Bytes one pass handles, when it means something. Turns operations a second into bytes a second. */
109
+ readonly bytes?: number;
110
+ /** Called once the process has finished with this corpus, for example to remove a temporary directory. */
111
+ readonly teardown?: () => void;
112
+ }
113
+ /** One input size of a benchmark: small, large, or anything between. */
114
+ interface BenchSize<TInput> {
115
+ /** How a reader names this size, for example `"1 KiB"`. Unique within its benchmark. */
116
+ readonly label: string;
117
+ /** Build the corpus. Called once per process, before anything is timed. */
118
+ readonly build: () => BenchCorpus<TInput>;
119
+ /**
120
+ * Whether the base against head comparison runs this size. Defaults to `true`. Set `false` for a
121
+ * size whose processes are too slow for a pull request's time budget; a reading still measures it.
122
+ */
123
+ readonly compare?: boolean;
124
+ }
125
+ /** One benchmark: a function measured at one or more input sizes. */
126
+ interface Benchmark<TTarget, TInput, TResult> {
127
+ /** Unique within the suite. */
128
+ readonly name: string;
129
+ /** The sizes, smallest first. Each size is measured on its own. */
130
+ readonly sizes: readonly BenchSize<TInput>[];
131
+ /** Take the imported module under test and return the function to measure. */
132
+ readonly bind: (target: TTarget) => (input: TInput) => TResult;
133
+ /**
134
+ * Reduce a result to a number summed into a sink the runner checks, so the compiler cannot delete
135
+ * the measured call. Defaults to 1 for each result that is not `null` or `undefined`; supply it
136
+ * when the function returns nothing. The sum must grow over a measurement, so return a positive
137
+ * number for at least some results; a run whose sink did not grow is refused.
138
+ */
139
+ readonly weigh?: (result: TResult) => number;
140
+ /** The size label the long run (is the heap flat?) uses. Defaults to the first size. */
141
+ readonly longRun?: string;
142
+ }
143
+ /** A benchmark with its input and result types erased, as a suite holds it. */
144
+ type AnyBenchmark<TTarget> = Benchmark<TTarget, unknown, unknown>;
145
+ /** A suite: the benchmarks of one package. */
146
+ interface BenchSuite<TTarget> {
147
+ /** Shown in every report, for example `"@cosyte/script-utils phi-scan"`. */
148
+ readonly name: string;
149
+ /** At least one. */
150
+ readonly benchmarks: readonly AnyBenchmark<TTarget>[];
151
+ }
152
+ /**
153
+ * Type one benchmark. Only a type helper: it returns its argument, with the input and result types
154
+ * erased so benchmarks of different shapes can sit in one suite.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * import { defineBenchmark } from "@cosyte/bench";
159
+ *
160
+ * const b = defineBenchmark({
161
+ * name: "split",
162
+ * sizes: [{ label: "1 KiB", build: () => ({ inputs: ["a|b".repeat(341)] }) }],
163
+ * bind: () => (s: string) => s.split("|"),
164
+ * });
165
+ * b.name; // => "split"
166
+ * ```
167
+ */
168
+ declare function defineBenchmark<TTarget, TInput, TResult>(benchmark: Benchmark<TTarget, TInput, TResult>): AnyBenchmark<TTarget>;
169
+ /**
170
+ * Check a suite and freeze it. Throws a `TypeError` naming the problem: an empty name, no
171
+ * benchmarks, a repeated name or label, a benchmark with no sizes, or a `longRun` naming no size.
172
+ *
173
+ * @example
174
+ * ```ts
175
+ * import { defineBenchmark, defineSuite } from "@cosyte/bench";
176
+ *
177
+ * export default defineSuite({
178
+ * name: "example",
179
+ * benchmarks: [
180
+ * defineBenchmark({
181
+ * name: "split",
182
+ * sizes: [{ label: "1 KiB", build: () => ({ inputs: ["a|b".repeat(341)] }) }],
183
+ * bind: () => (s: string) => s.split("|"),
184
+ * }),
185
+ * ],
186
+ * });
187
+ * ```
188
+ */
189
+ declare function defineSuite<TTarget>(suite: BenchSuite<TTarget>): BenchSuite<TTarget>;
190
+ /** One case: a benchmark at one size. */
191
+ interface CaseRef {
192
+ readonly benchmark: string;
193
+ readonly size: string;
194
+ }
195
+
196
+ /**
197
+ * The timing primitives: the warmup, the throughput samples and the latency calls. They run inside
198
+ * a measuring process, one case per process, and take their clocks as arguments so their tests can
199
+ * drive them with exact arithmetic instead of a machine's noise.
200
+ *
201
+ * @packageDocumentation
202
+ */
203
+ /**
204
+ * The warmup rule, read from `PERF_CONTRACT` in `@cosyte/test-utils/perf` (ADR 0001 section 2) and
205
+ * renamed, so the scaling gate and the benchmarks warm up by one rule kept in one place.
206
+ *
207
+ * @internal
208
+ */
209
+ declare const WARMUP: Readonly<{
210
+ MIN_MS: number;
211
+ BATCH_MS: number;
212
+ MAX_MS: number;
213
+ STABLE_TOL: number;
214
+ STABLE_BATCHES: number;
215
+ }>;
216
+ /**
217
+ * Which warmup rule a process ended on. `standard` is ADR 0001 section 2's. `large` is the
218
+ * large-case plan, for a case whose every batch is a single pass: see {@link warmUp}.
219
+ */
220
+ type WarmupPlan = "standard" | "large";
221
+ /** What the warmup did. */
222
+ interface WarmupReport {
223
+ /**
224
+ * Whether the warmup settled: on the standard plan, three batches in a row within 5% of their
225
+ * median before 5 s ran out; on the large-case plan, two windows of passes with medians within 5%
226
+ * of each other before 30 s ran out.
227
+ */
228
+ readonly stable: boolean;
229
+ /** Wall time spent warming, in milliseconds. */
230
+ readonly elapsedMs: number;
231
+ /** Passes over the corpus. */
232
+ readonly passes: number;
233
+ /** Each batch's milliseconds per pass. On the large-case plan every batch after the switch is one pass. */
234
+ readonly batches: readonly number[];
235
+ /** The rule the warmup ended on. */
236
+ readonly plan: WarmupPlan;
237
+ }
238
+ /** Per-call latency, or why there is none. */
239
+ type LatencyReport = {
240
+ readonly status: "measured";
241
+ readonly calls: number;
242
+ readonly timerOverheadNs: number;
243
+ /** `null` when fewer than `LATENCY_TAIL_CALLS` calls lie above the percentile. */
244
+ readonly p50Ns: number | null;
245
+ readonly p95Ns: number | null;
246
+ readonly p99Ns: number | null;
247
+ readonly maxNs: number;
248
+ } | {
249
+ readonly status: "skipped";
250
+ readonly reason: "latency-below-timer-resolution";
251
+ readonly calls: number;
252
+ readonly timerOverheadNs: number;
253
+ readonly p50Ns: number;
254
+ };
255
+
256
+ /**
257
+ * The memory measurements: allocations per operation, peak heap per call, and whether the heap stays
258
+ * flat over a long run. Each runs in a measuring process started with `--expose-gc`, one case per
259
+ * process.
260
+ *
261
+ * They read the heap through a {@link HeapProbe}, so their tests can drive them with a scripted heap.
262
+ * The real probe follows ADR 0001 section 8: a zero-argument `gc()` only, settled by rule rather than
263
+ * by a count. The measuring process also runs with `--no-concurrent-array-buffer-sweeping`, so a
264
+ * collection frees the ArrayBuffers it found dead before it ends, and the external memory a
265
+ * collection reports before and after is what it held and what it kept.
266
+ *
267
+ * @packageDocumentation
268
+ */
269
+
270
+ /**
271
+ * Why a memory measurement has no number. `allocation-window-gc` is no longer written: a case whose
272
+ * every window collects is now counted with its collections. It stays here because documents
273
+ * written before carry it.
274
+ */
275
+ type MemorySkipReason = "gc-unavailable" | "gc-unsettled" | "allocation-window-gc";
276
+ /**
277
+ * How allocations were counted. `no-gc-window`: the heap's growth over windows in which no
278
+ * collection ran. `gc-accounted`: every window collected, so the growth over each window plus what
279
+ * its collections reclaimed.
280
+ */
281
+ type AllocationMethod = "no-gc-window" | "gc-accounted";
282
+ /** Allocations per operation. */
283
+ type AllocationReport = {
284
+ readonly status: "measured";
285
+ /** How they were counted. Absent in a document written before it was recorded: `no-gc-window`. */
286
+ readonly method?: AllocationMethod;
287
+ /** Collections that ran in the two windows counted: 0 for `no-gc-window`. */
288
+ readonly collections?: number;
289
+ /** The smaller of the two windows, in operations. */
290
+ readonly windowOps: number;
291
+ /** Bytes allocated on the V8 heap per operation. */
292
+ readonly heapBytesPerOp: number;
293
+ /** Bytes allocated outside it (Buffers, ArrayBuffers) per operation. */
294
+ readonly externalBytesPerOp: number;
295
+ } | {
296
+ readonly status: "skipped";
297
+ readonly reason: MemorySkipReason;
298
+ };
299
+ /** Peak memory per call: the V8 heap, external memory, and the two together. */
300
+ type PeakReport = {
301
+ readonly status: "measured";
302
+ readonly calls: number;
303
+ /** The highest V8 heap peak of any call, in bytes above the settled heap before it. */
304
+ readonly peakHeapBytes: number;
305
+ /** The median call's V8 heap peak. */
306
+ readonly medianPeakHeapBytes: number;
307
+ /**
308
+ * The highest external peak of any call (Buffers, ArrayBuffers), above the external memory
309
+ * held before it. Absent in a document written before external memory was read.
310
+ */
311
+ readonly peakExternalBytes?: number;
312
+ /** The median call's external peak. */
313
+ readonly medianPeakExternalBytes?: number;
314
+ /**
315
+ * The highest peak of the V8 heap and external memory together, above the two before the call:
316
+ * the figure a memory budget reads. Not the sum of the two peaks above, which need not come at
317
+ * one moment.
318
+ */
319
+ readonly peakTotalBytes?: number;
320
+ /** The median call's peak of the two together. */
321
+ readonly medianPeakTotalBytes?: number;
322
+ } | {
323
+ readonly status: "skipped";
324
+ readonly reason: MemorySkipReason;
325
+ };
326
+ /** Whether the heap stayed flat over a long run. */
327
+ type LongRunReport = {
328
+ readonly status: "measured";
329
+ /** `growing` when the fitted growth reaches `LONG_RUN_GROWTH_BYTES`. */
330
+ readonly verdict: "flat" | "growing";
331
+ /** Operations in all. */
332
+ readonly ops: number;
333
+ /** Operations per round, fixed by the first round. */
334
+ readonly opsPerRound: number;
335
+ /** The settled heap after each round, in bytes. */
336
+ readonly settledHeap: readonly number[];
337
+ /** Theil-Sen slope of the settled heap against operations, after the settling rounds. */
338
+ readonly slopeBytesPerOp: number;
339
+ /** The slope times the operations it was fitted over: the growth the verdict reads. */
340
+ readonly growthBytes: number;
341
+ /** The smallest leak, in bytes per operation, this run could call growing: the bound over the span. */
342
+ readonly detectableBytesPerOp: number;
343
+ } | {
344
+ readonly status: "skipped";
345
+ readonly reason: MemorySkipReason;
346
+ };
347
+
348
+ /** An interval on a median, from the sign test. */
349
+ interface MedianInterval {
350
+ /** The `rank`-th smallest value. */
351
+ readonly low: number;
352
+ /** The `rank`-th largest value. */
353
+ readonly high: number;
354
+ /** Which order statistics bound it. */
355
+ readonly rank: number;
356
+ /** The chance that each end alone misses the median, exactly: `P(Binomial(n, 1/2) <= rank - 1)`. */
357
+ readonly tail: number;
358
+ }
359
+
360
+ /**
361
+ * The base against head comparison, and its calibration.
362
+ *
363
+ * A comparison is `ROUNDS` rounds. In each round every case runs once on the base build and once on
364
+ * the head build, each in a fresh process, back to back; even rounds run the base first, odd rounds
365
+ * the head. A drift in the machine over the run lands on both sides of a round, seconds apart, and
366
+ * cancels in their ratio; an order effect lands on the head in half the rounds and on the base in
367
+ * the other half, so it widens the spread of the ratios instead of biasing their median.
368
+ *
369
+ * Each round gives one ratio per case, `head / base` of the two processes' fastest samples. The
370
+ * fastest, not the median, because the noise on a shared machine is one-sided (another tenant only
371
+ * ever slows a sample down), and ADR 0001 section 1 measured `min` as the one estimator that keeps a
372
+ * stall on one side out of a ratio. The verdict reads the sign test's interval on the median of the
373
+ * per-round ratios, which assumes nothing about the shape of the noise, only that rounds are
374
+ * independent.
375
+ *
376
+ * @packageDocumentation
377
+ */
378
+
379
+ /** What a comparison says about one case. */
380
+ type Verdict = "regression" | "improvement" | "no-regression" | "inconclusive" | "skipped";
381
+ /**
382
+ * Judge one case from its per-round ratios.
383
+ *
384
+ * - `regression`: the whole interval is above `1 + THRESHOLD`. The head is slower by more than the
385
+ * threshold, with each end of the interval wrong at most `ALPHA` of the time.
386
+ * - `improvement`: the whole interval is below `1 / (1 + THRESHOLD)`.
387
+ * - `no-regression`: the whole interval is at or below `1 + THRESHOLD`: a slowdown larger than
388
+ * the threshold is ruled out.
389
+ * - `inconclusive`: the interval straddles `1 + THRESHOLD`, or there are too few rounds for one.
390
+ * Not a pass and not a failure: the rounds disagreed too much to tell.
391
+ *
392
+ * @example
393
+ * ```ts
394
+ * import { judge } from "@cosyte/bench";
395
+ *
396
+ * judge([1.1, 1.12, 1.09, 1.11, 1.1, 1.13, 1.1, 1.08, 1.12, 1.1, 1.11, 1.09]).verdict; // => "regression"
397
+ * judge([1, 1.01, 0.99, 1, 1.02, 0.98, 1, 1.01, 0.99, 1, 1, 1.01]).verdict; // => "no-regression"
398
+ * ```
399
+ */
400
+ declare function judge(ratios: readonly number[], threshold?: number, alpha?: number): {
401
+ verdict: Exclude<Verdict, "skipped">;
402
+ interval: MedianInterval | null;
403
+ medianRatio: number;
404
+ };
405
+ /** One case of a comparison. */
406
+ interface CaseComparison extends CaseRef {
407
+ /** Each round's base process: the fastest of its samples, milliseconds per operation. */
408
+ readonly baseMsPerOp: readonly number[];
409
+ /** Each round's head process: the fastest of its samples, milliseconds per operation. */
410
+ readonly headMsPerOp: readonly number[];
411
+ /** Each round's base samples, whole, so another estimator can be judged on the same runs. */
412
+ readonly baseSamples: readonly (readonly number[])[];
413
+ /** Each round's head samples, whole. */
414
+ readonly headSamples: readonly (readonly number[])[];
415
+ /**
416
+ * Each round's base process: the warmup rule it ended on. Absent in a document written before it
417
+ * was recorded.
418
+ */
419
+ readonly basePlans?: readonly WarmupPlan[];
420
+ /** Each round's head process: the warmup rule it ended on. */
421
+ readonly headPlans?: readonly WarmupPlan[];
422
+ /** Each round's `head / base`. */
423
+ readonly ratios: readonly number[];
424
+ readonly medianRatio: number | null;
425
+ readonly interval: MedianInterval | null;
426
+ readonly verdict: Verdict;
427
+ /** Why the case was skipped, when it was. A skip is not a pass. */
428
+ readonly skipReason: string | null;
429
+ }
430
+ /** A whole comparison. */
431
+ interface ComparisonBody {
432
+ readonly rounds: number;
433
+ readonly threshold: number;
434
+ readonly alpha: number;
435
+ /** The slowdown injected on the head side, for calibration. Zero in a real comparison. */
436
+ readonly inject: number;
437
+ readonly cases: readonly CaseComparison[];
438
+ }
439
+ /** A rate with its exact upper bound. */
440
+ interface CountedRate {
441
+ readonly events: number;
442
+ readonly trials: number;
443
+ readonly rate: number;
444
+ /** The one-sided upper bound at `REQUIRED_CONFIDENCE` (Clopper-Pearson). */
445
+ readonly upperBound: number;
446
+ }
447
+ /** The ratio one case was observed at over a whole calibration. */
448
+ interface ObservedRatio extends CaseRef {
449
+ /** Measured rounds, pooled over every comparison that measured the case. */
450
+ readonly rounds: number;
451
+ /**
452
+ * The median of those rounds' `head / base` ratios, or `null` when no round measured. Under an
453
+ * injected slowdown this is the slowdown the head side was actually measured at, which is not
454
+ * `1 + inject` (see `sampleThroughput`): a detection rate is read against it.
455
+ */
456
+ readonly medianRatio: number | null;
457
+ }
458
+ /** A calibration: many comparisons of known outcome. */
459
+ interface CalibrationBody {
460
+ /** The slowdown injected on the head side. Zero is an A/A calibration: every regression is a false alarm. */
461
+ readonly inject: number;
462
+ readonly comparisons: number;
463
+ /** Each comparison's cases, kept whole so a different rule can be judged against the same runs. */
464
+ readonly runs: readonly (readonly CaseComparison[])[];
465
+ /** Case verdicts of `regression`, out of the case verdicts that measured. A skipped case is not a trial. */
466
+ readonly perCase: CountedRate;
467
+ /**
468
+ * Comparisons with at least one `regression`, out of the comparisons whose outcome is known: every
469
+ * case measured, or a regression was called. A comparison that skipped a case and called no
470
+ * regression is neither a pass nor a trial.
471
+ */
472
+ readonly perComparison: CountedRate;
473
+ /** Comparisons with at least one skipped case, out of all comparisons. A required check is red on each (exit 2). */
474
+ readonly skippedComparisons: CountedRate;
475
+ /** Case verdicts by kind. */
476
+ readonly verdicts: Readonly<Record<Verdict, number>>;
477
+ /** Each case's observed ratio, in the order the cases first appear. */
478
+ readonly observed: readonly ObservedRatio[];
479
+ /** For an A/A calibration: whether the timing comparison may now be a required check, and why. */
480
+ readonly requiredRule: {
481
+ readonly applies: boolean;
482
+ readonly met: boolean;
483
+ readonly reason: string;
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Cold import time and the bytes an import loads.
489
+ *
490
+ * Each timed import runs in a fresh process with no compile cache, so nothing is compiled before it
491
+ * starts. The same number of fresh processes import an empty module, interleaved with the target's,
492
+ * so a reader can see how much of the time is the import machinery rather than the package.
493
+ *
494
+ * The bytes are the files Node actually loaded for the import, found by a module hook in one further
495
+ * process that is not timed (the hook itself costs time). This is what a Node consumer loads; it is
496
+ * not what a bundler would keep after tree shaking, which depends on what the consumer imports.
497
+ *
498
+ * @packageDocumentation
499
+ */
500
+
501
+ /** One file an import loaded. */
502
+ interface LoadedFile {
503
+ /** Relative to the working directory, so no report carries a home directory. */
504
+ readonly path: string;
505
+ readonly bytes: number;
506
+ /** Gzip at level 9: roughly what it costs to download. */
507
+ readonly gzipBytes: number;
508
+ }
509
+ /** Cold import time and loaded bytes. */
510
+ interface ImportCostReport {
511
+ readonly runs: number;
512
+ /** One per fresh process, milliseconds, in the order taken. */
513
+ readonly coldImportMs: readonly number[];
514
+ readonly medianMs: number;
515
+ readonly p95Ms: number;
516
+ /** The same for an empty module: the import machinery alone. */
517
+ readonly emptyImportMs: readonly number[];
518
+ readonly emptyMedianMs: number;
519
+ readonly files: readonly LoadedFile[];
520
+ readonly totalBytes: number;
521
+ readonly totalGzipBytes: number;
522
+ }
523
+
524
+ /**
525
+ * A reading: how fast, how much memory and how heavy one build is, on this machine.
526
+ *
527
+ * A reading is not a guarantee and travels with its machine: it is what the results repository keeps
528
+ * over time, compared only within one runner class.
529
+ *
530
+ * @packageDocumentation
531
+ */
532
+
533
+ /** Throughput of one case. */
534
+ type Throughput = {
535
+ readonly status: "measured";
536
+ /** One entry per process: its samples and their median, milliseconds per operation. */
537
+ readonly processes: readonly {
538
+ readonly samplesMsPerOp: readonly number[];
539
+ readonly medianMsPerOp: number;
540
+ /** The warmup rule the process ended on. Absent in a document written before it was recorded. */
541
+ readonly plan?: WarmupPlan;
542
+ }[];
543
+ /** The median of the process medians. */
544
+ readonly medianMsPerOp: number;
545
+ readonly opsPerSecond: number;
546
+ /** `null` when the suite gave no byte count for the corpus. */
547
+ readonly bytesPerSecond: number | null;
548
+ } | {
549
+ readonly status: "skipped";
550
+ readonly reason: string;
551
+ };
552
+ /** Everything a reading measured for one case. */
553
+ interface CaseReading extends CaseRef {
554
+ readonly inputsPerPass: number;
555
+ readonly bytesPerPass: number | null;
556
+ readonly throughput: Throughput;
557
+ readonly latency: LatencyReport | null;
558
+ readonly allocations: AllocationReport | null;
559
+ readonly peak: PeakReport | null;
560
+ readonly longRun: LongRunReport | null;
561
+ }
562
+ /** A whole reading. */
563
+ interface ReadingBody {
564
+ /** Fresh processes timed per case. */
565
+ readonly processes: number;
566
+ readonly cases: readonly CaseReading[];
567
+ /** `null` when import cost was not asked for. */
568
+ readonly importCost: ImportCostReport | null;
569
+ }
570
+
571
+ /**
572
+ * The machine a measurement was taken on. Every results document carries it, because a number
573
+ * means nothing apart from its machine: ADR 0001 measured a significant shift from a runner image
574
+ * update with no code change.
575
+ *
576
+ * Nothing here names a person: no host name, no user, no absolute path.
577
+ *
578
+ * @packageDocumentation
579
+ */
580
+ /** Where a measurement ran. */
581
+ interface Environment {
582
+ readonly node: string;
583
+ readonly v8: string;
584
+ readonly platform: string;
585
+ readonly arch: string;
586
+ readonly osRelease: string;
587
+ readonly cpuModel: string | null;
588
+ readonly logicalCpus: number;
589
+ readonly totalMemoryBytes: number;
590
+ /** The cgroup CPU quota (`cpu.max`), when Linux reports one: a throttled container measures differently. */
591
+ readonly cgroupCpuMax: string | null;
592
+ /** Whether `NODE_OPTIONS` was set. Its value is not recorded; it can hold paths. */
593
+ readonly nodeOptionsSet: boolean;
594
+ /**
595
+ * The 1, 5 and 15 minute load averages when the document was written (zeros where the platform
596
+ * has none). A busy machine is a noisy one: this is how a reader tells.
597
+ */
598
+ readonly loadAverage: readonly number[];
599
+ readonly runner: Runner;
600
+ }
601
+ /** The CI runner, when there is one. */
602
+ interface Runner {
603
+ readonly ci: boolean;
604
+ /** `github-actions`, or `null` off CI. */
605
+ readonly provider: string | null;
606
+ /** `github-hosted` or `self-hosted` (`RUNNER_ENVIRONMENT`). */
607
+ readonly environment: string | null;
608
+ /** The runner image (`ImageOS`) and its version (`ImageVersion`). */
609
+ readonly imageOS: string | null;
610
+ readonly imageVersion: string | null;
611
+ /**
612
+ * A key for "the same kind of machine": the provider, image and architecture on CI, or
613
+ * `local-<platform>-<arch>` off it. Results are only compared within one class.
614
+ */
615
+ readonly class: string;
616
+ }
617
+
618
+ /**
619
+ * The results document: one JSON file per reading, comparison or calibration, in a versioned shape a
620
+ * results repository can keep for years and read back.
621
+ *
622
+ * The shape is fixed by a JSON Schema ({@link RESULTS_SCHEMA}, also shipped as
623
+ * `schema/results-v1.schema.json`). Version 1 only grows: a new field may appear, no field changes
624
+ * meaning or goes away, and readers ignore fields they do not know. Anything else is version 2.
625
+ *
626
+ * @packageDocumentation
627
+ */
628
+
629
+ /** The version this package writes and reads. */
630
+ declare const SCHEMA_VERSION = 1;
631
+ /** Which build a document describes. */
632
+ interface BuildRef {
633
+ /** The entry measured, relative to the working directory. */
634
+ readonly target: string;
635
+ /** The commit it was built from, when known. */
636
+ readonly commit: string | null;
637
+ }
638
+ interface DocumentBase {
639
+ readonly schemaVersion: 1;
640
+ readonly tool: {
641
+ readonly name: "@cosyte/bench";
642
+ readonly version: string;
643
+ };
644
+ /** When the document was written, ISO 8601 UTC. */
645
+ readonly createdAt: string;
646
+ readonly suite: {
647
+ readonly name: string;
648
+ readonly file: string;
649
+ };
650
+ readonly environment: Environment;
651
+ /** The constants in force, so a document can be judged against the method that made it. */
652
+ readonly contract: {
653
+ readonly bench: BenchContractShape;
654
+ readonly warmup: typeof WARMUP;
655
+ };
656
+ }
657
+ /** A results document. */
658
+ type ResultsDocument = DocumentBase & ({
659
+ readonly kind: "reading";
660
+ readonly subject: BuildRef;
661
+ readonly reading: ReadingBody;
662
+ } | {
663
+ readonly kind: "comparison";
664
+ readonly base: BuildRef;
665
+ readonly head: BuildRef;
666
+ readonly comparison: ComparisonBody;
667
+ } | {
668
+ readonly kind: "calibration";
669
+ readonly base: BuildRef;
670
+ readonly head: BuildRef;
671
+ readonly calibration: CalibrationBody;
672
+ });
673
+ /** The JSON Schema keywords the results schema uses. */
674
+ interface Schema {
675
+ readonly $ref?: string;
676
+ readonly $defs?: Readonly<Record<string, Schema>>;
677
+ readonly const?: unknown;
678
+ readonly enum?: readonly unknown[];
679
+ readonly type?: string | readonly string[];
680
+ readonly minimum?: number;
681
+ readonly required?: readonly string[];
682
+ readonly properties?: Readonly<Record<string, Schema>>;
683
+ readonly items?: Schema;
684
+ readonly oneOf?: readonly Schema[];
685
+ }
686
+ /**
687
+ * The results schema, version 1, as JSON Schema (draft 2020-12). It holds what every reader relies
688
+ * on; a document may carry more.
689
+ *
690
+ * @example
691
+ * ```ts
692
+ * import { RESULTS_SCHEMA } from "@cosyte/bench";
693
+ *
694
+ * RESULTS_SCHEMA.$id; // => "https://github.com/cosyte/config/blob/main/packages/bench/schema/results-v1.schema.json"
695
+ * ```
696
+ */
697
+ declare const RESULTS_SCHEMA: {
698
+ readonly $schema: "https://json-schema.org/draft/2020-12/schema";
699
+ readonly $id: "https://github.com/cosyte/config/blob/main/packages/bench/schema/results-v1.schema.json";
700
+ readonly title: "@cosyte/bench results, version 1";
701
+ readonly type: "object";
702
+ readonly required: readonly ["schemaVersion", "kind", "tool", "createdAt", "suite", "environment", "contract"];
703
+ readonly properties: {
704
+ readonly schemaVersion: {
705
+ readonly const: 1;
706
+ };
707
+ readonly kind: {
708
+ readonly enum: readonly ["reading", "comparison", "calibration"];
709
+ };
710
+ readonly tool: {
711
+ readonly type: "object";
712
+ readonly required: readonly ["name", "version"];
713
+ readonly properties: {
714
+ readonly name: {
715
+ readonly const: "@cosyte/bench";
716
+ };
717
+ readonly version: {
718
+ readonly type: "string";
719
+ };
720
+ };
721
+ };
722
+ readonly createdAt: {
723
+ readonly type: "string";
724
+ };
725
+ readonly suite: {
726
+ readonly type: "object";
727
+ readonly required: readonly ["name", "file"];
728
+ readonly properties: {
729
+ readonly name: {
730
+ readonly type: "string";
731
+ };
732
+ readonly file: {
733
+ readonly type: "string";
734
+ };
735
+ };
736
+ };
737
+ readonly environment: {
738
+ readonly $ref: "#/$defs/environment";
739
+ };
740
+ readonly contract: {
741
+ readonly type: "object";
742
+ readonly required: readonly ["bench", "warmup"];
743
+ readonly properties: {
744
+ readonly bench: {
745
+ readonly type: "object";
746
+ };
747
+ readonly warmup: {
748
+ readonly type: "object";
749
+ };
750
+ };
751
+ };
752
+ };
753
+ readonly oneOf: readonly [{
754
+ readonly required: readonly ["subject", "reading"];
755
+ readonly properties: {
756
+ readonly kind: {
757
+ readonly const: "reading";
758
+ };
759
+ readonly subject: {
760
+ readonly $ref: "#/$defs/build";
761
+ };
762
+ readonly reading: {
763
+ readonly $ref: "#/$defs/reading";
764
+ };
765
+ };
766
+ }, {
767
+ readonly required: readonly ["base", "head", "comparison"];
768
+ readonly properties: {
769
+ readonly kind: {
770
+ readonly const: "comparison";
771
+ };
772
+ readonly base: {
773
+ readonly $ref: "#/$defs/build";
774
+ };
775
+ readonly head: {
776
+ readonly $ref: "#/$defs/build";
777
+ };
778
+ readonly comparison: {
779
+ readonly $ref: "#/$defs/comparison";
780
+ };
781
+ };
782
+ }, {
783
+ readonly required: readonly ["base", "head", "calibration"];
784
+ readonly properties: {
785
+ readonly kind: {
786
+ readonly const: "calibration";
787
+ };
788
+ readonly base: {
789
+ readonly $ref: "#/$defs/build";
790
+ };
791
+ readonly head: {
792
+ readonly $ref: "#/$defs/build";
793
+ };
794
+ readonly calibration: {
795
+ readonly $ref: "#/$defs/calibration";
796
+ };
797
+ };
798
+ }];
799
+ readonly $defs: {
800
+ readonly build: {
801
+ readonly type: "object";
802
+ readonly required: readonly ["target", "commit"];
803
+ readonly properties: {
804
+ readonly target: {
805
+ readonly type: "string";
806
+ };
807
+ readonly commit: {
808
+ readonly type: readonly ["string", "null"];
809
+ };
810
+ };
811
+ };
812
+ readonly environment: {
813
+ readonly type: "object";
814
+ readonly required: readonly ["node", "v8", "platform", "arch", "osRelease", "cpuModel", "logicalCpus", "totalMemoryBytes", "cgroupCpuMax", "runner"];
815
+ readonly properties: {
816
+ readonly node: {
817
+ readonly type: "string";
818
+ };
819
+ readonly v8: {
820
+ readonly type: "string";
821
+ };
822
+ readonly platform: {
823
+ readonly type: "string";
824
+ };
825
+ readonly arch: {
826
+ readonly type: "string";
827
+ };
828
+ readonly osRelease: {
829
+ readonly type: "string";
830
+ };
831
+ readonly cpuModel: {
832
+ readonly type: readonly ["string", "null"];
833
+ };
834
+ readonly logicalCpus: {
835
+ readonly type: "integer";
836
+ readonly minimum: 0;
837
+ };
838
+ readonly totalMemoryBytes: {
839
+ readonly type: "integer";
840
+ readonly minimum: 0;
841
+ };
842
+ readonly cgroupCpuMax: {
843
+ readonly type: readonly ["string", "null"];
844
+ };
845
+ readonly loadAverage: {
846
+ readonly type: "array";
847
+ readonly items: {
848
+ readonly type: "number";
849
+ };
850
+ };
851
+ readonly runner: {
852
+ readonly type: "object";
853
+ readonly required: readonly ["ci", "provider", "class"];
854
+ readonly properties: {
855
+ readonly ci: {
856
+ readonly type: "boolean";
857
+ };
858
+ readonly provider: {
859
+ readonly type: readonly ["string", "null"];
860
+ };
861
+ readonly class: {
862
+ readonly type: "string";
863
+ };
864
+ };
865
+ };
866
+ };
867
+ };
868
+ readonly interval: Schema;
869
+ readonly caseComparison: {
870
+ readonly type: "object";
871
+ readonly required: readonly ["benchmark", "size", "baseMsPerOp", "headMsPerOp", "ratios", "medianRatio", "interval", "verdict", "skipReason"];
872
+ readonly properties: {
873
+ readonly benchmark: {
874
+ readonly type: "string";
875
+ };
876
+ readonly size: {
877
+ readonly type: "string";
878
+ };
879
+ readonly baseMsPerOp: {
880
+ readonly type: "array";
881
+ readonly items: {
882
+ readonly type: "number";
883
+ };
884
+ };
885
+ readonly headMsPerOp: {
886
+ readonly type: "array";
887
+ readonly items: {
888
+ readonly type: "number";
889
+ };
890
+ };
891
+ readonly ratios: {
892
+ readonly type: "array";
893
+ readonly items: {
894
+ readonly type: "number";
895
+ };
896
+ };
897
+ readonly medianRatio: {
898
+ readonly type: readonly ["number", "null"];
899
+ };
900
+ readonly basePlans: {
901
+ readonly type: "array";
902
+ readonly items: {
903
+ readonly enum: readonly ["standard", "large"];
904
+ };
905
+ };
906
+ readonly headPlans: {
907
+ readonly type: "array";
908
+ readonly items: {
909
+ readonly enum: readonly ["standard", "large"];
910
+ };
911
+ };
912
+ readonly interval: {
913
+ readonly $ref: "#/$defs/interval";
914
+ };
915
+ readonly verdict: {
916
+ readonly enum: readonly ["regression", "improvement", "no-regression", "inconclusive", "skipped"];
917
+ };
918
+ readonly skipReason: {
919
+ readonly type: readonly ["string", "null"];
920
+ };
921
+ };
922
+ };
923
+ readonly comparison: {
924
+ readonly type: "object";
925
+ readonly required: readonly ["rounds", "threshold", "alpha", "inject", "cases"];
926
+ readonly properties: {
927
+ readonly rounds: {
928
+ readonly type: "integer";
929
+ readonly minimum: 0;
930
+ };
931
+ readonly threshold: {
932
+ readonly type: "number";
933
+ };
934
+ readonly alpha: {
935
+ readonly type: "number";
936
+ };
937
+ readonly inject: {
938
+ readonly type: "number";
939
+ };
940
+ readonly cases: {
941
+ readonly type: "array";
942
+ readonly items: {
943
+ readonly $ref: "#/$defs/caseComparison";
944
+ };
945
+ };
946
+ };
947
+ };
948
+ readonly rate: {
949
+ readonly type: "object";
950
+ readonly required: readonly ["events", "trials", "rate", "upperBound"];
951
+ readonly properties: {
952
+ readonly events: {
953
+ readonly type: "integer";
954
+ readonly minimum: 0;
955
+ };
956
+ readonly trials: {
957
+ readonly type: "integer";
958
+ readonly minimum: 0;
959
+ };
960
+ readonly rate: {
961
+ readonly type: "number";
962
+ };
963
+ readonly upperBound: {
964
+ readonly type: "number";
965
+ };
966
+ };
967
+ };
968
+ readonly calibration: {
969
+ readonly type: "object";
970
+ readonly required: readonly ["inject", "comparisons", "runs", "perCase", "perComparison", "verdicts", "requiredRule"];
971
+ readonly properties: {
972
+ readonly inject: {
973
+ readonly type: "number";
974
+ };
975
+ readonly comparisons: {
976
+ readonly type: "integer";
977
+ readonly minimum: 0;
978
+ };
979
+ readonly runs: {
980
+ readonly type: "array";
981
+ readonly items: {
982
+ readonly type: "array";
983
+ readonly items: {
984
+ readonly $ref: "#/$defs/caseComparison";
985
+ };
986
+ };
987
+ };
988
+ readonly perCase: {
989
+ readonly $ref: "#/$defs/rate";
990
+ };
991
+ readonly perComparison: {
992
+ readonly $ref: "#/$defs/rate";
993
+ };
994
+ readonly skippedComparisons: {
995
+ readonly $ref: "#/$defs/rate";
996
+ };
997
+ readonly verdicts: {
998
+ readonly type: "object";
999
+ };
1000
+ readonly observed: {
1001
+ readonly type: "array";
1002
+ readonly items: {
1003
+ readonly type: "object";
1004
+ readonly required: readonly ["benchmark", "size", "rounds", "medianRatio"];
1005
+ readonly properties: {
1006
+ readonly benchmark: {
1007
+ readonly type: "string";
1008
+ };
1009
+ readonly size: {
1010
+ readonly type: "string";
1011
+ };
1012
+ readonly rounds: {
1013
+ readonly type: "integer";
1014
+ readonly minimum: 0;
1015
+ };
1016
+ readonly medianRatio: {
1017
+ readonly type: readonly ["number", "null"];
1018
+ };
1019
+ };
1020
+ };
1021
+ };
1022
+ readonly requiredRule: {
1023
+ readonly type: "object";
1024
+ readonly required: readonly ["applies", "met", "reason"];
1025
+ readonly properties: {
1026
+ readonly applies: {
1027
+ readonly type: "boolean";
1028
+ };
1029
+ readonly met: {
1030
+ readonly type: "boolean";
1031
+ };
1032
+ readonly reason: {
1033
+ readonly type: "string";
1034
+ };
1035
+ };
1036
+ };
1037
+ };
1038
+ };
1039
+ readonly reading: {
1040
+ readonly type: "object";
1041
+ readonly required: readonly ["processes", "cases", "importCost"];
1042
+ readonly properties: {
1043
+ readonly processes: {
1044
+ readonly type: "integer";
1045
+ readonly minimum: 0;
1046
+ };
1047
+ readonly cases: {
1048
+ readonly type: "array";
1049
+ readonly items: {
1050
+ readonly $ref: "#/$defs/caseReading";
1051
+ };
1052
+ };
1053
+ readonly importCost: Schema;
1054
+ };
1055
+ };
1056
+ readonly caseReading: {
1057
+ readonly type: "object";
1058
+ readonly required: readonly ["benchmark", "size", "inputsPerPass", "bytesPerPass", "throughput", "latency", "allocations", "peak", "longRun"];
1059
+ readonly properties: {
1060
+ readonly benchmark: {
1061
+ readonly type: "string";
1062
+ };
1063
+ readonly size: {
1064
+ readonly type: "string";
1065
+ };
1066
+ readonly inputsPerPass: {
1067
+ readonly type: "integer";
1068
+ readonly minimum: 0;
1069
+ };
1070
+ readonly bytesPerPass: {
1071
+ readonly type: readonly ["number", "null"];
1072
+ };
1073
+ readonly throughput: Schema;
1074
+ readonly latency: Schema;
1075
+ readonly allocations: Schema;
1076
+ readonly peak: Schema;
1077
+ readonly longRun: Schema;
1078
+ };
1079
+ };
1080
+ };
1081
+ };
1082
+ /**
1083
+ * Parse and check a results document. Throws a `TypeError` listing every problem when the text is
1084
+ * not JSON, is a version this package does not read, or does not match the schema.
1085
+ *
1086
+ * @example
1087
+ * ```ts
1088
+ * import { readResults } from "@cosyte/bench";
1089
+ *
1090
+ * try {
1091
+ * readResults('{"schemaVersion": 2}');
1092
+ * } catch (error) {
1093
+ * String(error).includes("version 2"); // => true
1094
+ * }
1095
+ * ```
1096
+ */
1097
+ declare function readResults(text: string): ResultsDocument;
1098
+
1099
+ /**
1100
+ * Starting measuring processes and reading what they measured.
1101
+ *
1102
+ * @packageDocumentation
1103
+ */
1104
+
1105
+ /**
1106
+ * A run that could not produce a measurement. The command line reports it and exits 2.
1107
+ *
1108
+ * @example
1109
+ * ```ts
1110
+ * import { BenchError } from "@cosyte/bench";
1111
+ *
1112
+ * new BenchError("no result").name; // => "BenchError"
1113
+ * ```
1114
+ */
1115
+ declare class BenchError extends Error {
1116
+ readonly name = "BenchError";
1117
+ }
1118
+
1119
+ export { BENCH_CONTRACT, BenchError, RESULTS_SCHEMA, SCHEMA_VERSION, defineBenchmark, defineSuite, judge, readResults };
1120
+ export type { AllocationReport, AnyBenchmark, BenchContractShape, BenchCorpus, BenchSize, BenchSuite, Benchmark, BuildRef, CalibrationBody, CaseComparison, CaseReading, CaseRef, ComparisonBody, CountedRate, Environment, ImportCostReport, LatencyReport, LoadedFile, LongRunReport, MemorySkipReason, PeakReport, ReadingBody, ResultsDocument, Runner, Throughput, Verdict, WarmupReport };