Skip to content

Commit 1f513ae

Browse files
committed
Improvements to reporters to better handle noisy samples
1 parent a066b8f commit 1f513ae

File tree

7 files changed

+146
-55
lines changed

7 files changed

+146
-55
lines changed

example/empty.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* Copyright © 2026 Apeleg Limited. All rights reserved.
2+
*
3+
* Licensed under the Apache License, Version 2.0 (the "License") with LLVM
4+
* exceptions; you may not use this file except in compliance with the
5+
* License. You may obtain a copy of the License at
6+
*
7+
* http://llvm.org/foundation/relicensing/LICENSE.txt
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
import { runSuite } from '../src/index.js';
17+
import advancedReport from '../src/reporters/advanced.js';
18+
import simpleReport from '../src/reporters/simple.js';
19+
20+
const result = await runSuite({
21+
name: 'Empty',
22+
functions: [
23+
{
24+
name: 'A',
25+
fn() {},
26+
},
27+
{
28+
name: 'B',
29+
fn() {},
30+
},
31+
{
32+
name: 'C',
33+
fn() {},
34+
},
35+
{
36+
name: 'D',
37+
fn() {},
38+
},
39+
{
40+
name: 'E',
41+
fn() {},
42+
},
43+
],
44+
});
45+
46+
console.log('=== START SIMPLE REPORT ===');
47+
simpleReport(result);
48+
console.log('=== END SIMPLE REPORT ===');
49+
50+
console.log('');
51+
52+
console.log('=== START ADVANCED REPORT ===');
53+
advancedReport(result);
54+
console.log('=== END ADVANCED REPORT ===');

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@apeleghq/benchmark",
3-
"version": "1.0.5",
3+
"version": "1.0.6",
44
"description": "A statistically rigorous benchmarking library with paired t-tests, baseline correction, and confidence intervals",
55
"type": "module",
66
"main": "./dist/index.cjs",

src/report.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ function computeFunctionStats(
6565
p95: stats.percentile(samples, 95),
6666
marginOfError95: tCrit * se,
6767
samples,
68+
rawMean: stats.mean(rawSamples),
69+
rawMedian: stats.median(rawSamples),
70+
rawStdDev: stats.stdDev(rawSamples),
6871
rawSamples,
6972
};
7073
}

src/reporters/advanced.ts

Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,24 @@
1414
*/
1515

1616
import pc from 'picocolors';
17-
import { mean, stdDev } from '../stats.js';
1817
import type {
1918
IFunctionStatistics,
2019
IPairedComparison,
2120
ISuiteReport,
2221
} from '../types.js';
2322

