Skip to content

Commit 1003f6b

Browse files
authored
fix: time units are not normalized (issue #122) (#318)
This PR fixes an issue when the new benchmark results are using a different time unit than the previous one. This happens specifically when the benchmark values are very close to the boundary between 2 units, ex. `990 ns/iter` and `1.1 us/iter`. In that case, we need to normalize the newly coming results to the previously used unit so that the values are comparable. * extract addBenchmarkEntry function * normalize new entry values to match last entry units * handle `<time>`, `<time>/iter` and `ops/<time>` units
1 parent 689894f commit 1003f6b

11 files changed

+607
-63
lines changed

src/addBenchmarkEntry.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Benchmark } from './extract';
2+
import * as core from '@actions/core';
3+
import { BenchmarkSuites } from './write';
4+
import { normalizeBenchmark } from './normalizeBenchmark';
5+
6+
export function addBenchmarkEntry(
7+
benchName: string,
8+
benchEntry: Benchmark,
9+
entries: BenchmarkSuites,
10+
maxItems: number | null,
11+
): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
12+
let prevBench: Benchmark | null = null;
13+
let normalizedCurrentBench: Benchmark = benchEntry;
14+
15+
// Add benchmark result
16+
if (entries[benchName] === undefined) {
17+
entries[benchName] = [benchEntry];
18+
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
19+
} else {
20+
const suites = entries[benchName];
21+
// Get the last suite which has different commit ID for alert comment
22+
for (const e of [...suites].reverse()) {
23+
if (e.commit.id !== benchEntry.commit.id) {
24+
prevBench = e;
25+
break;
26+
}
27+
}
28+
29+
normalizedCurrentBench = normalizeBenchmark(prevBench, benchEntry);
30+
31+
suites.push(normalizedCurrentBench);
32+
33+
if (maxItems !== null && suites.length > maxItems) {
34+
suites.splice(0, suites.length - maxItems);
35+
core.debug(
36+
`Number of data items for '${benchName}' was truncated to ${maxItems} due to max-items-in-charts`,
37+
);
38+
}
39+
}
40+
return { prevBench, normalizedCurrentBench };
41+
}

src/canonicalizeUnit.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function canonicalizeUnit(u: string): string {
2+
return u.trim().toLowerCase().replace(/µs|μs/g, 'us');
3+
}

src/extractRangeInfo.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export function extractRangeInfo(range: string | undefined): { prefix: string; value: number } | undefined {
2+
if (!range) {
3+
return undefined;
4+
}
5+
6+
const matches = range.match(/(?<prefix>(\+-|±)\s*)(?<value>\d.*)/);
7+
8+
if (!matches || !matches.groups) {
9+
return undefined;
10+
}
11+
12+
const valueString = matches.groups.value;
13+
14+
const value = Number(valueString);
15+
16+
if (isNaN(value)) {
17+
return undefined;
18+
}
19+
20+
return {
21+
value,
22+
prefix: matches.groups.prefix ?? '',
23+
};
24+
}

src/normalizeBenchmark.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Benchmark } from './extract';
2+
import { normalizeBenchmarkResult } from './normalizeBenchmarkResult';
3+
4+
export function normalizeBenchmark(prevBench: Benchmark | null, currentBench: Benchmark): Benchmark {
5+
if (!prevBench) {
6+
return currentBench;
7+
}
8+
9+
return {
10+
...currentBench,
11+
benches: currentBench.benches
12+
.map((currentBenchResult) => ({
13+
currentBenchResult,
14+
prevBenchResult: prevBench.benches.find((result) => result.name === currentBenchResult.name),
15+
}))
16+
.map(({ currentBenchResult, prevBenchResult }) =>
17+
normalizeBenchmarkResult(prevBenchResult, currentBenchResult),
18+
),
19+
};
20+
}

src/normalizeBenchmarkResult.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { BenchmarkResult } from './extract';
2+
import { normalizeValueByUnit } from './normalizeValueByUnit';
3+
import { extractRangeInfo } from './extractRangeInfo';
4+
5+
export function normalizeBenchmarkResult(
6+
prevBenchResult: BenchmarkResult | undefined | null,
7+
currentBenchResult: BenchmarkResult,
8+
): BenchmarkResult {
9+
if (!prevBenchResult) {
10+
return currentBenchResult;
11+
}
12+
13+
const prevUnit = prevBenchResult.unit;
14+
const currentUnit = currentBenchResult.unit;
15+
const currentRange = currentBenchResult.range;
16+
const currentRangeInfo = extractRangeInfo(currentRange);
17+
18+
const normalizedValue = normalizeValueByUnit(prevUnit, currentUnit, currentBenchResult.value);
19+
const normalizedUnit = currentBenchResult.value !== normalizedValue ? prevUnit : currentUnit;
20+
const normalizedRangeInfo = currentRangeInfo
21+
? {
22+
prefix: currentRangeInfo.prefix,
23+
value: normalizeValueByUnit(prevUnit, currentUnit, currentRangeInfo.value),
24+
}
25+
: undefined;
26+
27+
return {
28+
...currentBenchResult,
29+
value: normalizedValue,
30+
unit: normalizedUnit,
31+
range: normalizedRangeInfo ? `${normalizedRangeInfo.prefix}${normalizedRangeInfo.value}` : currentRange,
32+
};
33+
}

