@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +223 -2
- package/dist/child-entry.d.ts +2 -0
- package/dist/child-entry.mjs +554 -0
- package/dist/child-entry.mjs.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +1490 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/import-entry.d.ts +2 -0
- package/dist/import-entry.mjs +42 -0
- package/dist/import-entry.mjs.map +1 -0
- package/dist/index.d.ts +1120 -0
- package/dist/index.mjs +572 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +74 -3
- package/schema/results-v1.schema.json +781 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
29
|
+
## 0.1.1
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- 27ccbb7: First release of `@cosyte/bench`, the benchmark runner of the performance standard (ADR 0005). `cosyte-bench run` takes a reading of one build: throughput at each input size a suite declares, latency p50, p95 and p99, allocations per operation, peak heap per call, whether the heap stays flat over a long run, cold import time and the bytes an import loads. `cosyte-bench compare` runs a base build and a head build interleaved on one machine, 12 rounds of fresh processes, and judges each case with the sign test's interval on the median ratio (a regression is a whole interval above +3%). `cosyte-bench calibrate` runs many comparisons of known outcome, A/A or with a planted slowdown, and reports the false alarm or detection rate with its exact upper bound, counting only cases and comparisons that measured, the rate of comparisons that skipped a case, and the change each case was observed at. Every result is JSON in a versioned shape (`schemaVersion: 1`, `schema/results-v1.schema.json`) carrying the machine, the Node version, the runner, every sample and the constants in force; `cosyte-bench check` and `readResults` validate it. A measured function that throws is reported by case, phase and error class, never by its message, because a message may quote an input.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cosyte LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,224 @@
|
|
|
1
|
-
|
|
1
|
+
<a href="https://cosyte.com">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
|
|
4
|
+
<img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
|
|
5
|
+
</picture>
|
|
6
|
+
</a>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
# @cosyte/bench
|
|
9
|
+
|
|
10
|
+
> How fast, how much memory, how heavy: measured the same way in every package, and kept as data.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/@cosyte/bench)
|
|
13
|
+
[](https://github.com/cosyte/config/actions/workflows/ci.yml)
|
|
14
|
+
[](https://github.com/cosyte/config/blob/main/LICENSE)
|
|
15
|
+
[](https://nodejs.org)
|
|
16
|
+
|
|
17
|
+
Benchmarks for the @cosyte/\* packages: throughput, latency percentiles, memory, cold import time and loaded bytes, as versioned JSON, and a base against head comparison interleaved on one machine.
|
|
18
|
+
|
|
19
|
+
## Why this exists
|
|
20
|
+
|
|
21
|
+
The scaling gate in `@cosyte/test-utils/perf` catches work that grows faster than its input, and it
|
|
22
|
+
says plainly that it cannot see a change that makes everything 10% slower: both of its phases slow
|
|
23
|
+
down together and the ratio does not move. This package is the other half. It measures one build
|
|
24
|
+
the way a consumer runs it (the built entry, in a plain Node process), writes what it measured with
|
|
25
|
+
the machine it measured it on, and compares a pull request's build against its base on one machine,
|
|
26
|
+
interleaved, so the machine's noise lands on both sides.
|
|
27
|
+
|
|
28
|
+
It also keeps us honest about what a number means. Every reading names its runner, its Node version
|
|
29
|
+
and every sample behind it; every comparison carries the interval behind its verdict; and the
|
|
30
|
+
comparison's false alarm rate is something we measure on the runner it runs on, not something we
|
|
31
|
+
assume.
|
|
32
|
+
|
|
33
|
+
## Status
|
|
34
|
+
|
|
35
|
+
`@cosyte/bench` is on the Cosyte 0.1.x line: the public API is settled and bump types follow ordinary semver.
|
|
36
|
+
|
|
37
|
+
It is new, and joins the line the other packages in this repository share. What is decided is in [ADR 0005](https://github.com/cosyte/config/blob/main/documentation/decisions/0005-the-performance-standard.md),
|
|
38
|
+
the performance standard, including every constant in `BENCH_CONTRACT` and where it comes from. The
|
|
39
|
+
results format is versioned on its own (`schemaVersion: 1`) and only grows within a version.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pnpm add -D @cosyte/bench
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Node `>=22.15.0`. No runtime dependencies.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
A suite is a module whose default export is `defineSuite(...)`: a `.mjs` file on any supported
|
|
52
|
+
Node, or a `.ts` file on a Node that strips types (22.18 and later). It is handed the module under test
|
|
53
|
+
rather than importing it, so the same suite measures a base build and a head build. Every corpus is
|
|
54
|
+
built in code, from an index: the runner never reads a data file, so the inputs are synthetic by
|
|
55
|
+
construction.
|
|
56
|
+
|
|
57
|
+
```ts runnable
|
|
58
|
+
import { defineBenchmark, defineSuite } from "@cosyte/bench";
|
|
59
|
+
|
|
60
|
+
const message = (segments: number) => "MSH|^~\\&|LAB\r" + "OBX|1|NM|GLU||99\r".repeat(segments);
|
|
61
|
+
|
|
62
|
+
// In bench/parse.bench.ts this is the module's default export.
|
|
63
|
+
const suite = defineSuite({
|
|
64
|
+
name: "@cosyte/example parse",
|
|
65
|
+
benchmarks: [
|
|
66
|
+
defineBenchmark({
|
|
67
|
+
name: "parse",
|
|
68
|
+
sizes: [
|
|
69
|
+
{
|
|
70
|
+
label: "10 segments",
|
|
71
|
+
build: () => ({ inputs: Array.from({ length: 64 }, () => message(10)) }),
|
|
72
|
+
},
|
|
73
|
+
{ label: "10,000 segments", build: () => ({ inputs: [message(10_000)] }), compare: false },
|
|
74
|
+
],
|
|
75
|
+
bind: (pkg: { parse: (raw: string) => string[] }) => (raw: string) => pkg.parse(raw),
|
|
76
|
+
weigh: (segments) => segments.length,
|
|
77
|
+
}),
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
suite.benchmarks[0]?.sizes.length; // => 2
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then, from the package's directory, after a build:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
# A reading of this build: throughput, latency, memory, import cost.
|
|
88
|
+
cosyte-bench run --suite bench/parse.bench.ts --target dist/index.mjs --out bench.json
|
|
89
|
+
|
|
90
|
+
# Base against head, interleaved on this machine. The base is built in another checkout.
|
|
91
|
+
cosyte-bench compare --suite bench/parse.bench.ts \
|
|
92
|
+
--base ../base/dist/index.mjs --head dist/index.mjs --summary comparison.md
|
|
93
|
+
|
|
94
|
+
# Check that a results file matches the schema.
|
|
95
|
+
cosyte-bench check bench.json
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Exit codes: 0 clean, 1 a finding (a regression, or a results file that does not match the schema),
|
|
99
|
+
2 could not measure (a skip, a process that died, an input it could not read). A skip is not a pass.
|
|
100
|
+
|
|
101
|
+
## Entry points
|
|
102
|
+
|
|
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 |
|
|
109
|
+
|
|
110
|
+
The module entry point has no side effects: importing it starts no process. The schema is plain JSON:
|
|
111
|
+
|
|
112
|
+
```ts runnable
|
|
113
|
+
import schema from "@cosyte/bench/schema/results-v1.schema.json" with { type: "json" };
|
|
114
|
+
|
|
115
|
+
schema.properties.schemaVersion.const; // => 1
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ts runnable
|
|
119
|
+
import { BENCH_CONTRACT, judge } from "@cosyte/bench";
|
|
120
|
+
|
|
121
|
+
BENCH_CONTRACT.ROUNDS; // => 12
|
|
122
|
+
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"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Overrides
|
|
126
|
+
|
|
127
|
+
There are none, on purpose. Every constant that shapes a measurement is frozen in `BENCH_CONTRACT`:
|
|
128
|
+
a comparison's false alarm rate was measured with those values, and a package that changed one would
|
|
129
|
+
be running an instrument nobody has calibrated. What a package does choose is in its suite and on
|
|
130
|
+
the command line:
|
|
131
|
+
|
|
132
|
+
- the sizes, and which of them a pull request's comparison runs (`compare: false` leaves a slow size
|
|
133
|
+
to the readings);
|
|
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;
|
|
136
|
+
- `--only <benchmark or case>`, `--processes <n>`, `--no-memory` and `--no-import` on `run`.
|
|
137
|
+
|
|
138
|
+
## What it measures
|
|
139
|
+
|
|
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.
|
|
163
|
+
|
|
164
|
+
## The comparison
|
|
165
|
+
|
|
166
|
+
Each of 12 rounds runs every case once on the base build and once on the head build, each in a fresh
|
|
167
|
+
process, back to back; even rounds run the base first, odd rounds the head. A round gives one ratio
|
|
168
|
+
per case: the head's fastest sample over the base's. The verdict reads the sign test's interval on
|
|
169
|
+
the median of those 12 ratios (the 3rd smallest to the 3rd largest), which assumes nothing about the
|
|
170
|
+
shape of the noise:
|
|
171
|
+
|
|
172
|
+
- `regression`: the whole interval is above +3%;
|
|
173
|
+
- `improvement`: the whole interval is below 1 / 1.03;
|
|
174
|
+
- `no-regression`: the whole interval is at or below +3%;
|
|
175
|
+
- `inconclusive`: it straddles +3%. Not a pass and not a failure.
|
|
176
|
+
|
|
177
|
+
With the two sides the same, each case's chance of a false `regression` is at most 79 / 4096, about
|
|
178
|
+
1.9%, whatever the noise, as long as the rounds are independent. How often it actually happens on a
|
|
179
|
+
given runner, and how large a slowdown it reliably finds there, is what `cosyte-bench calibrate`
|
|
180
|
+
measures. The comparison posts its numbers and is not a required check until an A/A calibration on
|
|
181
|
+
its runner class shows, with 95% confidence, that false alarms are under 1% of the comparisons that
|
|
182
|
+
measured, and that comparisons with a skipped case are under 1% of all of them (ADR 0005). A
|
|
183
|
+
skipped case is never counted as a pass.
|
|
184
|
+
|
|
185
|
+
`calibrate --inject <fraction>` plants a slowdown on the head side: after each timed pass it reads
|
|
186
|
+
the clock until that fraction of the pass's own time has passed. The change a comparison then
|
|
187
|
+
measures is not exactly that fraction, and differs by workload and machine, so a calibration
|
|
188
|
+
reports the change it observed for each case, and a detection rate is read against that.
|
|
189
|
+
|
|
190
|
+
## PHI and safety
|
|
191
|
+
|
|
192
|
+
This package processes no patient data. Inputs come from the suite's own code, never from a file
|
|
193
|
+
path the runner is given. Reports carry names, sizes, counts and timings. When the measured function
|
|
194
|
+
or a suite's build throws, the report names the case, the phase and the error's class, and leaves out
|
|
195
|
+
its message, because a parser's message may quote its input; the message stays on the error's `cause`
|
|
196
|
+
for whoever runs the case directly. Results name the machine by its CPU model, operating system and
|
|
197
|
+
CI runner image, never by host name, user or absolute path.
|
|
198
|
+
|
|
199
|
+
## API
|
|
200
|
+
|
|
201
|
+
- `defineSuite(suite)`, `defineBenchmark(benchmark)`: write a suite. `defineSuite` checks it and
|
|
202
|
+
throws a `TypeError` naming the problem.
|
|
203
|
+
- `judge(ratios, threshold?, alpha?)`: the comparison's verdict for one case from its per-round ratios.
|
|
204
|
+
- `readResults(text)`: parse and check a results document; throws a `TypeError` listing every
|
|
205
|
+
problem. `RESULTS_SCHEMA` is the schema, also shipped as `schema/results-v1.schema.json`.
|
|
206
|
+
- `BENCH_CONTRACT`: the frozen constants.
|
|
207
|
+
- `BenchError`: what a run that could not measure throws.
|
|
208
|
+
|
|
209
|
+
## Compatibility
|
|
210
|
+
|
|
211
|
+
Node `>=22.15.0`, for the synchronous module hooks the import measurement uses.
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
Questions, bug reports and proposals go to
|
|
216
|
+
[the issue tracker](https://github.com/cosyte/config/issues). Pull requests are welcome, in
|
|
217
|
+
[cosyte/config](https://github.com/cosyte/config), where this package lives.
|
|
218
|
+
|
|
219
|
+
A change to a constant in `BENCH_CONTRACT`, to the warmup or to the verdict changes what every
|
|
220
|
+
calibration measured, so it goes through ADR 0005's review triggers and a new calibration.
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT, copyright Cosyte LLC. See [LICENSE](https://github.com/cosyte/config/blob/main/LICENSE).
|