ostia 0.2.1 → 0.2.3
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 +301 -40
- package/chunk-2s4xk1mg.js +12 -0
- package/chunk-dpxy38mq.js +22 -0
- package/cli.js +124 -67
- package/index.d.ts +336 -34
- package/index.js +1 -1
- package/package.json +4 -1
- package/runner.ts +5 -5
- package/chunk-9dqf5cxf.js +0 -8
- package/chunk-h6579788.js +0 -21
package/README.md
CHANGED
|
@@ -13,8 +13,9 @@ regression. `ostia ci` gates a whole `ostia.config.json`/`ostia.config.ts` of
|
|
|
13
13
|
workloads - subprocess commands and in-process `group()`/`task()` suites alike -
|
|
14
14
|
against a saved baseline, skipping anything whose input fingerprint hasn't changed.
|
|
15
15
|
Any task can run in its own subprocess for clean JIT/heap isolation from its
|
|
16
|
-
suite-mates, and every
|
|
17
|
-
straight into an LLM agent's context
|
|
16
|
+
suite-mates, and every command has a `--format minimal` mode - a versioned JSON
|
|
17
|
+
protocol built for piping straight into an LLM agent's context (see
|
|
18
|
+
[Using ostia from an AI agent](#using-ostia-from-an-ai-agent)).
|
|
18
19
|
|
|
19
20
|
Zero runtime dependencies. Requires Bun ≥ 1.4.
|
|
20
21
|
|
|
@@ -101,6 +102,46 @@ ostia ci # on your branch: rerun changed workloads, exit 1 on regre
|
|
|
101
102
|
Profile CI: ✓
|
|
102
103
|
```
|
|
103
104
|
|
|
105
|
+
## Using ostia from an AI agent
|
|
106
|
+
|
|
107
|
+
`--format minimal` is a versioned protocol (`protocolVersion: 1`) built for piping
|
|
108
|
+
straight into an LLM agent's context or a script: one JSON object per line, nothing
|
|
109
|
+
else on stdout.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
ostia time --samples 10 "bun a.ts" --format minimal
|
|
113
|
+
ostia compare before.json after.json --format minimal
|
|
114
|
+
ostia ci --format minimal; echo $?
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Every line is a JSON object with `event` and `protocolVersion: 1`:
|
|
118
|
+
|
|
119
|
+
| `event` | When | Key fields |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `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 }` |
|
|
122
|
+
| `unmatched` | Once per workload present on only one side of `compare`/`ci` | `workloadId`, `task`, `side: "base" \| "cand"` |
|
|
123
|
+
| `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` |
|
|
124
|
+
|
|
125
|
+
Stability: keys are never renamed or removed within `protocolVersion: 1` - only ever
|
|
126
|
+
added, so an agent that reads a field it knows keeps working as the protocol grows.
|
|
127
|
+
|
|
128
|
+
Exit codes are the same across every command that produces a verdict: `0` pass, `1`
|
|
129
|
+
at least one workload regressed (`compare`/`ci` only - `time`/`bench` never return
|
|
130
|
+
`1`), `2` a harness error (a command failed to run cleanly, nothing was compared, a
|
|
131
|
+
bad flag, a missing config/baseline). On any exit `2`, stderr's last line is one more
|
|
132
|
+
JSON object - `{ event: "error", protocolVersion: 1, code, message, data? }`, `code`
|
|
133
|
+
one of `invalid-flag` / `config-missing` / `baseline-missing` / `no-matches` /
|
|
134
|
+
`spawn-failed` / `command-failed` / `timeout` / `time-source-no-match` /
|
|
135
|
+
`document-load-failed` / `no-cpu-evidence` / `internal` - so a script doesn't have to
|
|
136
|
+
pattern-match prose to tell one failure from another. This error line (and only this
|
|
137
|
+
line) is on stderr; every `minimal`/`jsonl`/`json` line above is pure JSON on stdout,
|
|
138
|
+
nothing else mixed in.
|
|
139
|
+
|
|
140
|
+
`--format jsonl` is the same idea for the full document instead of the condensed
|
|
141
|
+
protocol above: one line per `Measurement`, plus a `document` header line, each
|
|
142
|
+
tagged `kind: "document" | "measurement"` so a consumer doesn't have to guess a
|
|
143
|
+
line's shape.
|
|
144
|
+
|
|
104
145
|
## What ostia is for
|
|
105
146
|
|
|
106
147
|
- Time subprocesses or in-process functions without a profiler attached to the timing runs.
|
|
@@ -134,11 +175,26 @@ ostia time --samples 25 --warmup 3 --cpu --heap "bun src/server.ts"
|
|
|
134
175
|
ostia time --format json --export-json out.json "bun a.ts"
|
|
135
176
|
```
|
|
136
177
|
|
|
137
|
-
|
|
178
|
+
Each `<command>` is a string, whitespace-split into argv exactly like hyperfine's `-N`
|
|
179
|
+
(no shell - no quoting, globbing, pipes, or redirection), so it can't express an argument
|
|
180
|
+
that itself contains a space. `ostia time [flags] -- <argv...>` is the escape hatch:
|
|
181
|
+
everything after `--` becomes one more command, given as argv verbatim (space preserved,
|
|
182
|
+
never flag-parsed), alongside any given the normal way:
|
|
183
|
+
|
|
184
|
+
```sh
|
|
185
|
+
ostia time -- bun -e "console.log('a b')"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
There's no `--` equivalent for `--prepare` (below) - a hook that itself needs an argument
|
|
189
|
+
with a space needs `ostia.config.ts`'s array form (`prepare: ["cp", "fixture a", "fixture b"]`)
|
|
190
|
+
instead, since the CLI flag is always one whitespace-split string.
|
|
191
|
+
|
|
192
|
+
`--samples N` is an exact trial count *per command* (with 2+ commands, each gets its
|
|
193
|
+
own N trials, not a total split across them); `--budget MS`
|
|
138
194
|
is a wall-clock time budget instead (default: a hyperfine-style ~3s min-total-time
|
|
139
195
|
loop when neither is given); `--min-samples N` is a hard floor when `--samples` isn't
|
|
140
196
|
given. The same three names work on `ostia bench` (`--budget`/`--samples`/
|
|
141
|
-
`--min-samples`), where `--budget`
|
|
197
|
+
`--min-samples`), where `--samples`/`--budget` are per-task the same way -
|
|
142
198
|
`warmup` differs by surface, though: a trial count here, a
|
|
143
199
|
*fraction* of the budget for `ostia bench`, since in-process warmup has no natural
|
|
144
200
|
"N calls" unit before the JIT has even seen the function once.
|
|
@@ -166,6 +222,15 @@ The prepare command is part of the workload id (and lands on the document as
|
|
|
166
222
|
caches them separately. The library API also takes a function
|
|
167
223
|
(`prepare: ({ phase, index }) => ...`, see [`time(opts)`](#timeopts--profiledocument)).
|
|
168
224
|
|
|
225
|
+
A hook's stderr is captured rather than streamed live to the terminal (it would otherwise
|
|
226
|
+
flood it, re-running before every one of possibly hundreds of trials) - bounded to 1 MiB
|
|
227
|
+
(head 512 KiB + tail 512 KiB, joined by a `bytes elided` marker if it goes over) and
|
|
228
|
+
folded into the thrown error on the trial where the hook actually times out or exits
|
|
229
|
+
non-zero, so a broken setup script is still easy to debug without an unbounded capture
|
|
230
|
+
risking the run's memory. Contrast `--time-source`'s own output capture (above), which is
|
|
231
|
+
deliberately *not* bounded: the summary line the regex needs could be anywhere in a large
|
|
232
|
+
output, so truncating it there would trade a memory bound for silently-wrong matches.
|
|
233
|
+
|
|
169
234
|
`--time-source REGEX` takes each trial's time from the command's *own output* instead of
|
|
170
235
|
its wall clock: the first `REGEX` match in stdout (then stderr), capture group 1, in
|
|
171
236
|
`--time-unit` units (`ns` | `us` | `ms` | `s`, default `ms`). Meant for tools that report a
|
|
@@ -179,12 +244,59 @@ ostia time --time-source "built in (\d+)ms" "bun build.ts"
|
|
|
179
244
|
|
|
180
245
|
The parsed value becomes `timing.samples`, so `compare`/`ci`/every renderer treat it
|
|
181
246
|
exactly like wall time; each trial keeps `wallNs` alongside `reportedNs` so the document
|
|
182
|
-
has both. A trial whose output doesn't match
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
the
|
|
187
|
-
|
|
247
|
+
has both. A trial whose output doesn't match the pattern contributes no sample (it's
|
|
248
|
+
never a fallback to `wallNs`, which would silently mix wall-clock time into a
|
|
249
|
+
reported-time series) and the measurement carries a `time-source-no-match` warning
|
|
250
|
+
(`data: { pattern, trials, output }`, `output` capped at 2 KiB) - it no longer aborts the
|
|
251
|
+
whole run the way it used to. If *every* trial of a command misses, that command has no
|
|
252
|
+
timing stats at all (same as every trial timing out - see `--timeout` above); other
|
|
253
|
+
commands in the same `time()` call keep their data regardless. To gate wall time *and*
|
|
254
|
+
the reported time independently, declare the command twice - once plain, once with
|
|
255
|
+
`--time-source` - and they're two workloads with two verdicts. Note the reported number
|
|
256
|
+
has whatever resolution the tool printed (usually whole ms), so its confidence interval
|
|
257
|
+
is coarser than a nanosecond wall clock's.
|
|
258
|
+
|
|
259
|
+
A `RegExp` pattern must not carry the `g`, `y`, or `d` flag - the same compiled pattern is
|
|
260
|
+
`exec`'d once per trial for the run's whole life, and `g`/`y` would make it alternate
|
|
261
|
+
match/no-match across trials via `lastIndex` instead of testing the same thing every time.
|
|
262
|
+
Constructing a workload with one throws `RangeError: timeSource pattern must not use the
|
|
263
|
+
g or y flag` immediately, before any trial runs. A plain (flagless) `RegExp` or a string
|
|
264
|
+
pattern is always safe to reuse. (`--time-source` on the CLI is always a plain string, so
|
|
265
|
+
this only comes up with a `RegExp` literal in the library API.)
|
|
266
|
+
|
|
267
|
+
`--timeout MS` kills a trial (or `--prepare` hook) with SIGKILL if it hasn't finished after
|
|
268
|
+
`MS` ms, so a hung command can't stall the whole run. No default for `time`/`bench` (unset
|
|
269
|
+
never times out); `ostia ci` defaults every workload to 10 minutes unless its config sets
|
|
270
|
+
`timeoutMs`. A timed-out trial resolves (never throws) with `Trial.timedOut: true` and
|
|
271
|
+
contributes no sample; if every trial of a command times out, that command has no timing
|
|
272
|
+
stats and prints like a skipped workload instead of an empty row.
|
|
273
|
+
|
|
274
|
+
`time(opts)` / `bench(opts)` also take a `signal?: AbortSignal`: aborting kills every
|
|
275
|
+
in-flight child process with SIGKILL, stops scheduling new trials, and resolves (never
|
|
276
|
+
rejects) with the document built from whatever measurements had already completed, plus
|
|
277
|
+
an `aborted` warning on the document's last measurement. `Ctrl-C` on the CLI wires this up
|
|
278
|
+
for you - `ostia time`/`ostia bench` cancel cleanly, still write `--export-json` of
|
|
279
|
+
whatever finished, and exit `130`, instead of the process just dying mid-spawn.
|
|
280
|
+
|
|
281
|
+
A non-zero exit doesn't stop a command's trial loop by default: every trial still runs,
|
|
282
|
+
each trial's own exit code lands on `Trial.exitCode`, and the measurement carries a
|
|
283
|
+
`nonzero-exit` warning (`data.exitCodes`) same as always. `--ignore-failure[=CODE,...]`
|
|
284
|
+
(hyperfine's flag; given bare, ignores every exit code) treats the listed codes as
|
|
285
|
+
success - the trial still contributes its sample, just with no warning and no effect on
|
|
286
|
+
the exit code below. `--fail-on-nonzero` stops a command's loop after its *first*
|
|
287
|
+
non-ignored non-zero exit instead of always running its full sample count (that trial's
|
|
288
|
+
sample is still recorded):
|
|
289
|
+
|
|
290
|
+
```sh
|
|
291
|
+
ostia time --ignore-failure=1 "may-exit-1-harmlessly.sh"
|
|
292
|
+
ostia time --fail-on-nonzero "bun build.ts"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Exit codes: `0` pass, `2` harness error (a command had a non-ignored non-zero exit, or a
|
|
296
|
+
workload has no timing stats at all - see `--timeout`/`--time-source` above - or a bad
|
|
297
|
+
flag/missing command), `130` cancelled with `Ctrl-C`. `1` is never returned by `time` or
|
|
298
|
+
`bench` - it's reserved for `compare`/`ci` regressions, so a script can tell "the
|
|
299
|
+
benchmark itself couldn't run cleanly" apart from "it ran, and got slower."
|
|
188
300
|
|
|
189
301
|
Timing table (two commands get a Relative column automatically):
|
|
190
302
|
|
|
@@ -231,6 +343,13 @@ trials either way; only the few expensive tasks in a suite pay for the extra rig
|
|
|
231
343
|
those are exactly where a 3-sample mean is shakiest. Fast calls are batched so a trial
|
|
232
344
|
spans at least 1µs and a full budget yields about 10k trials at most.
|
|
233
345
|
|
|
346
|
+
Exit codes: `0` pass, `2` harness error (a suite file failed to import/run, a suite or
|
|
347
|
+
isolated task's subprocess timed out - see `--timeout` below - or a bad flag), `130`
|
|
348
|
+
cancelled with `Ctrl-C`. Tasks are in-process function calls, not subprocesses, so there's
|
|
349
|
+
no per-task exit code / `--ignore-failure` the way `ostia time` has; a task that throws
|
|
350
|
+
fails its suite's subprocess the same way it always has. `1` is never returned - it's
|
|
351
|
+
reserved for `compare`/`ci` regressions.
|
|
352
|
+
|
|
234
353
|
| per-trial cost | fits in 500ms | default floor |
|
|
235
354
|
|---|---|---|
|
|
236
355
|
| 30ns | thousands | 20 (time-bound; ends in the tens of thousands) |
|
|
@@ -257,6 +376,12 @@ headroom, so numbers taken at `--jobs > 1` are noisier and not like-for-like wit
|
|
|
257
376
|
baseline measured at 1. It defaults to 1 for that reason; opt in for exploratory runs,
|
|
258
377
|
keep 1 for anything you `compare` or `ci` against.
|
|
259
378
|
|
|
379
|
+
`--gc`/`--cpu`/`--alloc`/`--isolate` each take a `--no-` counterpart
|
|
380
|
+
(`--no-gc`/`--no-cpu`/`--no-alloc`/`--no-isolate`) that resolves to an explicit `false`,
|
|
381
|
+
overriding a `true` from `ostia.config.json`'s `bench` section the same way the plain
|
|
382
|
+
flag overrides a config `false` - each flag is `cli ?? config ?? builtin default`, so
|
|
383
|
+
`ostia bench --no-gc` always wins over a config-wide `{ "gc": true }` for that one run.
|
|
384
|
+
|
|
260
385
|
`--isolate` gives every task its own child process instead of sharing its suite file's,
|
|
261
386
|
isolating each task's JIT tier state, inline caches and heap shape from every other task
|
|
262
387
|
in the run - the same guarantee suite files already get from each other, at task
|
|
@@ -288,6 +413,12 @@ extension its timing) may not reflect steady state - the cpu measurement carries
|
|
|
288
413
|
`jit-cold` warning (`{ llintPct, baselinePct, dfgPct, ftlPct }`), printed alongside the
|
|
289
414
|
CPU capture in the terminal table and folded into the task's line in `--format minimal`.
|
|
290
415
|
|
|
416
|
+
`--timeout MS` kills a suite file's subprocess (or, under `--isolate`, one task's dedicated
|
|
417
|
+
subprocess) with SIGKILL if it hasn't finished after `MS` ms - the same option `ostia time`
|
|
418
|
+
has, applied at the subprocess granularity `--isolate` already runs at rather than per task.
|
|
419
|
+
No default (unset never times out); `ostia ci` defaults every `suites` entry to 10 minutes
|
|
420
|
+
unless its config sets `bench.timeoutMs`.
|
|
421
|
+
|
|
291
422
|
`--preload PATH` (repeatable) imports a script before each suite file loads, in the same
|
|
292
423
|
subprocess - the same shape as Bun's own `--preload` / `bunfig.toml`'s `preload` array. Use
|
|
293
424
|
it to install globals a suite needs at import time (jsdom's `document`/`window`) or register
|
|
@@ -369,6 +500,36 @@ ostia compare after.json --baseline .ostia/baselines/main.json
|
|
|
369
500
|
timing: +11.2% median, 95% CI [+10.0%, +16.4%], p<0.001 (regressed)
|
|
370
501
|
```
|
|
371
502
|
|
|
503
|
+
Exit codes: `0` pass, `1` at least one workload regressed, `2` nothing was compared (zero
|
|
504
|
+
matched workloads - a stale baseline, a totally rewritten config) or a harness error
|
|
505
|
+
(documents failed to load, or a bad flag).
|
|
506
|
+
|
|
507
|
+
A workload id present on only one document prints in an `Unmatched` section (table and
|
|
508
|
+
markdown formats) instead of silently vanishing:
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
Unmatched:
|
|
512
|
+
baseline only: old-task
|
|
513
|
+
candidate only: new-task
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
`--format table` also prints a `threshold` header line when the machine's noise floor
|
|
517
|
+
widened the effective threshold past `thresholds.timingPct`:
|
|
518
|
+
|
|
519
|
+
```
|
|
520
|
+
threshold 5% (widened to 6.2% by noise floor)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`ostia compare` reads `ostia.config.ts`/`ostia.config.json`'s `thresholds` when present
|
|
524
|
+
(same discovery as `ostia ci`), so a project-tuned threshold applies here too instead of
|
|
525
|
+
only gating `ostia ci`; `--no-config` ignores it and uses `DEFAULT_THRESHOLDS`.
|
|
526
|
+
`--timing-pct N` / `--alpha N` override individual fields on top of whichever base was
|
|
527
|
+
picked. The source is printed above the report:
|
|
528
|
+
|
|
529
|
+
```
|
|
530
|
+
thresholds: ostia.config.ts
|
|
531
|
+
```
|
|
532
|
+
|
|
372
533
|
When both documents carry `git` metadata (see below), `ostia compare` prints a summary
|
|
373
534
|
line above the verdicts:
|
|
374
535
|
|
|
@@ -383,6 +544,15 @@ just a point estimate past the threshold - see
|
|
|
383
544
|
below. Comparisons with fewer than 5 samples on either side fall back to the
|
|
384
545
|
old point-estimate rule and carry a `thin-comparison` warning instead.
|
|
385
546
|
|
|
547
|
+
When `base`/`cand` differ in `platform.os`, `platform.arch`, `bunVersion`, or (when both
|
|
548
|
+
carry `environment`) `cpuModel`/`cores`, every comparison in the document carries an
|
|
549
|
+
`environment-mismatch` warning (`data.fields`: `{ field, base, cand }[]`) - a timing delta
|
|
550
|
+
between two different machines or Bun versions may reflect that, not the code change under
|
|
551
|
+
test. `table` and `markdown` print it once, in the header, instead of once per workload;
|
|
552
|
+
`minimal` folds it into each task line's `warnings[]` alongside its measurement warnings,
|
|
553
|
+
so `line.warnings.some(w => w.code === "environment-mismatch")` keeps working the same way
|
|
554
|
+
it does for a measurement warning.
|
|
555
|
+
|
|
386
556
|
#### Statistics: a real significance test, not a percentage threshold
|
|
387
557
|
|
|
388
558
|
A point estimate past `timingPct` is not enough to call something a
|
|
@@ -421,7 +591,9 @@ already past 75% of available cores at measurement time.
|
|
|
421
591
|
Render a saved `ProfileDocument` without re-running anything. `--format` covers
|
|
422
592
|
both the data formats (`table`/`json`/`jsonl`/`markdown`/`minimal`) and the CPU
|
|
423
593
|
visualization formats (`collapsed`/`mermaid`/`speedscope`/`cpuprofile`) - one
|
|
424
|
-
command instead of two.
|
|
594
|
+
command instead of two. `time`/`bench`/`compare --format` only accept the data
|
|
595
|
+
formats; export the document and run `ostia report --format <viz>` on it for
|
|
596
|
+
a visualization.
|
|
425
597
|
|
|
426
598
|
```sh
|
|
427
599
|
ostia report out.json # table (default)
|
|
@@ -432,17 +604,17 @@ ostia report out.json --format minimal
|
|
|
432
604
|
```
|
|
433
605
|
|
|
434
606
|
Minimal format - one JSON object per timing run, no header, no raw sample array, no prose.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
Numbers stay in ns so they line up with `compare` deltas and the JSON document.
|
|
607
|
+
See [Using ostia from an AI agent](#using-ostia-from-an-ai-agent) above for the full
|
|
608
|
+
protocol (event types, the exit-code contract, the stderr error line). A `run` event:
|
|
438
609
|
|
|
439
610
|
```
|
|
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":[]
|
|
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"}
|
|
611
|
+
{"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":[]}
|
|
442
612
|
```
|
|
443
613
|
|
|
444
|
-
`ostia compare
|
|
445
|
-
each line, so "did this PR regress" is
|
|
614
|
+
`ostia compare`/`ostia ci --format minimal` add `delta: { medianPct, meanPct, verdict, pass,
|
|
615
|
+
ci95?, pValue?, effectiveTimingPct, matched }` to each `run` line, so "did this PR regress" is
|
|
616
|
+
`lines.some(l => l.delta?.verdict === "regressed")` - and a trailing `summary` line carries
|
|
617
|
+
the same verdict for the whole run.
|
|
446
618
|
|
|
447
619
|
Markdown:
|
|
448
620
|
|
|
@@ -500,13 +672,14 @@ ostia ci --full # ignore cache
|
|
|
500
672
|
ostia ci --baseline main
|
|
501
673
|
ostia ci --export-json out.json
|
|
502
674
|
ostia ci --save-baseline # after a pass, promote today's numbers to the baseline
|
|
675
|
+
ostia ci --on-missing-baseline fail
|
|
676
|
+
ostia ci --no-noise-check
|
|
503
677
|
```
|
|
504
678
|
|
|
505
679
|
Pass:
|
|
506
680
|
|
|
507
681
|
```
|
|
508
682
|
1 workloads
|
|
509
|
-
1 affected by this change
|
|
510
683
|
0 cached
|
|
511
684
|
1 executed
|
|
512
685
|
1 passed 0 regressed
|
|
@@ -518,7 +691,6 @@ Fail:
|
|
|
518
691
|
|
|
519
692
|
```
|
|
520
693
|
1 workloads
|
|
521
|
-
1 affected by this change
|
|
522
694
|
0 cached
|
|
523
695
|
1 executed
|
|
524
696
|
0 passed 1 regressed (+1278.7% median on work)
|
|
@@ -526,7 +698,26 @@ Fail:
|
|
|
526
698
|
Profile CI: ✗
|
|
527
699
|
```
|
|
528
700
|
|
|
529
|
-
Exit codes: `0` pass, `1` regression, `2` harness error
|
|
701
|
+
Exit codes: `0` pass, `1` regression, `2` harness error - missing config/baseline, every
|
|
702
|
+
sampled trial of a `command` workload exited non-zero (a harness failure, reported as
|
|
703
|
+
`N failed` and distinct from a timing regression), an `onMissingBaseline: "fail"` mismatch,
|
|
704
|
+
or a spawn failure.
|
|
705
|
+
|
|
706
|
+
A configured workload with no matching row in the baseline (by workload id) doesn't just
|
|
707
|
+
silently pass: `onMissingBaseline` (config field, or `--on-missing-baseline warn|fail`)
|
|
708
|
+
decides what happens. Left unset, `ci` exits `2` (naming the baseline file and suggesting
|
|
709
|
+
`ostia baseline save`) only when *every* configured workload is missing - a totally
|
|
710
|
+
stale/wrong baseline - and otherwise lists what's missing in the report without affecting
|
|
711
|
+
the exit code, since one new workload next to an otherwise-matching baseline isn't a hard
|
|
712
|
+
error. `"fail"`/`"warn"` explicitly always fail/never fail on any mismatch, regardless of
|
|
713
|
+
how many workloads are missing.
|
|
714
|
+
|
|
715
|
+
`ci` also measures this machine's noise floor once per invocation (the same ~200ms
|
|
716
|
+
reference measurement `time()`/`bench()` take) and stamps it on both the candidate
|
|
717
|
+
document and `ostia baseline save`'s output, so `compare`'s noise-floor threshold widening
|
|
718
|
+
(see [Statistics](#statistics-a-real-significance-test-not-a-percentage-threshold)) applies
|
|
719
|
+
to `ci`-gated regressions too, not only ad hoc `time`/`bench` runs. `noiseCheck: false` in
|
|
720
|
+
config, or `--no-noise-check`, skips it.
|
|
530
721
|
|
|
531
722
|
#### `ostia.config.ts` / `ostia.config.json`
|
|
532
723
|
|
|
@@ -594,20 +785,39 @@ with `pattern` a regex source string (or a `RegExp` in `.ts`). Both are part of
|
|
|
594
785
|
id. A function-form `prepare` can't be fingerprinted, so that workload never comes from
|
|
595
786
|
cache - it always executes, like a workload with no `inputs`.
|
|
596
787
|
|
|
788
|
+
`timeoutMs`, `ignoreExitCodes`, and `failOnNonzero` are also `command`-only and mean the
|
|
789
|
+
same as `ostia time`'s `--timeout` / `--ignore-failure` / `--fail-on-nonzero`. `ostia ci`
|
|
790
|
+
defaults every workload's `timeoutMs` to 10 minutes when the workload doesn't set one
|
|
791
|
+
(`bench.timeoutMs` does the same for `suites` workloads); none of the three are part of
|
|
792
|
+
the workload id, so tuning them doesn't orphan a cached run or a saved baseline.
|
|
793
|
+
|
|
597
794
|
Two directory options, both optional: `outDir` (default `node_modules/.cache/ostia`) for
|
|
598
795
|
scratch/cache/artifacts, and `baselineDir` (default `.ostia/baselines`) for baselines. They're
|
|
599
796
|
independent - `baselineDir` doesn't move just because you override `outDir`.
|
|
600
797
|
|
|
798
|
+
`onMissingBaseline` (`"warn"` | `"fail"`, default unset - see [`ostia
|
|
799
|
+
ci`](#ostia-ci) above) and `noiseCheck` (default `true`) are top-level config fields, not
|
|
800
|
+
per-workload: `--on-missing-baseline` / `--no-noise-check` override them per invocation.
|
|
801
|
+
|
|
601
802
|
#### Baselines (local and CI)
|
|
602
803
|
|
|
603
804
|
Baselines are JSON under `.ostia/baselines/` (gitignored). `ostia ci` only needs the file
|
|
604
805
|
on disk; it does not need to be committed.
|
|
605
806
|
|
|
807
|
+
A workload's id identifies *what* is measured, not where the measuring process ran: for a
|
|
808
|
+
`command` workload it hashes the command argv, `prepare`, and `timeSource`, and
|
|
809
|
+
deliberately excludes `process.cwd()`. That means the same command measured from a CI
|
|
810
|
+
runner, a developer's checkout, or a different git worktree of the same repo produces the
|
|
811
|
+
same id and matches the same baseline row - `label` changes and switching directories
|
|
812
|
+
never orphan a baseline.
|
|
813
|
+
|
|
606
814
|
`ostia baseline save [name]` measures every configured workload (the same code path
|
|
607
815
|
`ostia ci` gates against, no comparison) and writes it to `<baselineDir>/<name>.json`
|
|
608
816
|
(default name: config's `"baseline"` field, or `"main"`). `ostia baseline list` shows every
|
|
609
817
|
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`).
|
|
818
|
+
`ostia baseline show <name> [--format]` renders one (delegates to `ostia report`). A
|
|
819
|
+
baseline name must match `/^[A-Za-z0-9._-]+$/` and can't start with `-` - a typo'd flag
|
|
820
|
+
(`ostia baseline save --verbose`) is a usage error instead of a literal filename.
|
|
611
821
|
|
|
612
822
|
Every document stamps `git: { sha, branch, dirty }` (from `git rev-parse` / `git status
|
|
613
823
|
--porcelain` in the process's cwd, 200ms timeout, silently absent outside a repo or
|
|
@@ -690,6 +900,10 @@ const doc = await time({
|
|
|
690
900
|
cpuIntervalUs: 200,
|
|
691
901
|
outDir: "node_modules/.cache/ostia", // default; artifacts land under here
|
|
692
902
|
noiseCheck: true, // default; set false to skip the ~200ms noise floor measurement
|
|
903
|
+
timeoutMs: 30_000, // kill a hung trial/prepare hook with SIGKILL; no default
|
|
904
|
+
signal: controller.signal, // abort to cancel: kills in-flight children, keeps partial results
|
|
905
|
+
ignoreExitCodes: [1], // treat exit code 1 as success; still samples, no nonzero-exit warning
|
|
906
|
+
failOnNonzero: false, // default; true stops a command's loop after its first bad exit
|
|
693
907
|
})
|
|
694
908
|
```
|
|
695
909
|
|
|
@@ -716,7 +930,11 @@ const doc = await time({
|
|
|
716
930
|
In-process capture. `origin: "jsc"` is the only path that reports JIT tiers
|
|
717
931
|
(LLInt / Baseline / DFG / FTL). Default `origin: "inspector"` writes portable CDP-shaped
|
|
718
932
|
evidence instead. `document` is a full `ProfileDocument` (the one workload and
|
|
719
|
-
measurement), so it composes with `renderers.*` or `saveDocument` directly.
|
|
933
|
+
measurement), so it composes with `renderers.*` or `saveDocument` directly. `profile()`
|
|
934
|
+
also takes a `signal?: AbortSignal`, but `fn` runs in this process - there's no child to
|
|
935
|
+
kill, so an already-aborted signal only skips the profiler instrumentation (still running
|
|
936
|
+
`fn` plain and returning its `result`, with an `aborted` warning in place of CPU evidence);
|
|
937
|
+
it can't interrupt `fn` once it's running.
|
|
720
938
|
|
|
721
939
|
```ts
|
|
722
940
|
const { result, measurement, document } = await profile(
|
|
@@ -925,6 +1143,10 @@ range(100, 10_000) // -> [100, 800, 6400, 10000]
|
|
|
925
1143
|
range(100, 100_000) // -> [100, 800, 6400, 51200, 100000]
|
|
926
1144
|
```
|
|
927
1145
|
|
|
1146
|
+
### `bench(opts)` → `ProfileDocument`
|
|
1147
|
+
|
|
1148
|
+
In-process suite runner, same behavior as `ostia bench`.
|
|
1149
|
+
|
|
928
1150
|
```ts
|
|
929
1151
|
// demo.ts
|
|
930
1152
|
import { bench } from "ostia"
|
|
@@ -939,30 +1161,56 @@ const doc = await bench({
|
|
|
939
1161
|
})
|
|
940
1162
|
```
|
|
941
1163
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1164
|
+
### `run(opts?)` → `ProfileDocument`
|
|
1165
|
+
|
|
1166
|
+
In-file entrypoint: call it at the bottom of a suite file run directly with
|
|
1167
|
+
`bun suite.ts` (no `ostia bench` CLI, no `bench({ suites })` call) to execute every
|
|
1168
|
+
`group()`/`task()` registered so far, print a report, and return the document.
|
|
945
1169
|
|
|
946
1170
|
```ts
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
{ command: build, label: "wall clock" }, // same command, no timeSource: wall time
|
|
955
|
-
],
|
|
956
|
-
samples: 5,
|
|
1171
|
+
// suite.ts
|
|
1172
|
+
import { rm } from "node:fs/promises"
|
|
1173
|
+
import { group, run, task } from "ostia"
|
|
1174
|
+
|
|
1175
|
+
group("parse", () => {
|
|
1176
|
+
task("small input", () => parse(smallBuf))
|
|
1177
|
+
task("large input", () => parse(largeBuf))
|
|
957
1178
|
})
|
|
1179
|
+
|
|
1180
|
+
try {
|
|
1181
|
+
await run()
|
|
1182
|
+
} finally {
|
|
1183
|
+
await rm(fixtureDir, { recursive: true })
|
|
1184
|
+
}
|
|
958
1185
|
```
|
|
959
1186
|
|
|
960
|
-
|
|
1187
|
+
```sh
|
|
1188
|
+
bun suite.ts
|
|
1189
|
+
```
|
|
1190
|
+
|
|
1191
|
+
`run({ filter: "parse" })` narrows to matching `group/name` ids, same regex as `ostia bench
|
|
1192
|
+
--filter`/`bench({ filter })` - there's no CLI here to read a `--filter` flag from, so pass it
|
|
1193
|
+
as an option, e.g. from `process.argv` or an env var your `run()` call reads itself.
|
|
1194
|
+
|
|
1195
|
+
This trades away the isolation `ostia bench`/`bench()` give each suite file (and each
|
|
1196
|
+
isolated task under `--isolate`) its own fresh subprocess: everything under `run()` runs in
|
|
1197
|
+
the process that already imported the suite, so `TaskOptions.isolate` has nothing to isolate
|
|
1198
|
+
into and is ignored. Prefer `ostia bench`/`bench()` for numbers you'll `compare`/`ci`
|
|
1199
|
+
against; reach for `run()` for a single suite file's inline edit/run loop, or when a `finally`
|
|
1200
|
+
around the run needs to clean up fixtures the suite set up (`ostia bench`'s subprocess model
|
|
1201
|
+
has no call in the file that returns after every task finishes, so that cleanup would
|
|
1202
|
+
otherwise need a `process.on("exit", ...)` hook instead).
|
|
1203
|
+
|
|
1204
|
+
`run(opts)` accepts the same suite-wide `filter`/`budgetMs`/`samples`/`minSamples`/`warmup`/
|
|
1205
|
+
`gc`/`cpu`/`alloc`/`noiseCheck` fields as `bench(opts)`, plus `quiet` (skip the printed
|
|
1206
|
+
report, still return the document) and `format` (renderer for that report, default `"table"`).
|
|
1207
|
+
|
|
1208
|
+
### `compareDocuments(base, cand, thresholds?)` → `CompareResult`
|
|
961
1209
|
|
|
962
1210
|
Same matching and thresholds as `ostia compare` / `ostia ci`.
|
|
963
1211
|
|
|
964
1212
|
```ts
|
|
965
|
-
const
|
|
1213
|
+
const result = compareDocuments(baselineDoc, candidateDoc, {
|
|
966
1214
|
timingPct: 5,
|
|
967
1215
|
frameSelfPct: 10,
|
|
968
1216
|
heapTypePct: 10,
|
|
@@ -970,8 +1218,20 @@ const diffs = compareDocuments(baselineDoc, candidateDoc, {
|
|
|
970
1218
|
alpha: 0.01, // Mann-Whitney significance level
|
|
971
1219
|
bootstrapIterations: 2000,
|
|
972
1220
|
})
|
|
1221
|
+
|
|
1222
|
+
result.comparisons // Comparison[], one per workload id present on both sides
|
|
1223
|
+
result.unmatched // { baseOnly: Workload[]; candOnly: Workload[] } - present on only one side
|
|
1224
|
+
result.summary // { matched, regressed, improved, unchanged, geomeanPct, effectiveTimingPct, verdict }
|
|
973
1225
|
```
|
|
974
1226
|
|
|
1227
|
+
`summary.geomeanPct` is the geometric mean of `cand/base` median ratios over matched timing
|
|
1228
|
+
comparisons, as a signed percent (negative: candidate faster on average); `null` when no
|
|
1229
|
+
comparison had a finite timing ratio. `summary.verdict` is `"fail"` when any comparison
|
|
1230
|
+
failed. `ostia compare` persists `result.comparisons` as `comparisons`, `result.summary` as
|
|
1231
|
+
`comparisonSummary`, and `result.unmatched`'s workload ids (not full `Workload`s, to keep the
|
|
1232
|
+
document small) as `unmatched: { baseOnly: string[]; candOnly: string[] }` on the candidate
|
|
1233
|
+
document it writes/renders.
|
|
1234
|
+
|
|
975
1235
|
### `saveDocument` / `loadDocument`
|
|
976
1236
|
|
|
977
1237
|
```ts
|
|
@@ -993,8 +1253,8 @@ Pure functions of a `ProfileDocument`. Each returns `{ text? }` and/or `{ files?
|
|
|
993
1253
|
| `table` | terminal timing / CPU / heap / comparison text |
|
|
994
1254
|
| `markdown` | agent- and human-readable report |
|
|
995
1255
|
| `json` | pretty JSON document |
|
|
996
|
-
| `jsonl` | one
|
|
997
|
-
| `minimal` | one
|
|
1256
|
+
| `jsonl` | one `kind: "document"` header line, then one `kind: "measurement"` line per run |
|
|
1257
|
+
| `minimal` | protocol v1: one `run`/`unmatched`/`summary` event per line, no sample array; for LLM/CI consumption (see [Using ostia from an AI agent](#using-ostia-from-an-ai-agent)) |
|
|
998
1258
|
| `collapsed` | folded stacks (`name;name;name count`) |
|
|
999
1259
|
| `mermaid` | top-N call tree |
|
|
1000
1260
|
| `speedscope` | speedscope.app JSON |
|
|
@@ -1014,6 +1274,7 @@ Units in the IR are fixed: ns (time), bytes (memory), µs (sampling interval).
|
|
|
1014
1274
|
| mitata / hyperfine | ostia |
|
|
1015
1275
|
|---|---|
|
|
1016
1276
|
| `bench("name", fn)` | `task("name", fn)` |
|
|
1277
|
+
| `run({ filter })` at the bottom of the suite file | `run({ filter })` at the bottom of the suite file (see [`run(opts?)`](#runopts--profiledocument)) |
|
|
1017
1278
|
| `baseline()` | `{ baseline: true }` on a `task()` |
|
|
1018
1279
|
| `.range(name, start, end, mult)` | `sweep({ dim: range(start, end, mult) }, ...)` |
|
|
1019
1280
|
| generator setup (`function* () { ...; yield () => fn() }`) | `task(name, fn, { before, after })` |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
class we extends Error{}function I(n){if(typeof n.pattern==="string")return;let{flags:s}=n.pattern;if(s.includes("g")||s.includes("y")||s.includes("d"))throw RangeError("timeSource pattern must not use the g or y flag")}function O(...n){let s=n.filter((o)=>o!==void 0);if(s.length===0)return;if(s.length===1)return s[0];return AbortSignal.any(s)}var Ke={ns:1,us:1000,ms:1e6,s:1e9};async function z(n){let s=n.timeSource!==void 0,o=Bun.nanoseconds(),u=!1,l=n.timeoutMs!==void 0?AbortSignal.timeout(n.timeoutMs):void 0;l?.addEventListener("abort",()=>{u=!0},{once:!0});let f=O(l,n.signal),T=Bun.spawn(n.argv,{cwd:n.cwd,env:n.env,stdout:s?"pipe":"ignore",stderr:s?"pipe":"ignore",stdin:"ignore",...f&&{signal:f,killSignal:"SIGKILL"}}),B=s?Promise.all([new Response(T.stdout).text(),new Response(T.stderr).text()]):void 0,C=await T.exited,_=Bun.nanoseconds(),U=T.resourceUsage?.(),M={wallNs:_-o,exitCode:u?null:C,userNs:U?Number(U.cpuTime.user)*1000:void 0,systemNs:U?Number(U.cpuTime.system)*1000:void 0,maxRssBytes:U?.maxRSS,...u?{timedOut:!0}:{}};if(B){let[W,ne]=await B;if(n.timeSource&&!u)try{M.reportedNs=Ye(n.timeSource,W,ne,n.argv)}catch(Q){if(!(Q instanceof we))throw Q;M.timeSourceNoMatch=!0,M.timeSourceMissOutput=Ze(W,ne)}}return M}function Ye(n,s,o,u=[]){let l=typeof n.pattern==="string"?new RegExp(n.pattern):n.pattern,f=n.group??1,T=l.exec(s)??l.exec(o),B=u.length>0?` for "${u.join(" ")}"`:"";if(!T)throw new we(`timeSource pattern ${l} did not match the output${B}. Output was:
|
|
3
|
+
${ve(s,o)}`);let C=T[f];if(C===void 0)throw Error(`timeSource pattern ${l} matched${B} but has no capture group ${f} (matched text: "${T[0]}").`);let _=Number(C);if(!Number.isFinite(_))throw Error(`timeSource pattern ${l} group ${f} captured "${C}"${B}, which is not a number.`);return _*Ke[n.unit??"ms"]}function ve(n,s){let u=(f)=>f.length>800?`${f.slice(0,800)}\u2026(${f.length-800} more)`:f,l=[];if(n.trim())l.push(`--- stdout ---
|
|
4
|
+
${u(n.trimEnd())}`);if(s.trim())l.push(`--- stderr ---
|
|
5
|
+
${u(s.trimEnd())}`);return l.length>0?l.join(`
|
|
6
|
+
`):"(empty)"}var he=2048;function Ze(n,s){let o=ve(n,s);if(Buffer.byteLength(o,"utf8")<=he)return o;let u=o.slice(0,he);while(Buffer.byteLength(u,"utf8")>he)u=u.slice(0,-1);return`${u}\u2026`}var Xe=1048576,Qe=(n)=>`
|
|
7
|
+
\u2026(${n} bytes elided)\u2026
|
|
8
|
+
`;async function en(n,s=Xe){let o=Math.floor(s/2),u=n.getReader(),l=[],f=0,T=[],B=0,C=0;try{for(;;){let{done:ne,value:Q}=await u.read();if(ne)break;if(!Q||Q.byteLength===0)continue;C+=Q.byteLength;let j=Q;if(f<o){let G=o-f;if(j.byteLength<=G){l.push(j),f+=j.byteLength;continue}l.push(j.subarray(0,G)),f+=G,j=j.subarray(G)}T.push(j),B+=j.byteLength;while(B>o&&T.length>0){let G=T[0],Z=B-o;if(G.byteLength<=Z)T.shift(),B-=G.byteLength;else T[0]=G.subarray(Z),B-=Z}}}finally{u.releaseLock()}let _=new TextDecoder,U=_.decode(Re(l));if(C<=f+B)return U;let M=_.decode(Re(T)),W=C-f-B;return`${U}${Qe(W)}${M}`}function Re(n){let s=n.reduce((l,f)=>l+f.byteLength,0),o=new Uint8Array(s),u=0;for(let l of n)o.set(l,u),u+=l.byteLength;return o}async function N(n,s,o){if(typeof n==="function"){await n(s);return}let u=ke(n),l=!1,f=o.timeoutMs!==void 0?AbortSignal.timeout(o.timeoutMs):void 0;f?.addEventListener("abort",()=>{l=!0},{once:!0});let T=O(f,o.signal),B=Bun.spawn(u,{cwd:o.cwd,env:o.env,stdout:"ignore",stderr:"pipe",stdin:"ignore",...T&&{signal:T,killSignal:"SIGKILL"}}),C=en(B.stderr).catch(()=>""),_=await B.exited;if(l){let U=(await C).trim();throw Error(`prepare command "${u.join(" ")}" timed out after ${o.timeoutMs}ms before ${s.phase} trial ${s.index}.${U?`
|
|
9
|
+
${U}`:""}`)}if(o.signal?.aborted)return;if(_!==0){let U=(await C).trim();throw Error(`prepare command "${u.join(" ")}" exited with code ${_} before ${s.phase} trial ${s.index}.${U?`
|
|
10
|
+
${U}`:""}`)}}function ke(n){if(n===void 0||typeof n==="function")return;return Array.isArray(n)?n:b(n)}function Ie(n){if(n===void 0)return;return{pattern:typeof n.pattern==="string"?n.pattern:n.pattern.source,...n.group!==void 0&&{group:n.group},...n.unit!==void 0&&{unit:n.unit}}}function b(n){return n.trim().split(/\s+/).filter(Boolean)}import{renameSync as tn}from"fs";var Ne={name:"ostia",version:"0.2.3",description:"Fast profiling and benchmarking for Bun.",type:"module",engines:{bun:">=1.4.0"},exports:{".":"./src/index.ts"},bin:{ostia:"./src/cli/main.ts"},scripts:{build:"bun scripts/build.ts",test:"bun test",typecheck:"tsc6",lint:"biome check .","lint:fix":"biome check --write . --unsafe","lint:changed":"biome check --changed --no-errors-on-unmatched .","lint:fix:changed":"biome check --write --unsafe --changed --no-errors-on-unmatched .",knip:"knip-bun",bench:"bun src/cli/main.ts bench bench/*.ts","bench:baseline":"bun src/cli/main.ts bench bench/*.ts --budget 3000 --min-samples 30 --export-json .ostia/baselines/bench-main.json",baseline:"bun src/cli/main.ts baseline save",dogfood:"bun test && bun src/cli/main.ts ci",examples:"bun scripts/run-examples.ts"},devDependencies:{"@biomejs/biome":"2.5.12","@types/bun":"^1.4.0",knip:"^6.34.0",typescript:"npm:@typescript/typescript6@^6.0.2"}};var w=Ne.version;function R(n){return JSON.stringify(fe(n))}function fe(n){if(Array.isArray(n))return n.map(fe);if(n!==null&&typeof n==="object"){let s={};for(let o of Object.keys(n).sort())s[o]=fe(n[o]);return s}return n}function e(n,...s){let o=Bun.CryptoHasher.hash("sha256",R(s),"hex");return`${n}_${o.slice(0,16)}`}var ge,Ee=!1;function ye(n){let s=Bun.spawnSync(["git",...n],{timeout:200,stdout:"pipe",stderr:"ignore"});return s.success?s.stdout.toString().trim():void 0}function Pe(){if(Ee)return ge;Ee=!0;try{let n=ye(["rev-parse","--short","HEAD"]);if(n===void 0)ge=void 0;else{let s=ye(["rev-parse","--abbrev-ref","HEAD"]),o=ye(["status","--porcelain"]);ge={sha:n,branch:s??"HEAD",dirty:(o?.length??0)>0}}}catch{ge=void 0}return ge}function t(n,s,o){let u=Pe();return{schemaVersion:2,toolVersion:w,bunVersion:Bun.version,platform:{os:process.platform,arch:process.arch},createdAt:new Date().toISOString(),workloads:n,measurements:s,...o!==void 0&&{environment:o},...u!==void 0&&{git:u}}}function k(n,s,o={}){if(o.timeSource)I(o.timeSource);let u=ke(o.prepare),l=Ie(o.timeSource),f=typeof o.prepare==="function"?o.prepare.toString():u;return{id:e("wl","subprocess",n,...f!==void 0||l!==void 0?[f??null,l??null]:[]),kind:"subprocess",command:n,label:s,...u!==void 0&&{prepare:u},...l!==void 0&&{timeSource:l}}}function q(n,s){return{id:e("wl","inprocess",n.name,n.toString()),kind:"inprocess",label:s}}function Be(n,s,o={}){return{id:o.params!==void 0?e("wl","inprocess-entry",n,s,o.params):e("wl","inprocess-entry",n,s),kind:"inprocess",entry:{file:n,task:s,...o.group!==void 0&&{group:o.group}},...o.label!==void 0&&{label:o.label},...o.baseline!==void 0&&{baseline:o.baseline},...o.description!==void 0&&{description:o.description},...o.groupDescription!==void 0&&{groupDescription:o.groupDescription},...o.isolated!==void 0&&{isolated:o.isolated},...o.params!==void 0&&{params:o.params},...o.skipped!==void 0&&{skipped:o.skipped}}}function m(n){return{id:e("run",n.workload.id,"timing",n.configFingerprint,Bun.version,w),workloadId:n.workload.id,phase:"timing",instrumented:!1,configFingerprint:n.configFingerprint,trials:n.trials,timing:n.timing,warnings:n.warnings,artifacts:[],memory:rn(n.trials),...n.interleaved!==void 0&&{interleaved:n.interleaved}}}function rn(n){let s=n.map((o)=>o.maxRssBytes).filter((o)=>o!==void 0);if(s.length===0)return;return{origin:"resourceUsage",perTrial:n.map((o)=>({rssBytes:o.maxRssBytes})),maxRssBytes:Math.max(...s)}}function d(n){return{id:e("run",n.workload.id,n.phase,n.configFingerprint,Bun.version,w),workloadId:n.workload.id,phase:n.phase,instrumented:!0,configFingerprint:n.configFingerprint,trials:[{i:0,wallNs:n.diagnosticWallNs,exitCode:n.exitCode}],diagnosticWallNs:n.diagnosticWallNs,cpu:n.cpu,heap:n.heap,memory:n.memory,jit:n.jit,warnings:n.warnings,artifacts:n.artifacts}}async function K(n,s,o){let l=await Bun.file(o).arrayBuffer(),f=new Bun.CryptoHasher("sha256");return f.update(l),{id:e("art",n,s,o),kind:s,path:o,sha256:f.digest("hex"),bytes:l.byteLength}}function a(n){return e("cfg",n)}function V(n){return`${JSON.stringify(fe(n),null,2)}
|
|
11
|
+
`}async function c(n,s){let o=V(n),u=`${s}.tmp-${process.pid}`;await Bun.write(u,o),tn(u,s)}function on(n){if(n.schemaVersion===2)return n;let{runs:s,comparisons:o,...u}=n;return{...u,schemaVersion:2,measurements:s.map(({baselineRunId:l,...f})=>f),...o!==void 0&&{comparisons:o.map(({baselineRunId:l,candidateRunId:f,...T})=>({...T,baselineMeasurementId:l,candidateMeasurementId:f}))}}}class D extends Error{code;path;schemaVersion;constructor(n,s,o={}){super(s);this.name="OstiaDocumentError",this.code=n,this.path=o.path,this.schemaVersion=o.schemaVersion}}async function r(n){let s=await Bun.file(n).text(),o;try{o=JSON.parse(s)}catch(l){let f=l instanceof Error?l.message:String(l);throw new D("invalid-json",`${n}: invalid JSON (${f})`,{path:n})}let u=o!==null&&typeof o==="object"?o.schemaVersion:void 0;if(typeof u!=="number")throw new D("not-a-document",`${n}: not a ProfileDocument (missing schemaVersion)`,{path:n});if(u!==1&&u!==2)throw new D("unsupported-schema",`${n}: unsupported ProfileDocument schemaVersion ${u} (this ostia reads 1\u20132)`,{path:n,schemaVersion:u});return on(o)}import Te from"os";function h(n){if(n.length===0)throw Error("computeTimingStats: samples must be non-empty");let s=n.length,o=Oe(n),u=0;for(let g=0;g<s;g++)u+=n[g];let l=u/s,f=i(o,0.5),T=0;for(let g=0;g<s;g++){let L=n[g]-l;T+=L*L}let B=Math.sqrt(T/s),C=o[0],_=o[s-1],U=i(o,0.25),M=i(o,0.75),W=M-U,ne=U-1.5*W,Q=M+1.5*W,j=U-3*W,G=M+3*W,Z=0,te=0;for(let g=0;g<s;g++){let L=n[g];if(L<j||L>G)te++;else if(L<ne||L>Q)Z++}let ue=i(o,0.99),ee=new Float64Array(s);for(let g=0;g<s;g++)ee[g]=Math.abs(n[g]-f);ee.sort();let ie=i(ee,0.5);return{unit:"ns",samples:n,mean:l,median:f,stddev:B,min:C,max:_,outliers:{mild:Z,severe:te},p75:M,p99:ue,mad:ie}}function Oe(n){let s=new Float64Array(n.length);return s.set(n),s.sort(),s}function i(n,s){let o=n.length;if(o===1)return n[0];let u=s*(o-1),l=Math.floor(u),f=Math.ceil(u);if(l===f)return n[l];let T=u-l;return n[l]*(1-T)+n[f]*T}var sn=5000000,an=200;function S(n,s,o="subprocess",u=[]){let l=[],f=n.samples[0];if(f!==void 0){let C=Oe(n.samples),_=i(C,0.25),M=i(C,0.75)-_;if(f>n.median+3*M&&M>0)l.push({code:"slow-first-run",message:`First run took ${(f/1e6).toFixed(2)}ms, much slower than the median ${(n.median/1e6).toFixed(2)}ms. Consider more warmup.`,data:{firstNs:f,medianNs:n.median}})}if(n.outliers.mild+n.outliers.severe>0)l.push({code:"outliers-detected",message:`${n.outliers.mild+n.outliers.severe} outlier(s) detected (${n.outliers.severe} severe, ${n.outliers.mild} mild).`,data:n.outliers});if(o==="subprocess"&&n.median<sn)l.push({code:"fast-command",message:`Median run time (${(n.median/1e6).toFixed(3)}ms) is very fast; results may be dominated by spawn overhead.`,data:{medianNs:n.median}});if(o==="inprocess"&&n.median<an)l.push({code:"below-timer-resolution",message:`Median run time (${n.median.toFixed(0)}ns) is close to timer resolution; consider a larger batch size or a coarser operation.`,data:{medianNs:n.median}});let T=new Set(u),B=s.filter((C)=>C!==void 0&&C!==0&&!T.has(C));if(B.length>0)l.push({code:"nonzero-exit",message:`${B.length} of ${s.length} trial(s) exited non-zero.`,data:{exitCodes:B}});return l}var un=200,Ae=64,cn=(()=>{let n=new Uint8Array(4096);for(let s=0;s<n.length;s++)n[s]=s*2654435761&255;return n})();function ln(n,s){let o=s;for(let u=0;u<n.length;u++)o^=n[u],o=Math.imul(o,16777619);return o>>>0}function mn(n){let s=h(n),o=s.mad??0;return{floorPct:s.median===0?0:o/s.median*100,referenceMedianNs:s.median,samples:n.length}}function Fe(n=un){let s=n*1e6,o=[],u=0,l=Bun.nanoseconds(),f=0;while(f<s){let T=Bun.nanoseconds();for(let C=0;C<Ae;C++)u^=ln(cn,C);let B=Bun.nanoseconds();o.push((B-T)/Ae),f=Bun.nanoseconds()-l}return mn(o)}var dn=0.75;function p(){let[n=0,s=0]=Te.loadavg();return{cpuModel:Te.cpus()[0]?.model??"unknown",cores:Te.availableParallelism(),loadAvg1:n,loadAvg5:s,noise:Fe()}}function F(n){if(n.loadAvg1<=n.cores*dn)return;return{code:"noisy-machine",message:`Load average ${n.loadAvg1.toFixed(2)} exceeds 75% of ${n.cores} available core(s); timing noise may be elevated.`,data:{loadAvg1:n.loadAvg1,cores:n.cores}}}import{profile as fn}from"bun:jsc";var pn=new Map([["LLInt","llint"],["Baseline","baseline"],["DFG","dfg"],["FTL","ftl"]]),Ce=4294967295;function Ue(n,s){let o=s??n.interval*1e6,u=new Map,l=[];function f(g,L,J,ce){let le=u.get(g);if(le===void 0)le=new Map,u.set(g,le);let ae=L??"",me=le.get(ae);if(me===void 0)me=l.length,le.set(ae,me),l.push({key:e("fr",g,ae),name:g,url:L,line:J,col:ce});return me}function T(g){let L=g.line===Ce,J=L?void 0:g.line-1,ce=L||g.column===Ce?void 0:g.column-1;return f(g.name,g.sourceURL,J,ce)}let B=f("(root)",void 0,void 0,void 0),C=1,_={id:0,frameIx:B,children:new Map,selfUs:0,samples:0,totalUs:0},U=new Map([[0,_]]),M={llint:0,baseline:0,dfg:0,ftl:0},W=new Map,ne=[],Q=[];for(let g of n.traces){let L=g.frames,J=_;for(let ae=L.length-1;ae>=0;ae--){let me=T(L[ae]),de=J.children.get(me);if(!de)de={id:C++,frameIx:me,children:new Map,selfUs:0,samples:0,totalUs:0},J.children.set(me,de),U.set(de.id,de);J=de}J.selfUs+=o,J.samples+=1,ne.push(J.id),Q.push(o);let ce=L[0],le=ce&&pn.get(ce.category);if(le){M[le]++;let ae=W.get(le)??new Map;ae.set(J.frameIx,(ae.get(J.frameIx)??0)+1),W.set(le,ae)}}function j(g){let L=g.selfUs;for(let J of g.children.values())L+=j(J);return g.totalUs=L,L}j(_);let G=new Map;function Z(g){let L=G.get(g.frameIx);if(L)L.selfUs+=g.selfUs,L.totalUs+=g.totalUs,L.samples+=g.samples;else G.set(g.frameIx,{frameIx:g.frameIx,selfUs:g.selfUs,totalUs:g.totalUs,samples:g.samples});for(let J of g.children.values())Z(J)}Z(_);let te=[...U.values()].map((g)=>({id:g.id,frameIx:g.frameIx,children:[...g.children.values()].map((L)=>L.id)})),ue={origin:"jsc-profile",samplingIntervalUs:o,frames:l,nodes:te,totals:[...G.values()].sort((g,L)=>L.selfUs-g.selfUs),samples:{nodeIds:ne,timeDeltasUs:Q}},ee=[...W.entries()].flatMap(([g,L])=>[...L.entries()].sort((J,ce)=>ce[1]-J[1]).slice(0,3).map(([J,ce])=>({tier:g,frameKey:l[J].key,samples:ce})));return{cpu:ue,jit:{origin:"jsc-profile",tiers:M,topFramesByTier:ee}}}var gn=1000;async function E(n,s={}){let o=s.intervalUs??gn,u,l=Bun.nanoseconds(),f=await fn(async()=>(u=await n(),u),o),T=Bun.nanoseconds()-l,{cpu:B,jit:C}=Ue(f.stackTraces,o);return{result:u,cpu:B,jit:C,diagnosticWallNs:T}}var bn=500,hn=20,$e=3,wn=10,kn=2,yn=0.1,Tn=1000,xn=1e4;function _e(n){let s=Math.log10(Math.max(1,n)/1e6),o=Math.round($e+kn*s);return Math.min(wn,Math.max($e,o))}function Sn(n,s){let o=Math.floor(s/n);return Math.min(hn,Math.max(o,_e(n)))}var De=0;function A(n){if(typeof n==="number")De+=n;else if(n!==void 0&&n!==null)De+=1}function be(n){return n!==null&&typeof n==="object"&&typeof n.then==="function"}function Le(n,s){return Math.max(1,Math.ceil(Tn/n),Math.ceil(s/(n*xn)))}async function We(n,s={}){let o=(s.budgetMs??bn)*1e6,u=o*(s.warmup??yn),l=Bun.nanoseconds(),f=0,T=0;while(T<u){let ee=n();A(be(ee)?await ee:ee),f++,T=Bun.nanoseconds()-l}let B;if(f>0)B=Math.max(1,T/f);else{let ee=Bun.nanoseconds(),ie=n();A(be(ie)?await ie:ie),B=Math.max(1,Bun.nanoseconds()-ee)}let C=Le(B,o);if(C>1){let ee=Bun.nanoseconds();for(let ie=0;ie<C;ie++){let g=n();A(be(g)?await g:g)}B=Math.max(1,(Bun.nanoseconds()-ee)/C),C=Le(B,o)}let _=B*C,U=s.samples??s.minSamples??Sn(_,o),M=s.samples!==void 0?0:o,W=[],ne=Bun.nanoseconds(),Q=0,j=0;while(j<U||Q<M){let ee=Bun.nanoseconds();for(let g=0;g<C;g++){let L=n();A(be(L)?await L:L)}let ie=Bun.nanoseconds();if(W.push({i:j,wallNs:(ie-ee)/C}),j++,Q=Bun.nanoseconds()-ne,s.gc)Bun.gc(!0);if(s.samples!==void 0&&j>=s.samples)break}let G=W.map((ee)=>ee.wallNs),Z=h(G);if(C>1)Z.batch=C;let te=S(Z,[],"inprocess"),ue=_e(_);if(W.length<ue)te.push({code:"low-sample-count",message:`Only ${W.length} sample(s) at ~${Mn(_)} per trial; ${ue} is the floor for this cost class. Raise minSamples or the time budget for a steadier number.`,data:{samples:W.length,target:ue,trialCostNs:_}});return{trials:W,timing:Z,warnings:te}}function Mn(n){if(n>=1e9)return`${(n/1e9).toFixed(2)}s`;if(n>=1e6)return`${(n/1e6).toFixed(1)}ms`;if(n>=1000)return`${(n/1000).toFixed(1)}\xB5s`;return`${n.toFixed(0)}ns`}var Me=[],oe,pe;function xe(n,s,o,u){let l=oe;oe={name:n,description:o?.description,isolate:o?.isolate,gc:o?.gc,cpu:o?.cpu,alloc:o?.alloc,before:o?.before,after:o?.after,skip:u.skip,only:u.only};try{s()}finally{oe=l}}var re=Object.assign((n,s,o)=>xe(n,s,o,{}),{skip:(n,s,o)=>xe(n,s,o,{skip:!0}),only:(n,s,o)=>xe(n,s,o,{only:!0})});function Se(n,s,o,u){let l=pe!==void 0||o?.params!==void 0?{...pe,...o?.params}:void 0;Me.push({groupName:oe?.name,groupDescription:oe?.description,groupIsolate:oe?.isolate,groupGc:oe?.gc,groupCpu:oe?.cpu,groupAlloc:oe?.alloc,groupBefore:oe?.before,groupAfter:oe?.after,name:n,fn:s,baseline:o?.baseline,params:l,skipped:u.skip||oe?.skip,only:u.only||oe?.only,opts:o})}var se=Object.assign((n,s,o)=>Se(n,s,o,{}),{skip:(n,s,o)=>Se(n,s,o,{skip:!0}),only:(n,s,o)=>Se(n,s,o,{only:!0})});function x(){return Me}function Y(){Me.length=0,oe=void 0,pe=void 0}function X(n,s){let o=pe;pe=n;try{return s()}finally{pe=o}}function y(n){return n.groupName?`${n.groupName}/${n.name}`:n.name}function H(n,s){return n.opts?.isolate??n.groupIsolate??s}function Ge(n,s){return n.opts?.gc??n.groupGc??s}function je(n,s){return n.opts?.cpu??n.groupCpu??s}function He(n,s){return n.opts?.alloc??n.groupAlloc??s}function v(n,s){if(!s)return[...n];let o=new RegExp(s);return n.filter((u)=>o.test(y(u)))}import{heapStats as Rn}from"bun:jsc";var vn=100;function Je(){try{return Rn().heapSize}catch{return process.memoryUsage().heapUsed}}async function Ve(n,s=vn){let o=Bun.nanoseconds();Bun.gc(!0);let u=Je();for(let T=0;T<s;T++){let B=n();if(B instanceof Promise)await B}Bun.gc(!0);let l=Je(),f=Bun.nanoseconds()-o;return{memory:{origin:"heapStats",bytesPerOp:Math.max(0,(l-u)/s)},diagnosticWallNs:f}}var In=200,Nn=20;async function qe(n,s=In){let o=s*1e6,u=async()=>{let B=Bun.nanoseconds();while(Bun.nanoseconds()-B<o){let C=n();if(C instanceof Promise)await C}},{cpu:l,jit:f,diagnosticWallNs:T}=await E(u);return{cpu:l,jit:f,diagnosticWallNs:T}}function ze(n){let{llint:s,baseline:o,dfg:u,ftl:l}=n.tiers,f=s+o+u+l;if(f===0)return;let T=s/f*100,B=o/f*100,C=u/f*100,_=l/f*100;if(T+B<=Nn)return;return{code:"jit-cold",message:`${(T+B).toFixed(1)}% of CPU samples were in the llint/baseline tiers: the JIT never warmed this task up.`,data:{llintPct:T,baselinePct:B,dfgPct:C,ftlPct:_}}}async function P(n,s,o){let l=s.some((U)=>!U.skipped)&&o.noiseCheck!==!1?p():void 0,f=l?F(l):void 0,T=new Map,B=new Map;s.forEach((U,M)=>{if(U.groupName===void 0||U.skipped)return;if(!T.has(U.groupName))T.set(U.groupName,M);B.set(U.groupName,M)});let C=[],_=[];for(let U=0;U<s.length;U++){let M=s[U],W=y(M),ne=Be(n,W,{label:W,baseline:M.baseline,group:M.groupName,description:M.opts?.description,groupDescription:M.groupDescription,isolated:o.markIsolated,params:M.params,skipped:M.skipped});if(C.push(ne),M.skipped)continue;let Q=M.groupName!==void 0&&T.get(M.groupName)===U,j=M.groupName!==void 0&&B.get(M.groupName)===U;if(Q&&M.groupBefore)await M.groupBefore();if(M.opts?.before)await M.opts.before();let G={budgetMs:M.opts?.budgetMs??o.budgetMs,samples:M.opts?.samples??o.samples,minSamples:M.opts?.minSamples??o.minSamples,warmup:o.warmup,gc:Ge(M,o.gc??!1)},Z=await We(M.fn,G);if(_.push(m({workload:ne,configFingerprint:a({budgetMs:G.budgetMs??null,samples:G.samples??null,minSamples:G.minSamples??null,gc:G.gc??!1}),trials:Z.trials,timing:Z.timing,warnings:f&&_.length===0?[...Z.warnings,f]:Z.warnings})),je(M,o.cpu??!1)){let te=await qe(M.fn),ue=ze(te.jit);_.push(d({workload:ne,phase:"cpu",configFingerprint:a({cpu:!0}),diagnosticWallNs:te.diagnosticWallNs,cpu:te.cpu,jit:te.jit,warnings:ue?[ue]:[],artifacts:[]}))}if(He(M,o.alloc??!1)){let te=await Ve(M.fn);_.push(d({workload:ne,phase:"memstats",configFingerprint:a({alloc:!0}),diagnosticWallNs:te.diagnosticWallNs,memory:te.memory,warnings:[],artifacts:[]}))}if(M.opts?.after)await M.opts.after();if(j&&M.groupAfter)await M.groupAfter()}return t(C,_,l)}
|
|
12
|
+
export{R,e,h,i,S,I,O,z,N,b,w,t,k,q,m,d,K,a,V,c,D,r,p,F,E,A,re,se,x,Y,X,y,H,v,P};
|