@cosyte/bench 0.1.1 → 0.1.3

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 027dbf5: A reading measures the empty-call floor once per run: the peak of a call that does nothing, read in
8
+ its own process through the same probe as the cases, which is what the probe itself costs. It is in
9
+ the reading as `memoryFloor`, the Markdown prints it, and every case's peak also gives each figure
10
+ above the floor's median, never below zero: `peakTotalAboveFloorBytes`,
11
+ `medianPeakTotalAboveFloorBytes`, and the same for the heap and external memory.
12
+
13
+ `budgetPeakBytes(caseReading)` is the peak a memory budget reads. For a case whose inputs average
14
+ under `BENCH_CONTRACT.FLOOR_INPUT_BYTES` (64 KiB) it is the median call's total above the floor's
15
+ median; for any other, or for a suite that gave no byte count, the highest raw `peakTotalBytes`, as
16
+ before. On a GitHub-hosted runner the floor's median call is about 3.3 KiB and its highest about
17
+ 28 KiB, and that highest lands in a small case's highest peak, so a small input reads its median
18
+ call. A budget script that read `peakTotalBytes` should call `budgetPeakBytes` instead, and a suite
19
+ that added its own empty "floor" case can drop it.
20
+
21
+ One case that cannot be measured no longer stops the run. `cosyte-bench run` writes every other case,
22
+ lists what failed in the reading's `failures` (the phase, `timing`, `memory` or `floor`, the case,
23
+ and the first line of the error) and in the Markdown, prints the whole error to stderr, and exits 2.
24
+ A case whose timing failed is left out of `cases`; one whose memory failed keeps its timing. A script
25
+ that ran each case alone to get past one failure can run them together and read `failures`.
26
+
27
+ Every new field is added within results version 1, and documents written before still validate.
28
+
29
+ ## 0.1.2
30
+
31
+ ### Patch Changes
32
+
33
+ - c0c095a: Large cases are measured instead of skipped. A case whose single call takes longer than a warmup
34
+ batch (50 ms) and has not settled by the scaling gate's rule after 500 ms moves to the large-case
35
+ plan: one call at a time until the median of the last 5 calls is within 5% of the median of the 5
36
+ before them, at most 30 s. Each timing process records the plan it ended on (`plan` in a reading's
37
+ `throughput.processes`, `basePlans` and `headPlans` in a comparison), and a reading marks such a
38
+ case. A case that settles by the standard rule warms up exactly as before.
39
+
40
+ Allocations per operation are counted on a case whose every window collects: the largest pair of
41
+ windows is counted with what its collections reclaimed (`method: "gc-accounted"`, with the number of
42
+ `collections`), where before the case was skipped as `allocation-window-gc`.
43
+
44
+ The peak reports external memory (Buffers, ArrayBuffers) beside the V8 heap, and the two together:
45
+ `peakExternalBytes`, `peakTotalBytes` and their medians. A memory budget reads `peakTotalBytes`.
46
+ Memory processes run with `--no-concurrent-array-buffer-sweeping`, so a collection frees dead
47
+ ArrayBuffers before it ends and the external memory it reports is exact.
48
+
49
+ The long run also runs on a benchmark's largest size whose 30 rounds fit in 60 s, besides its
50
+ long-run size. A reading now times every size of a benchmark before measuring its memory.
51
+
52
+ Every new field is added within results version 1 and none is renamed; documents written before
53
+ still validate.
54
+
3
55
  ## 0.1.1
4
56
 
5
57
  ### Patch Changes
package/README.md CHANGED
@@ -98,14 +98,21 @@ cosyte-bench check bench.json
98
98
  Exit codes: 0 clean, 1 a finding (a regression, or a results file that does not match the schema),
99
99
  2 could not measure (a skip, a process that died, an input it could not read). A skip is not a pass.
100
100
 
