ostia 0.1.6 → 0.2.0

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 CHANGED
@@ -1,11 +1,20 @@
1
1
  # ostia
2
2
 
3
- ostia is a profiling and benchmarking toolkit for Bun.
4
-
5
- Use it when you want wall-clock timings, CPU hotspots, heap summaries, or JIT tier
6
- data in one place, then diff those results or fail CI when something gets slower.
7
- Everything lands in one schema-versioned JSON document (`ProfileDocument`), so the
8
- CLI and the library speak the same language.
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 run --runs 10 --warmup 2 "bun fixtures/fast.ts" "bun fixtures/slow.ts"
32
+ ostia time --samples 10 --warmup 2 "bun fixtures/fast.ts" "bun fixtures/slow.ts"
24
33
  ```
25
34
 
26
35
  ```
27
- Command Mean [ms] Min…Max [ms] Relative
28
- --------------------------------------------------------------------
29
- bun fixtures/fast.ts 8.413 ± 1.340 7.623…12.365 1.00×
30
- bun fixtures/slow.ts 21.712 ± 1.157 20.831…24.220 2.62× slower
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 run --runs 5 --cpu --cpu-interval 200 --export-json node_modules/.cache/ostia/doc.json fixtures/work.ts
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
- Command Mean [ms] Min…Max [ms]
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%
65
+
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.
44
73
 
45
- CPU capture - bun fixtures/work.ts (instrumented, 200µs interval, diagnostic wall 297.578ms)
46
- 100.0% 284.19ms self hashLoop
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
- bun run baseline # on known-good: measure ostia.config.json -> .ostia/baselines/main.json
61
- ostia ci # on your branch: rerun changed workloads, exit 1 on regression
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,81 @@ Profile CI: ✓
83
113
  ## CLI reference
84
114
 
85
115
  ```
86
- ostia run <command...> time commands; optional --cpu / --heap capture
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 run`
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 run "bun a.ts" "bun b.ts"
103
- ostia run --runs 25 --warmup 3 --cpu --heap "bun src/server.ts"
104
- ostia run --format json --export-json out.json "bun a.ts"
105
- ```
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"
135
+ ```
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.
106
153
 
107
154
  Timing table (two commands get a Relative column automatically):
108
155
 
109
156
  ```
110
- Command Mean [ms] Min…Max [ms] Relative
111
- --------------------------------------------------------------------
112
- bun fixtures/fast.ts 8.413 ± 1.340 7.62312.365 1.00×
113
- bun fixtures/slow.ts 21.712 ± 1.157 20.83124.220 2.62× slower
157
+ Task Median Spread Range Relative
158
+ --------------------------------------------------------------------------------
159
+ bun fixtures/fast.ts 7.94 ms 8.31 ms…8.56 ms 7.68 ms8.57 ms 1.00×
160
+ bun fixtures/slow.ts 21.3 ms 21.5 ms…22.1 ms 21.1 ms22.1 ms 2.69× slower
161
+ ! outliers-detected
162
+
163
+ Warnings:
164
+ bun fixtures/slow.ts: 1 outlier(s) detected (1 severe, 0 mild).
114
165
  ```
115
166
 
116
167
  Heap summary (type counts from the snapshot trial):
117
168
 
118
169
  ```
119
- Command Mean [ms] Min…Max [ms]
120
- --------------------------------------------------------
121
- bun fixtures/allocate.ts 27.079 ± 6.844 23.88891.799
170
+ Task Median Spread Range
171
+ ---------------------------------------------------------------------------
172
+ bun fixtures/allocate.ts 23.8 ms 24.9 ms…29.4 ms 22.6 ms30.0 ms
173
+ ! outliers-detected
174
+
175
+ Warnings:
176
+ bun fixtures/allocate.ts: 8 outlier(s) detected (1 severe, 7 mild).
122
177
 
123
- Heap snapshot - bun fixtures/allocate.ts (instrumented, 2518 objects, 0.12MB)
178
+ Heap snapshot - bun fixtures/allocate.ts (instrumented, 2516 objects, 0.12MB)
124
179
  1369 string
125
- 426 code
126
- 321 closure
180
+ 423 code
181
+ 319 closure
127
182
  216 object shape
128
- 104 hidden
183
+ 105 hidden
129
184
  artifact: node_modules/.cache/ostia/artifacts/<run-id>-heap.heapsnapshot
130
185
  ```
131
186
 
132
187
  ### `ostia bench`
133
188
 
134
189
  In-process microbenchmarks registered with `group()` / `task()`. Each task samples
135
- for `--time-budget` (default 500ms). `--min-samples` is a hard floor kept even when it
190
+ for `--budget` (default 500ms). `--min-samples` is a hard floor kept even when it
136
191
  overruns the budget. Left unset, the floor is cost-aware in both directions: as many
137
192
  trials as fit in the budget (capped at 20) so one slow task can't blow the suite's total,
138
193
  but never below the floor a task's per-trial cost earns it - 3 at ≤1ms, two more per
