ostia 0.1.7 → 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,6 +237,22 @@ 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
@@ -223,13 +294,25 @@ shell's environment.
223
294
 
224
295
 
225
296
  ```
226
- Command Mean [ms] Min…Max [ms] Relative
227
- --------------------------------------------------------------------------------------
228
- stats/computeTimingStats (1e3 samples) 0.014 ± 0.016 0.012…0.598 1.00×
229
- stats/computeTimingStats (1e4 samples) 0.484 ± 0.052 0.4340.680 38.22× slower
230
- 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).
231
311
  ```
232
312
 
313
+ Tasks with a `group()` print the group name once, indented; ungrouped tasks and
314
+ subprocess commands print flat.
315
+
233
316
  ### `ostia compare`
234
317
 
235
318
  Match workloads by id, rank timing / frame / heap deltas, print a verdict per workload.
@@ -240,16 +323,63 @@ ostia compare after.json --baseline .ostia/baselines/main.json
240
323
  ```
241
324
 
242
325
  ```
243
- bun fixtures/fast.ts
244
- 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:
245
332
 
246
- ✗ work
247
- timing: +1249.2% median (regressed)
248
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.
249
376
 
250
377
  ### `ostia report`
251
378
 
252
- 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.
253
383
 
254
384
  ```sh
255
385
  ostia report out.json # table (default)
@@ -265,7 +395,7 @@ sample (tens of thousands for a fast task), which is tokens a reviewer never rea
265
395
  Numbers stay in ns so they line up with `compare` deltas and the JSON document.
266
396
 
267
397
  ```
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"}
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"}
269
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"}
270
400
  ```
271
401
 
@@ -277,39 +407,42 @@ Markdown:
277
407
  ```
278
408
  # Profile Report
279
409
 
280
- 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)
281
411
 
282
412
  ## Timing
283
413
 
284
- | Command | Mean ± SD (ms) | Min…Max (ms) | Median (ms) |
285
- |---|---|---|---|
286
- | 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 |
287
417
  ```
288
418
 
289
- ### `ostia viz`
419
+ #### CPU visualization formats
290
420
 
291
421
  Turn CPU evidence into files for other tools. Formats: `collapsed`, `mermaid`,
292
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.
293
426
 
294
427
  ```sh
295
- ostia viz doc.json --format collapsed
296
- ostia viz doc.json --format mermaid
297
- 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
298
431
  ```
299
432
 
300
433
  Collapsed stacks (one line per stack; feeds `flamegraph.pl` and friends):
301
434
 
302
435
  ```
303
- (root);(module);hashLoop 1055
436
+ (root);(module);hashLoop 209
304
437
  ```
305
438
 
306
439
  Mermaid call tree (top N nodes by self time, never the whole profile):
307
440
 
308
441
  ```
309
442
  graph TD
310
- n1["(root) (self 0.00ms, total 284.19ms)"]
311
- n2["(module) (self 0.00ms, total 284.19ms)"]
312
- 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)"]
313
446
  n1 --> n2
314
447
  n2 --> n3
315
448
  ```
@@ -324,6 +457,7 @@ ostia ci
324
457
  ostia ci --full # ignore cache
325
458
  ostia ci --baseline main
326
459
  ostia ci --export-json out.json
460
+ ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
327
461
  ```
328
462
 
329
463
  Pass:
@@ -345,16 +479,36 @@ Fail:
345
479
  1 affected by this change
346
480
  0 cached
347
481
  1 executed
348
- 0 passed 1 regressed (+1249.2% median on work)
482
+ 0 passed 1 regressed (+1278.7% median on work)
349
483
 
350
484
  Profile CI: ✗
351
485
  ```
352
486
 
353
487
  Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline, spawn failure).
354
488
 
355
- #### `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
+ ```
356
509
 
357
510
  ```json
511
+ // ostia.config.json - equivalent, no defineConfig wrapper needed
358
512
  {
359
513
  "baseline": "main",
360
514
  "thresholds": { "timingPct": 5 },
@@ -363,12 +517,26 @@ Exit codes: `0` pass, `1` regression, `2` harness error (missing config/baseline
363
517
  "label": "parse",
364
518
  "command": ["bun", "bench/parse.ts"],
365
519
  "inputs": ["src/**/*.ts"]
520
+ },
521
+ {
522
+ "label": "dogfood-suites",
523
+ "suites": ["bench/*.ts"]
366
524
  }
367
525
  ]
368
526
  }
369
527
  ```
370
528
 
371
- `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).
372
540
 