101
+ A `run` whose measuring process fails for one case still measures and writes every other case. The
102
+ reading lists what failed in `failures` (the phase, `timing`, `memory` or `floor`, the case, and the
103
+ first line of the error; the whole error goes to stderr) and the command exits 2. A case whose timing
104
+ failed is left out of `cases`; one whose memory failed keeps its timing with its memory `null`. A
105
+ case whose warmup never settled is not a failure: it is in `cases`, its throughput `skipped` with
106
+ the reason `warmup-unstable`.
107
+
101
108
  ## Entry points
102
109
 
103
- | entry point | what it is |
104
- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
105
- | `cosyte-bench` | the bin: `run`, `compare`, `calibrate` and `check` |
106
- | `@cosyte/bench` | writing a suite (`defineSuite`, `defineBenchmark`), judging ratios (`judge`), reading results (`readResults`, `RESULTS_SCHEMA`), and `BENCH_CONTRACT` |
107
- | `@cosyte/bench/schema/results-v1.schema.json` | the results schema as JSON Schema, for a tool in another language |
108
- | `@cosyte/bench/package.json` | the manifest |
110
+ | entry point | what it is |
111
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
112
+ | `cosyte-bench` | the bin: `run`, `compare`, `calibrate` and `check` |
113
+ | `@cosyte/bench` | writing a suite (`defineSuite`, `defineBenchmark`), judging ratios (`judge`), reading results (`readResults`, `RESULTS_SCHEMA`, `budgetPeakBytes`), and `BENCH_CONTRACT` |
114
+ | `@cosyte/bench/schema/results-v1.schema.json` | the results schema as JSON Schema, for a tool in another language |
115
+ | `@cosyte/bench/package.json` | the manifest |
109
116
 
110
117
  The module entry point has no side effects: importing it starts no process. The schema is plain JSON:
111
118
 
@@ -131,21 +138,54 @@ the command line:
131
138
 
132
139
  - the sizes, and which of them a pull request's comparison runs (`compare: false` leaves a slow size
133
140
  to the readings);
134
- - `weigh`, the number a result is reduced to, and `longRun`, the size the long run uses;
141
+ - `weigh`, the number a result is reduced to, and `longRun`, the size the long run uses besides the
142
+ largest size whose long run fits in 60 s;
135
143
  - `--only <benchmark or case>`, `--processes <n>`, `--no-memory` and `--no-import` on `run`.
136
144
 
137
145
  ## What it measures
138
146
 