@@ -155,7 +210,7 @@ without re-deriving the policy from the raw sample array.
155
210
 
156
211
  ```sh
157
212
  ostia bench bench/*.ts
158
- ostia bench --time-budget 500 --min-samples 50 bench/stats.ts
213
+ ostia bench --budget 500 --min-samples 50 bench/stats.ts
159
214
  ostia bench bench/*.ts --jobs auto # suite files in parallel, see below
160
215
  ostia bench bench/*.ts --format minimal # one compact JSON object per task
161
216
  ```
@@ -182,13 +237,31 @@ Bun/V8 batch calls together and amortize it away). `task(name, fn, { gc })` /
182
237
  override pattern as `isolate` - useful when a few allocation-heavy tasks need GC settled
183
238
  between trials but the rest of the suite doesn't.
184
239
 
240
+ `--cpu` captures one extra `phase: "cpu"` measurement per task on top of its timing
241
+ numbers: the task looped for a fixed 200ms window under the JSC sampling profiler
242
+ (JIT tiers included), never mixed into the timing numbers themselves. `--alloc` captures
243
+ an extra `phase: "memstats"` measurement: bytes allocated per call, from a
244
+ `Bun.gc(true)`-bracketed batch of 100 calls (`MemoryEvidence.bytesPerOp`). Both follow the
245
+ same per-task/per-group override pattern as `isolate`/`gc`: `task(name, fn, { cpu, alloc })`
246
+ / `group(name, fn, { cpu, alloc })`. The terminal table prints an `Alloc/op` column when a
247
+ `memstats` measurement is present. With `--cpu` on, `ostia compare` reports per-frame CPU
248
+ deltas for bench tasks the same way it already does for `ostia time --cpu`.
249
+
250
+ When a `--cpu` capture spends more than 20% of its samples in the llint/baseline tiers,
251
+ the JIT never warmed the task up in that 200ms window, so its CPU numbers (and by
252
+ extension its timing) may not reflect steady state - the cpu measurement carries a
253
+ `jit-cold` warning (`{ llintPct, baselinePct, dfgPct, ftlPct }`), printed alongside the
254
+ CPU capture in the terminal table and folded into the task's line in `--format minimal`.
255
+
185
256
  `--preload PATH` (repeatable) imports a script before each suite file loads, in the same
186
257
  subprocess - the same shape as Bun's own `--preload` / `bunfig.toml`'s `preload` array. Use
