ostia
v0.2.5
Published
Fast profiling and benchmarking for Bun.
Readme
ostia
ostia is a profiling and benchmarking toolkit for Bun. One schema-versioned JSON
document (ProfileDocument) holds wall-clock timing, CPU hotspots, heap summaries,
JIT tier data, and allocation counts for a subprocess command or an in-process
function - the CLI and the library speak the same language, so anything you can do
with ostia time/ostia bench you can do with time()/bench() too.
Comparing two documents reports a bootstrap confidence interval and a Mann-Whitney
p-value, not a bare percentage past a threshold, and widens the regression bar to
the machine's own noise floor so ambient jitter is never mistaken for a real
regression. ostia ci gates a whole ostia.config.json/ostia.config.ts of
workloads - subprocess commands and in-process group()/task() suites alike -
against a saved baseline, skipping anything whose input fingerprint hasn't changed.
Any task can run in its own subprocess for clean JIT/heap isolation from its
suite-mates, and every command has a --format minimal mode - a versioned JSON
protocol built for piping straight into an LLM agent's context (see
Using ostia from an AI agent).
Zero runtime dependencies. Requires Bun ≥ 1.4.
Install
bun add ostiaQuick start
Compare two commands:
ostia time --samples 10 --warmup 2 "bun fixtures/fast.ts" "bun fixtures/slow.ts"Apple M2 · 8 cores · load 10.1 · noise floor 6.2%
Task Median Spread Range Relative
--------------------------------------------------------------------------------
bun fixtures/fast.ts 16.6 ms 25.3 ms…32.8 ms 10.4 ms…33.2 ms 1.00×
! noisy-machine
bun fixtures/slow.ts 40.2 ms 48.2 ms…63.9 ms 28.6 ms…64.7 ms 2.43× slower
Warnings:
bun fixtures/fast.ts: Load average 10.10 exceeds 75% of 8 available core(s); timing noise may be elevated.The header line (machine, cores, load, noise floor) prints whenever a document
carries an environment - on by default; skip the ~200ms reference
measurement with --no-noise-check. compare/ci widen the regression
threshold to at least the noise floor, so a change smaller than the machine's
own jitter is never called a regression - see
Statistics
below.
Find a CPU hotspot (profiler runs as a separate labeled trial, never mixed into the timing numbers above):
ostia time --samples 5 --cpu --cpu-interval 200 --export-json node_modules/.cache/ostia/doc.json "bun fixtures/work.ts"Apple M2 · 8 cores · load 8.5 · noise floor 0.9%
Task Median Spread Range
-----------------------------------------------------------------------
bun fixtures/work.ts 470.6 ms 478.7 ms…499.3 ms 400.8 ms…500.1 ms
! noisy-machine
Warnings:
bun fixtures/work.ts: Load average 8.48 exceeds 75% of 8 available core(s); timing noise may be elevated.
CPU capture - bun fixtures/work.ts (instrumented, 200µs interval, diagnostic wall 531.143ms)
100.0% 502.67ms self hashLoop
0.0% 0.00ms self (root)
0.0% 0.00ms self (module)
artifact: node_modules/.cache/ostia/artifacts/<run-id>-cpu.cpuprofileScratch/artifact output defaults to node_modules/.cache/ostia (already gitignored
everywhere, no setup needed - same convention as Babel/ESLint/Jest caches). Baselines are
the one exception: they default to .ostia/baselines/ at the repo root instead, since
they need to survive node_modules reinstalls between branches and CI jobs, so gitignore
.ostia/ if you use ostia ci.
Gate a change against a local baseline:
ostia baseline save # on known-good: measure ostia.config.json -> .ostia/baselines/main.json
ostia ci # on your branch: rerun changed workloads, exit 1 on regression2 workloads
0 affected by this change
2 cached
0 executed
2 passed 0 regressed
Profile CI: ✓Using ostia from an AI agent
--format minimal is a versioned protocol (protocolVersion: 1) built for piping
straight into an LLM agent's context or a script: one JSON object per line, nothing
else on stdout.
ostia time --samples 10 "bun a.ts" --format minimal
ostia compare before.json after.json --format minimal
ostia ci --format minimal; echo $?Every line is a JSON object with event and protocolVersion: 1:
| event | When | Key fields |
|---|---|---|
| run | Once per timing measurement, for every command | workloadId (join key back to Workload.id), task, unit/samples/batch/mean/median/stddev/min/max/p75/p99/mad, relative, noiseFloorPct, warnings[], and (on compare/ci) delta: { medianPct, meanPct, verdict, pass, ci95?, pValue?, effectiveTimingPct, matched } |
| unmatched | Once per workload present on only one side of compare/ci | workloadId, task, side: "base" \| "cand" |
| summary | Exactly once, last line - compare/ci only, never for a bare time/bench | command, matched/regressed/improved/unchanged/unmatched counts, cached/executed/failed/missingBaseline (ci only), geomeanPct, effectiveTimingPct, noiseFloorPct, baseline: { name, path } (ci only), git: { base?, cand? }, exportedTo, verdict, exitCode |
Stability: keys are never renamed or removed within protocolVersion: 1 - only ever
added, so an agent that reads a field it knows keeps working as the protocol grows.
Exit codes are the same across every command that produces a verdict: 0 pass, 1
at least one workload regressed (compare/ci only - time/bench never return
1), 2 a harness error (a command failed to run cleanly, nothing was compared, a
bad flag, a missing config/baseline). On any exit 2, stderr's last line is one more
JSON object - { event: "error", protocolVersion: 1, code, message, data? }, code
one of invalid-flag / config-missing / baseline-missing / no-matches /
spawn-failed / command-failed / timeout / time-source-no-match /
document-load-failed / no-cpu-evidence / internal - so a script doesn't have to
pattern-match prose to tell one failure from another. This error line (and only this
line) is on stderr; every minimal/jsonl/json line above is pure JSON on stdout,
nothing else mixed in.
--format jsonl is the same idea for the full document instead of the condensed
protocol above: one line per Measurement, plus a document header line, each
tagged kind: "document" | "measurement" so a consumer doesn't have to guess a
line's shape.
What ostia is for
- Time subprocesses or in-process functions without a profiler attached to the timing runs.
- Capture CPU (
--cpu), heap (--heap), or JSC JIT tiers (profile(..., { origin: "jsc" })) as separate evidence on the same document. - Diff two documents (
ostia compare) or fail CI (ostia ci) with exit codes0/1/2. - Emit files other tools already understand: collapsed stacks, Mermaid, speedscope JSON,
raw
.cpuprofile.
CLI reference
ostia time <command...> time commands; optional --cpu / --heap capture
ostia bench <suite.ts...> in-process group()/task() suites (time-budgeted)
ostia compare <a> <b> diff two ProfileDocuments
ostia report <document.json> render a saved document (table/json/markdown/collapsed/...)
ostia ci run configured workloads vs a baseline, gate regressions
ostia baseline save|list|show manage baseline ProfileDocumentsEvery subcommand takes --help for its full flag list.
ostia time
Clean wall-clock timing by default. --cpu / --heap schedule one extra instrumented
trial each, labeled separately in the document.
ostia time "bun a.ts" "bun b.ts"
ostia time --samples 25 --warmup 3 --cpu --heap "bun src/server.ts"
ostia time --format json --export-json out.json "bun a.ts"Each <command> is a string, whitespace-split into argv exactly like hyperfine's -N
(no shell - no quoting, globbing, pipes, or redirection), so it can't express an argument
that itself contains a space. ostia time [flags] -- <argv...> is the escape hatch:
everything after -- becomes one more command, given as argv verbatim (space preserved,
never flag-parsed), alongside any given the normal way:
ostia time -- bun -e "console.log('a b')"There's no -- equivalent for --prepare (below) - a hook that itself needs an argument
with a space needs ostia.config.ts's array form (prepare: ["cp", "fixture a", "fixture b"])
instead, since the CLI flag is always one whitespace-split string.
--samples N is an exact trial count per command (with 2+ commands, each gets its
own N trials, not a total split across them); --budget MS
is a wall-clock time budget instead (default: a hyperfine-style ~3s min-total-time
loop when neither is given); --min-samples N is a hard floor when --samples isn't
given. The same three names work on ostia bench (--budget/--samples/
--min-samples), where --samples/--budget are per-task the same way -
warmup differs by surface, though: a trial count here, a
fraction of the budget for ostia bench, since in-process warmup has no natural
"N calls" unit before the JIT has even seen the function once.
With 2+ commands, trials round-robin across them by default (one trial per command,
repeated) rather than running one command's whole loop to completion before the next
starts - drift over the run's wall-clock span (thermal throttling, a noisy neighbor
process) then lands on every command equally instead of favoring whichever ran first
or last. --no-interleave (interleave: false) goes back to running each command's
loop to completion in turn. Interleaved measurements carry Measurement.interleaved: true.
Meaningless (and ignored) with a single command.
--prepare CMD runs CMD before every trial (warmup and --cpu/--heap trials
included), unmeasured, in the same cwd - hyperfine's --prepare. It's whitespace-split
like the commands themselves (no shell) and must exit 0. Given once it applies to every
command; given once per command it pairs up in order, which is how the same command gets
timed warm and cold side by side:
ostia time --prepare "true" --prepare "rm -rf dist" "bun build.ts" "bun build.ts"The prepare command is part of the workload id (and lands on the document as
Workload.prepare), so a command with and without one are two workloads, and ostia ci
caches them separately. The library API also takes a function
(prepare: ({ phase, index }) => ..., see time(opts)).
A hook's stderr is captured rather than streamed live to the terminal (it would otherwise
flood it, re-running before every one of possibly hundreds of trials) - bounded to 1 MiB
(head 512 KiB + tail 512 KiB, joined by a bytes elided marker if it goes over) and
folded into the thrown error on the trial where the hook actually times out or exits
non-zero, so a broken setup script is still easy to debug without an unbounded capture
risking the run's memory. Contrast --time-source's own output capture (above), which is
deliberately not bounded: the summary line the regex needs could be anywhere in a large
output, so truncating it there would trade a memory bound for silently-wrong matches.
--time-source REGEX takes each trial's time from the command's own output instead of
its wall clock: the first REGEX match in stdout (then stderr), capture group 1, in
--time-unit units (ns | us | ms | s, default ms). Meant for tools that report a
more precise cost than wall time - a build tool whose built in 342ms line excludes the
runtime's startup - so a Bun startup regression isn't misattributed to the tool, and vice
versa:
ostia time --time-source "built in (\d+)ms" "bun build.ts"The parsed value becomes timing.samples, so compare/ci/every renderer treat it
exactly like wall time; each trial keeps wallNs alongside reportedNs so the document
has both. A trial whose output doesn't match the pattern contributes no sample (it's
never a fallback to wallNs, which would silently mix wall-clock time into a
reported-time series) and the measurement carries a time-source-no-match warning
(data: { pattern, trials, output }, output capped at 2 KiB) - it no longer aborts the
whole run the way it used to. If every trial of a command misses, that command has no
timing stats at all (same as every trial timing out - see --timeout above); other
commands in the same time() call keep their data regardless. To gate wall time and
the reported time independently, declare the command twice - once plain, once with
--time-source - and they're two workloads with two verdicts. Note the reported number
has whatever resolution the tool printed (usually whole ms), so its confidence interval
is coarser than a nanosecond wall clock's.
A RegExp pattern must not carry the g, y, or d flag - the same compiled pattern is
exec'd once per trial for the run's whole life, and g/y would make it alternate
match/no-match across trials via lastIndex instead of testing the same thing every time.
Constructing a workload with one throws RangeError: timeSource pattern must not use the
g or y flag immediately, before any trial runs. A plain (flagless) RegExp or a string
pattern is always safe to reuse. (--time-source on the CLI is always a plain string, so
this only comes up with a RegExp literal in the library API.)
--timeout MS kills a trial (or --prepare hook) with SIGKILL if it hasn't finished after
MS ms, so a hung command can't stall the whole run. No default for time/bench (unset
never times out); ostia ci defaults every workload to 10 minutes unless its config sets
timeoutMs. A timed-out trial resolves (never throws) with Trial.timedOut: true and
contributes no sample; if every trial of a command times out, that command has no timing
stats and prints like a skipped workload instead of an empty row.
time(opts) / bench(opts) also take a signal?: AbortSignal: aborting kills every
in-flight child process with SIGKILL, stops scheduling new trials, and resolves (never
rejects) with the document built from whatever measurements had already completed, plus
an aborted warning on the document's last measurement. Ctrl-C on the CLI wires this up
for you - ostia time/ostia bench cancel cleanly, still write --export-json of
whatever finished, and exit 130, instead of the process just dying mid-spawn.
A non-zero exit doesn't stop a command's trial loop by default: every trial still runs,
each trial's own exit code lands on Trial.exitCode, and the measurement carries a
nonzero-exit warning (data.exitCodes) same as always. --ignore-failure[=CODE,...]
(hyperfine's flag; given bare, ignores every exit code) treats the listed codes as
success - the trial still contributes its sample, just with no warning and no effect on
the exit code below. --fail-on-nonzero stops a command's loop after its first
non-ignored non-zero exit instead of always running its full sample count (that trial's
sample is still recorded):
ostia time --ignore-failure=1 "may-exit-1-harmlessly.sh"
ostia time --fail-on-nonzero "bun build.ts"Exit codes: 0 pass, 2 harness error (a command had a non-ignored non-zero exit, or a
workload has no timing stats at all - see --timeout/--time-source above - or a bad
flag/missing command), 130 cancelled with Ctrl-C. 1 is never returned by time or
bench - it's reserved for compare/ci regressions, so a script can tell "the
benchmark itself couldn't run cleanly" apart from "it ran, and got slower."
Timing table (two commands get a Relative column automatically):
Task Median Spread Range Relative
--------------------------------------------------------------------------------
bun fixtures/fast.ts 7.94 ms 8.31 ms…8.56 ms 7.68 ms…8.57 ms 1.00×
bun fixtures/slow.ts 21.3 ms 21.5 ms…22.1 ms 21.1 ms…22.1 ms 2.69× slower
! outliers-detected
Warnings:
bun fixtures/slow.ts: 1 outlier(s) detected (1 severe, 0 mild).Heap summary (type counts from the snapshot trial):
Task Median Spread Range
---------------------------------------------------------------------------
bun fixtures/allocate.ts 23.8 ms 24.9 ms…29.4 ms 22.6 ms…30.0 ms
! outliers-detected
Warnings:
bun fixtures/allocate.ts: 8 outlier(s) detected (1 severe, 7 mild).
Heap snapshot - bun fixtures/allocate.ts (instrumented, 2516 objects, 0.12MB)
1369 string
423 code
319 closure
216 object shape
105 hidden
artifact: node_modules/.cache/ostia/artifacts/<run-id>-heap.heapsnapshotostia bench
In-process microbenchmarks registered with group() / task(). Each task samples
for --budget (default 500ms). --min-samples is a hard floor kept even when it
overruns the budget. Left unset, the floor is cost-aware in both directions: as many
trials as fit in the budget (capped at 20) so one slow task can't blow the suite's total,
but never below the floor a task's per-trial cost earns it - 3 at ≤1ms, two more per
decade of cost, 10 from about 3s up. Cheap tasks are time-bound and collect thousands of
trials either way; only the few expensive tasks in a suite pay for the extra rigor, and
those are exactly where a 3-sample mean is shakiest. Fast calls are batched so a trial
spans at least 1µs and a full budget yields about 10k trials at most.
Exit codes: 0 pass, 2 harness error (a suite file failed to import/run, a suite or
isolated task's subprocess timed out - see --timeout below - or a bad flag), 130
cancelled with Ctrl-C. Tasks are in-process function calls, not subprocesses, so there's
no per-task exit code / --ignore-failure the way ostia time has; a task that throws
fails its suite's subprocess the same way it always has. 1 is never returned - it's
reserved for compare/ci regressions.
| per-trial cost | fits in 500ms | default floor | |---|---|---| | 30ns | thousands | 20 (time-bound; ends in the tens of thousands) | | 30ms | 16 | 16 | | 140ms | 3 | 7 | | 2.4s | 0 | 10 |
A run that ends below its cost-class floor (only possible with an explicit
--min-samples or per-task minSamples) carries a low-sample-count warning with
{ samples, target, trialCostNs }, so a renderer or an agent can flag a thin number
without re-deriving the policy from the raw sample array.
ostia bench bench/*.ts
ostia bench --budget 500 --min-samples 50 bench/stats.ts
ostia bench bench/*.ts --jobs auto # suite files in parallel, see below
ostia bench bench/*.ts --format minimal # one compact JSON object per task--jobs N|auto runs that many suite files at once, each still in its own child process.
Files are independent by design, so for a multi-file suite this is close to a linear
wall-clock win - but concurrent CPU-bound processes contend for cores, caches and turbo
headroom, so numbers taken at --jobs > 1 are noisier and not like-for-like with a
baseline measured at 1. It defaults to 1 for that reason; opt in for exploratory runs,
keep 1 for anything you compare or ci against.
--gc/--cpu/--alloc/--isolate each take a --no- counterpart
(--no-gc/--no-cpu/--no-alloc/--no-isolate) that resolves to an explicit false,
overriding a true from ostia.config.json's bench section the same way the plain
flag overrides a config false - each flag is cli ?? config ?? builtin default, so
ostia bench --no-gc always wins over a config-wide { "gc": true } for that one run.
--isolate gives every task its own child process instead of sharing its suite file's,
isolating each task's JIT tier state, inline caches and heap shape from every other task
in the run - the same guarantee suite files already get from each other, at task
granularity. task(name, fn, { isolate }) / group(name, fn, { isolate }) override the
suite-wide default for mixed suites (e.g. a couple of outlier-prone tasks isolated, the
rest sharing a process). --jobs then pools across those per-task processes the same way
it pools across per-file ones, so pair a higher --jobs with --isolate deliberately -
overhead now scales with task count, not file count.
--gc calls Bun.gc(true) between trials (default: off, which hides allocation cost as
Bun/V8 batch calls together and amortize it away). task(name, fn, { gc }) /
group(name, fn, { gc }) override the suite-wide default per task or group, the same
override pattern as isolate - useful when a few allocation-heavy tasks need GC settled
between trials but the rest of the suite doesn't.
--cpu captures one extra phase: "cpu" measurement per task on top of its timing
numbers: the task looped for a fixed 200ms window under the JSC sampling profiler
(JIT tiers included), never mixed into the timing numbers themselves. --alloc captures
an extra phase: "memstats" measurement: bytes allocated per call, from a
Bun.gc(true)-bracketed batch of 100 calls (MemoryEvidence.bytesPerOp). Both follow the
same per-task/per-group override pattern as isolate/gc: task(name, fn, { cpu, alloc })
/ group(name, fn, { cpu, alloc }). The terminal table prints an Alloc/op column when a
memstats measurement is present. With --cpu on, ostia compare reports per-frame CPU
deltas for bench tasks the same way it already does for ostia time --cpu.
When a --cpu capture spends more than 20% of its samples in the llint/baseline tiers,
the JIT never warmed the task up in that 200ms window, so its CPU numbers (and by
extension its timing) may not reflect steady state - the cpu measurement carries a
jit-cold warning ({ llintPct, baselinePct, dfgPct, ftlPct }), printed alongside the
CPU capture in the terminal table and folded into the task's line in --format minimal.
--timeout MS kills a suite file's subprocess (or, under --isolate, one task's dedicated
subprocess) with SIGKILL if it hasn't finished after MS ms - the same option ostia time
has, applied at the subprocess granularity --isolate already runs at rather than per task.
No default (unset never times out); ostia ci defaults every suites entry to 10 minutes
unless its config sets bench.timeoutMs.
--preload PATH (repeatable) imports a script before each suite file loads, in the same
subprocess - the same shape as Bun's own --preload / bunfig.toml's preload array. Use
it to install globals a suite needs at import time (jsdom's document/window) or register
a Bun.plugin() file-loader (e.g. compiling .svelte/.vue SFCs) before the suite's own
top-level code runs. Multiple --preload scripts run in the order given, so state one
installs (a plugin registration, a global) is visible to the next and to the suite itself.
ostia ships none of this itself - just the hook point (for a full jsdom/happy-dom global
setup or a Bun.plugin() component-compile hook, see
docs/preload-recipes.md):
// bench/jsdom-setup.ts
import { JSDOM } from "jsdom"
const dom = new JSDOM("<!doctype html>")
Object.assign(globalThis, { document: dom.window.document, window: dom.window })ostia bench --preload ./bench/jsdom-setup.ts bench/*.dom.bench.ts--bun-flags FLAGS (repeatable, space-separated flags within one value are all appended)
passes extra flags through to the bun invocation that spawns each suite file - the fix for
packages whose package.json exports map branches on a resolution condition Bun doesn't
set by default. Svelte 5's exports map, for example, is { "browser": "./src/index-client.js",
"default": "./src/index-server.js" }: without --conditions browser, Bun resolves default
(the server-rendering build), and mounting a component via @testing-library/svelte throws
lifecycle_function_unavailable since mount() isn't available server-side. The same applies
to Vue and other dual-target frameworks:
ostia bench --bun-flags="--conditions=browser" bench/*.dom.bench.tsUnlike BUN_OPTIONS (an env var Bun's CLI reads to prepend flags, which only reaches the
spawned suite process today because ostia's Bun.spawn() happens to inherit process.env),
--bun-flags is a declared, documented integration point that doesn't depend on the parent
shell's environment.
Task Median Spread Range Relative
----------------------------------------------------------------------------------------------------
stats:
stats/computeTimingStats (1e3 samples) 24.7 µs 33.8 µs…151.6 µs 22.4 µs…1073.2 µs 1.00×
! outliers-detected
stats/computeTimingStats (1e4 samples) 255.2 µs 331.7 µs…1067.6 µs 223.2 µs…20789.4 µs 10.33× slower
! outliers-detected
stats/timingWarnings (1e3 samples) 47.3 µs 88.5 µs…510.4 µs 43.0 µs…14383.1 µs 1.91× slower
! outliers-detected
Warnings:
stats/computeTimingStats (1e3 samples): 2455 outlier(s) detected (2108 severe, 347 mild).
stats/computeTimingStats (1e4 samples): 215 outlier(s) detected (70 severe, 145 mild).
stats/timingWarnings (1e3 samples): 541 outlier(s) detected (191 severe, 350 mild).Tasks with a group() print the group name once, indented; ungrouped tasks and
subprocess commands print flat.
There's no built-in watch mode. For an edit/re-run loop while writing a suite, pair
ostia bench with a file watcher and a small budget:
watchexec -e ts -- ostia bench bench/parse.ts --budget 100ostia compare
Match workloads by id, rank timing / frame / heap deltas, print a verdict per workload.
ostia compare before.json after.json
ostia compare after.json --baseline .ostia/baselines/main.json✗ bun fixtures/work.ts
timing: +11.2% median, 95% CI [+10.0%, +16.4%], p<0.001 (regressed)Exit codes: 0 pass, 1 at least one workload regressed, 2 nothing was compared (zero
matched workloads - a stale baseline, a totally rewritten config) or a harness error
(documents failed to load, or a bad flag).
A workload id present on only one document prints in an Unmatched section (table and
markdown formats) instead of silently vanishing:
Unmatched:
baseline only: old-task
candidate only: new-task--format table also prints a threshold header line when the machine's noise floor
widened the effective threshold past thresholds.timingPct:
threshold 5% (widened to 6.2% by noise floor)ostia compare reads ostia.config.ts/ostia.config.json's thresholds when present
(same discovery as ostia ci), so a project-tuned threshold applies here too instead of
only gating ostia ci; --no-config ignores it and uses DEFAULT_THRESHOLDS.
--timing-pct N / --alpha N override individual fields on top of whichever base was
picked. The source is printed above the report:
thresholds: ostia.config.tsWhen both documents carry git metadata (see below), ostia compare prints a summary
line above the verdicts:
base a1b2c3d (main) → cand d4e5f6a (my-opt, dirty)The verdict needs both a confidence interval clear of timingPct and a
significant Mann-Whitney p-value (thresholds.alpha, default 0.01), not
just a point estimate past the threshold - see
Statistics
below. Comparisons with fewer than 5 samples on either side fall back to the
old point-estimate rule and carry a thin-comparison warning instead.
When base/cand differ in platform.os, platform.arch, bunVersion, or (when both
carry environment) cpuModel/cores, every comparison in the document carries an
environment-mismatch warning (data.fields: { field, base, cand }[]) - a timing delta
between two different machines or Bun versions may reflect that, not the code change under
test. table and markdown print it once, in the header, instead of once per workload;
minimal folds it into each task line's warnings[] alongside its measurement warnings,
so line.warnings.some(w => w.code === "environment-mismatch") keeps working the same way
it does for a measurement warning.
Statistics: a real significance test, not a percentage threshold
A point estimate past timingPct is not enough to call something a
regression - both documents already carry full sample arrays, so compare
runs two tests instead:
- A bootstrap confidence interval on the difference of medians:
resample both sides with replacement
thresholds.bootstrapIterationstimes (default 2000; each side is randomly subsampled to at most 2000 samples first, so a many-thousand-sample task doesn't turn a compare into a multi-second operation), and report the 2.5th/97.5th percentiles asci95(percent of the baseline median). Reproducible: the PRNG seed is stored inComparison.timing.seed. - A Mann-Whitney U test (tie-corrected, normal approximation), reported
as
pValue- whether the two sample distributions differ at all, without assuming normality the way a t-test would.
regressed requires ci95[0] > thresholds.timingPct (the whole interval
clears the threshold) and pValue < thresholds.alpha; improved is the
mirror. Otherwise unchanged. This is why the earlier example (+11.2%
median, 95% CI [+10.0%, +16.4%]) is a clean regression: even the low end of
the interval is well past timingPct.
Both time() and bench() also stamp environment on every document (a
fixed-cost, deterministic, allocation-free hash loop sampled for ~200ms,
noise.floorPct = mad / median) unless noiseCheck: false / --no-noise-check
skips it. compare widens the effective threshold to
max(thresholds.timingPct, base.environment.noise.floorPct,
cand.environment.noise.floorPct) (Comparison.thresholds.effectiveTimingPct),
so a delta smaller than the machine's own jitter right now is never called a
regression. A noisy-machine warning fires when the 1-minute load average is
already past 75% of available cores at measurement time.
ostia report
Render a saved ProfileDocument without re-running anything. --format covers
both the data formats (table/json/jsonl/markdown/minimal) and the CPU
visualization formats (collapsed/mermaid/speedscope/cpuprofile) - one
command instead of two. time/bench/compare --format only accept the data
formats; export the document and run ostia report --format <viz> on it for
a visualization.
ostia report out.json # table (default)
ostia report out.json --format markdown
ostia report out.json --format json
ostia report out.json --format jsonl
ostia report out.json --format minimalMinimal format - one JSON object per timing run, no header, no raw sample array, no prose.
See Using ostia from an AI agent above for the full
protocol (event types, the exit-code contract, the stderr error line). A run event:
{"event":"run","protocolVersion":1,"schemaVersion":2,"workloadId":"wl_1a2b3c4d5e6f7890","task":"diffText()/append at end","group":"diffText()","unit":"ns","samples":9282,"batch":1,"mean":50213.4,"median":49871,"stddev":2104.7,"stddevPct":4.19,"min":48120,"max":81002,"p75":50920,"p99":58011,"mad":1780,"relative":1,"warnings":[]}ostia compare/ostia ci --format minimal add delta: { medianPct, meanPct, verdict, pass,
ci95?, pValue?, effectiveTimingPct, matched } to each run line, so "did this PR regress" is
lines.some(l => l.delta?.verdict === "regressed") - and a trailing summary line carries
the same verdict for the whole run.
Markdown:
# Profile Report
Bun 1.4.1 · ostia 0.1.0 · darwin/arm64 · 2026-09-05T13:14:50.085Z · a1b2c3d (main)
## Timing
| Task | Median | Spread (p75…p99) | Mean ± SD | Range | MAD |
|---|---|---|---|---|---|
| bun -e 1 | 5.03 ms | 5.42 ms…9.70 ms | 5.29 ms ± 0.84 ms | 4.77 ms…13.4 ms | 0.16 ms |CPU visualization formats
Turn CPU evidence into files for other tools. Formats: collapsed, mermaid,
speedscope, cpuprofile (pass-through of a real CDP artifact when present).
--measurement <id> renders only that measurement (default: every CPU
measurement in the document); --out-dir PATH writes files there instead of
stdout.
ostia report doc.json --format collapsed
ostia report doc.json --format mermaid
ostia report doc.json --format speedscope > flame.jsonCollapsed stacks (one line per stack; feeds flamegraph.pl and friends):
(root);(module);hashLoop 209Mermaid call tree (top N nodes by self time, never the whole profile):
graph TD
n1["(root) (self 0.00ms, total 267.91ms)"]
n2["(module) (self 0.00ms, total 267.91ms)"]
n3["hashLoop (self 267.91ms, total 267.91ms)"]
n1 --> n2
n2 --> n3ostia ci
Reads ostia.config.json, fingerprints each workload, reruns only what changed, compares
against a named baseline, exits 1 on regression.
ostia ci
ostia ci --full # ignore cache
ostia ci --baseline main
ostia ci --export-json out.json
ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
ostia ci --on-missing-baseline fail
ostia ci --no-noise-checkPass:
1 workloads
0 cached
1 executed
1 passed 0 regressed
Profile CI: ✓Fail:
1 workloads
0 cached
1 executed
0 passed 1 regressed (+1278.7% median on work)
Profile CI: ✗Exit codes: 0 pass, 1 regression, 2 harness error - missing config/baseline, every
sampled trial of a command workload exited non-zero (a harness failure, reported as
N failed and distinct from a timing regression), an onMissingBaseline: "fail" mismatch,
or a spawn failure.
A configured workload with no matching row in the baseline (by workload id) doesn't just
silently pass: onMissingBaseline (config field, or --on-missing-baseline warn|fail)
decides what happens. Left unset, ci exits 2 (naming the baseline file and suggesting
ostia baseline save) only when every configured workload is missing - a totally
stale/wrong baseline - and otherwise lists what's missing in the report without affecting
the exit code, since one new workload next to an otherwise-matching baseline isn't a hard
error. "fail"/"warn" explicitly always fail/never fail on any mismatch, regardless of
how many workloads are missing.
ci also measures this machine's noise floor once per invocation (the same ~200ms
reference measurement time()/bench() take) and stamps it on both the candidate
document and ostia baseline save's output, so compare's noise-floor threshold widening
(see Statistics) applies
to ci-gated regressions too, not only ad hoc time/bench runs. noiseCheck: false in
config, or --no-noise-check, skips it.
ostia.config.ts / ostia.config.json
loadConfig looks for ostia.config.ts first (Bun imports TypeScript natively), falling
back to ostia.config.json. Both forms are fully supported; pick .ts for autocomplete
and type-checking on every field, via defineConfig (an identity function purely for
typing, the same pattern as Vite/Vitest/ESLint):
// ostia.config.ts
import { defineConfig } from "ostia"
export default defineConfig({
baseline: "main",
thresholds: { timingPct: 5 },
workloads: [
{ label: "parse", command: ["bun", "bench/parse.ts"], inputs: ["src/**/*.ts"] },
{ label: "dogfood-suites", suites: ["bench/*.ts"] },
// Same command, three states: warm no-op, one edited input, cold. `prepare`
// runs before every trial; `timeSource` reads the tool's own "in Nms" line.
{ label: "build:warm", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" } },
{ label: "build:incremental", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" },
prepare: () => touchPost("fixture/posts/hello.md") },
{ label: "build:cold", command: ["bun", "cli.ts", "build", "fixture"], timeSource: { pattern: "in (\\d+)ms" },
prepare: "rm -rf fixture/dist" },
],
})// ostia.config.json - equivalent, no defineConfig wrapper needed
{
"baseline": "main",
"thresholds": { "timingPct": 5 },
"workloads": [
{
"label": "parse",
"command": ["bun", "bench/parse.ts"],
"inputs": ["src/**/*.ts"]
},
{
"label": "dogfood-suites",
"suites": ["bench/*.ts"]
}
]
}Each workload is exactly one of command (a subprocess timed with runs/warmup) or
suites (glob patterns, same resolution as bench's own suites config, run via
bench()). A suites workload gates every task in those files individually - one
candidate-vs-baseline comparison per task, matched by workload id the same way a command
workload already is, so ostia ci's regression detection covers in-process microbenchmarks,
not only subprocess commands. Unlike command workloads, a suites workload always
executes (there's no cheap way to know a suite file's task ids, and so its per-task cache
keys, without importing it first) - inputs-based cache skipping is command-only for now.
inputs is optional (and, for now, only consulted for command workloads). Workloads
with no inputs always rerun (cache fails conservative).
prepare and timeSource are command-only and mean the same as ostia time's
--prepare / --time-source: prepare is a command string or argv array in both config
forms, or a function in ostia.config.ts; timeSource is { pattern, group?, unit? }
with pattern a regex source string (or a RegExp in .ts). Both are part of the workload
id. A function-form prepare can't be fingerprinted, so that workload never comes from
cache - it always executes, like a workload with no inputs.
timeoutMs, ignoreExitCodes, and failOnNonzero are also command-only and mean the
same as ostia time's --timeout / --ignore-failure / --fail-on-nonzero. ostia ci
defaults every workload's timeoutMs to 10 minutes when the workload doesn't set one
(bench.timeoutMs does the same for suites workloads); none of the three are part of
the workload id, so tuning them doesn't orphan a cached run or a saved baseline.
Two directory options, both optional: outDir (default node_modules/.cache/ostia) for
scratch/cache/artifacts, and baselineDir (default .ostia/baselines) for baselines. They're
independent - baselineDir doesn't move just because you override outDir.
onMissingBaseline ("warn" | "fail", default unset - see ostia
ci above) and noiseCheck (default true) are top-level config fields, not
per-workload: --on-missing-baseline / --no-noise-check override them per invocation.
Baselines (local and CI)
Baselines are JSON under .ostia/baselines/ (gitignored). ostia ci only needs the file
on disk; it does not need to be committed.
A workload's id identifies what is measured, not where the measuring process ran: for a
command workload it hashes the command argv, prepare, and timeSource, and
deliberately excludes process.cwd(). That means the same command measured from a CI
runner, a developer's checkout, or a different git worktree of the same repo produces the
same id and matches the same baseline row - label changes and switching directories
never orphan a baseline.
ostia baseline save [name] measures every configured workload (the same code path
ostia ci gates against, no comparison) and writes it to <baselineDir>/<name>.json
(default name: config's "baseline" field, or "main"). ostia baseline list shows every
saved baseline (name, created date, workload count, and git sha/branch when available);
ostia baseline show <name> [--format] renders one (delegates to ostia report). A
baseline name must match /^[A-Za-z0-9._-]+$/ and can't start with - - a typo'd flag
(ostia baseline save --verbose) is a usage error instead of a literal filename.
Every document stamps git: { sha, branch, dirty } (from git rev-parse / git status
--porcelain in the process's cwd, 200ms timeout, silently absent outside a repo or
without git installed) - metadata only, never part of any fingerprint or id, so a
commit or a dirty working tree never orphans a cached run or baseline. Printed in the
markdown report's header line and ostia baseline list.
Local branch workflow:
git checkout master # known-good tip
ostia baseline save # -> .ostia/baselines/main.json
git checkout -b my-opt
# ... change code ...
ostia ci # or: bun run dogfoodThe baseline survives branch switches because it is not tracked. Re-seed only when you intentionally accept a new floor. Seeding on the branch you are guarding compares that branch to itself.
ostia ci --save-baseline folds that re-seed into the gate itself: after a pass (no
regressions), it writes the just-measured document as the new baseline at the same path
it just compared against - useful in a CI job that gates every merge to a trunk branch,
so each green run becomes the next run's floor with no separate step.
One-off outside this repo's config:
ostia time --export-json .ostia/baselines/main.json "bun bench.ts"
ostia ciOn pull requests, CI measures the base branch into that same path, checks out the PR,
and runs ostia ci against it. If the base has no package.json / ostia.config.json
yet (empty starter commit), CI seeds from the PR tip instead so dogfood still runs.
Library API
The CLI is a thin wrapper around the library. Same ProfileDocument either way.
import {
time,
profile,
bench,
group,
task,
range,
sweep,
keep,
compareDocuments,
createDocument,
defineConfig,
renderers,
saveDocument,
loadDocument,
} from "ostia"
import type { ProfileDocument } from "ostia"time(opts) → ProfileDocument
Subprocess timing, optional CPU/heap capture. Same behavior as ostia time.
const doc = await time({
commands: ["bun a.ts", "bun b.ts"],
prepare: "rm -rf dist", // before every trial of every command, unmeasured; or a
// function: ({ phase, index }) => ..., phase is "warmup" | "timing" | "cpu" | "heap"
timeSource: { pattern: /in (\d+)ms/, unit: "ms" }, // samples from the command's own output
samples: 10, // exact trial count
// budgetMs: 3000, // wall-clock budget instead of an exact count
// minSamples: 10, // hard floor when samples isn't given
warmup: 2,
interleave: true, // default when 2+ commands: round-robins trials across them
cpu: true,
heap: false,
cpuIntervalUs: 200,
outDir: "node_modules/.cache/ostia", // default; artifacts land under here
noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
timeoutMs: 30_000, // kill a hung trial/prepare hook with SIGKILL; no default
signal: controller.signal, // abort to cancel: kills in-flight children, keeps partial results
ignoreExitCodes: [1], // treat exit code 1 as success; still samples, no nonzero-exit warning
failOnNonzero: false, // default; true stops a command's loop after its first bad exit
})A command can also be an object - { command, label?, prepare?, timeSource? } - whose
prepare/timeSource override the top-level ones for that command. That's how one
command becomes several labeled workloads in the same document:
const build = ["bun", "cli.ts", "build", "fixture"]
const inMs = { pattern: /in (\d+)ms/ }
const doc = await time({
commands: [
{ command: build, label: "warm", timeSource: inMs },
{ command: build, label: "incremental", timeSource: inMs, prepare: () => touchPost() },
{ command: build, label: "cold", timeSource: inMs, prepare: "rm -rf fixture/dist" },
{ command: build, label: "wall clock" }, // same command, no timeSource: wall time
],
samples: 5,
})profile(fn, opts) → { result, measurement, document }
In-process capture. origin: "jsc" is the only path that reports JIT tiers
(LLInt / Baseline / DFG / FTL). Default origin: "inspector" writes portable CDP-shaped
evidence instead. document is a full ProfileDocument (the one workload and
measurement), so it composes with renderers.* or saveDocument directly. profile()
also takes a signal?: AbortSignal, but fn runs in this process - there's no child to
kill, so an already-aborted signal only skips the profiler instrumentation (still running
fn plain and returning its result, with an aborted warning in place of CPU evidence);
it can't interrupt fn once it's running.
const { result, measurement, document } = await profile(
() => hashLoop(8_000_000),
{ origin: "jsc", intervalUs: 100 },
)
console.log(measurement.jit?.tiers)
// {
// llint: 0,
// baseline: 9,
// dfg: 37,
// ftl: 2825,
// }
const { files } = await renderers.collapsed.render(document)createDocument(workloads, measurements) → ProfileDocument
For composing a document from several profile() calls (each of which returns
just one workload and measurement):
const a = await profile(() => taskA())
const b = await profile(() => taskB())
const document = createDocument(
[a.document.workloads[0]!, b.document.workloads[0]!],
[a.measurement, b.measurement],
)group / task / bench
Register in-process suites, then run them (same as ostia bench):
// suite.ts
import { group, task } from "ostia"
group("parse", () => {
task("small input", () => parse(smallBuf))
task("large input", () => parse(largeBuf))
// Per-task options override the suite-wide time budget / min samples.
task("full pipeline", () => build(), { budgetMs: 2000, minSamples: 10 })
})That is the whole registration surface: group() and task(). Presentation lives in
the renderers (--format), not in the suite file.
All module-scope code in a suite file runs up front, before any task is sampled.
{ before, after } is the hook that runs a task's own setup immediately before its
sampling and its teardown immediately after - once each, unmeasured, in the task's
own process (so both work with isolate):
group(
"parse",
() => {
let doc: Document
task("append", () => doc.append(node), {
before: () => {
doc = mountDocument()
},
after: () => doc.destroy(),
})
},
{
// Runs once around the whole group, outside every task's own before/after.
before: () => setupSharedFixture(),
after: () => teardownSharedFixture(),
},
)There is no per-trial hook (no setup/teardown between individual samples) - that
would defeat batching, which is how ostia keeps a sub-microsecond task's timer
overhead down. Reach for { gc } (Bun.gc(true) between trials) or { isolate }
(a fresh process per task) for per-trial concerns instead. Because before/after
run once per task, not once per instance, a suite that builds more than one instance
of something stateful (a mounted UI component, an open connection, a server) still
has to scope a query to the instance it belongs to, not write it against a
global/ambient lookup that assumes it's the only one alive - a suite that opens a
component's menu and queries getByRole(...) unscoped, for example, breaks once a
second instance of that component exists in the document; scope the query with
something like within(instance.container) instead.
Task functions may be async (() => unknown | Promise<unknown>, same for
before/after); await on a plain synchronous value still costs a microtask
turn, so an async task function measures a few nanoseconds slower per call than
the same body written synchronously - immaterial above microsecond cost, worth
knowing for a task near the timer's resolution floor.
Call keep(value) on an intermediate value inside a task body - a subcomputation
whose result the task doesn't return - to pin it against dead-code elimination the
same way ostia already protects a task's own return value:
import { keep } from "ostia"
task("parse then validate", () => {
const ast = parse(input)
keep(ast) // the task returns validate's result; without this, a smart-enough
// JIT could in principle prove `ast` is otherwise unused and skip building it
return validate(ast)
})Both take an optional description that flows into the document
(Workload.description / Workload.groupDescription) and into --format minimal, so
what a number measures and why travels with the data instead of living only in a
source comment a reader has to go find:
group(
"repaint",
() => {
task("1,000 chars", () => repaint(doc1k))
task("4,000 chars", () => repaint(doc4k), {
description: "worst case: full repaint every keystroke at the max document size",
})
},
{ description: "editor repaint cost as document size grows" },
)Mark one task per group as the Relative reference with { baseline: true };
otherwise Relative defaults to the fastest task in the group:
group("parse", () => {
task("current impl", () => parse(buf), { baseline: true })
task("candidate impl", () => parseFast(buf))
})task.skip(...) / group.skip(...) register without measuring: the runner never
samples them, but the document still carries the workload (marked
Workload.skipped), so a renderer prints - skipped instead of the task just
being absent, and compare reports it as unchanged (with a skipped warning)
rather than silently passing or failing to match a baseline. task.only(...) /
group.only(...) restrict a suite file to only the .only-marked tasks - --filter
still applies on top - and print a one-line notice (bench: 2 task(s) selected by
.only) to stderr, so a forgotten .only doesn't quietly narrow a run in CI:
group("parse", () => {
task.only("fast path", () => parse(buf)) // only this task runs this time
task("slow path", () => parseSlow(buf))
task.skip("flaky on CI", () => parseFlaky(buf))
}){ cpu } / { alloc } on task() or group() override the suite-wide bench({ cpu, alloc })
/ --cpu / --alloc default for that task or group, the same pattern as isolate/gc:
one extra phase: "cpu" measurement (JIT tiers included, from a fixed 200ms window under
the JSC sampling profiler) and/or one extra phase: "memstats" measurement
(MemoryEvidence.bytesPerOp, from a Bun.gc(true)-bracketed batch of 100 calls) alongside
the task's timing numbers, never mixed into them:
group("parse", () => {
task("current impl", () => parse(buf))
task("candidate impl", () => parseFast(buf), { cpu: true, alloc: true })
})sweep(dims, fn) → void
Cartesian product over one or more named dimensions, calling fn once per point.
task() calls inside fn automatically inherit that point as Workload.params -
a structured alternative to baking the point into the task name, so renderers can
pivot on it and compare matches on the same point across runs instead of just a name:
import { group, task, range, sweep } from "ostia"
group("parse", () => {
sweep({ size: range(100, 10_000), impl: ["current", "fast"] }, ({ size, impl }) => {
const input = buildInput(size) // setup, runs once per point, unmeasured
task(`${impl}`, () => impls[impl](input))
})
})An explicit { params } on a particular task() call merges over (and wins
against) the current sweep point:
task(`${impl}`, () => impls[impl](input), { params: { size, impl, variant: "warm" } })--format minimal includes params on every line. The markdown renderer pivots a
group into a table (rows = first dimension, columns = second) when every task in
it shares the same two param keys - exactly what the example above produces - and
otherwise renders params as a key=value suffix on the task name.
range(start, end, multiplier?) → number[]
Geometric point generator that feeds sweep() (and works standalone): default
multiplier 8, always ending on end even if the last step overshot it.
range(100, 10_000) // -> [100, 800, 6400, 10000]
range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]bench(opts) → ProfileDocument
In-process suite runner, same behavior as ostia bench.
// demo.ts
import { bench } from "ostia"
const doc = await bench({
suites: ["suite.ts"],
budgetMs: 500,
// samples: 50, // exact per-task trial count instead of a budget
minSamples: 50,
jobs: 1, // suite files at once; > 1 trades fidelity for wall time
noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
})run(opts?) → ProfileDocument
In-file entrypoint: call it at the bottom of a suite file run directly with
bun suite.ts (no ostia bench CLI, no bench({ suites }) call) to execute every
group()/task() registered so far, print a report, and return the document.
// suite.ts
import { rm } from "node:fs/promises"
import { group, run, task } from "ostia"
group("parse", () => {
task("small input", () => parse(smallBuf))
task("large input", () => parse(largeBuf))
})
try {
await run()
} finally {
await rm(fixtureDir, { recursive: true })
}bun suite.tsrun({ filter: "parse" }) narrows to matching group/name ids, same regex as ostia bench
--filter/bench({ filter }) - there's no CLI here to read a --filter flag from, so pass it
as an option, e.g. from process.argv or an env var your run() call reads itself.
This trades away the isolation ostia bench/bench() give each suite file (and each
isolated task under --isolate) its own fresh subprocess: everything under run() runs in
the process that already imported the suite, so TaskOptions.isolate has nothing to isolate
into and is ignored. Prefer ostia bench/bench() for numbers you'll compare/ci
against; reach for run() for a single suite file's inline edit/run loop, or when a finally
around the run needs to clean up fixtures the suite set up (ostia bench's subprocess model
has no call in the file that returns after every task finishes, so that cleanup would
otherwise need a process.on("exit", ...) hook instead).
run(opts) accepts the same suite-wide filter/budgetMs/samples/minSamples/warmup/
gc/cpu/alloc/noiseCheck fields as bench(opts), plus quiet (skip the printed
report, still return the document) and format (renderer for that report, default "table").
compareDocuments(base, cand, thresholds?) → CompareResult
Same matching and thresholds as ostia compare / ostia ci.
const result = compareDocuments(baselineDoc, candidateDoc, {
timingPct: 5,
frameSelfPct: 10,
heapTypePct: 10,
minFrameSelfUs: 1000,
alpha: 0.01, // Mann-Whitney significance level
bootstrapIterations: 2000,
})
result.comparisons // Comparison[], one per workload id present on both sides
result.unmatched // { baseOnly: Workload[]; candOnly: Workload[] } - present on only one side
result.summary // { matched, regressed, improved, unchanged, geomeanPct, effectiveTimingPct, verdict }summary.geomeanPct is the geometric mean of cand/base median ratios over matched timing
comparisons, as a signed percent (negative: candidate faster on average); null when no
comparison had a finite timing ratio. summary.verdict is "fail" when any comparison
failed. ostia compare persists result.comparisons as comparisons, result.summary as
comparisonSummary, and result.unmatched's workload ids (not full Workloads, to keep the
document small) as unmatched: { baseOnly: string[]; candOnly: string[] } on the candidate
document it writes/renders.
saveDocument / loadDocument
await saveDocument(doc, "doc.json")
const loaded: ProfileDocument = await loadDocument("doc.json")defineConfig(config) → Partial<OstiaConfig>
Identity function purely for typing ostia.config.ts - see
ostia.config.ts / ostia.config.json above.
renderers
Pure functions of a ProfileDocument. Each returns { text? } and/or { files? }.
| Name | Output |
|---|---|
| table | terminal timing / CPU / heap / comparison text |
| markdown | agent- and human-readable report |
| json | pretty JSON document |
| jsonl | one kind: "document" header line, then one kind: "measurement" line per run |
| minimal | protocol v1: one run/unmatched/summary event per line, no sample array; for LLM/CI consumption (see Using ostia from an AI agent) |
| collapsed | folded stacks (name;name;name count) |
| mermaid | top-N call tree |
| speedscope | speedscope.app JSON |
| cpuprofile | verbatim .cpuprofile when a CDP artifact exists |
const { text } = await renderers.markdown.render(doc)
const { files } = await renderers.collapsed.render(doc, {
measurementId: someCpuMeasurementId,
})Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
Migrating from mitata or hyperfine
| mitata / hyperfine | ostia |
|---|---|
| bench("name", fn) | task("name", fn) |
| run({ filter }) at the bottom of the suite file | run({ filter }) at the bottom of the suite file (see run(opts?)) |
| baseline() | { baseline: true } on a task() |
| .range(name, start, end, mult) | sweep({ dim: range(start, end, mult) }, ...) |
| generator setup (function* () { ...; yield () => fn() }) | task(name, fn, { before, after }) |
| do_not_optimize(value) | keep(value) |
| hyperfine -L var a,b,c cmd-{var} | sweep({ var: ["a", "b", "c"] }, ...) or per-command params |
| hyperfine --runs N --warmup N | ostia time --samples N --warmup N |
| hyperfine --prepare CMD | ostia time --prepare CMD (also time({ prepare }) / config prepare) |
| hyperfine --export-json / --export-markdown | ostia time --export-json PATH / --format markdown |
sweep()/range()/params are in-process (ostia bench); hyperfine -L substitutes into
a shell command template for a subprocess instead, so a direct port is one literal ostia time
command per substitution value rather than a template - see sweep(dims, fn)
and ostia.config.ts / ostia.config.json above.
Examples
examples/ has six runnable recipes (they spawn ../../src/cli/main.ts
or import ../../src directly; no install step):
compare-two-commands. Relative timing table.find-a-hotspot.--cpuplus collapsed / Mermaid viz.heap-usage.--heaptype breakdown.gate-a-regression. Config, local baseline, andostia ci.profile-in-process.profile(fn, { origin: "jsc" }).benchmark-a-function.bench()/group()/task().
cd examples/find-a-hotspot && bun run demo
bun run examples # all of them from the repo root