139
- | Measure | How | What it cannot see |
140
- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
141
- | Throughput, small to very large inputs | Per size, fresh processes; a warmup by the scaling gate's rule; 5 samples of whole passes of at least 50 ms; the median of the processes' medians | Anything the suite's sizes do not cover; numbers from another machine |
142
- | Latency p50, p95, p99 | One call at a time on a nanosecond clock, up to 2 s or 5,000 calls; a percentile is reported only with at least 10 calls above it | Calls shorter than 20 clock reads (skipped, typed); the tail of a size with few calls |
143
- | Allocations per operation | Heap growth over windows of `n` and `2n` operations in which no collection ran, in a process with a 64 MiB young generation; the difference cancels the cost of reading the heap | A window that always collects (skipped, typed) |
144
- | Peak heap | Per call, the higher of the heap just before any collection during the call and at its end, above the settled heap before it | Memory outside the V8 heap |
145
- | Heap flat over a long run | 30 rounds, the heap settled after each; a Theil-Sen slope over the last 24; growing when the fitted growth reaches 64 KiB | A leak too small to reach 64 KiB over the run (each result says the smallest it could see, `detectableBytesPerOp`) |
146
- | Cold import and loaded bytes | 20 fresh processes import the target, 20 import an empty module, interleaved; one more records every file the import loads, with its gzip size | What a bundler keeps after tree shaking |
147
-
148
- Memory measurements need `gc()`; the runner starts those processes with `--expose-gc` itself.
147
+ | Measure | How | What it cannot see |
148
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
149
+ | Throughput, small to very large inputs | Per size, fresh processes; a warmup by the scaling gate's rule, or by the large-case plan below when every warmup batch is one long pass; 5 samples of whole passes of at least 50 ms; the median of the processes' medians | Anything the suite's sizes do not cover; numbers from another machine |
150
+ | Latency p50, p95, p99 | One call at a time on a nanosecond clock, up to 2 s or 5,000 calls; a percentile is reported only with at least 10 calls above it | Calls shorter than 20 clock reads (skipped, typed); the tail of a size with few calls |
151
+ | Allocations per operation | Heap growth over windows of `n` and `2n` operations in which no collection ran, in a process with a 64 MiB young generation; the difference cancels the cost of reading the heap. When every window collects, the largest pair is counted with what its collections reclaimed (`method: "gc-accounted"`) | Native memory a library allocates outside V8's accounting |
152
+ | Peak memory | Per call, the higher of what was in use just before any collection during the call and at its end, above what was in use before it: the V8 heap (`peakHeapBytes`), external memory such as Buffers and ArrayBuffers (`peakExternalBytes`), and the two together (`peakTotalBytes`); each also above the run's empty-call floor (`peakTotalAboveFloorBytes` and the like), below | Native memory a library allocates outside V8's accounting |
153
+ | Heap flat over a long run | 30 rounds, the heap settled after each; a Theil-Sen slope over the last 24; growing when the fitted growth reaches 64 KiB. On the long-run size and on the largest size whose 30 rounds fit in 60 s | A leak too small to reach 64 KiB over the run (each result says the smallest it could see, `detectableBytesPerOp`); external memory |
154
+ | Cold import and loaded bytes | 20 fresh processes import the target, 20 import an empty module, interleaved; one more records every file the import loads, with its gzip size | What a bundler keeps after tree shaking |
155
+
156
+ Memory measurements need `gc()`; the runner starts those processes with `--expose-gc` itself, and
157
+ with `--no-concurrent-array-buffer-sweeping`, so a collection frees the ArrayBuffers it found dead
158
+ before it ends and the external memory it reports before and after is exact.
159
+
160
+ ### The empty-call floor and small inputs
161
+
162
+ Reading the peak costs memory of its own: settling the heap, the profiler that records each
163
+ collection, the record itself. A reading therefore measures an empty call once, in a fresh process
164
+ through the same probe (`memoryFloor`), and gives each peak figure both raw and above the floor's
165
+ median, never below zero (`peakTotalAboveFloorBytes`, `medianPeakTotalAboveFloorBytes`, and the same
166
+ for the heap and external memory).
167
+
168
+ On a GitHub-hosted runner the floor's median call is about 3.3 KiB and its highest about 28 KiB (one
169
+ call, the first). That highest call is the probe's, and it lands in a case's highest peak too, which
170
+ is why a few hundred bytes of input could not read under 20 times its size whatever the code did.
171
+
172
+ A memory budget reads `budgetPeakBytes(caseReading)`: for a case whose inputs average under
173
+ `BENCH_CONTRACT.FLOOR_INPUT_BYTES` (64 KiB) it is the median call's total above the floor's median,
174
+ the code's typical call without the probe; for any other case, or one whose suite gave no byte
175
+ count, it is the highest raw `peakTotalBytes`. At 64 KiB even the probe's highest call is about 2%
176
+ of a 20 times budget, so a large input keeps the raw highest figure, which needs nothing from
177
+ another process. A suite that added its own empty "floor" case can drop it.
178
+
179
+ ### Large cases
180
+
181
+ A case whose single call takes longer than a warmup batch (50 ms) cannot settle by the scaling
182
+ gate's warmup rule on a shared runner: every batch is one call, and one call's jitter is what the
183
+ batch was there to average away. When 500 ms of warmup have passed, the rule has not settled, and
184
+ the last three batches were each one call of at least 50 ms, the warmup moves to the large-case
185
+ plan: one call at a time until the median of the last 5 calls is within 5% of the median of the 5
186
+ before them, at most 30 s from the start. Its samples are the same 5, each one call at that size.
187
+ Each process records the plan it ended on (`plan`), and a reading marks such a case. A case that
188
+ settles by the standard rule never moves, so small cases are measured exactly as before.
149
189
 