23+
function getRatio(
24+
fastest: IFunctionStatistics,
25+
a: IFunctionStatistics,
26+
b: IFunctionStatistics,
27+
) {
28+
if (!(fastest.mean > 0)) {
29+
return a.rawMean / b.rawMean;
30+
}
31+
32+
return a.mean / b.mean;
33+
}
34+
2435
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2536
// Constants
2637
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -63,27 +74,27 @@ function lpad(s: string, w: number): string {
6374
function ft(ms: number): string {
6475
const a = Math.abs(ms);
6576
const sign = ms < 0 ? '−' : '';
66-
if (a === 0) return '0.00 ns';
77+
if (a === 0) return '0.000 ns';
6778
if (a < 0.000_000_000_001) return `${sign}${(a * 1e15).toFixed(3)} as`;
6879
if (a < 0.000_000_001) return `${sign}${(a * 1e12).toFixed(3)} fs`;
6980
if (a < 0.000_001) return `${sign}${(a * 1e9).toFixed(3)} ps`;
70-
if (a < 0.001) return `${sign}${(a * 1e6).toFixed(2)} ns`;
71-
if (a < 1) return `${sign}${(a * 1e3).toFixed(2)} µs`;
81+
if (a < 0.001) return `${sign}${(a * 1e6).toFixed(3)} ns`;
82+
if (a < 1) return `${sign}${(a * 1e3).toFixed(3)} µs`;
7283
if (a < 1000) return `${sign}${a.toFixed(3)} ms`;
7384
return `${sign}${(a / 1000).toFixed(3)} s`;
7485
}
7586

7687
/** Format throughput as operations per second with SI suffix. */
7788
function fops(ms: number): string {
78-
if (ms <= 0) return '∞ op/s';
89+
if (ms <= 0) return '∞';
7990
const ops = 1000 / ms;
80-
if (ops >= 1e18) return `${(ops / 1e18).toFixed(2)}E op/s`;
81-
if (ops >= 1e15) return `${(ops / 1e15).toFixed(2)}P op/s`;
82-
if (ops >= 1e12) return `${(ops / 1e12).toFixed(2)}T op/s`;
83-
if (ops >= 1e9) return `${(ops / 1e9).toFixed(2)}G op/s`;
84-
if (ops >= 1e6) return `${(ops / 1e6).toFixed(2)}M op/s`;
85-
if (ops >= 1e3) return `${(ops / 1e3).toFixed(2)}K op/s`;
86-
return `${ops.toFixed(2)} op/s`;
91+
if (ops >= 1e18) return `${(ops / 1e18).toFixed(2)}E`;
92+
if (ops >= 1e15) return `${(ops / 1e15).toFixed(2)}P`;
93+
if (ops >= 1e12) return `${(ops / 1e12).toFixed(2)}T`;
94+
if (ops >= 1e9) return `${(ops / 1e9).toFixed(2)}G`;
95+
if (ops >= 1e6) return `${(ops / 1e6).toFixed(2)}M`;
96+
if (ops >= 1e3) return `${(ops / 1e3).toFixed(2)}k`;
97+
return `${ops.toFixed(2)}`;
8798
}
8899

89100
/** Locale-formatted integer / number. */
@@ -290,6 +301,24 @@ function renderHeader(suite: Readonly<ISuiteReport>): string[] {
290301
L.push(' ' + bar('┃') + ' '.repeat(inner) + bar('┃'));
291302
L.push(' ' + bar('┗' + '━'.repeat(inner) + '┛'));
292303

304+
if (
305+
suite.functions.some(
306+
(fn) => fn.name !== suite.baselineName && !(fn.mean > 0),
307+
)
308+
) {
309+
L.push('');
310+
L.push(
311+
' ' +
312+
pc.yellow('⚠') +
313+
' ' +
314+
pc.yellow(
315+
`Raw ratios shown — some baseline-adjusted values are at or below the noise floor,`,
316+
),
317+
);
318+
L.push(' ' + pc.yellow('making adjusted ratios unreliable.'));
319+
L.push('');
320+
}
321+
293322
return L;
294323
}
295324

@@ -328,12 +357,12 @@ function renderWinner(
328357
pc.dim(`(${fpv(topComp.pValue)})`),
329358
);
330359
} else {
331-
const ratio2 = second.mean / fastest.mean;
360+
const ratio2 = getRatio(fastest, second, fastest);
332361
const parts = [pc.dim(`${fmul(ratio2)} faster than ${second.name}`)];
333362
if (fns.length > 2) {
334363
parts.push(
335364
pc.dim(
336-
`${fmul(slowest.mean / fastest.mean)} vs ${slowest.name}`,
365+
`${fmul(getRatio(fastest, slowest, fastest))} vs ${slowest.name}`,
337366
),
338367
);
339368
}
@@ -389,11 +418,11 @@ function renderLeaderboard(
389418
),
390419
);
391420

392-
const maxOps = fastest.mean > 0 ? 1 / fastest.mean : 0;
421+
const maxOps = fastest.mean > 0 ? 1 / fastest.mean : 1 / fastest.rawMean;
393422