187
258
  it to install globals a suite needs at import time (jsdom's `document`/`window`) or register
188
259
  a `Bun.plugin()` file-loader (e.g. compiling `.svelte`/`.vue` SFCs) before the suite's own
189
260
  top-level code runs. Multiple `--preload` scripts run in the order given, so state one
190
261
  installs (a plugin registration, a global) is visible to the next and to the suite itself.
191
- ostia ships none of this itself - just the hook point:
262
+ ostia ships none of this itself - just the hook point (for a full jsdom/happy-dom global
263
+ setup or a `Bun.plugin()` component-compile hook, see
264
+ [docs/preload-recipes.md](docs/preload-recipes.md)):
192
265
 
193
266
  ```ts
194
267
  // bench/jsdom-setup.ts
@@ -201,15 +274,45 @@ Object.assign(globalThis, { document: dom.window.document, window: dom.window })
201
274
  ostia bench --preload ./bench/jsdom-setup.ts bench/*.dom.bench.ts
202
275
  ```
203
276
 
277
+ `--bun-flags FLAGS` (repeatable, space-separated flags within one value are all appended)
278
+ passes extra flags through to the `bun` invocation that spawns each suite file - the fix for
279
+ packages whose `package.json` `exports` map branches on a resolution condition Bun doesn't
280
+ set by default. Svelte 5's `exports` map, for example, is `{ "browser": "./src/index-client.js",
281
+ "default": "./src/index-server.js" }`: without `--conditions browser`, Bun resolves `default`
282
+ (the server-rendering build), and mounting a component via `@testing-library/svelte` throws
283
+ `lifecycle_function_unavailable` since `mount()` isn't available server-side. The same applies
284
+ to Vue and other dual-target frameworks:
285
+
286
+ ```sh
287
+ ostia bench --bun-flags="--conditions=browser" bench/*.dom.bench.ts
288
+ ```
289
+
290
+ Unlike `BUN_OPTIONS` (an env var Bun's CLI reads to prepend flags, which only reaches the
291
+ spawned suite process today because ostia's `Bun.spawn()` happens to inherit `process.env`),
292
+ `--bun-flags` is a declared, documented integration point that doesn't depend on the parent
293
+ shell's environment.
294
+
204
295
 
205
296
  ```
206
- Command Mean [ms] Min…Max [ms] Relative
207
- --------------------------------------------------------------------------------------
208
- stats/computeTimingStats (1e3 samples) 0.014 ± 0.016 0.012…0.598 1.00×
209
- stats/computeTimingStats (1e4 samples) 0.484 ± 0.052 0.4340.680 38.22× slower
210
- stats/timingWarnings (1e3 samples) 0.036 ± 0.020 0.032…0.541 2.82× slower
297
+ Task Median Spread Range Relative
298
+ ----------------------------------------------------------------------------------------------------
299
+ stats:
300
+ stats/computeTimingStats (1e3 samples) 24.7 µs 33.8 µs…151.6 µs 22.4 µs1073.2 µs 1.00×
301
+ ! outliers-detected
302
+ stats/computeTimingStats (1e4 samples) 255.2 µs 331.7 µs…1067.6 µs 223.2 µs…20789.4 µs 10.33× slower
303
+ ! outliers-detected
304
+ stats/timingWarnings (1e3 samples) 47.3 µs 88.5 µs…510.4 µs 43.0 µs…14383.1 µs 1.91× slower
305
+ ! outliers-detected
306
+
307
+ Warnings:
308
+ stats/computeTimingStats (1e3 samples): 2455 outlier(s) detected (2108 severe, 347 mild).
309
+ stats/computeTimingStats (1e4 samples): 215 outlier(s) detected (70 severe, 145 mild).
310
+ stats/timingWarnings (1e3 samples): 541 outlier(s) detected (191 severe, 350 mild).
211
311
  ```
212
312
 
313
+ Tasks with a `group()` print the group name once, indented; ungrouped tasks and
314
+ subprocess commands print flat.
315
+
213
316
  ### `ostia compare`
214
317
 
215
318
  Match workloads by id, rank timing / frame / heap deltas, print a verdict per workload.
@@ -220,16 +323,63 @@ ostia compare after.json --baseline .ostia/baselines/main.json
220
323
  ```
221
324
 
222
325
  ```
223
- bun fixtures/fast.ts
224
- timing: -2.9% median (unchanged)
326
+ bun fixtures/work.ts
327
+ timing: +11.2% median, 95% CI [+10.0%, +16.4%], p<0.001 (regressed)
328
+ ```
329
+
330
+ When both documents carry `git` metadata (see below), `ostia compare` prints a summary
331
+ line above the verdicts:
225
332
 
226
- ✗ work
227
- timing: +1249.2% median (regressed)
228
333
  ```
334
+ base a1b2c3d (main) → cand d4e5f6a (my-opt, dirty)
335
+ ```
336
+
337
+ The verdict needs both a confidence interval clear of `timingPct` and a
338
+ significant Mann-Whitney p-value (`thresholds.alpha`, default `0.01`), not
339
+ just a point estimate past the threshold - see
340
+ [Statistics](#statistics-a-real-significance-test-not-a-percentage-threshold)
341
+ below. Comparisons with fewer than 5 samples on either side fall back to the
342
+ old point-estimate rule and carry a `thin-comparison` warning instead.
343
+
344
+ #### Statistics: a real significance test, not a percentage threshold
345
+
346
+ A point estimate past `timingPct` is not enough to call something a
347
+ regression - both documents already carry full sample arrays, so `compare`
348
+ runs two tests instead:
349
+
350
+ - A **bootstrap confidence interval** on the difference of medians:
351
+ resample both sides with replacement `thresholds.bootstrapIterations`
352
+ times (default 2000; each side is randomly subsampled to at most 2000
353
+ samples first, so a many-thousand-sample task doesn't turn a compare into
354
+ a multi-second operation), and report the 2.5th/97.5th percentiles as
355
+ `ci95` (percent of the baseline median). Reproducible: the PRNG seed is
356
+ stored in `Comparison.timing.seed`.
357
+ - A **Mann-Whitney U test** (tie-corrected, normal approximation), reported
358
+ as `pValue` - whether the two sample distributions differ at all, without
359
+ assuming normality the way a t-test would.
360
+
361
+ `regressed` requires `ci95[0] > thresholds.timingPct` (the *whole interval*
362
+ clears the threshold) **and** `pValue < thresholds.alpha`; `improved` is the
363
+ mirror. Otherwise `unchanged`. This is why the earlier example (`+11.2%
364
+ median, 95% CI [+10.0%, +16.4%]`) is a clean regression: even the low end of
365
+ the interval is well past `timingPct`.
366
+
367
+ Both `time()` and `bench()` also stamp `environment` on every document (a
368
+ fixed-cost, deterministic, allocation-free hash loop sampled for ~200ms,
369
+ `noise.floorPct = mad / median`) unless `noiseCheck: false` / `--no-noise-check`
370
+ skips it. `compare` widens the effective threshold to
371
+ `max(thresholds.timingPct, base.environment.noise.floorPct,
372
+ cand.environment.noise.floorPct)` (`Comparison.thresholds.effectiveTimingPct`),
373
+ so a delta smaller than the machine's own jitter right now is never called a
374
+ regression. A `noisy-machine` warning fires when the 1-minute load average is
375
+ already past 75% of available cores at measurement time.
229
376
 
230
377
  ### `ostia report`
231
378
 
232
- Render a saved `ProfileDocument` without re-running anything.
379
+ Render a saved `ProfileDocument` without re-running anything. `--format` covers
380
+ both the data formats (`table`/`json`/`jsonl`/`markdown`/`minimal`) and the CPU
381
+ visualization formats (`collapsed`/`mermaid`/`speedscope`/`cpuprofile`) - one
382
+ command instead of two.
233
383
 
234
384
  ```sh
235
385
  ostia report out.json # table (default)
@@ -245,7 +395,7 @@ sample (tens of thousands for a fast task), which is tokens a reviewer never rea
245
395
  Numbers stay in ns so they line up with `compare` deltas and the JSON document.
246
396
 
247
397
  ```
248
- {"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"}
398
+ {"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"}
249
399
  {"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"}
250
400
  ```
251
401
 
@@ -257,39 +407,42 @@ Markdown:
257
407
  ```
258
408
  # Profile Report
259
409
 
260
- Bun 1.4.0 · ostia 0.1.0 · darwin/arm64 · 2026-09-04T03:46:02.961Z
410
+ Bun 1.4.1 · ostia 0.1.0 · darwin/arm64 · 2026-09-05T13:14:50.085Z · a1b2c3d (main)
261
411
 
262
412
  ## Timing
263
413
 
264
- | Command | Mean ± SD (ms) | Min…Max (ms) | Median (ms) |
265
- |---|---|---|---|
266
- | bun -e 1 | 5.477 ± 0.303 | 5.1535.882 | 5.396 |
414
+ | Task | Median | Spread (p75…p99) | Mean ± SD | Range | MAD |
415
+ |---|---|---|---|---|---|
416
+ | bun -e 1 | 5.03 ms | 5.42 ms…9.70 ms | 5.29 ms ± 0.84 ms | 4.77 ms13.4 ms | 0.16 ms |
267
417
  ```
268
418
 
269
- ### `ostia viz`
419
+ #### CPU visualization formats
270
420
 
271
421
  Turn CPU evidence into files for other tools. Formats: `collapsed`, `mermaid`,
272
422
  `speedscope`, `cpuprofile` (pass-through of a real CDP artifact when present).
423
+ `--measurement <id>` renders only that measurement (default: every CPU
424
+ measurement in the document); `--out-dir PATH` writes files there instead of
425
+ stdout.
273
426
 
274
427
  ```sh
275
- ostia viz doc.json --format collapsed
276
- ostia viz doc.json --format mermaid
277
- ostia viz doc.json --format speedscope > flame.json
428
+ ostia report doc.json --format collapsed
429
+ ostia report doc.json --format mermaid
430
+ ostia report doc.json --format speedscope > flame.json
278
431
  ```
279
432
 
280
433
  Collapsed stacks (one line per stack; feeds `flamegraph.pl` and friends):
281
434
 
282
435
  ```
283
- (root);(module);hashLoop 1055
436
+ (root);(module);hashLoop 209
284
437
  ```
285
438
 
286
439
  Mermaid call tree (top N nodes by self time, never the whole profile):
287
440
 
288
441
  ```
289
442
  graph TD
290
- n1["(root) (self 0.00ms, total 284.19ms)"]
291
- n2["(module) (self 0.00ms, total 284.19ms)"]
292
- n3["hashLoop (self 284.19ms, total 284.19ms)"]
443
+ n1["(root) (self 0.00ms, total 267.91ms)"]
444
+ n2["(module) (self 0.00ms, total 267.91ms)"]
445
+ n3["hashLoop (self 267.91ms, total 267.91ms)"]
293
446
  n1 --> n2
294
447
  n2 --> n3
295
448
  ```
@@ -304,6 +457,7 @@ ostia ci
304
457
  ostia ci --full # ignore cache
305
458
  ostia ci --baseline main
306
459
  ostia ci --export-json out.json
460
+ ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
307
461
  ```
308
462
 
309
463
  Pass:
@@ -325,16 +479,36 @@ Fail:
325
479
  1 affected by this change
326
480
  0 cached
327
481
  1 executed
328
- 0 passed 1 regressed (+1249.2% median on work)
482
+ 0 passed 1 regressed (+1278.7% median on work)
329
483
 
330
484
  Profile CI: ✗
331
485
  ```
332
486
 
333
487
  Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline, spawn failure).
334
488
 
335
- #### `ostia.config.json`
489
+ #### `ostia.config.ts` / `ostia.config.json`
490
+
491
+ `loadConfig` looks for `ostia.config.ts` first (Bun imports TypeScript natively), falling
492
+ back to `ostia.config.json`. Both forms are fully supported; pick `.ts` for autocomplete
493
+ and type-checking on every field, via `defineConfig` (an identity function purely for
494
+ typing, the same pattern as Vite/Vitest/ESLint):
495
+
496
+ ```ts
497
+ // ostia.config.ts
498
+ import { defineConfig } from "ostia"
499
+
500
+ export default defineConfig({
501
+ baseline: "main",
502
+ thresholds: { timingPct: 5 },
503
+ workloads: [
504
+ { label: "parse", command: ["bun", "bench/parse.ts"], inputs: ["src/**/*.ts"] },
505
+ { label: "dogfood-suites", suites: ["bench/*.ts"] },
506
+ ],
507
+ })
508
+ ```
336
509
 
337
510
  ```json
