@cosyte/bench 0.1.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - c0c095a: Large cases are measured instead of skipped. A case whose single call takes longer than a warmup
8
+ batch (50 ms) and has not settled by the scaling gate's rule after 500 ms moves to the large-case
9
+ plan: one call at a time until the median of the last 5 calls is within 5% of the median of the 5
10
+ before them, at most 30 s. Each timing process records the plan it ended on (`plan` in a reading's
11
+ `throughput.processes`, `basePlans` and `headPlans` in a comparison), and a reading marks such a
12
+ case. A case that settles by the standard rule warms up exactly as before.
13
+
14
+ Allocations per operation are counted on a case whose every window collects: the largest pair of
15
+ windows is counted with what its collections reclaimed (`method: "gc-accounted"`, with the number of
16
+ `collections`), where before the case was skipped as `allocation-window-gc`.
17
+
18
+ The peak reports external memory (Buffers, ArrayBuffers) beside the V8 heap, and the two together:
19
+ `peakExternalBytes`, `peakTotalBytes` and their medians. A memory budget reads `peakTotalBytes`.
20
+ Memory processes run with `--no-concurrent-array-buffer-sweeping`, so a collection frees dead
21
+ ArrayBuffers before it ends and the external memory it reports is exact.
22
+
23
+ The long run also runs on a benchmark's largest size whose 30 rounds fit in 60 s, besides its
24
+ long-run size. A reading now times every size of a benchmark before measuring its memory.
25
+
26
+ Every new field is added within results version 1 and none is renamed; documents written before
27
+ still validate.
28
+
3
29
  ## 0.1.1
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -131,21 +131,35 @@ the command line:
131
131
 
132
132
  - the sizes, and which of them a pull request's comparison runs (`compare: false` leaves a slow size
133
133
  to the readings);
134
- - `weigh`, the number a result is reduced to, and `longRun`, the size the long run uses;
134
+ - `weigh`, the number a result is reduced to, and `longRun`, the size the long run uses besides the
135
+ largest size whose long run fits in 60 s;
135
136
  - `--only <benchmark or case>`, `--processes <n>`, `--no-memory` and `--no-import` on `run`.
136
137
 
137
138
  ## What it measures