394423
for (let i = 0; i < fns.length; i++) {
395424
const f = fns[i];
396-
const ops = f.mean > 0 ? 1 / f.mean : 0;
425+
const ops = fastest.mean > 0 ? 1 / f.mean : 1 / f.rawMean;
397426
const ratio = maxOps > 0 ? ops / maxOps : 0;
398427

399428
const medal = i < 3 ? MEDALS[i] : pc.dim(`#${i + 1}`);
@@ -413,11 +442,9 @@ function renderLeaderboard(
413442
let rel: string;
414443
if (i === 0) {
415444
rel = pc.green(' fastest');
416-
} else if (fastest.mean > 0) {
417-
const timesSlower = f.mean / fastest.mean;
418-
rel = pc.dim(` ${fmul(timesSlower)} slower`);
419445
} else {
420-
rel = '';
446+
const timesSlower = getRatio(fastest, f, fastest);
447+
rel = pc.dim(` ${fmul(timesSlower)} slower`);
421448
}
422449

423450
L.push(
@@ -576,7 +603,9 @@ function renderComparisons(
576603

577604
const aFaster = fA.mean <= fB.mean;
578605
const fasterName = aFaster ? c.a : c.b;
579-
const ratio = aFaster ? fB.mean / fA.mean : fA.mean / fB.mean;
606+
const ratio = aFaster
607+
? getRatio(fns[0], fB, fA)
608+
: getRatio(fns[0], fA, fB);
580609

581610
L.push('');
582611
L.push(' ' + pc.bold(c.a) + pc.dim(' vs ') + pc.bold(c.b));
@@ -689,7 +718,7 @@ function renderMatrix(
689718
}
690719

691720
// ratio > 1 ⇒ row is faster
692-
const ratio = colF.mean / rowF.mean;
721+
const ratio = getRatio(fns[0], colF, rowF);
693722

694723
const comp = comps.find(
695724
(cc) =>
@@ -726,8 +755,8 @@ function renderBaseline(
726755

727756
const L: string[] = [];
728757

729-
const baseLineMean = mean(baseline.rawSamples);
730-
const baselineStdDev = stdDev(baseline.rawSamples);
758+
const baseLineMean = baseline.rawMean;
759+
const baselineStdDev = baseline.rawStdDev;
731760

732761
L.push(secLine('Measurement Overhead'));
733762
L.push('');
@@ -744,33 +773,31 @@ function renderBaseline(
744773
' ' + pc.dim('All reported times have this overhead subtracted.'),
745774
);
746775

747-
if (fastest.mean > 0) {
748-
const ratio = baseLineMean / fastest.mean;
749-
if (ratio > 0.1) {
750-
L.push('');
751-
L.push(
752-
' ' +
753-
pc.yellow('⚠') +
754-
' ' +
755-
pc.yellow(
756-
`Overhead is ${(ratio * 100).toFixed(1)}% of the fastest function.`,
757-
),
758-
);
759-
L.push(
760-
' ' +
761-
pc.dim(
762-
' Consider increasing work per iteration for more accurate results.',
763-
),
764-
);
765-
} else {
766-
L.push(
767-
' ' +
768-
pc.dim(
769-
`Overhead is ${(ratio * 100).toFixed(2)}% of the fastest — `,
770-
) +
771-
pc.green('negligible'),
772-
);
773-
}
776+
const ratio = baseLineMean / fastest.rawMean;
777+
if (ratio > 0.1) {
778+
L.push('');
779+
L.push(
780+
' ' +
781+
pc.yellow('⚠') +
782+
' ' +
783+
pc.yellow(
784+
`Overhead is ${(ratio * 100).toFixed(1)}% of the fastest function.`,
785+
),
786+
);
787+
L.push(
788+
' ' +
789+
pc.dim(
790+
' Consider increasing work per iteration for more accurate results.',
791+
),
792+
);
793+
} else {
794+
L.push(
795+
' ' +
796+
pc.dim(
797+
`Overhead is ${(ratio * 100).toFixed(2)}% of the fastest — `,
798+
) +
799+
pc.green('negligible'),
800+
);
774801
}
775802

776803
return L;

src/reporters/simple.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
*/
3030

3131
import pc from 'picocolors';
32-
import { mean } from '../stats.js';
3332
import type {
3433
IFunctionStatistics,
3534
IPairedComparison,
@@ -305,8 +304,7 @@ function emitBaseline(
305304
baseline: Readonly<IFunctionStatistics>,
306305
unit: Readonly<Unit>,
307306
): void {
308-
const raw = baseline.rawSamples;
309-
const avg = mean(raw);
307+
const avg = baseline.rawMean;
310308

311309
ln(
312310
` ${pc.dim(

src/reporters/xunit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ function buildFnCase(
149149
safeXml` <property name="vendor:benchmark.wallClock_ms" value="${wallClockMs}" />`,
150150
// ── Per-trial data (JSON arrays — enables downstream re-analysis)
151151
safeXml` <property name="vendor:benchmark.samples" value="${JSON.stringify(f.samples)}" />`,
152+
safeXml` <property name="vendor:benchmark.rawMean_ms" value="${f.rawMean}" />`,
153+
safeXml` <property name="vendor:benchmark.rawMedian_ms" value="${f.rawMedian}" />`,
154+
safeXml` <property name="vendor:benchmark.rawStdDev_ms" value="${f.rawStdDev}" />`,
152155
safeXml` <property name="vendor:benchmark.rawSamples" value="${JSON.stringify(f.rawSamples)}" />`,
153156
safeXml` </properties>`,
154157
safeXml` <system-out>${fnSummaryText(f)}</system-out>`,

src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,12 @@ export interface IFunctionStatistics {
186186
*/
187187
samples: number[];
188188

189+
/** Arithmetic mean of raw per-iteration times (ms). */
190+
rawMean: number;
191+
/** Median of raw per-iteration times (ms). */
192+
rawMedian: number;
193+
/** Raw sample standard deviation (Bessel-corrected) (ms). */
194+
rawStdDev: number;
189195
/**
190196
* Raw (uncorrected) per-iteration times, one per trial (ms).
191197
* Provided so consumers can inspect or apply their own correction.

0 commit comments

Comments
 (0)