511
+ // ostia.config.json - equivalent, no defineConfig wrapper needed
338
512
  {
339
513
  "baseline": "main",
340
514
  "thresholds": { "timingPct": 5 },
@@ -343,12 +517,26 @@ Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline
343
517
  "label": "parse",
344
518
  "command": ["bun", "bench/parse.ts"],
345
519
  "inputs": ["src/**/*.ts"]
520
+ },
521
+ {
522
+ "label": "dogfood-suites",
523
+ "suites": ["bench/*.ts"]
346
524
  }
347
525
  ]
348
526
  }
349
527
  ```
350
528
 
351
- `inputs` is optional. Workloads with no `inputs` always rerun (cache fails conservative).
529
+ Each workload is exactly one of `command` (a subprocess timed with `runs`/`warmup`) or
530
+ `suites` (glob patterns, same resolution as `bench`'s own `suites` config, run via
531
+ `bench()`). A `suites` workload gates every task in those files individually - one
532
+ candidate-vs-baseline comparison per task, matched by workload id the same way a `command`
533
+ workload already is, so `ostia ci`'s regression detection covers in-process microbenchmarks,
534
+ not only subprocess commands. Unlike `command` workloads, a `suites` workload always
535
+ executes (there's no cheap way to know a suite file's task ids, and so its per-task cache
536
+ keys, without importing it first) - `inputs`-based cache skipping is `command`-only for now.
537
+
538
+ `inputs` is optional (and, for now, only consulted for `command` workloads). Workloads
539
+ with no `inputs` always rerun (cache fails conservative).
352
540
 
353
541
  Two directory options, both optional: `outDir` (default `node_modules/.cache/ostia`) for
354
542
  scratch/cache/artifacts, and `baselineDir` (default `.ostia/baselines`) for baselines. They're
@@ -359,11 +547,23 @@ independent - `baselineDir` doesn't move just because you override `outDir`.
359
547
  Baselines are JSON under `.ostia/baselines/` (gitignored). `ostia ci` only needs the file
360
548
  on disk; it does not need to be committed.
361
549
 
550
+ `ostia baseline save [name]` measures every configured workload (the same code path
551
+ `ostia ci` gates against, no comparison) and writes it to `<baselineDir>/<name>.json`
552
+ (default name: config's `"baseline"` field, or `"main"`). `ostia baseline list` shows every
553
+ saved baseline (name, created date, workload count, and git sha/branch when available);
554
+ `ostia baseline show <name> [--format]` renders one (delegates to `ostia report`).
555
+
556
+ Every document stamps `git: { sha, branch, dirty }` (from `git rev-parse` / `git status
557
+ --porcelain` in the process's cwd, 200ms timeout, silently absent outside a repo or
558
+ without `git` installed) - metadata only, never part of any fingerprint or id, so a
559
+ commit or a dirty working tree never orphans a cached run or baseline. Printed in the
560
+ markdown report's header line and `ostia baseline list`.
561
+
362
562
  Local branch workflow:
363
563
 
364
564
  ```sh
