ostia 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,16 +1,57 @@
1
- interface RunOptions {
2
- commands: (string | string[])[];
3
- runs?: number;
1
+ export declare function defineConfig(config: Partial<OstiaConfig>): Partial<OstiaConfig>;
2
+
3
+ export interface CommandSpec {
4
+ command: string | string[];
5
+ label?: string;
6
+ /** Overrides `TimeOptions.prepare` for this command. */
7
+ prepare?: PrepareHook;
8
+ /** Overrides `TimeOptions.timeSource` for this command. */
9
+ timeSource?: TimeSource;
10
+ }
11
+
12
+ export interface TimeOptions {
13
+ commands: (string | string[] | CommandSpec)[];
14
+ /** Runs before every trial of every command (warmup and instrumented
15
+ * trials included), unmeasured, in `cwd`/`env`: a command string / argv
16
+ * array spawned and awaited, or a function. hyperfine's `--prepare`. A
17
+ * `CommandSpec.prepare` overrides it per command. */
18
+ prepare?: PrepareHook;
19
+ /** Take every command's timing from a number in its own output instead of
20
+ * its wall clock (e.g. a build tool's `built in 342ms` line, which
21
+ * excludes runtime startup). A `CommandSpec.timeSource` overrides it per
22
+ * command. The parsed value becomes `timing.samples`; each trial keeps
23
+ * `wallNs` too. */
24
+ timeSource?: TimeSource;
25
+ /** Exact trial count. When set, `budgetMs` is ignored. */
26
+ samples?: number;
27
+ /** Wall-clock time budget for the sampling loop, ms (default: a
28
+ * hyperfine-style ~3s min-total-time loop when neither `samples` nor
29
+ * `budgetMs` is given). */
30
+ budgetMs?: number;
31
+ /** Hard floor on trials when no exact `samples` count is given. */
32
+ minSamples?: number;
4
33
  warmup?: number;
34
+ /** Round-robin trials across commands (one trial per command, repeated)
35
+ * instead of running each command's whole trial loop to completion before
36
+ * the next command starts. Default: true when 2+ commands are given (a
37
+ * single command has nothing to interleave against). Spreads any drift
38
+ * over the run's wall-clock span (thermal throttling, a noisy neighbor
39
+ * process) evenly across every command instead of favoring whichever ran
40
+ * first or last. */
41
+ interleave?: boolean;
5
42
  cwd?: string;
6
43
  env?: Record<string, string>;
7
44
  cpu?: boolean;
8
45
  heap?: boolean;
9
46
  cpuIntervalUs?: number;
10
47
  outDir?: string;
48
+ /** Measure this machine's noise floor before the first command (default:
49
+ * true) and stamp it on the document as `environment`. Set false to skip
50
+ * the ~200ms reference measurement. */
51
+ noiseCheck?: boolean;
11
52
  }
12
53
 
13
- export declare function run(opts: RunOptions): Promise<ProfileDocument>;
54
+ export declare function time(opts: TimeOptions): Promise<ProfileDocument>;
14
55
 
15
56
  interface ProfileOptions {
16
57
  intervalUs?: number;
@@ -19,13 +60,76 @@ interface ProfileOptions {
19
60
 
20
61
  interface ProfileResult<T> {
21
62
  result: T;
22
- run: Run;
63
+ measurement: Measurement;
64
+ document: ProfileDocument;
23
65
  }
24
66
 
25
67
  export declare function profile<T>(fn: () => T | Promise<T>, opts?: ProfileOptions): Promise<ProfileResult<T>>;
26
68
 
69
+ export interface WorkloadConfig {
70
+ label?: string;
71
+ command?: string[];
72
+ suites?: string[];
73
+ inputs?: string[];
74
+ /** `command` only. Runs before every trial (warmup included), unmeasured:
75
+ * a command string / argv array in both `.ts` and JSON config, or a
76
+ * function in `ostia.config.ts`. A function-form hook makes the workload
77
+ * uncacheable for `ostia ci` (its effect can't be fingerprinted), so it
78
+ * always executes. */
79
+ prepare?: PrepareHook;
80
+ /** `command` only. Take timing from a number in the command's own output
81
+ * instead of its wall clock; see `TimeSource`. */
82
+ timeSource?: TimeSource;
83
+ }
84
+
85
+ interface BenchConfig {
86
+ /** Suite file globs, resolved with Bun.Glob against the config's directory
87
+ * (e.g. "bench/**\/*.bench.ts"). Ignored when suite files are also given
88
+ * on the command line - CLI args replace this list rather than merging
89
+ * with it. */
90
+ suites?: string[];
91
+ preload?: string[];
92
+ jobs?: number | "auto";
93
+ budgetMs?: number;
94
+ samples?: number;
95
+ minSamples?: number;
96
+ gc?: boolean;
97
+ cpu?: boolean;
98
+ alloc?: boolean;
99
+ filter?: string;
100
+ isolate?: boolean;
101
+ outDir?: string;
102
+ }
103
+
104
+ export interface OstiaConfig {
105
+ runs: number | null;
106
+ warmup: number;
107
+ outDir: string;
108
+ baselineDir: string;
109
+ baseline: string;
110
+ cpuIntervalUs: number;
111
+ thresholds: Thresholds;
112
+ workloads: WorkloadConfig[];
113
+ bench?: BenchConfig;
114
+ }
115
+
116
+ interface Thresholds {
117
+ timingPct: number;
118
+ frameSelfPct: number;
119
+ heapTypePct: number;
120
+ minFrameSelfUs: number;
121
+ /** Significance level for the Mann-Whitney p-value: a `regressed` /
122
+ * `improved` verdict also requires `pValue < alpha`. */
123
+ alpha: number;
124
+ /** Bootstrap resample rounds for the timing CI. Capped work regardless:
125
+ * see `bootstrapMedianDiffCi`. */
126
+ bootstrapIterations: number;
127
+ }
128
+
129
+ export declare function compareDocuments(base: ProfileDocument, cand: ProfileDocument, thresholds?: Thresholds): Comparison[];
130
+
27
131
  export interface ProfileDocument {
28
- schemaVersion: 1;
132
+ schemaVersion: 2;
29
133
  toolVersion: string;
30
134
  bunVersion: string;
31
135
  platform: {
@@ -34,8 +138,41 @@ export interface ProfileDocument {
34
138
  };
35
139
  createdAt: string;
36
140
  workloads: Workload[];
37
- runs: Run[];
141
+ measurements: Measurement[];
38
142
  comparisons?: Comparison[];
143
+ /** Machine conditions when this document was measured. Additive, no
144
+ * schema bump. Absent when `noiseCheck: false` (or `--no-noise-check`)
145
+ * skipped the reference measurement. */
146
+ environment?: Environment;
147
+ /** Repo state when this document was measured, from `git rev-parse` /
148
+ * `git status --porcelain` in the process's cwd. Additive, no schema
149
+ * bump. Absent outside a git repo (or when `git` itself isn't
150
+ * available). Metadata only: never part of any fingerprint or id, so a
151
+ * commit or a dirty working tree never orphans a cached run or baseline. */
152
+ git?: GitMetadata;
153
+ }
154
+
155
+ interface GitMetadata {
156
+ sha: string;
157
+ branch: string;
158
+ dirty: boolean;
159
+ }
160
+
161
+ interface NoiseFloor {
162
+ /** `mad / median` of the reference workload's trial times, as a percent -
163
+ * how noisy this machine is right now, independent of what's being
164
+ * measured. */
165
+ floorPct: number;
166
+ referenceMedianNs: number;
167
+ samples: number;
168
+ }
169
+
170
+ interface Environment {
171
+ cpuModel: string;
172
+ cores: number;
173
+ loadAvg1: number;
174
+ loadAvg5: number;
175
+ noise: NoiseFloor;
39
176
  }
40
177
 
41
178
  export interface Workload {
@@ -44,6 +181,22 @@ export interface Workload {
44
181
  label?: string;
45
182
  command?: string[];
46
183
  shell?: string;
184
+ /** Command form of the `prepare` hook that ran before every trial of this
185
+ * command (`ostia time --prepare`, `time({ prepare })`, config `prepare`).
186
+ * Part of the workload id: the same command with and without a prepare
187
+ * step measures different things. A function-form hook isn't
188
+ * serializable and is omitted here (its source text is still hashed into
189
+ * the id). */
190
+ prepare?: string[];
191
+ /** Where this command's timing samples came from when not the subprocess
192
+ * wall clock: a regex over the command's own output and the unit of the
193
+ * number it captures. Part of the workload id, so the same command timed
194
+ * by wall clock and by its own report are two workloads. */
195
+ timeSource?: {
196
+ pattern: string;
197
+ group?: number;
198
+ unit?: "ns" | "us" | "ms" | "s";
199
+ };
47
200
  /** `task` is the "group/name" id the bench registry assigns; `group` is the
48
201
  * enclosing `group()` name when there is one. Renderers prefer `group` over
49
202
  * splitting `task` on "/", so task names may contain slashes. */
@@ -67,11 +220,23 @@ export interface Workload {
67
220
  * on the task, its group, or the suite), vs. sharing its suite file's
68
221
  * subprocess with other tasks. */
69
222
  isolated?: boolean;
223
+ /** Structured parameters this task point represents (e.g. `{ size: 800,
224
+ * impl: "fast" }`), from `task(name, fn, { params })` or a `sweep()` point.
225
+ * Lets renderers pivot and `compare` match on them instead of only on the
226
+ * task name. Part of the workload id when present, so two points that
227
+ * share a task name (a `sweep()`'s whole point) don't collide. */
228
+ params?: Record<string, string | number | boolean>;
229
+ /** From `task.skip()` or a `group.skip()` this task was inside. The runner
230
+ * never measures it, so this workload has no matching `Measurement`; a
231
+ * renderer prints it as a "- skipped" row instead of omitting it, and
232
+ * `compare` treats it as `unchanged` (with a `skipped` warning) rather
233
+ * than silently passing or failing to match it. */
234
+ skipped?: boolean;
70
235
  }
71
236
 
72
237
  type Phase = "timing" | "cpu" | "heap" | "memstats";
73
238
 
74
- interface Run {
239
+ interface Measurement {
75
240
  id: string;
76
241
  workloadId: string;
77
242
  phase: Phase;
@@ -86,12 +251,23 @@ interface Run {
86
251
  jit?: JitTierBreakdown;
87
252
  warnings: Warning[];
88
253
  artifacts: ArtifactRef[];
89
- baselineRunId?: string;
254
+ baselineMeasurementId?: string;
255
+ /** True when this timing measurement's trials were run round-robin against
256
+ * the other commands in the same `time()` call (`--interleave`, default on
257
+ * for 2+ commands) rather than run to completion before the next command
258
+ * started, so drift over the run's wall-clock span (thermal throttling, a
259
+ * noisy neighbor process) lands on every command equally instead of
260
+ * favoring whichever ran first or last. */
261
+ interleaved?: boolean;
90
262
  }
91
263
 
92
264
  interface Trial {
93
265
  i: number;
94
266
  wallNs: number;
267
+ /** The command's self-reported time (ns) when the workload has a
268
+ * `timeSource`; `timing.samples` are these, not `wallNs`, in that case.
269
+ * `wallNs` stays alongside so a document keeps both. */
270
+ reportedNs?: number;
95
271
  exitCode?: number;
96
272
  userNs?: number;
97
273
  systemNs?: number;
@@ -110,6 +286,15 @@ interface TimingStats {
110
286
  mild: number;
111
287
  severe: number;
112
288
  };
289
+ /** 75th percentile, ns. Optional: absent on documents saved before this
290
+ * field existed (`loadDocument` never backfills it). */
291
+ p75?: number;
292
+ /** 99th percentile, ns. Same caveat as `p75`. */
293
+ p99?: number;
294
+ /** Median absolute deviation, ns: the median of `|sample - median|` across
295
+ * all samples. A robust spread measure that (unlike stddev) isn't skewed
296
+ * by the long right tail typical of wall-clock timings. */
297
+ mad?: number;
113
298
  }
114
299
 
115
300
  interface Frame {
@@ -166,6 +351,11 @@ interface MemoryEvidence {
166
351
  maxRssBytes?: number;
167
352
  peakCommitBytes?: number;
168
353
  pageFaults?: number;
354
+ /** Bytes allocated per call, from `ostia bench --alloc`: heap size delta
355
+ * (`bun:jsc`'s `heapStats().heapSize`, falling back to
356
+ * `process.memoryUsage().heapUsed`) around one `Bun.gc(true)`-bracketed
357
+ * batch, divided by the batch size. */
358
+ bytesPerOp?: number;
169
359
  }
170
360
 
171
361
  interface JitTierBreakdown {
@@ -183,7 +373,7 @@ interface JitTierBreakdown {
183
373
  }[];
184
374
  }
185
375
 
186
- export type WarningCode = "slow-first-run" | "outliers-detected" | "fast-command" | "nonzero-exit" | "instrumented-timing" | "artifact-missing" | "empty-profile" | "below-timer-resolution" | "cache-fallback-rerun" | "low-sample-count";
376
+ export type WarningCode = "slow-first-run" | "outliers-detected" | "fast-command" | "nonzero-exit" | "instrumented-timing" | "artifact-missing" | "empty-profile" | "below-timer-resolution" | "cache-fallback-rerun" | "low-sample-count" | "thin-comparison" | "noisy-machine" | "skipped" | "jit-cold";
187
377
 
188
378
  export interface Warning {
189
379
  code: WarningCode;
@@ -201,13 +391,28 @@ interface ArtifactRef {
201
391
 
202
392
  interface Comparison {
203
393
  id: string;
204
- baselineRunId: string;
205
- candidateRunId: string;
394
+ baselineMeasurementId: string;
395
+ candidateMeasurementId: string;
206
396
  timing?: {
207
397
  medianDeltaPct: number;
208
398
  meanDeltaPct: number;
399
+ /** Same value as `medianDeltaPct`, named for what it is used for: the
400
+ * effect size the verdict rule tests against `thresholds.timingPct`. */
401
+ effectPct: number;
402
+ /** 95% bootstrap confidence interval on the difference of medians,
403
+ * percent of the baseline median. Absent when either side had fewer
404
+ * than 5 samples (see the `thin-comparison` warning). */
405
+ ci95?: [number, number];
406
+ /** Two-sided Mann-Whitney U p-value, tie-corrected normal approximation.
407
+ * Same absence condition as `ci95`. */
408
+ pValue?: number;
409
+ /** Seed for the bootstrap's PRNG, so `ci95` is reproducible. */
410
+ seed?: number;
209
411
  verdict: "improved" | "regressed" | "unchanged";
210
412
  };
413
+ /** Attached when timing fell back to the point-estimate rule (thin
414
+ * samples) or otherwise carries a caveat about this comparison. */
415
+ warnings?: Warning[];
211
416
  frames?: {
212
417
  frameKey: string;
213
418
  name: string;
@@ -228,15 +433,54 @@ interface Comparison {
228
433
  frameSelfPct: number;
229
434
  heapTypePct: number;
230
435
  minFrameSelfUs: number;
436
+ alpha: number;
437
+ bootstrapIterations: number;
438
+ /** `max(timingPct, base.environment.noise.floorPct,
439
+ * cand.environment.noise.floorPct)` - the threshold timing was actually
440
+ * tested against, once machine noise widens it past `timingPct`. */
441
+ effectiveTimingPct: number;
231
442
  };
232
443
  verdict: "pass" | "fail";
233
444
  }
234
445
 
446
+ export interface PrepareRun {
447
+ phase: "warmup" | "timing" | "cpu" | "heap";
448
+ index: number;
449
+ }
450
+
451
+ export type PrepareFn = (run: PrepareRun) => unknown;
452
+
453
+ export type PrepareHook = string | string[] | PrepareFn;
454
+
455
+ export type TimeUnit = "ns" | "us" | "ms" | "s";
456
+
457
+ export interface TimeSource {
458
+ pattern: string | RegExp;
459
+ group?: number;
460
+ unit?: TimeUnit;
461
+ }
462
+
463
+ export declare function keep(value: unknown): void;
464
+
235
465
  interface BenchOptions {
236
466
  suites: string[];
237
- timeBudgetMs?: number;
467
+ /** Wall-clock sampling budget per task, ms (default 500). */
468
+ budgetMs?: number;
469
+ /** Exact trial count per task. When set, the budget is ignored - the
470
+ * in-process equivalent of `time()`'s `samples`. */
471
+ samples?: number;
238
472
  minSamples?: number;
239
473
  gc?: boolean;
474
+ /** Capture one extra `phase: "cpu"` measurement per task (200ms of the
475
+ * task looped under the JSC sampling profiler, JIT tiers included), never
476
+ * mixed into the timing numbers. `TaskOptions.cpu` / `GroupOptions.cpu`
477
+ * override this per task or group. */
478
+ cpu?: boolean;
479
+ /** Capture one extra `phase: "memstats"` measurement per task: bytes
480
+ * allocated per call, from a `Bun.gc(true)`-bracketed batch.
481
+ * `TaskOptions.alloc` / `GroupOptions.alloc` override this per task or
482
+ * group. */
483
+ alloc?: boolean;
240
484
  filter?: string;
241
485
  /** Suite files to run at once, each still in its own child process (default:
242
486
  * 1). Files are independent by design, so this is a wall-clock win for
@@ -270,27 +514,27 @@ interface BenchOptions {
270
514
  * resolution condition Bun doesn't set by default (e.g. Svelte/Vue's
271
515
  * `browser` vs `default` builds). */
272
516
  bunFlags?: string[];
517
+ /** Measure this machine's noise floor before the first task per suite
518
+ * subprocess (default: true) and stamp it on the document as
519
+ * `environment`. Set false to skip the ~200ms reference measurement. */
520
+ noiseCheck?: boolean;
273
521
  }
274
522
 
275
523
  export declare function bench(opts: BenchOptions): Promise<ProfileDocument>;
276
524
 
277
- interface Thresholds {
278
- timingPct: number;
279
- frameSelfPct: number;
280
- heapTypePct: number;
281
- minFrameSelfUs: number;
282
- }
283
-
284
- export declare function compareDocuments(base: ProfileDocument, cand: ProfileDocument, thresholds?: Thresholds): Comparison[];
285
-
286
525
  export declare function range(start: number, end: number, multiplier?: number): number[];
287
526
 
527
+ type Hook = () => unknown | Promise<unknown>;
528
+
288
529
  export interface TaskOptions {
289
530
  /** Marks this task as the Relative reference for its group in the table
290
531
  * renderer, mirroring mitata's `baseline()`. At most one per group. */
291
532
  baseline?: boolean;
292
- /** Per-task time budget; overrides the suite-wide `--time-budget` / `timeBudgetMs`. */
293
- timeBudgetMs?: number;
533
+ /** Per-task time budget; overrides the suite-wide `--budget` / `budgetMs`. */
534
+ budgetMs?: number;
535
+ /** Per-task exact trial count; overrides the suite-wide `--samples` /
536
+ * `samples`. When set, the budget is ignored for this task. */
537
+ samples?: number;
294
538
  /** Per-task hard floor on trials; overrides the suite-wide `--min-samples` /
295
539
  * `minSamples`. */
296
540
  minSamples?: number;
@@ -305,6 +549,30 @@ export interface TaskOptions {
305
549
  /** Overrides the suite-wide `bench({ gc })` / `--gc` (and any
306
550
  * `GroupOptions.gc`) for this task only. */
307
551
  gc?: boolean;
552
+ /** Overrides the suite-wide `bench({ cpu })` / `--cpu` (and any
553
+ * `GroupOptions.cpu`) for this task only: after the timing measurement,
554
+ * capture one extra `phase: "cpu"` measurement (JIT tiers included) on
555
+ * the same workload, never mixed into the timing numbers. */
556
+ cpu?: boolean;
557
+ /** Overrides the suite-wide `bench({ alloc })` / `--alloc` (and any
558
+ * `GroupOptions.alloc`) for this task only: after the timing measurement,
559
+ * capture one extra `phase: "memstats"` measurement with bytes
560
+ * allocated per call. */
561
+ alloc?: boolean;
562
+ /** Structured parameters this task represents (e.g. `{ size: 800, impl:
563
+ * "fast" }`), written to `Workload.params` and folded into the workload id
564
+ * so points with the same task name don't collide. Inside `sweep()`, the
565
+ * current point is inherited automatically; an explicit `params` here
566
+ * merges over it (explicit keys win). */
567
+ params?: Record<string, string | number | boolean>;
568
+ /** Runs once, unmeasured, immediately before this task's warmup - in the
569
+ * task's own process, so it works with `isolate`. No per-trial hook: that
570
+ * would defeat batching. Use `gc` (Bun.gc between trials) or `isolate`
571
+ * (a fresh process per task) for per-trial concerns instead. */
572
+ before?: Hook;
573
+ /** Runs once, unmeasured, immediately after this task's last trial. Same
574
+ * process/no-per-trial caveats as `before`. */
575
+ after?: Hook;
308
576
  }
309
577
 
310
578
  export interface GroupOptions {
@@ -317,11 +585,57 @@ export interface GroupOptions {
317
585
  /** Default `gc` for every task in this group, unless a task overrides it
318
586
  * with its own `TaskOptions.gc`. */
319
587
  gc?: boolean;
588
+ /** Default `cpu` for every task in this group, unless a task overrides it
589
+ * with its own `TaskOptions.cpu`. */
590
+ cpu?: boolean;
591
+ /** Default `alloc` for every task in this group, unless a task overrides
592
+ * it with its own `TaskOptions.alloc`. */
593
+ alloc?: boolean;
594
+ /** Runs once, unmeasured, before the group's first task's warmup (not
595
+ * before every task) - in whichever process runs that task, so it works
596
+ * with `isolate`. */
597
+ before?: Hook;
598
+ /** Runs once, unmeasured, after the group's last task's last trial. */
599
+ after?: Hook;
320
600
  }
321
601
 
322
- export declare function group(name: string, fn: () => void, opts?: GroupOptions): void;
602
+ interface GroupFn {
603
+ (name: string, fn: () => void, opts?: GroupOptions): void;
604
+ /** Registers every task inside as skipped: the runner never measures
605
+ * them, but the document still carries their workloads (marked
606
+ * `Workload.skipped`). */
607
+ skip: (name: string, fn: () => void, opts?: GroupOptions) => void;
608
+ /** When any task or group in the suite uses `.only`, the runner restricts
609
+ * the whole suite file to only those tasks (before `--filter` narrows
610
+ * further) and prints a one-line notice to stderr. */
611
+ only: (name: string, fn: () => void, opts?: GroupOptions) => void;
612
+ }
613
+
614
+ export declare const group: GroupFn;
615
+
616
+ interface TaskFn {
617
+ (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions): void;
618
+ /** Registers the task as skipped: the runner never measures it, but the
619
+ * document still carries its workload (marked `Workload.skipped`) so a
620
+ * renderer or `compare` can say so explicitly instead of the task simply
621
+ * being absent. */
622
+ skip: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
623
+ /** When any task or group in the suite uses `.only`, the runner restricts
624
+ * the whole suite file to only those tasks (before `--filter` narrows
625
+ * further) and prints a one-line notice to stderr. */
626
+ only: (name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions) => void;
627
+ }
628
+
629
+ export declare const task: TaskFn;
630
+
631
+ type SweepPoint<T extends Record<string, readonly unknown[]>> = {
632
+ [K in keyof T]: T[K][number];
633
+ };
323
634
 
324
- export declare function task(name: string, fn: () => unknown | Promise<unknown>, opts?: TaskOptions): void;
635
+ export declare function sweep<T extends Record<string, readonly unknown[]>>(dims: T, fn: (point: SweepPoint<T>) => void): void;
636
+
637
+ export declare function newDocument(workloads: Workload[], measurements: Measurement[], environment?: Environment): ProfileDocument;
638
+ export { newDocument as createDocument };
325
639
 
326
640
  export declare function saveDocument(doc: ProfileDocument, path: string): Promise<void>;
327
641
 
@@ -349,14 +663,24 @@ export interface MinimalLine {
349
663
  group?: string;
350
664
  description?: string;
351
665
  groupDescription?: string;
352
- unit: "ns";
353
- samples: number;
354
- mean: number;
355
- median: number;
356
- stddev: number;
357
- stddevPct: number;
358
- min: number;
359
- max: number;
666
+ /** From `task(name, fn, { params })` or a `sweep()` point. */
667
+ params?: Record<string, string | number | boolean>;
668
+ /** From `task.skip()` / `group.skip()`: no measurement was taken, so every
669
+ * stats field below is absent on this line. */
670
+ skipped?: true;
671
+ unit?: "ns";
672
+ samples?: number;
673
+ mean?: number;
674
+ median?: number;
675
+ stddev?: number;
676
+ stddevPct?: number;
677
+ min?: number;
678
+ max?: number;
679
+ /** 75th/99th percentile and median absolute deviation, ns. Absent on
680
+ * documents saved before these fields existed. */
681
+ p75?: number;
682
+ p99?: number;
683
+ mad?: number;
360
684
  /** Median over the group's reference median (its baseline task, else its
361
685
  * fastest). Only present when the document has more than one timing run. */
362
686
  relative?: number;
@@ -372,5 +696,10 @@ export interface MinimalLine {
372
696
  meanPct: number;
373
697
  verdict: "improved" | "regressed" | "unchanged";
374
698
  pass: boolean;
699
+ /** 95% bootstrap CI on the difference of medians and the Mann-Whitney
700
+ * p-value behind the verdict. Absent on a thin (<5 samples/side)
701
+ * comparison, which falls back to a point-estimate threshold. */
702
+ ci95?: [number, number];
703
+ pValue?: number;
375
704
  };
376
705
  }
package/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- import{d,f,S,n,k,B}from"./chunk-3qbcj1m9.js";import{o,t,U,M}from"./chunk-zrck2q0b.js";export{d as bench,f as compareDocuments,U as group,t as loadDocument,B as profile,S as range,n as renderers,k as run,o as saveDocument,M as task};
2
+ import{c,b,J,V,r,G,C,z}from"./chunk-h6579788.js";import{n,s,t,T,q,K}from"./chunk-9dqf5cxf.js";export{c as bench,b as compareDocuments,n as createDocument,G as defineConfig,q as group,T as keep,t as loadDocument,z as profile,J as range,r as renderers,s as saveDocument,V as sweep,K as task,C as time};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ostia",
3
- "version": "0.1.7",
3
+ "version": "0.2.1",
4
4
  "description": "Fast profiling and benchmarking for Bun.",
5
5
  "type": "module",
6
6
  "exports": {
package/runner.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
- import{r,C,i,s,o,l,p,N,v,m,R,D,F}from"./chunk-zrck2q0b.js";var z=500,H=20,A=3,j=10,X=2,q=0.1,K=1000,Q=1e4;function J(t){let a=Math.log10(Math.max(1,t)/1e6),c=Math.round(A+X*a);return Math.min(j,Math.max(A,c))}function V(t,a){let c=Math.floor(a/t);return Math.min(H,Math.max(c,J(t)))}var L=0;function O(t){if(typeof t==="number")L+=t;else if(t!==void 0&&t!==null)L+=1}function I(t){return t!==null&&typeof t==="object"&&typeof t.then==="function"}function G(t,a){return Math.max(1,Math.ceil(K/t),Math.ceil(a/(t*Q)))}async function U(t,a={}){let c=(a.timeBudgetMs??z)*1e6,e=c*(a.warmupFraction??q),S=Bun.nanoseconds(),g=0,B=0;while(B<e){let u=t();O(I(u)?await u:u),g++,B=Bun.nanoseconds()-S}let f;if(g>0)f=Math.max(1,B/g);else{let u=Bun.nanoseconds(),b=t();O(I(b)?await b:b),f=Math.max(1,Bun.nanoseconds()-u)}let d=G(f,c);if(d>1){let u=Bun.nanoseconds();for(let b=0;b<d;b++){let M=t();O(I(M)?await M:M)}f=Math.max(1,(Bun.nanoseconds()-u)/d),d=G(f,c)}let n=f*d,h=a.minSamples??V(n,c),w=[],k=Bun.nanoseconds(),T=0,x=0;while(x<h||T<c){let u=Bun.nanoseconds();for(let M=0;M<d;M++){let P=t();O(I(P)?await P:P)}let b=Bun.nanoseconds();if(w.push({i:x,wallNs:(b-u)/d}),x++,T=Bun.nanoseconds()-k,a.gc)Bun.gc(!0)}let W=w.map((u)=>u.wallNs),E=l(W),y=p(E,[],"inprocess"),_=J(n);if(w.length<_)y.push({code:"low-sample-count",message:`Only ${w.length} sample(s) at ~${Y(n)} per trial; ${_} is the floor for this cost class. Raise minSamples or the time budget for a steadier number.`,data:{samples:w.length,target:_,trialCostNs:n}});return{trials:w,timing:E,warnings:y}}function Y(t){if(t>=1e9)return`${(t/1e9).toFixed(2)}s`;if(t>=1e6)return`${(t/1e6).toFixed(1)}ms`;if(t>=1000)return`${(t/1000).toFixed(1)}\xB5s`;return`${t.toFixed(0)}ns`}async function Z(){let[t,a,c]=process.argv.slice(2);if(!t||!a)return process.stderr.write(`bench runner: usage: runner.ts <suiteFile> <outputPath> [optsJson]
4
- `),2;let e=c?JSON.parse(c):{};for(let n of e.preload??[])await import(n);v(),await import(t);let S=N();if(S.length===0)return process.stderr.write(`bench runner: ${t} registered no tasks (no task() calls found).
5
- `),2;let g=F(S,e.filter);if(e.taskIds){let n=new Set(e.taskIds);g=g.filter((h)=>n.has(m(h)))}if(g.length===0)return process.stderr.write(`bench runner: --filter ${JSON.stringify(e.filter)} matched zero of ${S.length} registered tasks in ${t}.
6
- `),2;if(e.planPath){let n=g.map((h)=>({id:m(h),isolate:R(h,e.isolate??!1)}));await Bun.write(e.planPath,JSON.stringify({tasks:n}))}let B=e.taskIds?g:g.filter((n)=>!R(n,e.isolate??!1)),f=[],d=[];for(let n of B){let h=m(n),w=C(t,h,{label:h,baseline:n.baseline,group:n.groupName,description:n.opts?.description,groupDescription:n.groupDescription,isolated:e.markIsolated});f.push(w);let k={...e,...n.opts?.timeBudgetMs!==void 0&&{timeBudgetMs:n.opts.timeBudgetMs},...n.opts?.minSamples!==void 0&&{minSamples:n.opts.minSamples},gc:D(n,e.gc??!1)},T=await U(n.fn,k);d.push(i({workload:w,configFingerprint:s({timeBudgetMs:k.timeBudgetMs??null,minSamples:k.minSamples??null,gc:k.gc??!1}),trials:T.trials,timing:T.timing,warnings:T.warnings}))}return await o(r(f,d),a),0}Z().then((t)=>process.exit(t));
3
+ import{n,O,i,a,o,s,d,f,g,U,B,A,h,I,j,_,L,H}from"./chunk-9dqf5cxf.js";import{heapStats as X}from"bun:jsc";var Y=100;function z(){try{return X().heapSize}catch{return process.memoryUsage().heapUsed}}async function Z(l,m=Y){let w=Bun.nanoseconds();Bun.gc(!0);let t=z();for(let u=0;u<m;u++){let p=l();if(p instanceof Promise)await p}Bun.gc(!0);let k=z(),c=Bun.nanoseconds()-w;return{memory:{origin:"heapStats",bytesPerOp:Math.max(0,(k-t)/m)},diagnosticWallNs:c}}var ee=200,te=20;async function q(l,m=ee){let w=m*1e6,t=async()=>{let p=Bun.nanoseconds();while(Bun.nanoseconds()-p<w){let b=l();if(b instanceof Promise)await b}},{cpu:k,jit:c,diagnosticWallNs:u}=await d(t);return{cpu:k,jit:c,diagnosticWallNs:u}}function K(l){let{llint:m,baseline:w,dfg:t,ftl:k}=l.tiers,c=m+w+t+k;if(c===0)return;let u=m/c*100,p=w/c*100,b=t/c*100,S=k/c*100;if(u+p<=te)return;return{code:"jit-cold",message:`${(u+p).toFixed(1)}% of CPU samples were in the llint/baseline tiers: the JIT never warmed this task up.`,data:{llintPct:u,baselinePct:p,dfgPct:b,ftlPct:S}}}async function ne(){let[l,m,w]=process.argv.slice(2);if(!l||!m)return process.stderr.write(`bench runner: usage: runner.ts <suiteFile> <outputPath> [optsJson]
4
+ `),2;let t=w?JSON.parse(w):{};for(let r of t.preload??[])await import(r);A(),await import(l);let k=B();if(k.length===0)return process.stderr.write(`bench runner: ${l} registered no tasks (no task() calls found).
5
+ `),2;let c=k.filter((r)=>r.only),u=c.length>0?c:k;if(c.length>0)process.stderr.write(`bench: ${c.length} task(s) selected by .only
6
+ `);let p=H(u,t.filter);if(t.taskIds){let r=new Set(t.taskIds);p=p.filter((e)=>r.has(h(e)))}if(p.length===0)return process.stderr.write(`bench runner: --filter ${JSON.stringify(t.filter)} matched zero of ${u.length} registered task(s) in ${l}.
7
+ `),2;if(t.planPath){let r=p.map((e)=>({id:h(e),isolate:I(e,t.isolate??!1)}));await Bun.write(t.planPath,JSON.stringify({tasks:r}))}let b=t.taskIds?p:p.filter((r)=>!I(r,t.isolate??!1)),M=b.some((r)=>!r.skipped)&&t.noiseCheck!==!1?f():void 0,v=M?g(M):void 0,W=new Map,x=new Map;b.forEach((r,e)=>{if(r.groupName===void 0||r.skipped)return;if(!W.has(r.groupName))W.set(r.groupName,e);x.set(r.groupName,e)});let F=[],P=[];for(let r=0;r<b.length;r++){let e=b[r],R=h(e),T=O(l,R,{label:R,baseline:e.baseline,group:e.groupName,description:e.opts?.description,groupDescription:e.groupDescription,isolated:t.markIsolated,params:e.params,skipped:e.skipped});if(F.push(T),e.skipped)continue;let Q=e.groupName!==void 0&&W.get(e.groupName)===r,V=e.groupName!==void 0&&x.get(e.groupName)===r;if(Q&&e.groupBefore)await e.groupBefore();if(e.opts?.before)await e.opts.before();let E=e.opts?.budgetMs??t.budgetMs,J=e.opts?.samples??t.samples,D=e.opts?.minSamples??t.minSamples,N={...t,...E!==void 0&&{budgetMs:E},...J!==void 0&&{samples:J},...D!==void 0&&{minSamples:D},gc:j(e,t.gc??!1)},C=await U(e.fn,N);if(P.push(i({workload:T,configFingerprint:o({budgetMs:N.budgetMs??null,samples:N.samples??null,minSamples:N.minSamples??null,gc:N.gc??!1}),trials:C.trials,timing:C.timing,warnings:v&&P.length===0?[...C.warnings,v]:C.warnings})),_(e,t.cpu??!1)){let y=await q(e.fn),G=K(y.jit);P.push(a({workload:T,phase:"cpu",configFingerprint:o({cpu:!0}),diagnosticWallNs:y.diagnosticWallNs,cpu:y.cpu,jit:y.jit,warnings:G?[G]:[],artifacts:[]}))}if(L(e,t.alloc??!1)){let y=await Z(e.fn);P.push(a({workload:T,phase:"memstats",configFingerprint:o({alloc:!0}),diagnosticWallNs:y.diagnosticWallNs,memory:y.memory,warnings:[],artifacts:[]}))}if(e.opts?.after)await e.opts.after();if(V&&e.groupAfter)await e.groupAfter()}return await s(n(F,P,M),m),0}ne().then((l)=>process.exit(l));