138
139
 
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.
140
+ | Measure | How | What it cannot see |
141
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
142
+ | 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 |
143
+ | 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 |
144
+ | 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 |
145
+ | 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`), which is what a memory budget reads | Native memory a library allocates outside V8's accounting |
146
+ | 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 |
147
+ | 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 |
148
+
149
+ Memory measurements need `gc()`; the runner starts those processes with `--expose-gc` itself, and
150
+ with `--no-concurrent-array-buffer-sweeping`, so a collection frees the ArrayBuffers it found dead
151
+ before it ends and the external memory it reports before and after is exact.
152
+
153
+ ### Large cases
154
+
155
+ A case whose single call takes longer than a warmup batch (50 ms) cannot settle by the scaling
156
+ gate's warmup rule on a shared runner: every batch is one call, and one call's jitter is what the
157
+ batch was there to average away. When 500 ms of warmup have passed, the rule has not settled, and
158
+ the last three batches were each one call of at least 50 ms, the warmup moves to the large-case
159
+ plan: one call at a time until the median of the last 5 calls is within 5% of the median of the 5
160
+ before them, at most 30 s from the start. Its samples are the same 5, each one call at that size.
161
+ Each process records the plan it ended on (`plan`), and a reading marks such a case. A case that
162
+ settles by the standard rule never moves, so small cases are measured exactly as before.
149
163
 
150
164
  ## The comparison
151
165
 
@@ -29,6 +29,10 @@ var BENCH_CONTRACT = Object.freeze({
29
29
  GC_SETTLE_ROUNDS: 2,
30
30
  GC_MAX_ROUNDS: 7,
31
31
  IMPORT_RUNS: 20,
32
+ LARGE_PASS_MS: 50,
33
+ LARGE_WARMUP_WINDOW: 5,
34
+ LARGE_WARMUP_MAX_MS: 3e4,
35
+ LONG_RUN_MAX_MS: 6e4,
32
36
  REQUIRED_FALSE_ALARM_RATE: 0.01,
33
37
  REQUIRED_CONFIDENCE: 0.95
34
38
  });
@@ -78,9 +82,27 @@ function realProbe() {
78
82
  profiler.start();
79
83
  fn();
80
84
  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 };
85
+ const report = {
86
+ gcs: stats.length,
87
+ peakBeforeGc: 0,
88
+ peakExternalBeforeGc: 0,
89
+ peakTotalBeforeGc: 0,
90
+ reclaimedHeap: 0,
91
+ reclaimedExternal: 0
92
+ };
93
+ for (const s of stats) {
94
+ const before = s.beforeGC.heapStatistics;
95
+ const after = s.afterGC.heapStatistics;
96
+ report.peakBeforeGc = Math.max(report.peakBeforeGc, before.usedHeapSize);
97
+ report.peakExternalBeforeGc = Math.max(report.peakExternalBeforeGc, before.externalMemory);
98
+ report.peakTotalBeforeGc = Math.max(
99
+ report.peakTotalBeforeGc,
100
+ before.usedHeapSize + before.externalMemory
101
+ );
102
+ report.reclaimedHeap += before.usedHeapSize - after.usedHeapSize;
103
+ report.reclaimedExternal += before.externalMemory - after.externalMemory;
104
+ }
105
+ return report;
84
106
  }
85
107
  };
86
108
  }
@@ -102,43 +124,63 @@ function measureAllocations(call, probe, msPerOp2) {
102
124
  const s = settle(probe);
103
125
  const h0 = probe.usedHeap();
104
126
  const e0 = probe.external();
105
- const { gcs } = probe.profile(() => {
127
+ const p = probe.profile(() => {
106
128
  for (let i = 0; i < ops; i++) call(i);
107
129
  });
108
- return { settled: s.settled, gcs, heap: probe.usedHeap() - h0, ext: probe.external() - e0 };
130
+ return {
131
+ settled: s.settled,
132
+ gcs: p.gcs,
133
+ heap: probe.usedHeap() - h0 + p.reclaimedHeap,
134
+ ext: probe.external() - e0 + p.reclaimedExternal
135
+ };
109
136
  };
137
+ const perOp = (method, n, a2, b2) => ({
138
+ status: "measured",
139
+ method,
140
+ collections: a2.gcs + b2.gcs,
141
+ windowOps: n,
142
+ heapBytesPerOp: round((b2.heap - a2.heap) / n, 1),
143
+ externalBytesPerOp: round((b2.ext - a2.ext) / n, 1)
144
+ });
110
145
  const paced = Math.floor(BENCH_CONTRACT.ALLOC_WINDOW_MS / msPerOp2);
111
146
  const first = Math.max(1, Math.min(BENCH_CONTRACT.ALLOC_WINDOW_OPS, paced));
147
+ let largest = null;
112
148
  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
- }
149
+ const a2 = window(n);
150
+ const b2 = window(2 * n);
151
+ if (!a2.settled || !b2.settled) return { status: "skipped", reason: "gc-unsettled" };
152
+ if (a2.gcs === 0 && b2.gcs === 0) return perOp("no-gc-window", n, a2, b2);
153
+ largest ??= [a2, b2];
124
154
  }
125
- return { status: "skipped", reason: "allocation-window-gc" };
155
+ const [a, b] = largest;
156
+ return perOp("gc-accounted", first, a, b);
126
157
  }
127
158
  function measurePeak(call, probe) {
128
- const peaks = [];
159
+ const heap = [];
160
+ const external = [];
161
+ const total = [];
129
162
  for (let i = 0; i < BENCH_CONTRACT.PEAK_CALLS; i++) {
130
163
  const s = settle(probe);
131
164
  if (!s.settled) return { status: "skipped", reason: "gc-unsettled" };
132
- const { peakBeforeGc } = probe.profile(() => {
165
+ const e0 = probe.external();
166
+ const p = probe.profile(() => {
133
167
  call(i);
134
168
  });
135
- peaks.push(Math.max(peakBeforeGc, probe.usedHeap()) - s.heap);
169
+ const h1 = probe.usedHeap();
170
+ const e1 = probe.external();
171
+ heap.push(Math.max(p.peakBeforeGc, h1) - s.heap);
172
+ external.push(Math.max(p.peakExternalBeforeGc, e1) - e0);
173
+ total.push(Math.max(p.peakTotalBeforeGc, h1 + e1) - (s.heap + e0));
136
174
  }
137
175
  return {
138
176
  status: "measured",
139
- calls: peaks.length,
140
- peakHeapBytes: Math.max(...peaks),
141
- medianPeakHeapBytes: median(peaks)
177
+ calls: heap.length,
178
+ peakHeapBytes: Math.max(...heap),
179
+ medianPeakHeapBytes: median(heap),
180
+ peakExternalBytes: Math.max(...external),
181
+ medianPeakExternalBytes: median(external),
182
+ peakTotalBytes: Math.max(...total),
183
+ medianPeakTotalBytes: median(total)
142
184
  };
143
185
  }
144
186
  function measureLongRun(pass, opsPerPass, probe, clock) {
@@ -208,6 +250,17 @@ function batchesAreStable(batches) {
208
250
  const mid = median(window);
209
251
  return window.every((b) => Math.abs(b - mid) / mid <= WARMUP.STABLE_TOL);
210
252
  }
253
+ function isLargeCase(batches) {
254
+ if (batches.length < WARMUP.STABLE_BATCHES) return false;
255
+ return batches.slice(-WARMUP.STABLE_BATCHES).every((b) => b >= BENCH_CONTRACT.LARGE_PASS_MS);
256
+ }
257
+ function windowsAreStable(passes) {
258
+ const w = BENCH_CONTRACT.LARGE_WARMUP_WINDOW;
259
+ if (passes.length < 2 * w) return false;
260
+ const last = median(passes.slice(-w));
261
+ const before = median(passes.slice(-2 * w, -w));
262
+ return Math.abs(last - before) / before <= WARMUP.STABLE_TOL;
263
+ }
211
264
  function warmUp(pass, clock) {
212
265
  const start = clock();
213
266
  const batches = [];
@@ -224,10 +277,31 @@ function warmUp(pass, clock) {
224
277
  passes += inBatch;
225
278
  batches.push(batchMs / inBatch);
226
279
  const elapsedMs = clock() - start;
227
- if (elapsedMs >= WARMUP.MIN_MS && batchesAreStable(batches)) {
228
- return { stable: true, elapsedMs, passes, batches };
280
+ if (elapsedMs >= WARMUP.MIN_MS) {
281
+ if (batchesAreStable(batches)) {
282
+ return { stable: true, elapsedMs, passes, batches, plan: "standard" };
283
+ }
284
+ if (isLargeCase(batches)) return warmUpLarge(pass, clock, start, batches, passes);
285
+ }
286
+ if (elapsedMs >= WARMUP.MAX_MS) {
287
+ return { stable: false, elapsedMs, passes, batches, plan: "standard" };
288
+ }
289
+ }
290
+ }
291
+ function warmUpLarge(pass, clock, start, batches, passesSoFar) {
292
+ let passes = passesSoFar;
293
+ for (; ; ) {
294
+ const p0 = clock();
295
+ pass();
296
+ const t = clock();
297
+ passes++;
298
+ batches.push(t - p0);
299
+ const elapsedMs = t - start;
300
+ if (windowsAreStable(batches))
301
+ return { stable: true, elapsedMs, passes, batches, plan: "large" };
302
+ if (elapsedMs >= BENCH_CONTRACT.LARGE_WARMUP_MAX_MS) {
303
+ return { stable: false, elapsedMs, passes, batches, plan: "large" };
229
304
  }
230
- if (elapsedMs >= WARMUP.MAX_MS) return { stable: false, elapsedMs, passes, batches };
231
305
  }
232
306
  }
233
307
  function sampleThroughput(pass, opsPerPass, clock, inject = 0) {