150
190
  ## The comparison
151
191
 
@@ -189,6 +229,8 @@ CI runner image, never by host name, user or absolute path.
189
229
  - `judge(ratios, threshold?, alpha?)`: the comparison's verdict for one case from its per-round ratios.
190
230
  - `readResults(text)`: parse and check a results document; throws a `TypeError` listing every
191
231
  problem. `RESULTS_SCHEMA` is the schema, also shipped as `schema/results-v1.schema.json`.
232
+ - `budgetPeakBytes(caseReading)`: the peak a memory budget reads for a case: the median call above
233
+ the empty-call floor for an input under 64 KiB, the highest raw total for any other.
192
234
  - `BENCH_CONTRACT`: the frozen constants.
193
235
  - `BenchError`: what a run that could not measure throws.
194
236
 
@@ -21,6 +21,7 @@ var BENCH_CONTRACT = Object.freeze({
21
21
  ALLOC_WINDOW_MS: 100,
22
22
  ALLOC_SEMI_SPACE_MIB: 64,
23
23
  PEAK_CALLS: 20,
24
+ FLOOR_INPUT_BYTES: 64 * 1024,
24
25
  LONG_RUN_ROUNDS: 30,
25
26
  LONG_RUN_SETTLE_ROUNDS: 5,
26
27
  LONG_RUN_ROUND_MS: 100,
@@ -29,6 +30,10 @@ var BENCH_CONTRACT = Object.freeze({
29
30
  GC_SETTLE_ROUNDS: 2,
30
31
  GC_MAX_ROUNDS: 7,
31
32
  IMPORT_RUNS: 20,
33
+ LARGE_PASS_MS: 50,
34
+ LARGE_WARMUP_WINDOW: 5,
35
+ LARGE_WARMUP_MAX_MS: 3e4,
36
+ LONG_RUN_MAX_MS: 6e4,
32
37
  REQUIRED_FALSE_ALARM_RATE: 0.01,
33
38
  REQUIRED_CONFIDENCE: 0.95
34
39
  });
@@ -78,9 +83,27 @@ function realProbe() {
78
83
  profiler.start();
79
84
  fn();
80
85
  const stats = profiler.stop().statistics;
81
- let peak = 0;
82
- for (const s of stats) peak = Math.max(peak, s.beforeGC.heapStatistics.usedHeapSize);
83
- return { gcs: stats.length, peakBeforeGc: peak };
86
+ const report = {
87
+ gcs: stats.length,
88
+ peakBeforeGc: 0,
89
+ peakExternalBeforeGc: 0,
90
+ peakTotalBeforeGc: 0,
91
+ reclaimedHeap: 0,
92
+ reclaimedExternal: 0
93
+ };
94
+ for (const s of stats) {
95
+ const before = s.beforeGC.heapStatistics;
96
+ const after = s.afterGC.heapStatistics;
97
+ report.peakBeforeGc = Math.max(report.peakBeforeGc, before.usedHeapSize);
98
+ report.peakExternalBeforeGc = Math.max(report.peakExternalBeforeGc, before.externalMemory);
99
+ report.peakTotalBeforeGc = Math.max(
100
+ report.peakTotalBeforeGc,
101
+ before.usedHeapSize + before.externalMemory
102
+ );
103
+ report.reclaimedHeap += before.usedHeapSize - after.usedHeapSize;
104
+ report.reclaimedExternal += before.externalMemory - after.externalMemory;
105
+ }
106
+ return report;
84
107
  }
85
108
  };
86
109
  }
