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 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%
44
65
 
45
- CPU capture - bun fixtures/work.ts (instrumented, 200µs interval, diagnostic wall 297.578ms)
46
- 100.0% 284.19ms self hashLoop
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
- 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,116 @@ 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"
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
- 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
192
+ Task Median Spread Range Relative
193
+ --------------------------------------------------------------------------------
194
+ bun fixtures/fast.ts 7.94 ms 8.31 ms…8.56 ms 7.68 ms8.57 ms 1.00×
195
+ bun fixtures/slow.ts 21.3 ms 21.5 ms…22.1 ms 21.1 ms22.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
- Command Mean [ms] Min…Max [ms]
120
- --------------------------------------------------------
121
- bun fixtures/allocate.ts 27.079 ± 6.844 23.88891.799
205
+ Task Median Spread Range
206
+ ---------------------------------------------------------------------------
207
+ bun fixtures/allocate.ts 23.8 ms 24.9 ms…29.4 ms 22.6 ms30.0 ms
208
+ ! outliers-detected
122
209
 
123
- Heap snapshot - bun fixtures/allocate.ts (instrumented, 2518 objects, 0.12MB)
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
- 426 code
126
- 321 closure
215
+ 423 code
216
+ 319 closure
127
217
  216 object shape
128
- 104 hidden
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 `--time-budget` (default 500ms). `--min-samples` is a hard floor kept even when it
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 --time-budget 500 --min-samples 50 bench/stats.ts
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
- 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
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 µs1073.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
- bun fixtures/fast.ts
244
- timing: -2.9% median (unchanged)
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.0 · ostia 0.1.0 · darwin/arm64 · 2026-09-04T03:46:02.961Z
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
- | Command | Mean ± SD (ms) | Min…Max (ms) | Median (ms) |
285
- |---|---|---|---|
286
- | bun -e 1 | 5.477 ± 0.303 | 5.1535.882 | 5.396 |
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 ms13.4 ms | 0.16 ms |
287
459
  ```
288
460
 
289
- ### `ostia viz`
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 viz doc.json --format collapsed
296
- ostia viz doc.json --format mermaid
297
- ostia viz doc.json --format speedscope > flame.json
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 1055
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 284.19ms)"]
311
- n2["(module) (self 0.00ms, total 284.19ms)"]
312
- n3["hashLoop (self 284.19ms, total 284.19ms)"]
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 (+1249.2% median on work)
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
- `inputs` is optional. Workloads with no `inputs` always rerun (cache fails conservative).
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
- bun run baseline # -> .ostia/baselines/main.json
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 run --export-json .ostia/baselines/main.json "bun bench.ts"
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
- run,
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
- ### `run(opts)` → `ProfileDocument`
673
+ ### `time(opts)` → `ProfileDocument`
429
674
 
430
- Subprocess timing, optional CPU/heap capture. Same behavior as `ostia run`.
675
+ Subprocess timing, optional CPU/heap capture. Same behavior as `ostia time`.
431
676
 
432
677
  ```ts
433
- const doc = await run({
678
+ const doc = await time({
434
679
  commands: ["bun a.ts", "bun b.ts"],
435
- runs: 10,
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, run }`
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, run } = await profile(() => hashLoop(8_000_000), {
452
- origin: "jsc",
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(run.jit?.tiers)
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(), { timeBudgetMs: 2000, minSamples: 10 })
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
- 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.
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
- (mirrors mitata's `baseline()`); otherwise `Relative` defaults to the fastest
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
- ### `range(start, end, multiplier?)` `number[]`
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
- 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.
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
- for (const size of range(100, 10_000)) {
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(`${size} items`, () => parse(input))
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
- timeBudgetMs: 500,
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, { runId: someCpuRunId })
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`