src/normalizeValueByUnit.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { canonicalizeUnit } from './canonicalizeUnit';
2+
3+
export function normalizeValueByUnit(prevUnit: string, currentUnit: string, value: number): number {
4+
const prev = canonicalizeUnit(prevUnit);
5+
const current = canonicalizeUnit(currentUnit);
6+
for (const units of SUPPORTED_UNITS) {
7+
const prevUnitIndex = units.indexOf(prev);
8+
const currentUnitIndex = units.indexOf(current);
9+
10+
if (prevUnitIndex >= 0 && currentUnitIndex >= 0) {
11+
const unitDiff = prevUnitIndex - currentUnitIndex;
12+
13+
return value * UNIT_CONVERSION_MULTIPLIER ** unitDiff;
14+
}
15+
}
16+
17+
return value;
18+
}
19+
20+
const UNIT_CONVERSION_MULTIPLIER = 1000;
21+
const TIME_UNITS = ['s', 'ms', 'us', 'ns'];
22+
const ITER_UNITS = TIME_UNITS.map((unit) => `${unit}/iter`);
23+
const OPS_PER_TIME_UNIT = [...TIME_UNITS].reverse().map((unit) => `ops/${unit}`);
24+
const SUPPORTED_UNITS = [TIME_UNITS, ITER_UNITS, OPS_PER_TIME_UNIT];

src/write.ts

Lines changed: 19 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Config, ToolType } from './config';
99
import { DEFAULT_INDEX_HTML } from './default_index_html';
1010
import { leavePRComment } from './comment/leavePRComment';
1111
import { leaveCommitComment } from './comment/leaveCommitComment';
12+
import { addBenchmarkEntry } from './addBenchmarkEntry';
1213