373
541
  Two directory options, both optional: `outDir` (default `node_modules/.cache/ostia`) for
374
542
  scratch/cache/artifacts, and `baselineDir` (default `.ostia/baselines`) for baselines. They're
@@ -379,11 +547,23 @@ independent - `baselineDir` doesn't move just because you override `outDir`.
379
547
  Baselines are JSON under `.ostia/baselines/` (gitignored). `ostia ci` only needs the file
380
548
  on disk; it does not need to be committed.
381
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
+
382
562
  Local branch workflow:
383
563
 
384
564
  ```sh
385
565
  git checkout master # known-good tip
386
- bun run baseline # -> .ostia/baselines/main.json
566
+ ostia baseline save # -> .ostia/baselines/main.json
387
567
 
388
568
  git checkout -b my-opt
389
569
  # ... change code ...
@@ -394,10 +574,15 @@ The baseline survives branch switches because it is not tracked. Re-seed only wh
394
574
  intentionally accept a new floor. Seeding on the branch you are guarding compares that
395
575
  branch to itself.
396
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
+
397
582
  One-off outside this repo's config:
398
583
 
399
584
  ```sh
400
- ostia run --export-json .ostia/baselines/main.json "bun bench.ts"
585
+ ostia time --export-json .ostia/baselines/main.json "bun bench.ts"
401
586
  ostia ci
402
587
  ```
403
588
 
@@ -411,13 +596,17 @@ The CLI is a thin wrapper around the library. Same `ProfileDocument` either way.
411
596
 
412
597
  ```ts