365
565
  git checkout master # known-good tip
366
- bun run baseline # -> .ostia/baselines/main.json
566
+ ostia baseline save # -> .ostia/baselines/main.json
367
567
 
368
568
  git checkout -b my-opt
369
569
  # ... change code ...
@@ -374,10 +574,15 @@ The baseline survives branch switches because it is not tracked. Re-seed only wh
374
574
  intentionally accept a new floor. Seeding on the branch you are guarding compares that
375
575
  branch to itself.
376
576
 
577
+ `ostia ci --save-baseline` folds that re-seed into the gate itself: after a pass (no
578
+ regressions), it writes the just-measured document as the new baseline at the same path
579
+ it just compared against - useful in a CI job that gates every merge to a trunk branch,
580
+ so each green run becomes the next run's floor with no separate step.
581
+
377
582
  One-off outside this repo's config:
378
583
 
379
584
  ```sh
380
- ostia run --export-json .ostia/baselines/main.json "bun bench.ts"
585
+ ostia time --export-json .ostia/baselines/main.json "bun bench.ts"
381
586
  ostia ci
382
587
  ```
383
588
 
@@ -391,13 +596,17 @@ The CLI is a thin wrapper around the library. Same `ProfileDocument` either way.
391
596
 
392
597
  ```ts
393
598
  import {
394
- run,
599
+ time,
395
600
  profile,
396
601
  bench,
397
602
  group,
398
603
  task,
399
604
  range,
605
+ sweep,
606
+ keep,
400
607
  compareDocuments,
608
+ createDocument,
609
+ defineConfig,
401
610
  renderers,
402
611
  saveDocument,
403
612
  loadDocument,
@@ -405,41 +614,62 @@ import {
405
614
  import type { ProfileDocument } from "ostia"
406
615
  ```