1314
export type BenchmarkSuites = { [name: string]: Benchmark[] };
1415
export interface DataJson {
@@ -321,39 +322,16 @@ function addBenchmarkToDataJson(
321322
bench: Benchmark,
322323
data: DataJson,
323324
maxItems: number | null,
324-
): Benchmark | null {
325+
): { prevBench: Benchmark | null; normalizedCurrentBench: Benchmark } {
325326
const repoMetadata = getCurrentRepoMetadata();
326327
const htmlUrl = repoMetadata.html_url ?? '';
327328

328-
let prevBench: Benchmark | null = null;
329329
data.lastUpdate = Date.now();
330330
data.repoUrl = htmlUrl;
331331

332-
// Add benchmark result
333-
if (data.entries[benchName] === undefined) {
334-
data.entries[benchName] = [bench];
335-
core.debug(`No suite was found for benchmark '${benchName}' in existing data. Created`);
336-
} else {
337-
const suites = data.entries[benchName];
338-
// Get last suite which has different commit ID for alert comment
339-
for (const e of suites.slice().reverse()) {
340-
if (e.commit.id !== bench.commit.id) {
341-
prevBench = e;
342-
break;
343-
}
344-
}
332+
const { prevBench, normalizedCurrentBench } = addBenchmarkEntry(benchName, bench, data.entries, maxItems);
345333

346-
suites.push(bench);
347-
348-
if (maxItems !== null && suites.length > maxItems) {
349-
suites.splice(0, suites.length - maxItems);
350-
core.debug(
351-
`Number of data items for '${benchName}' was truncated to ${maxItems} due to max-items-in-charts`,
352-
);
353-
}
354-
}
355-
356-
return prevBench;
334+
return { prevBench, normalizedCurrentBench };
357335
}
358336

359337
function isRemoteRejectedError(err: unknown): err is Error {
@@ -367,7 +345,7 @@ async function writeBenchmarkToGitHubPagesWithRetry(
367345
bench: Benchmark,
368346
config: Config,
369347
retry: number,
370-
): Promise<Benchmark | null> {
348+
): Promise<{ prevBench: Benchmark | null; normalizedCurrentBench: Benchmark }> {
371349
const {
372350
name,
373351
tool,
@@ -421,7 +399,7 @@ async function writeBenchmarkToGitHubPagesWithRetry(
421399
await io.mkdirP(benchmarkDataDirFullPath);
422400

423401
const data = await loadDataJs(dataPath);
424-
const prevBench = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
402+
const { prevBench, normalizedCurrentBench } = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
425403

426404
await storeDataJs(dataPath, data);
427405

@@ -469,10 +447,13 @@ async function writeBenchmarkToGitHubPagesWithRetry(
469447
);
470448
}
471449

472-
return prevBench;
450+
return { prevBench, normalizedCurrentBench };
473451
}
474452

475-
async function writeBenchmarkToGitHubPages(bench: Benchmark, config: Config): Promise<Benchmark | null> {
453+
async function writeBenchmarkToGitHubPages(
454+
bench: Benchmark,
455+
config: Config,
456+
): Promise<{ prevBench: Benchmark | null; normalizedCurrentBench: Benchmark }> {
476457
const { ghPagesBranch, skipFetchGhPages, ghRepository, githubToken } = config;
477458
if (!ghRepository) {
478459
if (!skipFetchGhPages) {
@@ -508,14 +489,14 @@ async function writeBenchmarkToExternalJson(
508489
bench: Benchmark,
509490
jsonFilePath: string,
510491
config: Config,
511-
): Promise<Benchmark | null> {
492+
): Promise<{ prevBench: Benchmark | null; normalizedCurrentBench: Benchmark }> {
512493
const { name, maxItemsInChart, saveDataFile } = config;
513494
const data = await loadDataJson(jsonFilePath);
514-
const prevBench = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
495+
const { prevBench, normalizedCurrentBench } = addBenchmarkToDataJson(name, bench, data, maxItemsInChart);
515496

516497
if (!saveDataFile) {
517498
core.debug('Skipping storing benchmarks in external data file');
518-
return prevBench;
499+
return { prevBench, normalizedCurrentBench };
519500
}
520501

521502
try {
@@ -526,12 +507,12 @@ async function writeBenchmarkToExternalJson(
526507
throw new Error(`Could not store benchmark data as JSON at ${jsonFilePath}: ${err}`);
527508
}
528509

529-
return prevBench;
510+
return { prevBench, normalizedCurrentBench };
530511
}
531512

532513
export async function writeBenchmark(bench: Benchmark, config: Config) {
533514
const { name, externalDataJsonPath } = config;
534-
const prevBench = externalDataJsonPath
515+
const { prevBench, normalizedCurrentBench } = externalDataJsonPath
535516
? await writeBenchmarkToExternalJson(bench, externalDataJsonPath, config)
536517
: await writeBenchmarkToGitHubPages(bench, config);
537518

@@ -540,9 +521,9 @@ export async function writeBenchmark(bench: Benchmark, config: Config) {
540521
if (prevBench === null) {
541522
core.debug('Alert check was skipped because previous benchmark result was not found');
542523
} else {
543-
await handleComment(name, bench, prevBench, config);
544-
await handleSummary(name, bench, prevBench, config);
545-
await handleAlert(name, bench, prevBench, config);
524+
await handleComment(name, normalizedCurrentBench, prevBench, config);
525+
await handleSummary(name, normalizedCurrentBench, prevBench, config);
526+
await handleAlert(name, normalizedCurrentBench, prevBench, config);
546527
}
547528
}
548529

test/extractRangeInfo.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { extractRangeInfo } from '../src/extractRangeInfo';
2+
3+
describe('extractRangeInfo', () => {
4+
it("should extract range with '±'", () => {
5+
expect(extractRangeInfo('±20')).toEqual({ value: 20, prefix: '±' });
6+
});
7+
8+
it("should extract range with '± '", () => {
9+
expect(extractRangeInfo('± 20')).toEqual({ value: 20, prefix: '± ' });
10+
});
11+
12+
it("should extract range with '+-'", () => {
13+
expect(extractRangeInfo('+-20')).toEqual({ value: 20, prefix: '+-' });
14+
});
15+
16+
it("should extract range with '+- '", () => {
17+
expect(extractRangeInfo('+- 20')).toEqual({ value: 20, prefix: '+- ' });
18+
});
19+
20+
it('should extract single-digit integer', () => {
21+
expect(extractRangeInfo('±2')).toEqual({ value: 2, prefix: '±' });
22+
});
23+
it('should extract decimal and preserve prefix space', () => {
24+
expect(extractRangeInfo('± 0.5')).toEqual({ value: 0.5, prefix: '± ' });
25+
});
26+
it('should extract scientific notation', () => {
27+
expect(extractRangeInfo('+-1e-3')).toEqual({ value: 1e-3, prefix: '+-' });
28+
});
29+
it('should tolerate surrounding whitespace', () => {
30+
expect(extractRangeInfo(' ±20 ')).toEqual({ value: 20, prefix: '±' });
31+
});
32+
33+
it('should NOT extract range with unknown prefix', () => {
34+
expect(extractRangeInfo('unknown prefix 20')).toBeUndefined();
35+
});
36+
37+
it('should NOT extract range with invalid number', () => {
38+
expect(extractRangeInfo('± boo')).toBeUndefined();
39+
expect(extractRangeInfo('+- boo')).toBeUndefined();
40+
expect(extractRangeInfo('± 1boo')).toBeUndefined();
41+
expect(extractRangeInfo('+- 1boo')).toBeUndefined();
42+
});
43+
});

0 commit comments

Comments
 (0)