413
598
  import {
414
- run,
599
+ time,
415
600
  profile,
416
601
  bench,
417
602
  group,
418
603
  task,
419
604
  range,
605
+ sweep,
606
+ keep,
420
607
  compareDocuments,
608
+ createDocument,
609
+ defineConfig,
421
610
  renderers,
422
611
  saveDocument,
423
612
  loadDocument,
@@ -425,41 +614,62 @@ import {
425
614
  import type { ProfileDocument } from "ostia"
426
615
  ```
427
616
 
428
- ### `run(opts)` → `ProfileDocument`
617
+ ### `time(opts)` → `ProfileDocument`
429
618
 
430
- Subprocess timing, optional CPU/heap capture. Same behavior as `ostia run`.
619
+ Subprocess timing, optional CPU/heap capture. Same behavior as `ostia time`.
431
620
 
432
621
  ```ts
433
- const doc = await run({
622
+ const doc = await time({
434
623
  commands: ["bun a.ts", "bun b.ts"],
435
- 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
436
627
  warmup: 2,
628
+ interleave: true, // default when 2+ commands: round-robins trials across them
437
629
  cpu: true,
438
630
  heap: false,
439
631
  cpuIntervalUs: 200,
440
632
  outDir: "node_modules/.cache/ostia", // default; artifacts land under here
633
+ noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
441
634
  })
442
635
  ```
443
636
 
444
- ### `profile(fn, opts)` → `{ result, run }`
637
+ ### `profile(fn, opts)` → `{ result, measurement, document }`
445
638
 
446
639
  In-process capture. `origin: "jsc"` is the only path that reports JIT tiers
447
640
  (LLInt / Baseline / DFG / FTL). Default `origin: "inspector"` writes portable CDP-shaped
448
- evidence instead.
641
+ evidence instead. `document` is a full `ProfileDocument` (the one workload and
642
+ measurement), so it composes with `renderers.*` or `saveDocument` directly.
449
643
 
450
644
  ```ts
451
- const { result, run } = await profile(() => hashLoop(8_000_000), {
452
- origin: "jsc",
453
- intervalUs: 100,
454
- })
645
+ const { result, measurement, document } = await profile(
646
+ () => hashLoop(8_000_000),
647
+ { origin: "jsc", intervalUs: 100 },
648
+ )
455
649
 
456
- console.log(run.jit?.tiers)
650
+ console.log(measurement.jit?.tiers)
457
651
  // {
458
652
  // llint: 0,
459
653
  // baseline: 9,
460
654
  // dfg: 37,
461
655
  // ftl: 2825,
462
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
+ )
463
673
  ```
464
674
 
465
675
  ### `group` / `task` / `bench`
@@ -474,25 +684,70 @@ group("parse", () => {
474
684
  task("small input", () => parse(smallBuf))
475
685
  task("large input", () => parse(largeBuf))
476
686
  // Per-task options override the suite-wide time budget / min samples.
477
- task("full pipeline", () => build(), { timeBudgetMs: 2000, minSamples: 10 })
687
+ task("full pipeline", () => build(), { budgetMs: 2000, minSamples: 10 })
478
688
  })
479
689
  ```
480
690
 
481
691
  That is the whole registration surface: `group()` and `task()`. Presentation lives in
482
692
  the renderers (`--format`), not in the suite file.
483
693
 
484
- All module-scope code in a suite file runs up front, before any task is sampled -
485
- there's no hook that runs a task's own setup immediately before its sampling and
486
- its teardown immediately after, the way mitata's generator-based `bench()` drove
487
- one case to completion before starting the next. If a suite builds more than one
488
- instance of something stateful (a mounted UI component, an open connection, a
489
- server) at module scope, every instance already exists by the time any task
490
- samples - so a query has to be scoped to the instance it belongs to, not written
491
- against a global/ambient lookup that assumes it's the only one alive. Porting a
492
- mitata suite that opens a component's menu and queries `getByRole(...)`
493
- unscoped, for example, breaks once a second instance of that component exists
494
- in the document; scope the query with something like `within(instance.container)`
495
- instead.
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
+ ```
496
751
 
497
752
  Both take an optional `description` that flows into the document
498
753
  (`Workload.description` / `Workload.groupDescription`) and into `--format minimal`, so
@@ -512,9 +767,8 @@ group(
512
767
  )
513
768
  ```
514
769
 
515
- Mark one task per group as the `Relative` reference with `{ baseline: true }`
516
- (mirrors mitata's `baseline()`); otherwise `Relative` defaults to the fastest
517
- 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:
518
772
 
519
773
  ```ts
520
774
  group("parse", () => {
@@ -523,24 +777,72 @@ group("parse", () => {
523
777
  })
524
778
  ```
525
779
 
526
- ### `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
+ ```
527
796
 
528
- Geometric sweep points for parameterizing `task()` over a size dimension - mitata's
529
- `.range(name, start, end, multiplier)` point generation (default multiplier `8`, always
530
- ending on `end` even if the last step overshot it), without the name templating: build
531
- 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:
532
803
 
533
804
  ```ts
534
- 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"
535
820
 
536
821
  group("parse", () => {
537
- for (const size of range(100, 10_000)) {
822
+ sweep({ size: range(100, 10_000), impl: ["current", "fast"] }, ({ size, impl }) => {
538
823
  const input = buildInput(size) // setup, runs once per point, unmeasured
539
- task(`${size} items`, () => parse(input))
540
- }
824
+ task(`${impl}`, () => impls[impl](input))
825
+ })
541
826
  })
542
827
  ```
543
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
+
544
846
  ```ts
545
847
  range(100, 10_000) // -> [100, 800, 6400, 10000]
546
848
  range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]
@@ -552,9 +854,11 @@ import { bench } from "ostia"
552
854
 
553
855
  const doc = await bench({
554
856
  suites: ["suite.ts"],
555
- timeBudgetMs: 500,
857
+ budgetMs: 500,
858
+ // samples: 50, // exact per-task trial count instead of a budget
556
859
  minSamples: 50,
557
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
558
862
  })
559
863
  ```
560
864
 
@@ -568,6 +872,8 @@ const diffs = compareDocuments(baselineDoc, candidateDoc, {
568
872
  frameSelfPct: 10,
569
873
  heapTypePct: 10,
570
874
  minFrameSelfUs: 1000,
875
+ alpha: 0.01, // Mann-Whitney significance level
876
+ bootstrapIterations: 2000,
571
877
  })
572
878
  ```
573
879
 
@@ -578,6 +884,11 @@ await saveDocument(doc, "doc.json")
578
884
  const loaded: ProfileDocument = await loadDocument("doc.json")
579
885
  ```
580
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
+
581
892
  ### `renderers`
582
893
 
583
894
  Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files? }`.
@@ -596,11 +907,31 @@ Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files?
596
907
 
597
908
  ```ts
598
909
  const { text } = await renderers.markdown.render(doc)
599
- const { files } = await renderers.collapsed.render(doc, { runId: someCpuRunId })
910
+ const { files } = await renderers.collapsed.render(doc, {
911
+ measurementId: someCpuMeasurementId,
912
+ })
600
913
  ```
601
914
 
602
915
  Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
603
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
+
604
935
  ## Examples
605
936
 
606
937
  [`examples/`](examples/) has six runnable recipes (they spawn `../../src/cli/main.ts`