Skip to content

Commit

Permalink
Refactor the convergence logic
Browse files Browse the repository at this point in the history
  • Loading branch information
nazarhussain committed Jan 24, 2025
1 parent 0615621 commit 357df85
Show file tree
Hide file tree
Showing 8 changed files with 171 additions and 91 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"test:unit": "vitest run test/unit/**/*.test.ts",
"lint": "eslint --color src/ test/",
"prepublishOnly": "yarn build",
"benchmark": "node --loader ts-node/esm ./src/cli/cli.ts 'test/perf/**/@(!(hooks)).test.ts'",
"benchmark": "node --loader ts-node/esm ./src/cli/cli.ts 'test/perf/**/@(!(errors)).test.ts'",
"writeDocs": "node --loader ts-node/esm scripts/writeOptionsMd.ts"
},
"devDependencies": {
Expand Down
21 changes: 7 additions & 14 deletions src/benchmark/benchmarkFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {createChainable} from "@vitest/runner/utils";
import {store} from "./globalState.js";
import {BenchApi, BenchmarkOpts, BenchmarkRunOptsWithFn, PartialBy} from "../types.js";
import {runBenchFn} from "./runBenchmarkFn.js";
import {optionsDefault} from "../cli/options.js";
import {getBenchmarkOptionsWithDefaults} from "./options.js";

export const bench: BenchApi = createBenchmarkFunction(function <T, T2>(
this: Record<"skip" | "only", boolean | undefined>,
Expand All @@ -17,17 +17,7 @@ export const bench: BenchApi = createBenchmarkFunction(function <T, T2>(

const globalOptions = store.getGlobalOptions() ?? {};
const parentOptions = store.getOptions(currentSuite) ?? {};
const options = {...globalOptions, ...parentOptions, ...opts};
const {timeoutBench, maxMs, minMs} = options;

let timeout = timeoutBench ?? optionsDefault.timeoutBench;
if (maxMs && maxMs > timeout) {
timeout = maxMs * 1.5;
}

if (minMs && minMs > timeout) {
timeout = minMs * 1.5;
}
const options = getBenchmarkOptionsWithDefaults({...globalOptions, ...parentOptions, ...opts});

async function handler(): Promise<void> {
// Ensure bench id is unique
Expand All @@ -39,7 +29,10 @@ export const bench: BenchApi = createBenchmarkFunction(function <T, T2>(
const benchmarkResultsCsvDir = process.env.BENCHMARK_RESULTS_CSV_DIR;
const persistRunsNs = Boolean(benchmarkResultsCsvDir);

const {result, runsNs} = await runBenchFn({...options, fn: benchTask, before, beforeEach}, persistRunsNs);
const {result, runsNs} = await runBenchFn<T, T2>(
{...options, fn: benchTask, before, beforeEach} as BenchmarkRunOptsWithFn<T, T2>,
persistRunsNs
);

// Store result for:
// - to persist benchmark data latter
Expand All @@ -59,7 +52,7 @@ export const bench: BenchApi = createBenchmarkFunction(function <T, T2>(
only: opts.only ?? this.only,
sequential: true,
concurrent: false,
timeout,
timeout: options.timeoutBench,
meta: {
"chainsafe/benchmark": true,
},
Expand Down
38 changes: 38 additions & 0 deletions src/benchmark/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {BenchmarkOpts} from "../types.js";

export const defaultBenchmarkOptions: Required<BenchmarkOpts> = {
minRuns: 1,
maxRuns: Infinity,
minMs: 100,
maxMs: Infinity,
maxWarmUpRuns: 1000,
maxWarmUpMs: 500,
convergeFactor: 0.5 / 100, // 0.5%
runsFactor: 1,
yieldEventLoopAfterEach: false,
timeoutBench: 10_000,
noThreshold: false,
triggerGC: false,
setupFiles: [],
skip: false,
only: false,
threshold: 2,
};

export function getBenchmarkOptionsWithDefaults(opts: BenchmarkOpts): Required<BenchmarkOpts> {
const options = Object.assign({}, defaultBenchmarkOptions, opts);

if (options.noThreshold) {
options.threshold = Infinity;
}

if (options.maxMs && options.maxMs > options.timeoutBench) {
options.timeoutBench = options.maxMs * 1.5;
}

if (options.minMs && options.minMs > options.timeoutBench) {
options.timeoutBench = options.minMs * 1.5;
}

return options;
}
4 changes: 2 additions & 2 deletions src/benchmark/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {color, consoleLog, symbols} from "../utils/output.js";
import {store} from "./globalState.js";
import {Benchmark, BenchmarkOpts, BenchmarkResult} from "../types.js";
import {formatResultRow} from "./format.js";
import {optionsDefault} from "../cli/options.js";
import {defaultBenchmarkOptions} from "./options.js";

export class BenchmarkReporter {
indents = 0;
Expand All @@ -16,7 +16,7 @@ export class BenchmarkReporter {

constructor({prevBench, benchmarkOpts}: {prevBench: Benchmark | null; benchmarkOpts: BenchmarkOpts}) {
this.prevResults = new Map<string, BenchmarkResult>();
this.threshold = benchmarkOpts.threshold ?? optionsDefault.threshold;
this.threshold = benchmarkOpts.threshold ?? defaultBenchmarkOptions.threshold;

if (prevBench) {
for (const bench of prevBench.results) {
Expand Down
107 changes: 40 additions & 67 deletions src/benchmark/runBenchmarkFn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {BenchmarkResult, BenchmarkOpts} from "../types.js";
import {getBenchmarkOptionsWithDefaults} from "./options.js";
import {createConvergenceCriteria} from "./termination.js";

export type BenchmarkRunOpts = BenchmarkOpts & {
id: string;
Expand All @@ -15,46 +17,43 @@ export async function runBenchFn<T, T2>(
opts: BenchmarkRunOptsWithFn<T, T2>,
persistRunsNs?: boolean
): Promise<{result: BenchmarkResult; runsNs: bigint[]}> {
const minRuns = opts.minRuns || 1;
const maxRuns = opts.maxRuns || Infinity;
const maxMs = opts.maxMs || Infinity;
const minMs = opts.minMs || 100;
const maxWarmUpMs = opts.maxWarmUpMs !== undefined ? opts.maxWarmUpMs : 500;
const maxWarmUpRuns = opts.maxWarmUpRuns !== undefined ? opts.maxWarmUpRuns : 1000;
// Ratio of maxMs that the warmup is allow to take from ellapsedMs
const {id, before, beforeEach, fn, ...rest} = opts;
const benchOptions = getBenchmarkOptionsWithDefaults(rest);
const {maxMs, maxRuns, maxWarmUpMs, maxWarmUpRuns, runsFactor, threshold} = benchOptions;

if (maxWarmUpMs >= maxMs) {
throw new Error(`Warmup time must be lower than max run time. maxWarmUpMs: ${maxWarmUpMs}, maxMs: ${maxMs}`);
}

if (maxWarmUpRuns >= maxRuns) {
throw new Error(`Warmup runs must be lower than max runs. maxWarmUpRuns: ${maxWarmUpRuns}, maxRuns: ${maxRuns}`);
}

// Ratio of maxMs that the warmup is allow to take from elapsedMs
const maxWarmUpRatio = 0.5;
const convergeFactor = opts.convergeFactor || 0.5 / 100; // 0.5%
const runsFactor = opts.runsFactor || 1;
const maxWarmUpNs = BigInt(maxWarmUpMs) * BigInt(1e6);
const sampleEveryMs = 100;
const maxWarmUpNs = BigInt(benchOptions.maxWarmUpMs) * BigInt(1e6);

const runsNs: bigint[] = [];
const startRunMs = Date.now();

const shouldTerminate = createConvergenceCriteria(startRunMs, benchOptions);

let runIdx = 0;
let totalNs = BigInt(0);

let totalWarmUpNs = BigInt(0);
let totalWarmUpRuns = 0;
let prevAvg0 = 0;
let prevAvg1 = 0;
let lastConvergenceSample = startRunMs;
let isWarmUp = maxWarmUpNs > 0 && maxWarmUpRuns > 0;
let isWarmUpPhase = maxWarmUpNs > 0 && maxWarmUpRuns > 0;

const inputAll = opts.before ? await opts.before() : (undefined as unknown as T2);
const inputAll = before ? await before() : (undefined as unknown as T2);

while (true) {
const elapsedMs = Date.now() - startRunMs;
const mustStop = elapsedMs >= maxMs || runIdx >= maxRuns;
const mayStop = elapsedMs > minMs && runIdx > minRuns;
// Exceeds limits, must stop now
if (mustStop) {
break;
}

const input = opts.beforeEach ? await opts.beforeEach(inputAll, runIdx) : (undefined as unknown as T);
const input = beforeEach ? await beforeEach(inputAll, runIdx) : (undefined as unknown as T);

const startNs = process.hrtime.bigint();
await opts.fn(input);
await fn(input);
const endNs = process.hrtime.bigint();

const runNs = endNs - startNs;
Expand All @@ -64,55 +63,29 @@ export async function runBenchFn<T, T2>(
await new Promise((r) => setTimeout(r, 0));
}

if (isWarmUp) {
if (isWarmUpPhase) {
// Warm-up, do not count towards results
totalWarmUpRuns += 1;
totalWarmUpNs += runNs;

// On any warm-up finish condition, mark isWarmUp = true to prevent having to check them again
if (totalWarmUpNs >= maxWarmUpNs || totalWarmUpRuns >= maxWarmUpRuns || elapsedMs / maxMs >= maxWarmUpRatio) {
isWarmUp = false;
}
} else {
// Persist results
runIdx += 1;
totalNs += runNs;
// If the caller wants the exact times of all runs, persist them
if (persistRunsNs) runsNs.push(runNs);

// When is a good time to stop a benchmark? A naive answer is after N milliseconds or M runs.
// This code aims to stop the benchmark when the average fn run time has converged at a value
// within a given convergence factor. To prevent doing expensive math to often for fast fn,
// it only takes samples every `sampleEveryMs`. It stores two past values to be able to compute
// a very rough linear and quadratic convergence.
if (Date.now() - lastConvergenceSample > sampleEveryMs) {
lastConvergenceSample = Date.now();
const avg = Number(totalNs / BigInt(runIdx));

// Compute convergence (1st order + 2nd order)
const a = prevAvg0;
const b = prevAvg1;
const c = avg;

// Only do convergence math if it may stop
if (mayStop) {
// Approx linear convergence
const convergence1 = Math.abs(c - a);
// Approx quadratic convergence
const convergence2 = Math.abs(b - (a + c) / 2);
// Take the greater of both to enforce linear and quadratic are below convergeFactor
const convergence = Math.max(convergence1, convergence2) / a;

// Okay to stop + has converged, stop now
if (convergence < convergeFactor) {
break;
}
}

prevAvg0 = prevAvg1;
prevAvg1 = avg;
isWarmUpPhase = false;
}

continue;
}

// Persist results
runIdx += 1;
totalNs += runNs;

if (shouldTerminate(runIdx, totalNs)) {
break;
}

// If the caller wants the exact times of all runs, persist them
if (persistRunsNs) runsNs.push(runNs);
}

if (runIdx === 0) {
Expand All @@ -139,11 +112,11 @@ either the before(), beforeEach() or fn() functions are too slow.

return {
result: {
id: opts.id,
id: id,
averageNs,
runsDone: runIdx,
totalMs: Date.now() - startRunMs,
threshold: opts.noThreshold === true ? Infinity : opts.threshold,
threshold,
},
runsNs,
};
Expand Down
63 changes: 63 additions & 0 deletions src/benchmark/termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {BenchmarkOpts} from "../types.js";

export type TerminationCriteria = (runIdx: number, totalNs: bigint) => boolean;

export function createConvergenceCriteria(
startMs: number,
{maxMs, maxRuns, minRuns, minMs, convergeFactor}: Required<BenchmarkOpts>
): TerminationCriteria {
let prevAvg0 = 0;
let prevAvg1 = 0;
let lastConvergenceSample = startMs;
const sampleEveryMs = 100;

return function canTerminate(runIdx: number, totalNs: bigint): boolean {
const currentMs = Date.now();
const elapsedMs = currentMs - startMs;
const mustStop = elapsedMs >= maxMs || runIdx >= maxRuns;
const mayStop = elapsedMs >= minMs && runIdx >= minRuns;

// Must stop
if (mustStop) return true;

// When is a good time to stop a benchmark? A naive answer is after N milliseconds or M runs.
// This code aims to stop the benchmark when the average fn run time has converged at a value
// within a given convergence factor. To prevent doing expensive math to often for fast fn,
// it only takes samples every `sampleEveryMs`. It stores two past values to be able to compute
// a very rough linear and quadratic convergence.a
if (currentMs - lastConvergenceSample <= sampleEveryMs) return false;

lastConvergenceSample = currentMs;
const avg = Number(totalNs / BigInt(runIdx));

// Compute convergence (1st order + 2nd order)
const a = prevAvg0;
const b = prevAvg1;
const c = avg;

if (mayStop) {
// Approx linear convergence
const convergence1 = Math.abs(c - a);
// Approx quadratic convergence
const convergence2 = Math.abs(b - (a + c) / 2);
// Take the greater of both to enforce linear and quadratic are below convergeFactor
const convergence = Math.max(convergence1, convergence2) / a;

// Okay to stop + has converged, stop now
if (convergence < convergeFactor) return true;
}

prevAvg0 = prevAvg1;
prevAvg1 = avg;
return false;
};
}

// test/perf/iteration.test.ts
// Array iteration
// ✔ sum array with raw for loop 1573.007 ops/s 635.7250 us/op - 4765 runs 3.53 s
// ✔ sum array with reduce 176.6890 ops/s 5.659663 ms/op - 271 runs 2.04 s
// ✔ sum array with reduce beforeEach 214638.3 ops/s 4.659000 us/op - 102478 runs 25.6 s
// ✔ sum array with reduce before beforeEach 269251.5 ops/s 3.714000 us/op - 997136 runs 5.66 s
// ✔ sum array with reduce high threshold 176.4852 ops/s 5.666196 ms/op - 109 runs 1.12 s
// ✔ sum array with reduce no threshold 177.5273 ops/s 5.632938 ms/op - 73 runs 0.915 s
Loading

0 comments on commit 357df85

Please sign in to comment.