407
616
 
408
- ### `run(opts)` → `ProfileDocument`
617
+ ### `time(opts)` → `ProfileDocument`
409
618
 
410
- Subprocess timing, optional CPU/heap capture. Same behavior as `ostia run`.
619
+ Subprocess timing, optional CPU/heap capture. Same behavior as `ostia time`.
411
620
 
412
621
  ```ts
413
- const doc = await run({
622
+ const doc = await time({
414
623
  commands: ["bun a.ts", "bun b.ts"],
415
- runs: 10,
624
+ samples: 10, // exact trial count
625
+ // budgetMs: 3000, // wall-clock budget instead of an exact count
626
+ // minSamples: 10, // hard floor when samples isn't given
416
627
  warmup: 2,
628
+ interleave: true, // default when 2+ commands: round-robins trials across them
417
629
  cpu: true,
418
630
  heap: false,
419
631
  cpuIntervalUs: 200,
420
632
  outDir: "node_modules/.cache/ostia", // default; artifacts land under here
633
+ noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
421
634
  })
422
635
  ```
423
636
 
424
- ### `profile(fn, opts)` → `{ result, run }`
637
+ ### `profile(fn, opts)` → `{ result, measurement, document }`
425
638
 
426
639
  In-process capture. `origin: "jsc"` is the only path that reports JIT tiers
427
640
  (LLInt / Baseline / DFG / FTL). Default `origin: "inspector"` writes portable CDP-shaped
428
- evidence instead.
641
+ evidence instead. `document` is a full `ProfileDocument` (the one workload and
642
+ measurement), so it composes with `renderers.*` or `saveDocument` directly.
429
643
 
430
644
  ```ts
431
- const { result, run } = await profile(() => hashLoop(8_000_000), {
432
- origin: "jsc",
433
- intervalUs: 100,
434
- })
645
+ const { result, measurement, document } = await profile(
646
+ () => hashLoop(8_000_000),
647
+ { origin: "jsc", intervalUs: 100 },
648
+ )
435
649
 
436
- console.log(run.jit?.tiers)
650
+ console.log(measurement.jit?.tiers)
437
651
  // {
438
652
  // llint: 0,
439
653
  // baseline: 9,
440
654
  // dfg: 37,
441
655
  // ftl: 2825,
442
656
  // }
657
+
658
+ const { files } = await renderers.collapsed.render(document)
659
+ ```
660
+
661
+ ### `createDocument(workloads, measurements)` → `ProfileDocument`
662
+
663
+ For composing a document from several `profile()` calls (each of which returns
664
+ just one workload and measurement):
665
+
666
+ ```ts
667
+ const a = await profile(() => taskA())
668
+ const b = await profile(() => taskB())
669
+ const document = createDocument(
670
+ [a.document.workloads[0]!, b.document.workloads[0]!],
671
+ [a.measurement, b.measurement],
672
+ )
443
673
  ```
444
674
 
445
675
  ### `group` / `task` / `bench`
@@ -454,13 +684,71 @@ group("parse", () => {
454
684
  task("small input", () => parse(smallBuf))
455
685
  task("large input", () => parse(largeBuf))
456
686
  // Per-task options override the suite-wide time budget / min samples.
457
- task("full pipeline", () => build(), { timeBudgetMs: 2000, minSamples: 10 })
687
+ task("full pipeline", () => build(), { budgetMs: 2000, minSamples: 10 })
458
688
  })
