@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 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
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ [![npm version](https://img.shields.io/npm/v/@cosyte/bench.svg)](https://www.npmjs.com/package/@cosyte/bench)
13
+ [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/config/ci.yml?branch=main&label=CI)](https://github.com/cosyte/config/actions/workflows/ci.yml)
14
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/cosyte/config/blob/main/LICENSE)
15
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.15.0-brightgreen.svg)](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).
@@ -0,0 +1,2 @@
1
+
2
+ export { };