Add new benchmarks to the CI pipeline. Guides through creating benchmark JSON files, integrating with bootstrap.sh, and ensuring proper CI upload via ci3.yml workflow.
Use this skill when:
Benchmarks flow through the system as follows:
bench-out/*.bench.json filesbench_merge in root bootstrap.sh combines all files, prefixing names with the package pathLive dashboard: https://aztecprotocol.github.io/benchmark-page-data/bench/?branch=next
The final benchmark name combines two parts:
bench_merge): Based on where the file livesThe dashboard splits names by / to create a collapsible tree. The last segment becomes the chart name, everything before it becomes the group hierarchy.
| Full Name | Group Path | Chart Name |
|---|---|---|
yarn-project/stdlib/Tx/private/getTxHash/avg |
yarn-project/stdlib/Tx/private/getTxHash |
avg |
yarn-project/kv-store/Map/Individual insertion |
yarn-project/kv-store/Map |
Individual insertion |
barretenberg/sol/Add2HonkVerifier |
barretenberg/sol |
Add2HonkVerifier |
Use / to create logical groupings:
[
{"name": "Tx/private/getTxHash/avg", "value": 1.2, "unit": "ms"},
{"name": "Tx/private/getTxHash/p50", "value": 1.1, "unit": "ms"},
{"name": "Tx/public/getTxHash/avg", "value": 2.3, "unit": "ms"}
]
Avoid flat names - they create no hierarchy and are hard to navigate:
[
{"name": "tx_private_gettxhash_avg", "value": 1.2, "unit": "ms"}
]
Common suffixes:
avg, p50, p95, p99, min, max, total_opcodes, _gates, memorygasPerSecond, jobs_per_secAll benchmark files must be arrays using the customSmallerIsBetter format:
[
{"name": "category/metric_name", "value": 12345, "unit": "gas"},
{"name": "category/another_metric", "value": 100.5, "unit": "ms"}
]
Rules:
[...], not an objectname, value, unitvalue must be numeric (lower is better).bench.jsonOptional fields (preserved by benchmark-action):
range (string): Variance info (e.g., "ยฑ 5%")extra (string): Metadata โ used for stacked chart grouping (see below)To render multiple metrics as a single stacked area chart (e.g., component breakdowns), add an extra field with a stacked:GROUP_NAME value. Entries sharing the same GROUP_NAME are overlaid on one chart.
[
{"name": "proving/cpus-8/total_ms", "value": 31663, "unit": "ms"},
{"name": "proving/cpus-8/oink_prove_ms", "value": 4992, "unit": "ms", "extra": "stacked:proving/cpus-8/components"},
{"name": "proving/cpus-8/sumcheck_ms", "value": 3318, "unit": "ms", "extra": "stacked:proving/cpus-8/components"},
{"name": "proving/cpus-8/circuit_ms", "value": 4642, "unit": "ms", "extra": "stacked:proving/cpus-8/components"}
]
How it works:
extra: "stacked:GROUP_NAME" โ entries with the same GROUP_NAME are rendered as one stacked chartextra field โ individual line chart (default behavior)bench_merge prefixing, same as name)extra field is one of the 5 fields preserved by the benchmark-action Zod schema (name, value, unit, range, extra); any other custom fields will be strippedWhen to use stacked charts:
TypeScript (most common):
// my_bench.test.ts
import { Timer } from '@aztec/foundation/timer';
import { writeFile, mkdir } from 'fs/promises';
describe('MyComponent benchmarks', () => {
const results: { name: string; value: number; unit: string }[] = [];
afterAll(async () => {
if (process.env.BENCH_OUTPUT) {
await mkdir(path.dirname(process.env.BENCH_OUTPUT), { recursive: true });
await writeFile(process.env.BENCH_OUTPUT, JSON.stringify(results));
}
});
it('benchmark operation', async () => {
const timer = new Timer();
// ... operation to benchmark ...
results.push({ name: 'MyComponent/operation/avg', value: timer.ms(), unit: 'ms' });
});
});
Shell (jq-based):
mkdir -p bench-out
jq -n '[
{name: "metric1", value: '$VALUE1', unit: "ms"},
{name: "metric2", value: '$VALUE2', unit: "gas"}
]' > bench-out/my-component.bench.json
Python:
import json
benchmark_list = [{"name": "category/metric", "value": 12345, "unit": "gas"}]
with open("bench-out/my-component.bench.json", "w") as f:
json.dump(benchmark_list, f)
Add to the package's bench_cmds function:
function bench_cmds {
local hash=$(hash)
echo "$hash BENCH_OUTPUT=bench-out/my_component.bench.json yarn-project/scripts/run_test.sh <package>/src/my_bench.test.ts"
}
Options: :ISOLATE=1, :CPUS=8, :MEM=16g, :TIMEOUT=7200
CPUS Suggestion: For long running or compute-heavy benchmarks allocate CPUs (:CPUS=N). Benchmarks have strict scheduling, so if you request X CPUs, you'll have them available for consistent results.
ISOLATE Suggestion: Use :ISOLATE=1 when your benchmark needs a clean, isolated environment with no network access and pinned resources. This runs the test in a Docker container, ensuring reproducible results without interference from other processes.
MEM Suggestion: Use :MEM=Xg (e.g., :MEM=16g) for memory-intensive benchmarks that may exceed the default allocation (CPUS ร 4GB). Pair with :ISOLATE=1 since memory limits are enforced via Docker.
TIMEOUT Suggestion: Use :TIMEOUT=N (in seconds) for benchmarks that take longer than the default timeout. For example, :TIMEOUT=1800 for 30 minutes, :TIMEOUT=7200 for 2 hours.
Important naming gotcha: Benchmark test files must use .bench.test.ts (with a dot before bench), NOT _bench.test.ts. The test discovery pattern [[ "$test" =~ \.bench\.test\.ts$ ]] specifically looks for .bench.test.ts.
# Run locally
BENCH_OUTPUT=bench-out/test.bench.json yarn test src/my_bench.test.ts
# Validate JSON
jq . bench-out/test.bench.json
jq 'all(has("name") and has("value") and has("unit"))' bench-out/test.bench.json
Benchmarks upload when:
ci-merge-queue, ci-full, or ci-full-no-test-cache (publishes to target branch, i.e. next or a merge-train branch)next)10-commit visibility window: The dashboard only shows benchmarks that ran in the last 10 commits. If a benchmark stops running, it disappears after ~10 merges.
yarn-project/stdlib/src/tx/tx_bench.test.tsl1-contracts/scripts/generate_benchmark_json.pyyarn-project/p2p/testbench/consolidate_benchmarks.shnoir-projects/fnd/noir-protocol-circuits/scripts/run_bench.sh