ostia 0.1.7 → 0.2.1
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/README.md +533 -106
- package/chunk-9dqf5cxf.js +8 -0
- package/chunk-h6579788.js +21 -0
- package/cli.js +148 -62
- package/index.d.ts +363 -34
- package/index.js +1 -1
- package/package.json +1 -1
- package/runner.ts +5 -4
- package/chunk-3qbcj1m9.js +0 -21
- package/chunk-zrck2q0b.js +0 -4
package/README.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# ostia
|
|
2
2
|
|
|
3
|
-
ostia is a profiling and benchmarking toolkit for Bun.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
ostia is a profiling and benchmarking toolkit for Bun. One schema-versioned JSON
|
|
4
|
+
document (`ProfileDocument`) holds wall-clock timing, CPU hotspots, heap summaries,
|
|
5
|
+
JIT tier data, and allocation counts for a subprocess command or an in-process
|
|
6
|
+
function - the CLI and the library speak the same language, so anything you can do
|
|
7
|
+
with `ostia time`/`ostia bench` you can do with `time()`/`bench()` too.
|
|
8
|
+
|
|
9
|
+
Comparing two documents reports a bootstrap confidence interval and a Mann-Whitney
|
|
10
|
+
p-value, not a bare percentage past a threshold, and widens the regression bar to
|
|
11
|
+
the machine's own noise floor so ambient jitter is never mistaken for a real
|
|
12
|
+
regression. `ostia ci` gates a whole `ostia.config.json`/`ostia.config.ts` of
|
|
13
|
+
workloads - subprocess commands and in-process `group()`/`task()` suites alike -
|
|
14
|
+
against a saved baseline, skipping anything whose input fingerprint hasn't changed.
|
|
15
|
+
Any task can run in its own subprocess for clean JIT/heap isolation from its
|
|
16
|
+
suite-mates, and every renderer has a `minimal` JSON-lines mode built for piping
|
|
17
|
+
straight into an LLM agent's context.
|
|
9
18
|
|
|
10
19
|
Zero runtime dependencies. Requires Bun ≥ 1.4.
|
|
11
20
|
|
|
@@ -20,31 +29,52 @@ bun add ostia
|
|
|
20
29
|
Compare two commands:
|
|
21
30
|
|
|
22
31
|
```sh
|
|
23
|
-
ostia
|
|
32
|
+
ostia time --samples 10 --warmup 2 "bun fixtures/fast.ts" "bun fixtures/slow.ts"
|
|
24
33
|
```
|
|
25
34
|
|
|
26
35
|
```
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
Apple M2 · 8 cores · load 10.1 · noise floor 6.2%
|
|
37
|
+
|
|
38
|
+
Task Median Spread Range Relative
|
|
39
|
+
--------------------------------------------------------------------------------
|
|
40
|
+
bun fixtures/fast.ts 16.6 ms 25.3 ms…32.8 ms 10.4 ms…33.2 ms 1.00×
|
|
41
|
+
! noisy-machine
|
|
42
|
+
bun fixtures/slow.ts 40.2 ms 48.2 ms…63.9 ms 28.6 ms…64.7 ms 2.43× slower
|
|
43
|
+
|
|
44
|
+
Warnings:
|
|
45
|
+
bun fixtures/fast.ts: Load average 10.10 exceeds 75% of 8 available core(s); timing noise may be elevated.
|
|
31
46
|
```
|
|
32
47
|
|
|
48
|
+
The header line (machine, cores, load, noise floor) prints whenever a document
|
|
49
|
+
carries an `environment` - on by default; skip the ~200ms reference
|
|
50
|
+
measurement with `--no-noise-check`. `compare`/`ci` widen the regression
|
|
51
|
+
threshold to at least the noise floor, so a change smaller than the machine's
|
|
52
|
+
own jitter is never called a regression - see
|
|
53
|
+
[Statistics](#statistics-a-real-significance-test-not-a-percentage-threshold)
|
|
54
|
+
below.
|
|
55
|
+
|
|
33
56
|
Find a CPU hotspot (profiler runs as a separate labeled trial, never mixed into the
|
|
34
57
|
timing numbers above):
|
|
35
58
|
|
|
36
59
|
```sh
|
|
37
|
-
ostia
|
|
60
|
+
ostia time --samples 5 --cpu --cpu-interval 200 --export-json node_modules/.cache/ostia/doc.json "bun fixtures/work.ts"
|
|
38
61
|
```
|
|
39
62
|
|
|
40
63
|
```
|
|
41
|
-
|
|
42
|
-
----------------------------------------------------
|
|
43
|
-
bun fixtures/work.ts 275.815 ± 2.853 273.334…281.384
|
|
64
|
+
Apple M2 · 8 cores · load 8.5 · noise floor 0.9%
|
|
44
65
|
|
|
45
|
-
|
|
46
|
-
|
|
66
|
+
Task Median Spread Range
|
|
67
|
+
-----------------------------------------------------------------------
|
|
68
|
+
bun fixtures/work.ts 470.6 ms 478.7 ms…499.3 ms 400.8 ms…500.1 ms
|
|
69
|
+
! noisy-machine
|
|
70
|
+
|
|
71
|
+
Warnings:
|
|
72
|
+
bun fixtures/work.ts: Load average 8.48 exceeds 75% of 8 available core(s); timing noise may be elevated.
|
|
73
|
+
|
|
74
|
+
CPU capture - bun fixtures/work.ts (instrumented, 200µs interval, diagnostic wall 531.143ms)
|
|
75
|
+
100.0% 502.67ms self hashLoop
|
|
47
76
|
0.0% 0.00ms self (root)
|
|
77
|
+
0.0% 0.00ms self (module)
|
|
48
78
|
artifact: node_modules/.cache/ostia/artifacts/<run-id>-cpu.cpuprofile
|
|
49
79
|
```
|
|
50
80
|
|
|
@@ -57,8 +87,8 @@ they need to survive `node_modules` reinstalls between branches and CI jobs, so
|
|
|
57
87
|
Gate a change against a local baseline:
|
|
58
88
|
|
|
59
89
|
```sh
|
|
60
|
-
|
|
61
|
-
ostia ci
|
|
90
|
+
ostia baseline save # on known-good: measure ostia.config.json -> .ostia/baselines/main.json
|
|
91
|
+
ostia ci # on your branch: rerun changed workloads, exit 1 on regression
|
|
62
92
|
```
|
|
63
93
|
|
|
64
94
|
```
|
|
@@ -83,56 +113,116 @@ Profile CI: ✓
|
|
|
83
113
|
## CLI reference
|
|
84
114
|
|
|
85
115
|
```
|
|
86
|
-
ostia
|
|
116
|
+
ostia time <command...> time commands; optional --cpu / --heap capture
|
|
87
117
|
ostia bench <suite.ts...> in-process group()/task() suites (time-budgeted)
|
|
88
118
|
ostia compare <a> <b> diff two ProfileDocuments
|
|
89
|
-
ostia report <document.json> render a saved document
|
|
90
|
-
ostia viz <document.json> render CPU evidence to a file format
|
|
119
|
+
ostia report <document.json> render a saved document (table/json/markdown/collapsed/...)
|
|
91
120
|
ostia ci run configured workloads vs a baseline, gate regressions
|
|
121
|
+
ostia baseline save|list|show manage baseline ProfileDocuments
|
|
92
122
|
```
|
|
93
123
|
|
|
94
124
|
Every subcommand takes `--help` for its full flag list.
|
|
95
125
|
|
|
96
|
-
### `ostia
|
|
126
|
+
### `ostia time`
|
|
97
127
|
|
|
98
128
|
Clean wall-clock timing by default. `--cpu` / `--heap` schedule one extra instrumented
|
|
99
129
|
trial each, labeled separately in the document.
|
|
100
130
|
|
|
101
131
|
```sh
|
|
102
|
-
ostia
|
|
103
|
-
ostia
|
|
104
|
-
ostia
|
|
132
|
+
ostia time "bun a.ts" "bun b.ts"
|
|
133
|
+
ostia time --samples 25 --warmup 3 --cpu --heap "bun src/server.ts"
|
|
134
|
+
ostia time --format json --export-json out.json "bun a.ts"
|
|
105
135
|
```
|
|
106
136
|
|
|
137
|
+
`--samples N` is an exact trial count; `--budget MS`
|
|
138
|
+
is a wall-clock time budget instead (default: a hyperfine-style ~3s min-total-time
|
|
139
|
+
loop when neither is given); `--min-samples N` is a hard floor when `--samples` isn't
|
|
140
|
+
given. The same three names work on `ostia bench` (`--budget`/`--samples`/
|
|
141
|
+
`--min-samples`), where `--budget` is a per-task sampling window -
|
|
142
|
+
`warmup` differs by surface, though: a trial count here, a
|
|
143
|
+
*fraction* of the budget for `ostia bench`, since in-process warmup has no natural
|
|
144
|
+
"N calls" unit before the JIT has even seen the function once.
|
|
145
|
+
|
|
146
|
+
With 2+ commands, trials round-robin across them by default (one trial per command,
|
|
147
|
+
repeated) rather than running one command's whole loop to completion before the next
|
|
148
|
+
starts - drift over the run's wall-clock span (thermal throttling, a noisy neighbor
|
|
149
|
+
process) then lands on every command equally instead of favoring whichever ran first
|
|
150
|
+
or last. `--no-interleave` (`interleave: false`) goes back to running each command's
|
|
151
|
+
loop to completion in turn. Interleaved measurements carry `Measurement.interleaved: true`.
|
|
152
|
+
Meaningless (and ignored) with a single command.
|
|
153
|
+
|
|
154
|
+
`--prepare CMD` runs `CMD` before *every* trial (warmup and `--cpu`/`--heap` trials
|
|
155
|
+
included), unmeasured, in the same cwd - hyperfine's `--prepare`. It's whitespace-split
|
|
156
|
+
like the commands themselves (no shell) and must exit 0. Given once it applies to every
|
|
157
|
+
command; given once per command it pairs up in order, which is how the same command gets
|
|
158
|
+
timed warm and cold side by side:
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
ostia time --prepare "true" --prepare "rm -rf dist" "bun build.ts" "bun build.ts"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The prepare command is part of the workload id (and lands on the document as
|
|
165
|
+
`Workload.prepare`), so a command with and without one are two workloads, and `ostia ci`
|
|
166
|
+
caches them separately. The library API also takes a function
|
|
167
|
+
(`prepare: ({ phase, index }) => ...`, see [`time(opts)`](#timeopts--profiledocument)).
|
|
168
|
+
|
|
169
|
+
`--time-source REGEX` takes each trial's time from the command's *own output* instead of
|
|
170
|
+
its wall clock: the first `REGEX` match in stdout (then stderr), capture group 1, in
|
|
171
|
+
`--time-unit` units (`ns` | `us` | `ms` | `s`, default `ms`). Meant for tools that report a
|
|
172
|
+
more precise cost than wall time - a build tool whose `built in 342ms` line excludes the
|
|
173
|
+
runtime's startup - so a Bun startup regression isn't misattributed to the tool, and vice
|
|
174
|
+
versa:
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
ostia time --time-source "built in (\d+)ms" "bun build.ts"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The parsed value becomes `timing.samples`, so `compare`/`ci`/every renderer treat it
|
|
181
|
+
exactly like wall time; each trial keeps `wallNs` alongside `reportedNs` so the document
|
|
182
|
+
has both. A trial whose output doesn't match aborts the run with the output quoted (the
|
|
183
|
+
workload asked for a number that isn't there). To gate wall time *and* the reported time
|
|
184
|
+
independently, declare the command twice - once plain, once with `--time-source` - and
|
|
185
|
+
they're two workloads with two verdicts. Note the reported number has whatever resolution
|
|
186
|
+
the tool printed (usually whole ms), so its confidence interval is coarser than a
|
|
187
|
+
nanosecond wall clock's.
|
|
188
|
+
|
|
107
189
|
Timing table (two commands get a Relative column automatically):
|
|
108
190
|
|
|
109
191
|
```
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
bun fixtures/fast.ts 8.
|
|
113
|
-
bun fixtures/slow.ts 21.
|
|
192
|
+
Task Median Spread Range Relative
|
|
193
|
+
--------------------------------------------------------------------------------
|
|
194
|
+
bun fixtures/fast.ts 7.94 ms 8.31 ms…8.56 ms 7.68 ms…8.57 ms 1.00×
|
|
195
|
+
bun fixtures/slow.ts 21.3 ms 21.5 ms…22.1 ms 21.1 ms…22.1 ms 2.69× slower
|
|
196
|
+
! outliers-detected
|
|
197
|
+
|
|
198
|
+
Warnings:
|
|
199
|
+
bun fixtures/slow.ts: 1 outlier(s) detected (1 severe, 0 mild).
|
|
114
200
|
```
|
|
115
201
|
|
|
116
202
|
Heap summary (type counts from the snapshot trial):
|
|
117
203
|
|
|
118
204
|
```
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
bun fixtures/allocate.ts
|
|
205
|
+
Task Median Spread Range
|
|
206
|
+
---------------------------------------------------------------------------
|
|
207
|
+
bun fixtures/allocate.ts 23.8 ms 24.9 ms…29.4 ms 22.6 ms…30.0 ms
|
|
208
|
+
! outliers-detected
|
|
122
209
|
|
|
123
|
-
|
|
210
|
+
Warnings:
|
|
211
|
+
bun fixtures/allocate.ts: 8 outlier(s) detected (1 severe, 7 mild).
|
|
212
|
+
|
|
213
|
+
Heap snapshot - bun fixtures/allocate.ts (instrumented, 2516 objects, 0.12MB)
|
|
124
214
|
1369 string
|
|
125
|
-
|
|
126
|
-
|
|
215
|
+
423 code
|
|
216
|
+
319 closure
|
|
127
217
|
216 object shape
|
|
128
|
-
|
|
218
|
+
105 hidden
|
|
129
219
|
artifact: node_modules/.cache/ostia/artifacts/<run-id>-heap.heapsnapshot
|
|
130
220
|
```
|
|
131
221
|
|
|
132
222
|
### `ostia bench`
|
|
133
223
|
|
|
134
224
|
In-process microbenchmarks registered with `group()` / `task()`. Each task samples
|
|
135
|
-
for `--
|
|
225
|
+
for `--budget` (default 500ms). `--min-samples` is a hard floor kept even when it
|
|
136
226
|
overruns the budget. Left unset, the floor is cost-aware in both directions: as many
|
|
137
227
|
trials as fit in the budget (capped at 20) so one slow task can't blow the suite's total,
|
|
138
228
|
but never below the floor a task's per-trial cost earns it - 3 at ≤1ms, two more per
|
|
@@ -155,7 +245,7 @@ without re-deriving the policy from the raw sample array.
|
|
|
155
245
|
|
|
156
246
|
```sh
|
|
157
247
|
ostia bench bench/*.ts
|
|
158
|
-
ostia bench --
|
|
248
|
+
ostia bench --budget 500 --min-samples 50 bench/stats.ts
|
|
159
249
|
ostia bench bench/*.ts --jobs auto # suite files in parallel, see below
|
|
160
250
|
ostia bench bench/*.ts --format minimal # one compact JSON object per task
|
|
161
251
|
```
|
|
@@ -182,6 +272,22 @@ Bun/V8 batch calls together and amortize it away). `task(name, fn, { gc })` /
|
|
|
182
272
|
override pattern as `isolate` - useful when a few allocation-heavy tasks need GC settled
|
|
183
273
|
between trials but the rest of the suite doesn't.
|
|
184
274
|
|
|
275
|
+
`--cpu` captures one extra `phase: "cpu"` measurement per task on top of its timing
|
|
276
|
+
numbers: the task looped for a fixed 200ms window under the JSC sampling profiler
|
|
277
|
+
(JIT tiers included), never mixed into the timing numbers themselves. `--alloc` captures
|
|
278
|
+
an extra `phase: "memstats"` measurement: bytes allocated per call, from a
|
|
279
|
+
`Bun.gc(true)`-bracketed batch of 100 calls (`MemoryEvidence.bytesPerOp`). Both follow the
|
|
280
|
+
same per-task/per-group override pattern as `isolate`/`gc`: `task(name, fn, { cpu, alloc })`
|
|
281
|
+
/ `group(name, fn, { cpu, alloc })`. The terminal table prints an `Alloc/op` column when a
|
|
282
|
+
`memstats` measurement is present. With `--cpu` on, `ostia compare` reports per-frame CPU
|
|
283
|
+
deltas for bench tasks the same way it already does for `ostia time --cpu`.
|
|
284
|
+
|
|
285
|
+
When a `--cpu` capture spends more than 20% of its samples in the llint/baseline tiers,
|
|
286
|
+
the JIT never warmed the task up in that 200ms window, so its CPU numbers (and by
|
|
287
|
+
extension its timing) may not reflect steady state - the cpu measurement carries a
|
|
288
|
+
`jit-cold` warning (`{ llintPct, baselinePct, dfgPct, ftlPct }`), printed alongside the
|
|
289
|
+
CPU capture in the terminal table and folded into the task's line in `--format minimal`.
|
|
290
|
+
|
|
185
291
|
`--preload PATH` (repeatable) imports a script before each suite file loads, in the same
|
|
186
292
|
subprocess - the same shape as Bun's own `--preload` / `bunfig.toml`'s `preload` array. Use
|
|
187
293
|
it to install globals a suite needs at import time (jsdom's `document`/`window`) or register
|
|
@@ -223,11 +329,30 @@ shell's environment.
|
|
|
223
329
|
|
|
224
330
|
|
|
225
331
|
```
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
stats
|
|
229
|
-
stats/computeTimingStats (
|
|
230
|
-
|
|
332
|
+
Task Median Spread Range Relative
|
|
333
|
+
----------------------------------------------------------------------------------------------------
|
|
334
|
+
stats:
|
|
335
|
+
stats/computeTimingStats (1e3 samples) 24.7 µs 33.8 µs…151.6 µs 22.4 µs…1073.2 µs 1.00×
|
|
336
|
+
! outliers-detected
|
|
337
|
+
stats/computeTimingStats (1e4 samples) 255.2 µs 331.7 µs…1067.6 µs 223.2 µs…20789.4 µs 10.33× slower
|
|
338
|
+
! outliers-detected
|
|
339
|
+
stats/timingWarnings (1e3 samples) 47.3 µs 88.5 µs…510.4 µs 43.0 µs…14383.1 µs 1.91× slower
|
|
340
|
+
! outliers-detected
|
|
341
|
+
|
|
342
|
+
Warnings:
|
|
343
|
+
stats/computeTimingStats (1e3 samples): 2455 outlier(s) detected (2108 severe, 347 mild).
|
|
344
|
+
stats/computeTimingStats (1e4 samples): 215 outlier(s) detected (70 severe, 145 mild).
|
|
345
|
+
stats/timingWarnings (1e3 samples): 541 outlier(s) detected (191 severe, 350 mild).
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Tasks with a `group()` print the group name once, indented; ungrouped tasks and
|
|
349
|
+
subprocess commands print flat.
|
|
350
|
+
|
|
351
|
+
There's no built-in watch mode. For an edit/re-run loop while writing a suite, pair
|
|
352
|
+
`ostia bench` with a file watcher and a small budget:
|
|
353
|
+
|
|
354
|
+
```sh
|
|
355
|
+
watchexec -e ts -- ostia bench bench/parse.ts --budget 100
|
|
231
356
|
```
|
|
232
357
|
|
|
233
358
|
### `ostia compare`
|
|
@@ -240,16 +365,63 @@ ostia compare after.json --baseline .ostia/baselines/main.json
|
|
|
240
365
|
```
|
|
241
366
|
|
|
242
367
|
```
|
|
243
|
-
|
|
244
|
-
timing:
|
|
368
|
+
✗ bun fixtures/work.ts
|
|
369
|
+
timing: +11.2% median, 95% CI [+10.0%, +16.4%], p<0.001 (regressed)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
When both documents carry `git` metadata (see below), `ostia compare` prints a summary
|
|
373
|
+
line above the verdicts:
|
|
245
374
|
|
|
246
|
-
✗ work
|
|
247
|
-
timing: +1249.2% median (regressed)
|
|
248
375
|
```
|
|
376
|
+
base a1b2c3d (main) → cand d4e5f6a (my-opt, dirty)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The verdict needs both a confidence interval clear of `timingPct` and a
|
|
380
|
+
significant Mann-Whitney p-value (`thresholds.alpha`, default `0.01`), not
|
|
381
|
+
just a point estimate past the threshold - see
|
|
382
|
+
[Statistics](#statistics-a-real-significance-test-not-a-percentage-threshold)
|
|
383
|
+
below. Comparisons with fewer than 5 samples on either side fall back to the
|
|
384
|
+
old point-estimate rule and carry a `thin-comparison` warning instead.
|
|
385
|
+
|
|
386
|
+
#### Statistics: a real significance test, not a percentage threshold
|
|
387
|
+
|
|
388
|
+
A point estimate past `timingPct` is not enough to call something a
|
|
389
|
+
regression - both documents already carry full sample arrays, so `compare`
|
|
390
|
+
runs two tests instead:
|
|
391
|
+
|
|
392
|
+
- A **bootstrap confidence interval** on the difference of medians:
|
|
393
|
+
resample both sides with replacement `thresholds.bootstrapIterations`
|
|
394
|
+
times (default 2000; each side is randomly subsampled to at most 2000
|
|
395
|
+
samples first, so a many-thousand-sample task doesn't turn a compare into
|
|
396
|
+
a multi-second operation), and report the 2.5th/97.5th percentiles as
|
|
397
|
+
`ci95` (percent of the baseline median). Reproducible: the PRNG seed is
|
|
398
|
+
stored in `Comparison.timing.seed`.
|
|
399
|
+
- A **Mann-Whitney U test** (tie-corrected, normal approximation), reported
|
|
400
|
+
as `pValue` - whether the two sample distributions differ at all, without
|
|
401
|
+
assuming normality the way a t-test would.
|
|
402
|
+
|
|
403
|
+
`regressed` requires `ci95[0] > thresholds.timingPct` (the *whole interval*
|
|
404
|
+
clears the threshold) **and** `pValue < thresholds.alpha`; `improved` is the
|
|
405
|
+
mirror. Otherwise `unchanged`. This is why the earlier example (`+11.2%
|
|
406
|
+
median, 95% CI [+10.0%, +16.4%]`) is a clean regression: even the low end of
|
|
407
|
+
the interval is well past `timingPct`.
|
|
408
|
+
|
|
409
|
+
Both `time()` and `bench()` also stamp `environment` on every document (a
|
|
410
|
+
fixed-cost, deterministic, allocation-free hash loop sampled for ~200ms,
|
|
411
|
+
`noise.floorPct = mad / median`) unless `noiseCheck: false` / `--no-noise-check`
|
|
412
|
+
skips it. `compare` widens the effective threshold to
|
|
413
|
+
`max(thresholds.timingPct, base.environment.noise.floorPct,
|
|
414
|
+
cand.environment.noise.floorPct)` (`Comparison.thresholds.effectiveTimingPct`),
|
|
415
|
+
so a delta smaller than the machine's own jitter right now is never called a
|
|
416
|
+
regression. A `noisy-machine` warning fires when the 1-minute load average is
|
|
417
|
+
already past 75% of available cores at measurement time.
|
|
249
418
|
|
|
250
419
|
### `ostia report`
|
|
251
420
|
|
|
252
|
-
Render a saved `ProfileDocument` without re-running anything.
|
|
421
|
+
Render a saved `ProfileDocument` without re-running anything. `--format` covers
|
|
422
|
+
both the data formats (`table`/`json`/`jsonl`/`markdown`/`minimal`) and the CPU
|
|
423
|
+
visualization formats (`collapsed`/`mermaid`/`speedscope`/`cpuprofile`) - one
|
|
424
|
+
command instead of two.
|
|
253
425
|
|
|
254
426
|
```sh
|
|
255
427
|
ostia report out.json # table (default)
|
|
@@ -265,7 +437,7 @@ sample (tens of thousands for a fast task), which is tokens a reviewer never rea
|
|
|
265
437
|
Numbers stay in ns so they line up with `compare` deltas and the JSON document.
|
|
266
438
|
|
|
267
439
|
```
|
|
268
|
-
{"task":"diffText()/append at end","group":"diffText()","samples":9282,"mean":50213.4,"median":49871,"stddev":2104.7,"stddevPct":4.19,"min":48120,"max":81002,"relative":1,"warnings":[],"unit":"ns"}
|
|
440
|
+
{"task":"diffText()/append at end","group":"diffText()","samples":9282,"mean":50213.4,"median":49871,"stddev":2104.7,"stddevPct":4.19,"min":48120,"max":81002,"p75":50920,"p99":58011,"mad":1780,"relative":1,"warnings":[],"unit":"ns"}
|
|
269
441
|
{"task":"repaint/4000 chars","group":"repaint","description":"full repaint every keystroke","samples":3,"mean":2.61e9,"median":2.4e9,"stddevPct":15.3,"relative":47800,"warnings":[{"code":"low-sample-count","data":{"samples":3,"target":10}}],"unit":"ns"}
|
|
270
442
|
```
|
|
271
443
|
|
|
@@ -277,39 +449,42 @@ Markdown:
|
|
|
277
449
|
```
|
|
278
450
|
# Profile Report
|
|
279
451
|
|
|
280
|
-
Bun 1.4.
|
|
452
|
+
Bun 1.4.1 · ostia 0.1.0 · darwin/arm64 · 2026-09-05T13:14:50.085Z · a1b2c3d (main)
|
|
281
453
|
|
|
282
454
|
## Timing
|
|
283
455
|
|
|
284
|
-
|
|
|
285
|
-
|
|
286
|
-
| bun -e 1 | 5.
|
|
456
|
+
| Task | Median | Spread (p75…p99) | Mean ± SD | Range | MAD |
|
|
457
|
+
|---|---|---|---|---|---|
|
|
458
|
+
| bun -e 1 | 5.03 ms | 5.42 ms…9.70 ms | 5.29 ms ± 0.84 ms | 4.77 ms…13.4 ms | 0.16 ms |
|
|
287
459
|
```
|
|
288
460
|
|
|
289
|
-
|
|
461
|
+
#### CPU visualization formats
|
|
290
462
|
|
|
291
463
|
Turn CPU evidence into files for other tools. Formats: `collapsed`, `mermaid`,
|
|
292
464
|
`speedscope`, `cpuprofile` (pass-through of a real CDP artifact when present).
|
|
465
|
+
`--measurement <id>` renders only that measurement (default: every CPU
|
|
466
|
+
measurement in the document); `--out-dir PATH` writes files there instead of
|
|
467
|
+
stdout.
|
|
293
468
|
|
|
294
469
|
```sh
|
|
295
|
-
ostia
|
|
296
|
-
ostia
|
|
297
|
-
ostia
|
|
470
|
+
ostia report doc.json --format collapsed
|
|
471
|
+
ostia report doc.json --format mermaid
|
|
472
|
+
ostia report doc.json --format speedscope > flame.json
|
|
298
473
|
```
|
|
299
474
|
|
|
300
475
|
Collapsed stacks (one line per stack; feeds `flamegraph.pl` and friends):
|
|
301
476
|
|
|
302
477
|
```
|
|
303
|
-
(root);(module);hashLoop
|
|
478
|
+
(root);(module);hashLoop 209
|
|
304
479
|
```
|
|
305
480
|
|
|
306
481
|
Mermaid call tree (top N nodes by self time, never the whole profile):
|
|
307
482
|
|
|
308
483
|
```
|
|
309
484
|
graph TD
|
|
310
|
-
n1["(root) (self 0.00ms, total
|
|
311
|
-
n2["(module) (self 0.00ms, total
|
|
312
|
-
n3["hashLoop (self
|
|
485
|
+
n1["(root) (self 0.00ms, total 267.91ms)"]
|
|
486
|
+
n2["(module) (self 0.00ms, total 267.91ms)"]
|
|
487
|
+
n3["hashLoop (self 267.91ms, total 267.91ms)"]
|
|
313
488
|
n1 --> n2
|
|
314
489
|
n2 --> n3
|
|
315
490
|
```
|
|
@@ -324,6 +499,7 @@ ostia ci
|
|
|
324
499
|
ostia ci --full # ignore cache
|
|
325
500
|
ostia ci --baseline main
|
|
326
501
|
ostia ci --export-json out.json
|
|
502
|
+
ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
|
|
327
503
|
```
|
|
328
504
|
|
|
329
505
|
Pass:
|
|
@@ -345,16 +521,43 @@ Fail:
|
|
|
345
521
|
1 affected by this change
|
|
346
522
|
0 cached
|
|
347
523
|
1 executed
|
|
348
|
-
0 passed 1 regressed (+
|
|
524
|
+
0 passed 1 regressed (+1278.7% median on work)
|
|
349
525
|
|
|
350
526
|
Profile CI: ✗
|
|
351
527
|
```
|
|
352
528
|
|
|
353
529
|
Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline, spawn failure).
|
|
354
530
|
|
|
355
|
-
#### `ostia.config.json`
|
|
531
|
+
#### `ostia.config.ts` / `ostia.config.json`
|
|
532
|
+
|
|
533
|
+
`loadConfig` looks for `ostia.config.ts` first (Bun imports TypeScript natively), falling
|
|
534
|
+
back to `ostia.config.json`. Both forms are fully supported; pick `.ts` for autocomplete
|
|
535
|
+
and type-checking on every field, via `defineConfig` (an identity function purely for
|
|
536
|
+
typing, the same pattern as Vite/Vitest/ESLint):
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
// ostia.config.ts
|
|
540
|
+
import { defineConfig } from "ostia"
|
|
541
|
+
|
|
542
|
+
export default defineConfig({
|
|
543
|
+
baseline: "main",
|
|
544
|
+
thresholds: { timingPct: 5 },
|
|
545
|
+
workloads: [
|
|
546
|
+
{ label: "parse", command: ["bun", "bench/parse.ts"], inputs: ["src/**/*.ts"] },
|
|
547
|
+
{ label: "dogfood-suites", suites: ["bench/*.ts"] },
|
|
548
|
+
// Same command, three states: warm no-op, one edited input, cold. `prepare`
|
|
549
|
+
// runs before every trial; `timeSource` reads the tool's own "in Nms" line.
|
|
550
|
+
{ label: "build:warm", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" } },
|
|
551
|
+
{ label: "build:incremental", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" },
|
|
552
|
+
prepare: () => touchPost("fixture/posts/hello.md") },
|
|
553
|
+
{ label: "build:cold", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" },
|
|
554
|
+
prepare: "rm -rf fixture/dist" },
|
|
555
|
+
],
|
|
556
|
+
})
|
|
557
|
+
```
|
|
356
558
|
|
|
357
559
|
```json
|
|
560
|
+
// ostia.config.json - equivalent, no defineConfig wrapper needed
|
|
358
561
|
{
|
|
359
562
|
"baseline": "main",
|
|
360
563
|
"thresholds": { "timingPct": 5 },
|
|
@@ -363,12 +566,33 @@ Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline
|
|
|
363
566
|
"label": "parse",
|
|
364
567
|
"command": ["bun", "bench/parse.ts"],
|
|
365
568
|
"inputs": ["src/**/*.ts"]
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
"label": "dogfood-suites",
|
|
572
|
+
"suites": ["bench/*.ts"]
|
|
366
573
|
}
|
|
367
574
|
]
|
|
368
575
|
}
|
|
369
576
|
```
|
|
370
577
|
|
|
371
|
-
|
|
578
|
+
Each workload is exactly one of `command` (a subprocess timed with `runs`/`warmup`) or
|
|
579
|
+
`suites` (glob patterns, same resolution as `bench`'s own `suites` config, run via
|
|
580
|
+
`bench()`). A `suites` workload gates every task in those files individually - one
|
|
581
|
+
candidate-vs-baseline comparison per task, matched by workload id the same way a `command`
|
|
582
|
+
workload already is, so `ostia ci`'s regression detection covers in-process microbenchmarks,
|
|
583
|
+
not only subprocess commands. Unlike `command` workloads, a `suites` workload always
|
|
584
|
+
executes (there's no cheap way to know a suite file's task ids, and so its per-task cache
|
|
585
|
+
keys, without importing it first) - `inputs`-based cache skipping is `command`-only for now.
|
|
586
|
+
|
|
587
|
+
`inputs` is optional (and, for now, only consulted for `command` workloads). Workloads
|
|
588
|
+
with no `inputs` always rerun (cache fails conservative).
|
|
589
|
+
|
|
590
|
+
`prepare` and `timeSource` are `command`-only and mean the same as `ostia time`'s
|
|
591
|
+
`--prepare` / `--time-source`: `prepare` is a command string or argv array in both config
|
|
592
|
+
forms, or a function in `ostia.config.ts`; `timeSource` is `{ pattern, group?, unit? }`
|
|
593
|
+
with `pattern` a regex source string (or a `RegExp` in `.ts`). Both are part of the workload
|
|
594
|
+
id. A function-form `prepare` can't be fingerprinted, so that workload never comes from
|
|
595
|
+
cache - it always executes, like a workload with no `inputs`.
|
|
372
596
|
|
|
373
597
|
Two directory options, both optional: `outDir` (default `node_modules/.cache/ostia`) for
|
|
374
598
|
scratch/cache/artifacts, and `baselineDir` (default `.ostia/baselines`) for baselines. They're
|
|
@@ -379,11 +603,23 @@ independent - `baselineDir` doesn't move just because you override `outDir`.
|
|
|
379
603
|
Baselines are JSON under `.ostia/baselines/` (gitignored). `ostia ci` only needs the file
|
|
380
604
|
on disk; it does not need to be committed.
|
|
381
605
|
|
|
606
|
+
`ostia baseline save [name]` measures every configured workload (the same code path
|
|
607
|
+
`ostia ci` gates against, no comparison) and writes it to `<baselineDir>/<name>.json`
|
|
608
|
+
(default name: config's `"baseline"` field, or `"main"`). `ostia baseline list` shows every
|
|
609
|
+
saved baseline (name, created date, workload count, and git sha/branch when available);
|
|
610
|
+
`ostia baseline show <name> [--format]` renders one (delegates to `ostia report`).
|
|
611
|
+
|
|
612
|
+
Every document stamps `git: { sha, branch, dirty }` (from `git rev-parse` / `git status
|
|
613
|
+
--porcelain` in the process's cwd, 200ms timeout, silently absent outside a repo or
|
|
614
|
+
without `git` installed) - metadata only, never part of any fingerprint or id, so a
|
|
615
|
+
commit or a dirty working tree never orphans a cached run or baseline. Printed in the
|
|
616
|
+
markdown report's header line and `ostia baseline list`.
|
|
617
|
+
|
|
382
618
|
Local branch workflow:
|
|
383
619
|
|
|
384
620
|
```sh
|
|
385
621
|
git checkout master # known-good tip
|
|
386
|
-
|
|
622
|
+
ostia baseline save # -> .ostia/baselines/main.json
|
|
387
623
|
|
|
388
624
|
git checkout -b my-opt
|
|
389
625
|
# ... change code ...
|
|
@@ -394,10 +630,15 @@ The baseline survives branch switches because it is not tracked. Re-seed only wh
|
|
|
394
630
|
intentionally accept a new floor. Seeding on the branch you are guarding compares that
|
|
395
631
|
branch to itself.
|
|
396
632
|
|
|
633
|
+
`ostia ci --save-baseline` folds that re-seed into the gate itself: after a pass (no
|
|
634
|
+
regressions), it writes the just-measured document as the new baseline at the same path
|
|
635
|
+
it just compared against - useful in a CI job that gates every merge to a trunk branch,
|
|
636
|
+
so each green run becomes the next run's floor with no separate step.
|
|
637
|
+
|
|
397
638
|
One-off outside this repo's config:
|
|
398
639
|
|
|
399
640
|
```sh
|
|
400
|
-
ostia
|
|
641
|
+
ostia time --export-json .ostia/baselines/main.json "bun bench.ts"
|
|
401
642
|
ostia ci
|
|
402
643
|
```
|
|
403
644
|
|
|
@@ -411,13 +652,17 @@ The CLI is a thin wrapper around the library. Same `ProfileDocument` either way.
|
|
|
411
652
|
|
|
412
653
|
```ts
|
|
413
654
|
import {
|
|
414
|
-
|
|
655
|
+
time,
|
|
415
656
|
profile,
|
|
416
657
|
bench,
|
|
417
658
|
group,
|
|
418
659
|
task,
|
|
419
660
|
range,
|
|
661
|
+
sweep,
|
|
662
|
+
keep,
|
|
420
663
|
compareDocuments,
|
|
664
|
+
createDocument,
|
|
665
|
+
defineConfig,
|
|
421
666
|
renderers,
|
|
422
667
|
saveDocument,
|
|
423
668
|
loadDocument,
|
|
@@ -425,41 +670,83 @@ import {
|
|
|
425
670
|
import type { ProfileDocument } from "ostia"
|
|
426
671
|
```
|
|
427
672
|
|
|
428
|
-
### `
|
|
673
|
+
### `time(opts)` → `ProfileDocument`
|
|
429
674
|
|
|
430
|
-
Subprocess timing, optional CPU/heap capture. Same behavior as `ostia
|
|
675
|
+
Subprocess timing, optional CPU/heap capture. Same behavior as `ostia time`.
|
|
431
676
|
|
|
432
677
|
```ts
|
|
433
|
-
const doc = await
|
|
678
|
+
const doc = await time({
|
|
434
679
|
commands: ["bun a.ts", "bun b.ts"],
|
|
435
|
-
|
|
680
|
+
prepare: "rm -rf dist", // before every trial of every command, unmeasured; or a
|
|
681
|
+
// function: ({ phase, index }) => ..., phase is "warmup" | "timing" | "cpu" | "heap"
|
|
682
|
+
timeSource: { pattern: /in (\d+)ms/, unit: "ms" }, // samples from the command's own output
|
|
683
|
+
samples: 10, // exact trial count
|
|
684
|
+
// budgetMs: 3000, // wall-clock budget instead of an exact count
|
|
685
|
+
// minSamples: 10, // hard floor when samples isn't given
|
|
436
686
|
warmup: 2,
|
|
687
|
+
interleave: true, // default when 2+ commands: round-robins trials across them
|
|
437
688
|
cpu: true,
|
|
438
689
|
heap: false,
|
|
439
690
|
cpuIntervalUs: 200,
|
|
440
691
|
outDir: "node_modules/.cache/ostia", // default; artifacts land under here
|
|
692
|
+
noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
|
|
693
|
+
})
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
A command can also be an object - `{ command, label?, prepare?, timeSource? }` - whose
|
|
697
|
+
`prepare`/`timeSource` override the top-level ones for that command. That's how one
|
|
698
|
+
command becomes several labeled workloads in the same document:
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
const build = ["bun", "cli.ts", "build", "fixture"]
|
|
702
|
+
const inMs = { pattern: /in (\d+)ms/ }
|
|
703
|
+
const doc = await time({
|
|
704
|
+
commands: [
|
|
705
|
+
{ command: build, label: "warm", timeSource: inMs },
|
|
706
|
+
{ command: build, label: "incremental", timeSource: inMs, prepare: () => touchPost() },
|
|
707
|
+
{ command: build, label: "cold", timeSource: inMs, prepare: "rm -rf fixture/dist" },
|
|
708
|
+
{ command: build, label: "wall clock" }, // same command, no timeSource: wall time
|
|
709
|
+
],
|
|
710
|
+
samples: 5,
|
|
441
711
|
})
|
|
442
712
|
```
|
|
443
713
|
|
|
444
|
-
### `profile(fn, opts)` → `{ result,
|
|
714
|
+
### `profile(fn, opts)` → `{ result, measurement, document }`
|
|
445
715
|
|
|
446
716
|
In-process capture. `origin: "jsc"` is the only path that reports JIT tiers
|
|
447
717
|
(LLInt / Baseline / DFG / FTL). Default `origin: "inspector"` writes portable CDP-shaped
|
|
448
|
-
evidence instead.
|
|
718
|
+
evidence instead. `document` is a full `ProfileDocument` (the one workload and
|
|
719
|
+
measurement), so it composes with `renderers.*` or `saveDocument` directly.
|
|
449
720
|
|
|
450
721
|
```ts
|
|
451
|
-
const { result,
|
|
452
|
-
|
|
453
|
-
intervalUs: 100,
|
|
454
|
-
|
|
722
|
+
const { result, measurement, document } = await profile(
|
|
723
|
+
() => hashLoop(8_000_000),
|
|
724
|
+
{ origin: "jsc", intervalUs: 100 },
|
|
725
|
+
)
|
|
455
726
|
|
|
456
|
-
console.log(
|
|
727
|
+
console.log(measurement.jit?.tiers)
|
|
457
728
|
// {
|
|
458
729
|
// llint: 0,
|
|
459
730
|
// baseline: 9,
|
|
460
731
|
// dfg: 37,
|
|
461
732
|
// ftl: 2825,
|
|
462
733
|
// }
|
|
734
|
+
|
|
735
|
+
const { files } = await renderers.collapsed.render(document)
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### `createDocument(workloads, measurements)` → `ProfileDocument`
|
|
739
|
+
|
|
740
|
+
For composing a document from several `profile()` calls (each of which returns
|
|
741
|
+
just one workload and measurement):
|
|
742
|
+
|
|
743
|
+
```ts
|
|
744
|
+
const a = await profile(() => taskA())
|
|
745
|
+
const b = await profile(() => taskB())
|
|
746
|
+
const document = createDocument(
|
|
747
|
+
[a.document.workloads[0]!, b.document.workloads[0]!],
|
|
748
|
+
[a.measurement, b.measurement],
|
|
749
|
+
)
|
|
463
750
|
```
|
|
464
751
|
|
|
465
752
|
### `group` / `task` / `bench`
|
|
@@ -474,25 +761,70 @@ group("parse", () => {
|
|
|
474
761
|
task("small input", () => parse(smallBuf))
|
|
475
762
|
task("large input", () => parse(largeBuf))
|
|
476
763
|
// Per-task options override the suite-wide time budget / min samples.
|
|
477
|
-
task("full pipeline", () => build(), {
|
|
764
|
+
task("full pipeline", () => build(), { budgetMs: 2000, minSamples: 10 })
|
|
478
765
|
})
|
|
479
766
|
```
|
|
480
767
|
|
|
481
768
|
That is the whole registration surface: `group()` and `task()`. Presentation lives in
|
|
482
769
|
the renderers (`--format`), not in the suite file.
|
|
483
770
|
|
|
484
|
-
All module-scope code in a suite file runs up front, before any task is sampled
|
|
485
|
-
|
|
486
|
-
its teardown immediately after, the
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
771
|
+
All module-scope code in a suite file runs up front, before any task is sampled.
|
|
772
|
+
`{ before, after }` is the hook that runs a task's own setup immediately before its
|
|
773
|
+
sampling and its teardown immediately after - once each, unmeasured, in the task's
|
|
774
|
+
own process (so both work with `isolate`):
|
|
775
|
+
|
|
776
|
+
```ts
|
|
777
|
+
group(
|
|
778
|
+
"parse",
|
|
779
|
+
() => {
|
|
780
|
+
let doc: Document
|
|
781
|
+
task("append", () => doc.append(node), {
|
|
782
|
+
before: () => {
|
|
783
|
+
doc = mountDocument()
|
|
784
|
+
},
|
|
785
|
+
after: () => doc.destroy(),
|
|
786
|
+
})
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
// Runs once around the whole group, outside every task's own before/after.
|
|
790
|
+
before: () => setupSharedFixture(),
|
|
791
|
+
after: () => teardownSharedFixture(),
|
|
792
|
+
},
|
|
793
|
+
)
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
There is no per-trial hook (no setup/teardown between individual samples) - that
|
|
797
|
+
would defeat batching, which is how ostia keeps a sub-microsecond task's timer
|
|
798
|
+
overhead down. Reach for `{ gc }` (`Bun.gc(true)` between trials) or `{ isolate }`
|
|
799
|
+
(a fresh process per task) for per-trial concerns instead. Because `before`/`after`
|
|
800
|
+
run once per task, not once per instance, a suite that builds more than one instance
|
|
801
|
+
of something stateful (a mounted UI component, an open connection, a server) still
|
|
802
|
+
has to scope a query to the instance it belongs to, not write it against a
|
|
803
|
+
global/ambient lookup that assumes it's the only one alive - a suite that opens a
|
|
804
|
+
component's menu and queries `getByRole(...)` unscoped, for example, breaks once a
|
|
805
|
+
second instance of that component exists in the document; scope the query with
|
|
806
|
+
something like `within(instance.container)` instead.
|
|
807
|
+
|
|
808
|
+
Task functions may be async (`() => unknown | Promise<unknown>`, same for
|
|
809
|
+
`before`/`after`); `await` on a plain synchronous value still costs a microtask
|
|
810
|
+
turn, so an `async` task function measures a few nanoseconds slower per call than
|
|
811
|
+
the same body written synchronously - immaterial above microsecond cost, worth
|
|
812
|
+
knowing for a task near the timer's resolution floor.
|
|
813
|
+
|
|
814
|
+
Call `keep(value)` on an intermediate value inside a task body - a subcomputation
|
|
815
|
+
whose result the task doesn't return - to pin it against dead-code elimination the
|
|
816
|
+
same way ostia already protects a task's own return value:
|
|
817
|
+
|
|
818
|
+
```ts
|
|
819
|
+
import { keep } from "ostia"
|
|
820
|
+
|
|
821
|
+
task("parse then validate", () => {
|
|
822
|
+
const ast = parse(input)
|
|
823
|
+
keep(ast) // the task returns validate's result; without this, a smart-enough
|
|
824
|
+
// JIT could in principle prove `ast` is otherwise unused and skip building it
|
|
825
|
+
return validate(ast)
|
|
826
|
+
})
|
|
827
|
+
```
|
|
496
828
|
|
|
497
829
|
Both take an optional `description` that flows into the document
|
|
498
830
|
(`Workload.description` / `Workload.groupDescription`) and into `--format minimal`, so
|
|
@@ -512,9 +844,8 @@ group(
|
|
|
512
844
|
)
|
|
513
845
|
```
|
|
514
846
|
|
|
515
|
-
Mark one task per group as the `Relative` reference with `{ baseline: true }
|
|
516
|
-
|
|
517
|
-
task in the group:
|
|
847
|
+
Mark one task per group as the `Relative` reference with `{ baseline: true }`;
|
|
848
|
+
otherwise `Relative` defaults to the fastest task in the group:
|
|
518
849
|
|
|
519
850
|
```ts
|
|
520
851
|
group("parse", () => {
|
|
@@ -523,24 +854,72 @@ group("parse", () => {
|
|
|
523
854
|
})
|
|
524
855
|
```
|
|
525
856
|
|
|
526
|
-
|
|
857
|
+
`task.skip(...)` / `group.skip(...)` register without measuring: the runner never
|
|
858
|
+
samples them, but the document still carries the workload (marked
|
|
859
|
+
`Workload.skipped`), so a renderer prints `- skipped` instead of the task just
|
|
860
|
+
being absent, and `compare` reports it as `unchanged` (with a `skipped` warning)
|
|
861
|
+
rather than silently passing or failing to match a baseline. `task.only(...)` /
|
|
862
|
+
`group.only(...)` restrict a suite file to only the `.only`-marked tasks - `--filter`
|
|
863
|
+
still applies on top - and print a one-line notice (`bench: 2 task(s) selected by
|
|
864
|
+
.only`) to stderr, so a forgotten `.only` doesn't quietly narrow a run in CI:
|
|
865
|
+
|
|
866
|
+
```ts
|
|
867
|
+
group("parse", () => {
|
|
868
|
+
task.only("fast path", () => parse(buf)) // only this task runs this time
|
|
869
|
+
task("slow path", () => parseSlow(buf))
|
|
870
|
+
task.skip("flaky on CI", () => parseFlaky(buf))
|
|
871
|
+
})
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
`{ cpu }` / `{ alloc }` on `task()` or `group()` override the suite-wide `bench({ cpu, alloc })`
|
|
875
|
+
/ `--cpu` / `--alloc` default for that task or group, the same pattern as `isolate`/`gc`:
|
|
876
|
+
one extra `phase: "cpu"` measurement (JIT tiers included, from a fixed 200ms window under
|
|
877
|
+
the JSC sampling profiler) and/or one extra `phase: "memstats"` measurement
|
|
878
|
+
(`MemoryEvidence.bytesPerOp`, from a `Bun.gc(true)`-bracketed batch of 100 calls) alongside
|
|
879
|
+
the task's timing numbers, never mixed into them:
|
|
880
|
+
|
|
881
|
+
```ts
|
|
882
|
+
group("parse", () => {
|
|
883
|
+
task("current impl", () => parse(buf))
|
|
884
|
+
task("candidate impl", () => parseFast(buf), { cpu: true, alloc: true })
|
|
885
|
+
})
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### `sweep(dims, fn)` → `void`
|
|
527
889
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
890
|
+
Cartesian product over one or more named dimensions, calling `fn` once per point.
|
|
891
|
+
`task()` calls inside `fn` automatically inherit that point as `Workload.params` -
|
|
892
|
+
a structured alternative to baking the point into the task name, so renderers can
|
|
893
|
+
pivot on it and `compare` matches on the same point across runs instead of just a name:
|
|
532
894
|
|
|
533
895
|
```ts
|
|
534
|
-
import { group, task, range } from "ostia"
|
|
896
|
+
import { group, task, range, sweep } from "ostia"
|
|
535
897
|
|
|
536
898
|
group("parse", () => {
|
|
537
|
-
|
|
899
|
+
sweep({ size: range(100, 10_000), impl: ["current", "fast"] }, ({ size, impl }) => {
|
|
538
900
|
const input = buildInput(size) // setup, runs once per point, unmeasured
|
|
539
|
-
task(`${
|
|
540
|
-
}
|
|
901
|
+
task(`${impl}`, () => impls[impl](input))
|
|
902
|
+
})
|
|
541
903
|
})
|
|
542
904
|
```
|
|
543
905
|
|
|
906
|
+
An explicit `{ params }` on a particular `task()` call merges over (and wins
|
|
907
|
+
against) the current sweep point:
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
task(`${impl}`, () => impls[impl](input), { params: { size, impl, variant: "warm" } })
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
`--format minimal` includes `params` on every line. The markdown renderer pivots a
|
|
914
|
+
group into a table (rows = first dimension, columns = second) when every task in
|
|
915
|
+
it shares the same two param keys - exactly what the example above produces - and
|
|
916
|
+
otherwise renders params as a `key=value` suffix on the task name.
|
|
917
|
+
|
|
918
|
+
### `range(start, end, multiplier?)` → `number[]`
|
|
919
|
+
|
|
920
|
+
Geometric point generator that feeds `sweep()` (and works standalone): default
|
|
921
|
+
multiplier `8`, always ending on `end` even if the last step overshot it.
|
|
922
|
+
|
|
544
923
|
```ts
|
|
545
924
|
range(100, 10_000) // -> [100, 800, 6400, 10000]
|
|
546
925
|
range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]
|
|
@@ -552,9 +931,29 @@ import { bench } from "ostia"
|
|
|
552
931
|
|
|
553
932
|
const doc = await bench({
|
|
554
933
|
suites: ["suite.ts"],
|
|
555
|
-
|
|
934
|
+
budgetMs: 500,
|
|
935
|
+
// samples: 50, // exact per-task trial count instead of a budget
|
|
556
936
|
minSamples: 50,
|
|
557
937
|
jobs: 1, // suite files at once; > 1 trades fidelity for wall time
|
|
938
|
+
noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
|
|
939
|
+
})
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
A command can also be an object - `{ command, label?, prepare?, timeSource? }` - whose
|
|
943
|
+
`prepare`/`timeSource` override the top-level ones for that command. That's how one
|
|
944
|
+
command becomes several labeled workloads in the same document:
|
|
945
|
+
|
|
946
|
+
```ts
|
|
947
|
+
const build = ["bun", "cli.ts", "build", "fixture"]
|
|
948
|
+
const inMs = { pattern: /in (\d+)ms/ }
|
|
949
|
+
const doc = await time({
|
|
950
|
+
commands: [
|
|
951
|
+
{ command: build, label: "warm", timeSource: inMs },
|
|
952
|
+
{ command: build, label: "incremental", timeSource: inMs, prepare: () => touchPost() },
|
|
953
|
+
{ command: build, label: "cold", timeSource: inMs, prepare: "rm -rf fixture/dist" },
|
|
954
|
+
{ command: build, label: "wall clock" }, // same command, no timeSource: wall time
|
|
955
|
+
],
|
|
956
|
+
samples: 5,
|
|
558
957
|
})
|
|
559
958
|
```
|
|
560
959
|
|
|
@@ -568,6 +967,8 @@ const diffs = compareDocuments(baselineDoc, candidateDoc, {
|
|
|
568
967
|
frameSelfPct: 10,
|
|
569
968
|
heapTypePct: 10,
|
|
570
969
|
minFrameSelfUs: 1000,
|
|
970
|
+
alpha: 0.01, // Mann-Whitney significance level
|
|
971
|
+
bootstrapIterations: 2000,
|
|
571
972
|
})
|
|
572
973
|
```
|
|
573
974
|
|
|
@@ -578,6 +979,11 @@ await saveDocument(doc, "doc.json")
|
|
|
578
979
|
const loaded: ProfileDocument = await loadDocument("doc.json")
|
|
579
980
|
```
|
|
580
981
|
|
|
982
|
+
### `defineConfig(config)` → `Partial<OstiaConfig>`
|
|
983
|
+
|
|
984
|
+
Identity function purely for typing `ostia.config.ts` - see
|
|
985
|
+
[`ostia.config.ts` / `ostia.config.json`](#ostiaconfigts--ostiaconfigjson) above.
|
|
986
|
+
|
|
581
987
|
### `renderers`
|
|
582
988
|
|
|
583
989
|
Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files? }`.
|
|
@@ -596,11 +1002,32 @@ Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files?
|
|
|
596
1002
|
|
|
597
1003
|
```ts
|
|
598
1004
|
const { text } = await renderers.markdown.render(doc)
|
|
599
|
-
const { files } = await renderers.collapsed.render(doc, {
|
|
1005
|
+
const { files } = await renderers.collapsed.render(doc, {
|
|
1006
|
+
measurementId: someCpuMeasurementId,
|
|
1007
|
+
})
|
|
600
1008
|
```
|
|
601
1009
|
|
|
602
1010
|
Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
|
|
603
1011
|
|
|
1012
|
+
## Migrating from mitata or hyperfine
|
|
1013
|
+
|
|
1014
|
+
| mitata / hyperfine | ostia |
|
|
1015
|
+
|---|---|
|
|
1016
|
+
| `bench("name", fn)` | `task("name", fn)` |
|
|
1017
|
+
| `baseline()` | `{ baseline: true }` on a `task()` |
|
|
1018
|
+
| `.range(name, start, end, mult)` | `sweep({ dim: range(start, end, mult) }, ...)` |
|
|
1019
|
+
| generator setup (`function* () { ...; yield () => fn() }`) | `task(name, fn, { before, after })` |
|
|
1020
|
+
| `do_not_optimize(value)` | `keep(value)` |
|
|
1021
|
+
| `hyperfine -L var a,b,c cmd-{var}` | `sweep({ var: ["a", "b", "c"] }, ...)` or per-command `params` |
|
|
1022
|
+
| `hyperfine --runs N --warmup N` | `ostia time --samples N --warmup N` |
|
|
1023
|
+
| `hyperfine --prepare CMD` | `ostia time --prepare CMD` (also `time({ prepare })` / config `prepare`) |
|
|
1024
|
+
| `hyperfine --export-json` / `--export-markdown` | `ostia time --export-json PATH` / `--format markdown` |
|
|
1025
|
+
|
|
1026
|
+
`sweep()`/`range()`/`params` are in-process (`ostia bench`); `hyperfine -L` substitutes into
|
|
1027
|
+
a shell command template for a subprocess instead, so a direct port is one literal `ostia time`
|
|
1028
|
+
command per substitution value rather than a template - see [`sweep(dims, fn)`](#sweepdims-fn--void)
|
|
1029
|
+
and [`ostia.config.ts` / `ostia.config.json`](#ostiaconfigts--ostiaconfigjson) above.
|
|
1030
|
+
|
|
604
1031
|
## Examples
|
|
605
1032
|
|
|
606
1033
|
[`examples/`](examples/) has six runnable recipes (they spawn `../../src/cli/main.ts`
|