459
689
  ```
460
690
 
461
691
  That is the whole registration surface: `group()` and `task()`. Presentation lives in
462
692
  the renderers (`--format`), not in the suite file.
463
693
 
694
+ All module-scope code in a suite file runs up front, before any task is sampled.
695
+ `{ before, after }` is the hook that runs a task's own setup immediately before its
696
+ sampling and its teardown immediately after - once each, unmeasured, in the task's
697
+ own process (so both work with `isolate`):
698
+
699
+ ```ts
700
+ group(
701
+ "parse",
702
+ () => {
703
+ let doc: Document
704
+ task("append", () => doc.append(node), {
705
+ before: () => {
706
+ doc = mountDocument()
707
+ },
708
+ after: () => doc.destroy(),
709
+ })
710
+ },
711
+ {
712
+ // Runs once around the whole group, outside every task's own before/after.
713
+ before: () => setupSharedFixture(),
714
+ after: () => teardownSharedFixture(),
715
+ },
716
+ )
717
+ ```
718
+
719
+ There is no per-trial hook (no setup/teardown between individual samples) - that
720
+ would defeat batching, which is how ostia keeps a sub-microsecond task's timer
721
+ overhead down. Reach for `{ gc }` (`Bun.gc(true)` between trials) or `{ isolate }`
722
+ (a fresh process per task) for per-trial concerns instead. Because `before`/`after`
723
+ run once per task, not once per instance, a suite that builds more than one instance
724
+ of something stateful (a mounted UI component, an open connection, a server) still
725
+ has to scope a query to the instance it belongs to, not write it against a
726
+ global/ambient lookup that assumes it's the only one alive - a suite that opens a
727
+ component's menu and queries `getByRole(...)` unscoped, for example, breaks once a
728
+ second instance of that component exists in the document; scope the query with
729
+ something like `within(instance.container)` instead.
730
+
731
+ Task functions may be async (`() => unknown | Promise<unknown>`, same for
732
+ `before`/`after`); `await` on a plain synchronous value still costs a microtask
733
+ turn, so an `async` task function measures a few nanoseconds slower per call than
734
+ the same body written synchronously - immaterial above microsecond cost, worth
735
+ knowing for a task near the timer's resolution floor.
736
+
737
+ Call `keep(value)` on an intermediate value inside a task body - a subcomputation
738
+ whose result the task doesn't return - to pin it against dead-code elimination the
739
+ same way ostia already protects a task's own return value:
740
+
741
+ ```ts
742
+ import { keep } from "ostia"
743
+
744
+ task("parse then validate", () => {
745
+ const ast = parse(input)
746
+ keep(ast) // the task returns validate's result; without this, a smart-enough
747
+ // JIT could in principle prove `ast` is otherwise unused and skip building it
748
+ return validate(ast)
749
+ })
750
+ ```
751
+
464
752
  Both take an optional `description` that flows into the document
465
753
  (`Workload.description` / `Workload.groupDescription`) and into `--format minimal`, so
466
754
  what a number measures and why travels with the data instead of living only in a
@@ -479,9 +767,8 @@ group(
479
767
  )
480
768
  ```
481
769
 
