o1js
Version:
TypeScript framework for zk-SNARKs and zkApps
473 lines (421 loc) • 14.4 kB
text/typescript
/**
* Regression testing framework for individual ZkProgram examples.
*
* Stores and compares metadata such as compile, proving, and verifying times.
* Can run in two modes:
* - **Dump**: write baseline results into
* {@link tests/perf-regression/perf-regression.json}
* - **Check**: validate current results against the stored baselines
*
* For regression testing of constraint systems (CS) and zkApps,
* see {@link tests/perf-regression/perf-regression.ts}.
*
* @note
* Command-line arguments:
* - `--dump` (alias `-d`): dump performance data into the baseline file.
* - `--check` (alias `-c`): check performance against the existing baseline.
* - `--file` (alias `-f`): specify a custom JSON path (default: `./tests/perf-regression/perf-regression.json`).
* - `--silent`: suppress all console output.
*
* These flags are mutually exclusive for modes (`--dump` and `--check` cannot be used together).
* When neither is provided, the script runs in log-only mode.
*/
import fs from 'fs';
import minimist from 'minimist';
import path from 'path';
import { ConstraintSystemSummary } from '../provable/core/provable-context.js';
export { PerfRegressionEntry, Performance, logPerf };
type MethodsInfo = Record<
string,
{
rows: number;
digest: string;
proveTime?: number;
verifyTime?: number;
}
>;
type PerfRegressionEntry = {
digest?: string;
compileTime?: number;
methods: MethodsInfo;
};
type PerfStack = {
start: number;
label?: 'compile' | 'prove' | 'verify' | string;
programName?: string;
methodsSummary?: Record<string, ConstraintSystemSummary>;
methodName?: string; // required for prove/verify; optional for compile
};
const argv = minimist(process.argv.slice(2), {
boolean: ['dump', 'check', 'silent'],
string: ['file'],
alias: {
f: 'file',
d: 'dump',
c: 'check',
},
});
const DUMP = Boolean(argv.dump);
const CHECK = Boolean(argv.check);
const SILENT = Boolean(argv.silent);
// Cannot use both dump and check
if (DUMP && CHECK) {
console.error('Error: You cannot use both --dump and --check at the same time!');
process.exit(1);
}
const FILE_PATH = path.isAbsolute(argv.file ?? '')
? (argv.file as string)
: path.join(
process.cwd(),
argv.file ? (argv.file as string) : './tests/perf-regression/perf-regression.json'
);
// Create directory & file if missing (only on dump)
if (DUMP) {
const dir = path.dirname(FILE_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
if (!fs.existsSync(FILE_PATH)) fs.writeFileSync(FILE_PATH, '{}', 'utf8');
}
/**
* Create a new performance tracking session for a program.
*
* @param programName Name of the program (key in perf-regression.json)
* @param methodsSummary Optional methods analysis (required for prove/verify checks)
* @param log Optional boolean (default: true). If `--silent` is passed via CLI,
* it overrides this and disables all logs.
* @returns An object with `start()` and `end()` methods
*/
function createPerformanceSession(
programName?: string,
methodsSummary?: Record<string, ConstraintSystemSummary>,
log = true
) {
const perfStack: PerfStack[] = [];
const shouldLog = SILENT ? false : log;
return {
/**
* Start measuring performance for a given phase.
*
* @param label The phase label: `'compile' | 'prove' | 'verify' | string`
* @param methodName Method name (required for `prove` and `verify`)
*/
start(label?: 'compile' | 'prove' | 'verify' | string, methodName?: string) {
perfStack.push({
label,
start: performance.now(),
programName,
methodsSummary,
methodName,
});
},
/**
* End the most recent measurement and:
* - Logs results to the console by default. This can be disabled by setting `log` to `false`
* when creating the session, or by passing the `--silent` flag when running the file.
* - Dump into baseline JSON (if `--dump`)
* - Check against baseline (if `--check`)
*/
end() {
const frame = perfStack.pop()!;
const { label, start, programName } = frame;
let { methodsSummary: cs, methodName } = frame;
const time = (performance.now() - start) / 1000;
// Base logging — only if log is enabled
// — shows contract.method for prove/verify
if (shouldLog && label) {
console.log(
`${label} ${programName ?? ''}${
(label === 'prove' || label === 'verify') && methodName ? '.' + methodName : ''
}... ${time.toFixed(3)} sec`
);
}
// If neither --dump nor --check, we’re done.
if (!DUMP && !CHECK) return;
// Only act for compile/prove/verify with required context
if (!programName || (label !== 'compile' && label !== 'prove' && label !== 'verify')) return;
// Load the baseline JSON used for both DUMP and CHECK modes.
const raw = fs.readFileSync(FILE_PATH, 'utf8');
const perfRegressionJson: Record<string, PerfRegressionEntry> = JSON.parse(raw);
// --- compile ---
if (label === 'compile') {
if (DUMP) {
dumpCompile(perfRegressionJson, programName, time);
return;
}
if (CHECK) {
checkCompile(perfRegressionJson, programName, time);
return;
}
}
// --- prove / verify (shared validation + separate actions) ---
if (label === 'prove' || label === 'verify') {
const info = validateMethodContext(label, cs, methodName, programName);
if (DUMP) {
if (label === 'prove') {
dumpMethodTime(perfRegressionJson, programName, methodName!, info, time, 'prove');
} else {
dumpMethodTime(perfRegressionJson, programName, methodName!, info, time, 'verify');
}
return;
}
if (CHECK) {
if (label === 'prove') {
checkMethodTime(
perfRegressionJson,
programName,
methodName!,
info.digest,
time,
'prove'
);
} else {
checkMethodTime(
perfRegressionJson,
programName,
methodName!,
info.digest,
time,
'verify'
);
}
return;
}
}
},
};
}
const Performance = {
/**
* Initialize a new performance session.
*
* @param programName Optional identifier for the program or label.
* - With a ZkProgram name and its `methodsSummary`, the session benchmarks
* compile, prove, and verify phases, storing or checking results against
* `perf-regression.json`.
* - Without a ZkProgram, `programName` acts as a freeform label and the session
* can be used like `console.time` / `console.timeEnd` to log timestamps.
* @param methodsSummary Optional analysis of ZkProgram methods, required when
* measuring prove/verify performance.
* @param log Optional boolean flag (default: `true`).
* - When set to `false`, disables all console output for both general labels
* and compile/prove/verify phase logs.
* - When the `--silent` flag is provided, it overrides this setting and disables
* all logging regardless of the `log` value.
*/
create(
programName?: string,
methodsSummary?: Record<string, ConstraintSystemSummary>,
log?: boolean
) {
return createPerformanceSession(programName, methodsSummary, log);
},
};
/// HELPERS (dump/check)
function dumpCompile(
perfRegressionJson: Record<string, PerfRegressionEntry>,
programName: string,
time: number
) {
const prev = perfRegressionJson[programName];
const merged: PerfRegressionEntry = prev
? { ...prev, compileTime: time }
: { compileTime: time, methods: {} };
perfRegressionJson[programName] = merged;
fs.writeFileSync(FILE_PATH, JSON.stringify(perfRegressionJson, null, 2));
}
function dumpMethodTime(
perfRegressionJson: Record<string, PerfRegressionEntry>,
programName: string,
methodName: string,
info: ConstraintSystemSummary,
time: number,
label: 'prove' | 'verify'
) {
const prev = perfRegressionJson[programName];
const merged: PerfRegressionEntry = prev
? { ...prev, methods: { ...prev.methods } }
: { methods: {} };
const prevMethod = merged.methods[methodName] ?? {};
merged.methods[methodName] = {
rows: info.rows,
digest: info.digest,
proveTime: label === 'prove' ? time : prevMethod.proveTime,
verifyTime: label === 'verify' ? time : prevMethod.verifyTime,
};
perfRegressionJson[programName] = merged;
fs.writeFileSync(FILE_PATH, JSON.stringify(perfRegressionJson, null, 2));
}
function checkCompile(
perfRegressionJson: Record<string, PerfRegressionEntry>,
programName: string,
actualTime: number
) {
checkAgainstBaseline({
perfRegressionJson,
programName,
label: 'compile',
methodName: '',
digest: '',
actualTime,
});
}
function checkMethodTime(
perfRegressionJson: Record<string, PerfRegressionEntry>,
programName: string,
methodName: string,
digest: string,
actualTime: number,
label: 'prove' | 'verify'
) {
checkAgainstBaseline({
perfRegressionJson,
programName,
label,
methodName,
digest,
actualTime,
});
}
// -------------------------
// HELPERS (validation + baselines)
// -------------------------
function validateMethodContext(
label: string,
cs: Record<string, ConstraintSystemSummary> | undefined,
methodName: string | undefined,
programName?: string
): ConstraintSystemSummary {
if (!cs || typeof cs !== 'object') {
throw new Error(
`methodsSummary is required for this label: ${label}. Pass it to Performance.create(programName, methodsSummary).`
);
}
if (!methodName) {
throw new Error(`Please provide the method name (start(${label}, methodName)).`);
}
const info = cs[methodName];
if (!info) {
const available = Object.keys(cs);
throw new Error(
`The method "${methodName}" does not exist in the analyzed constraint system for "${programName}". ` +
`Available: ${available.length ? available.join(', ') : '(none)'}`
);
}
return info;
}
/**
* Compare a measured time/digest against stored baselines.
* Throws an error if regression exceeds tolerance.
*/
function checkAgainstBaseline(params: {
perfRegressionJson: Record<string, PerfRegressionEntry>;
programName: string;
label: 'compile' | 'prove' | 'verify';
methodName: string;
digest: string;
actualTime: number;
}) {
const { perfRegressionJson, programName, label, methodName, digest, actualTime } = params;
const baseline = perfRegressionJson[programName];
if (!baseline) {
throw new Error(`No baseline for "${programName}". Seed it with --dump first.`);
}
// tolerances
const compileTol = 1.08; // 8%
const compileTiny = 1.1; // 10% for near-zero baselines (< 5e-5s)
const timeTolDefault = 1.1; // 10% for prove/verify
const timeTolSmall = 1.25; // 25% for very small times (<0.2s)
const labelPretty = label[0].toUpperCase() + label.slice(1);
if (label === 'compile') {
const expected = baseline.compileTime;
if (expected == null) {
throw new Error(
`No baseline compileTime for "${programName}". Run --dump (compile) to set it.`
);
}
const tol = expected < 5e-5 ? compileTiny : compileTol;
const allowedPct = (tol - 1) * 100;
const regressionPct = expected === 0 ? 0 : ((actualTime - expected) / expected) * 100;
const failed = actualTime > expected * tol;
// colorized perf log
logPerf(programName, label, expected, actualTime, regressionPct, allowedPct, failed);
if (failed) {
throw new Error(
`Compile regression for ${programName}\n` +
` Actual: ${actualTime.toFixed(6)}s\n` +
` Baseline: ${expected.toFixed(6)}s\n` +
` Regression: +${Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'}% (allowed +${allowedPct.toFixed(0)}%)`
);
}
return;
}
// prove/verify checks
const baseMethod = baseline.methods?.[methodName];
if (!baseMethod) {
throw new Error(
`No baseline method entry for ${programName}.${methodName}. Run --dump (${label}) to add it.`
);
}
if (baseMethod.digest !== digest) {
throw new Error(
`Digest mismatch for ${programName}.${methodName}\n` +
` Actual: ${digest}\n` +
` Expected: ${baseMethod.digest}\n`
);
}
const expected = label === 'prove' ? baseMethod.proveTime : baseMethod.verifyTime;
if (expected == null) {
throw new Error(
`No baseline ${label}Time for ${programName}.${methodName}. Run --dump (${label}) to set it.`
);
}
const tol = expected < 0.2 ? timeTolSmall : timeTolDefault;
const allowedPct = (tol - 1) * 100;
const regressionPct = expected === 0 ? 0 : ((actualTime - expected) / expected) * 100;
const failed = actualTime > expected * tol;
logPerf(
`${programName}.${methodName}`,
label,
expected,
actualTime,
regressionPct,
allowedPct,
failed
);
if (failed) {
throw new Error(
`${labelPretty} regression for ${programName}.${methodName}\n` +
` Actual: ${actualTime.toFixed(3)}s\n` +
` Baseline: ${expected.toFixed(3)}s\n` +
` Regression: +${Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'}% (allowed +${allowedPct.toFixed(0)}%)`
);
}
}
function logPerf(
scope: string,
label: string,
expected: number,
actual: number,
regressionPct: number,
allowedPct: number,
failed: boolean
) {
const COLORS = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
cyan: '\x1b[36m',
};
let color: string;
if (failed) color = COLORS.red;
else if (regressionPct > 0) color = COLORS.yellow;
else color = COLORS.green;
console.log(
`${COLORS.cyan}[Perf][${scope}]${COLORS.reset} ${label}: ` +
`baseline=${expected.toFixed(6)}s, actual=${actual.toFixed(6)}s, ` +
`${color}regression=${regressionPct >= 0 ? '+' : ''}${
Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'
}%${COLORS.reset} ` +
`(allowed +${allowedPct.toFixed(0)}%)`
);
}