@@ -102,43 +125,63 @@ function measureAllocations(call, probe, msPerOp2) {
102
125
  const s = settle(probe);
103
126
  const h0 = probe.usedHeap();
104
127
  const e0 = probe.external();
105
- const { gcs } = probe.profile(() => {
128
+ const p = probe.profile(() => {
106
129
  for (let i = 0; i < ops; i++) call(i);
107
130
  });
108
- return { settled: s.settled, gcs, heap: probe.usedHeap() - h0, ext: probe.external() - e0 };
131
+ return {
132
+ settled: s.settled,
133
+ gcs: p.gcs,
134
+ heap: probe.usedHeap() - h0 + p.reclaimedHeap,
135
+ ext: probe.external() - e0 + p.reclaimedExternal
136
+ };
109
137
  };
138
+ const perOp = (method, n, a2, b2) => ({
139
+ status: "measured",
140
+ method,
141
+ collections: a2.gcs + b2.gcs,
142
+ windowOps: n,
143
+ heapBytesPerOp: round((b2.heap - a2.heap) / n, 1),
144
+ externalBytesPerOp: round((b2.ext - a2.ext) / n, 1)
145
+ });
110
146
  const paced = Math.floor(BENCH_CONTRACT.ALLOC_WINDOW_MS / msPerOp2);
111
147
  const first = Math.max(1, Math.min(BENCH_CONTRACT.ALLOC_WINDOW_OPS, paced));
148
+ let largest = null;
112
149
  for (let n = first; n >= 1; n = Math.floor(n / 2)) {
113
- const a = window(n);
114
- const b = window(2 * n);
115
- if (!a.settled || !b.settled) return { status: "skipped", reason: "gc-unsettled" };
116
- if (a.gcs === 0 && b.gcs === 0) {
117
- return {
118
- status: "measured",
119
- windowOps: n,
120
- heapBytesPerOp: round((b.heap - a.heap) / n, 1),
121
- externalBytesPerOp: round((b.ext - a.ext) / n, 1)
122
- };
123
- }
150
+ const a2 = window(n);
151
+ const b2 = window(2 * n);
152
+ if (!a2.settled || !b2.settled) return { status: "skipped", reason: "gc-unsettled" };
153
+ if (a2.gcs === 0 && b2.gcs === 0) return perOp("no-gc-window", n, a2, b2);
154
+ largest ??= [a2, b2];
124
155
  }
125
- return { status: "skipped", reason: "allocation-window-gc" };
156
+ const [a, b] = largest;
157
+ return perOp("gc-accounted", first, a, b);
126
158
  }
127
159
  function measurePeak(call, probe) {
128
- const peaks = [];
160
+ const heap = [];
161
+ const external = [];
162
+ const total = [];
129
163
  for (let i = 0; i < BENCH_CONTRACT.PEAK_CALLS; i++) {
130
164
  const s = settle(probe);
131
165
  if (!s.settled) return { status: "skipped", reason: "gc-unsettled" };
132
- const { peakBeforeGc } = probe.profile(() => {
166
+ const e0 = probe.external();
167
+ const p = probe.profile(() => {
133
168
  call(i);
134
169
  });
135
- peaks.push(Math.max(peakBeforeGc, probe.usedHeap()) - s.heap);
170
+ const h1 = probe.usedHeap();
171
+ const e1 = probe.external();
172
+ heap.push(Math.max(p.peakBeforeGc, h1) - s.heap);
173
+ external.push(Math.max(p.peakExternalBeforeGc, e1) - e0);
174
+ total.push(Math.max(p.peakTotalBeforeGc, h1 + e1) - (s.heap + e0));
136
175
  }
137
176
  return {
138
177
  status: "measured",
139
- calls: peaks.length,
140
- peakHeapBytes: Math.max(...peaks),
141
- medianPeakHeapBytes: median(peaks)
178
+ calls: heap.length,
179
+ peakHeapBytes: Math.max(...heap),
180
+ medianPeakHeapBytes: median(heap),
181
+ peakExternalBytes: Math.max(...external),
182
+ medianPeakExternalBytes: median(external),
183
+ peakTotalBytes: Math.max(...total),
184
+ medianPeakTotalBytes: median(total)
142
185
  };
143
186
  }
144
187
  function measureLongRun(pass, opsPerPass, probe, clock) {
@@ -208,6 +251,17 @@ function batchesAreStable(batches) {
208
251
  const mid = median(window);
209
252
  return window.every((b) => Math.abs(b - mid) / mid <= WARMUP.STABLE_TOL);
210
253
  }
254
+ function isLargeCase(batches) {
255
+ if (batches.length < WARMUP.STABLE_BATCHES) return false;
256
+ return batches.slice(-WARMUP.STABLE_BATCHES).every((b) => b >= BENCH_CONTRACT.LARGE_PASS_MS);
257
+ }
258
+ function windowsAreStable(passes) {
259
+ const w = BENCH_CONTRACT.LARGE_WARMUP_WINDOW;
260
+ if (passes.length < 2 * w) return false;
261
+ const last = median(passes.slice(-w));
262
+ const before = median(passes.slice(-2 * w, -w));
263
+ return Math.abs(last - before) / before <= WARMUP.STABLE_TOL;
264
+ }
211
265
  function warmUp(pass, clock) {
212
266
  const start = clock();
213
267
  const batches = [];
@@ -224,10 +278,31 @@ function warmUp(pass, clock) {
224
278
  passes += inBatch;
225
279
  batches.push(batchMs / inBatch);
226
280
  const elapsedMs = clock() - start;
227
- if (elapsedMs >= WARMUP.MIN_MS && batchesAreStable(batches)) {
228
- return { stable: true, elapsedMs, passes, batches };
281
+ if (elapsedMs >= WARMUP.MIN_MS) {
282
+ if (batchesAreStable(batches)) {
283
+ return { stable: true, elapsedMs, passes, batches, plan: "standard" };
284
+ }
285
+ if (isLargeCase(batches)) return warmUpLarge(pass, clock, start, batches, passes);
286
+ }
287
+ if (elapsedMs >= WARMUP.MAX_MS) {
288
+ return { stable: false, elapsedMs, passes, batches, plan: "standard" };
289
+ }
290
+ }
291
+ }
292
+ function warmUpLarge(pass, clock, start, batches, passesSoFar) {
293
+ let passes = passesSoFar;
294
+ for (; ; ) {
295
+ const p0 = clock();
296
+ pass();
297
+ const t = clock();
298
+ passes++;
299
+ batches.push(t - p0);
300
+ const elapsedMs = t - start;
301
+ if (windowsAreStable(batches))
302
+ return { stable: true, elapsedMs, passes, batches, plan: "large" };
303
+ if (elapsedMs >= BENCH_CONTRACT.LARGE_WARMUP_MAX_MS) {
304
+ return { stable: false, elapsedMs, passes, batches, plan: "large" };
229
305
  }
230
- if (elapsedMs >= WARMUP.MAX_MS) return { stable: false, elapsedMs, passes, batches };
231
306
  }
232
307
  }
233
308
  function sampleThroughput(pass, opsPerPass, clock, inject = 0) {
@@ -413,10 +488,17 @@ function measureJob(job, shape, warmup, pass, call, deps) {
413
488
  };
414
489
  }
415
490
  function measuredSomething(result) {
491
+ if (result.mode === "floor") return result.peak.status === "measured";
416
492
  if (result.mode === "timing") return result.status === "measured";
417
493
  return [result.allocations, result.peak, result.longRun].some((r) => r?.status === "measured");
418
494
  }
419
495
  async function runJob(job, deps = realDeps()) {
496
+ if (job.mode === "floor") {
497
+ return {
498
+ mode: "floor",
499
+ peak: deps.probe === null ? SKIPPED_NO_GC : measurePeak(() => void 0, deps.probe)
500
+ };
501
+ }
420
502
  const suiteModule = await import(pathToFileURL(job.suite).href);
421
503
  const suite = checkSuite(suiteModule.default);
422
504
  const target = await import(pathToFileURL(job.target).href);