482
- Mark one task per group as the `Relative` reference with `{ baseline: true }`
483
- (mirrors mitata's `baseline()`); otherwise `Relative` defaults to the fastest
484
- task in the group:
770
+ Mark one task per group as the `Relative` reference with `{ baseline: true }`;
771
+ otherwise `Relative` defaults to the fastest task in the group:
485
772
 
486
773
  ```ts
487
774
  group("parse", () => {
@@ -490,24 +777,72 @@ group("parse", () => {
490
777
  })
491
778
  ```
492
779
 
493
- ### `range(start, end, multiplier?)` `number[]`
780
+ `task.skip(...)` / `group.skip(...)` register without measuring: the runner never
781
+ samples them, but the document still carries the workload (marked
782
+ `Workload.skipped`), so a renderer prints `- skipped` instead of the task just
783
+ being absent, and `compare` reports it as `unchanged` (with a `skipped` warning)
784
+ rather than silently passing or failing to match a baseline. `task.only(...)` /
785
+ `group.only(...)` restrict a suite file to only the `.only`-marked tasks - `--filter`
786
+ still applies on top - and print a one-line notice (`bench: 2 task(s) selected by
787
+ .only`) to stderr, so a forgotten `.only` doesn't quietly narrow a run in CI:
788
+
789
+ ```ts
790
+ group("parse", () => {
791
+ task.only("fast path", () => parse(buf)) // only this task runs this time
792
+ task("slow path", () => parseSlow(buf))
793
+ task.skip("flaky on CI", () => parseFlaky(buf))
794
+ })
795
+ ```
494
796
 
495
- Geometric sweep points for parameterizing `task()` over a size dimension - mitata's
496
- `.range(name, start, end, multiplier)` point generation (default multiplier `8`, always
497
- ending on `end` even if the last step overshot it), without the name templating: build
498
- the task name yourself in the loop.
797
+ `{ cpu }` / `{ alloc }` on `task()` or `group()` override the suite-wide `bench({ cpu, alloc })`
798
+ / `--cpu` / `--alloc` default for that task or group, the same pattern as `isolate`/`gc`:
799
+ one extra `phase: "cpu"` measurement (JIT tiers included, from a fixed 200ms window under
800
+ the JSC sampling profiler) and/or one extra `phase: "memstats"` measurement
801
+ (`MemoryEvidence.bytesPerOp`, from a `Bun.gc(true)`-bracketed batch of 100 calls) alongside
802
+ the task's timing numbers, never mixed into them:
499
803
 
500
804
  ```ts
501
- import { group, task, range } from "ostia"
805
+ group("parse", () => {
806
+ task("current impl", () => parse(buf))
807
+ task("candidate impl", () => parseFast(buf), { cpu: true, alloc: true })
808
+ })
809
+ ```
810
+
811
+ ### `sweep(dims, fn)` → `void`
812
+
813
+ Cartesian product over one or more named dimensions, calling `fn` once per point.
814
+ `task()` calls inside `fn` automatically inherit that point as `Workload.params` -
815
+ a structured alternative to baking the point into the task name, so renderers can
816
+ pivot on it and `compare` matches on the same point across runs instead of just a name:
817
+
818
+ ```ts
819
+ import { group, task, range, sweep } from "ostia"
502
820
 
503
821
  group("parse", () => {
504
- for (const size of range(100, 10_000)) {
822
+ sweep({ size: range(100, 10_000), impl: ["current", "fast"] }, ({ size, impl }) => {
505
823
  const input = buildInput(size) // setup, runs once per point, unmeasured
506
- task(`${size} items`, () => parse(input))
507
- }
824
+ task(`${impl}`, () => impls[impl](input))
825
+ })
508
826
  })
509
827
  ```
510
828
 
829
+ An explicit `{ params }` on a particular `task()` call merges over (and wins
830
+ against) the current sweep point:
831
+
832
+ ```ts
833
+ task(`${impl}`, () => impls[impl](input), { params: { size, impl, variant: "warm" } })
834
+ ```
835
+
836
+ `--format minimal` includes `params` on every line. The markdown renderer pivots a
837
+ group into a table (rows = first dimension, columns = second) when every task in
838
+ it shares the same two param keys - exactly what the example above produces - and
839
+ otherwise renders params as a `key=value` suffix on the task name.
840
+
841
+ ### `range(start, end, multiplier?)` → `number[]`
842
+
843
+ Geometric point generator that feeds `sweep()` (and works standalone): default
844
+ multiplier `8`, always ending on `end` even if the last step overshot it.
845
+
511
846
  ```ts
512
847
  range(100, 10_000) // -> [100, 800, 6400, 10000]
513
848
  range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]
@@ -519,9 +854,11 @@ import { bench } from "ostia"
519
854
 
520
855
  const doc = await bench({
521
856
  suites: ["suite.ts"],
522
- timeBudgetMs: 500,
857
+ budgetMs: 500,
858
+ // samples: 50, // exact per-task trial count instead of a budget
523
859
  minSamples: 50,
524
860
  jobs: 1, // suite files at once; > 1 trades fidelity for wall time
861
+ noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
525
862
  })
526
863
  ```
527
864
 
@@ -535,6 +872,8 @@ const diffs = compareDocuments(baselineDoc, candidateDoc, {
535
872
  frameSelfPct: 10,
536
873
  heapTypePct: 10,
537
874
  minFrameSelfUs: 1000,
875
+ alpha: 0.01, // Mann-Whitney significance level
876
+ bootstrapIterations: 2000,
538
877
  })
539
878
  ```
540
879
 
@@ -545,6 +884,11 @@ await saveDocument(doc, "doc.json")
545
884
  const loaded: ProfileDocument = await loadDocument("doc.json")
546
885
  ```
547
886
 
887
+ ### `defineConfig(config)` → `Partial<OstiaConfig>`
888
+
889
+ Identity function purely for typing `ostia.config.ts` - see
890
+ [`ostia.config.ts` / `ostia.config.json`](#ostiaconfigts--ostiaconfigjson) above.
891
+
548
892
  ### `renderers`
549
893
 
550
894
  Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files? }`.
@@ -563,11 +907,31 @@ Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files?
563
907
 
564
908
  ```ts
565
909
  const { text } = await renderers.markdown.render(doc)
566
- const { files } = await renderers.collapsed.render(doc, { runId: someCpuRunId })
910
+ const { files } = await renderers.collapsed.render(doc, {
911
+ measurementId: someCpuMeasurementId,
912
+ })
567
913
  ```
568
914
 
569
915
  Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
570
916
 
917
+ ## Migrating from mitata or hyperfine
918
+
919
+ | mitata / hyperfine | ostia |
920
+ |---|---|
921
+ | `bench("name", fn)` | `task("name", fn)` |
922
+ | `baseline()` | `{ baseline: true }` on a `task()` |
923
+ | `.range(name, start, end, mult)` | `sweep({ dim: range(start, end, mult) }, ...)` |
924
+ | generator setup (`function* () { ...; yield () => fn() }`) | `task(name, fn, { before, after })` |
925
+ | `do_not_optimize(value)` | `keep(value)` |
926
+ | `hyperfine -L var a,b,c cmd-{var}` | `sweep({ var: ["a", "b", "c"] }, ...)` or per-command `params` |
927
+ | `hyperfine --runs N --warmup N` | `ostia time --samples N --warmup N` |
928
+ | `hyperfine --export-json` / `--export-markdown` | `ostia time --export-json PATH` / `--format markdown` |
929
+
930
+ `sweep()`/`range()`/`params` are in-process (`ostia bench`); `hyperfine -L` substitutes into
931
+ a shell command template for a subprocess instead, so a direct port is one literal `ostia time`
932
+ command per substitution value rather than a template - see [`sweep(dims, fn)`](#sweepdims-fn--void)
933
+ and [`ostia.config.ts` / `ostia.config.json`](#ostiaconfigts--ostiaconfigjson) above.
934
+
571
935
  ## Examples
572
936
 
573
937
  [`examples/`](examples/) has six runnable recipes (they spawn `../../src/cli/main.ts`