feat(bench): measure memory over time via /proc sampling - #8468
Conversation
|
There was a problem hiding this comment.
Pull request overview
This PR updates Hardhat’s benchmark tooling to sample memory over time on Linux by walking /proc during each measured run, while also unifying single-command and step-sequence benchmarking onto a shared in-process runner and removing the hyperfine/GNU time dependency chain.
Changes:
- Replaces
hyperfine+ GNUtime-based benchmarking with a sharedbash-spawn runner that measures wall-clock (calibrated), CPU (bashtime), and/proc-sampled memory series + exact per-process peak RSS (VmHWM). - Extends regression benchmark output with new
(mem over time)entries and updates scenario schema/docs to reflect the new measurement semantics. - Removes installation steps for
hyperfine/timefrom setup scripts and CI workflow.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/setup.sh | Drops hyperfine installation from dev setup. |
| scripts/end-to-end/types.ts | Updates benchmark type docs to remove hyperfine wording. |
| scripts/end-to-end/schema/scenario.schema.json | Updates schema descriptions to match new runner semantics. |
| scripts/benchmark/regression.ts | Switches regression benchmarking to the shared measured runner; adds mem-over-time reporting. |
| scripts/benchmark/main.ts | Replaces hyperfine invocation with in-process runner and emits hyperfine-compatible JSON export. |
| scripts/benchmark/helpers/stats.ts | Updates stats docs to reflect per-run aggregation source. |
| scripts/benchmark/helpers/runner.ts | Adds shared measured runner (wall/CPU + optional /proc sampling). |
| scripts/benchmark/helpers/runner.test.ts | Adds unit tests for runner helpers (quoting, wrapping, parsing, formatting). |
| scripts/benchmark/helpers/plan.ts | Updates docs to remove hyperfine references. |
| scripts/benchmark/helpers/memory.ts | Removes GNU time wrapper implementation. |
| scripts/benchmark/helpers/memory.test.ts | Removes GNU time wrapper tests. |
| scripts/benchmark/helpers/mem-series.ts | Adds /proc process-tree sampler + series encoding utilities. |
| scripts/benchmark/helpers/mem-series.test.ts | Adds unit tests for labeling, encoding, summaries, and pivoting. |
| scripts/benchmark/helpers/args.ts | Removes memFile arg plumbing now that GNU time wrapper is gone. |
| .github/workflows/regression-benchmark.yml | Removes system dependency installation for hyperfine/GNU time. |
Comments suppressed due to low confidence (1)
scripts/benchmark/main.ts:128
- The benchmark creates a temp dir for the CPU timing file but never removes it on success, which can leak many /tmp directories over repeated runs. Consider cleaning it up after a successful run, while still keeping it around on failure for debugging.
const timingPath = path.join(
mkdtempSync(path.join(tmpdir(), "hardhat-bench-")),
"cpu.txt",
);
| export async function runMeasured( | ||
| command: string, | ||
| timingPath: string, | ||
| options: RunOptions, | ||
| ): Promise<MeasuredRun> { | ||
| const calibration = await shellSpawnOverheadSeconds(); | ||
|
|
||
| const sampler = procSamplingAvailable() ? new MemorySampler() : undefined; | ||
| const { wallSeconds } = await execute( | ||
| wrapWithCpuTiming(command, timingPath), | ||
| options, | ||
| sampler, | ||
| ); | ||
| const memory = sampler?.stop(); | ||
|
|
||
| const cpu = parseCpuTiming(readFileSync(timingPath, "utf-8"), timingPath); | ||
|
|
||
| return { | ||
| wallSeconds: Math.max(0, wallSeconds - calibration), | ||
| user: cpu.user, | ||
| system: cpu.system, | ||
| memory, | ||
| }; | ||
| } |
| ): Promise<{ wallSeconds: number }> { | ||
| return new Promise((resolve, reject) => { | ||
| const start = performance.now(); | ||
| const child = spawn("bash", ["-c", command], { |
| @@ -1,15 +1,25 @@ | |||
| import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; | |||
97be057 to
6fd5327
Compare
d786673 to
8472fb1
Compare
| const runs = benchArgs.runs ?? 10; | ||
|
|
||
| if (init) { | ||
| if (init || !existsSync(scenario.workingDir)) { |
There was a problem hiding this comment.
note: This was previously in e2eExec—which we no longer call—so we move the !existsSync check up here
99154bd to
8c72143
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Suppressed comments (1)
scripts/benchmark/regression.ts:809
representativeRunis documented as the run with the median peak RSS, but the code picks the representative run using the max tree-total RSS sample (summaries.map(s => s[4])). This can select a different run than the medianpeakRssMb, makingseries/seriesGzinconsistent with thepeakRssMbvalues and the PR description.
const summaries = defined.map((memory) =>
fiveNumberSummary(memory.samples.map(treeTotalMb)).map(Math.round),
);
const representative = pickRepresentativeRun(summaries.map((s) => s[4]));
const p50Stats = computeStats(summaries.map((s) => s[2]));
Replace hyperfine and GNU time with a shared in-process runner that spawns each measured run once and captures everything in a single pass: - wall-clock via performance.now() minus a hyperfine-style shell-spawn calibration offset (validated: means agree within ~1 ms) - CPU via bash's `time` builtin (exact child rusage, no extra process) - memory by sampling /proc/<pid>/status across the process tree every 100 ms: VmRSS per process label (hardhat, solc, ...) becomes a new "<name> (mem over time)" entry; VmHWM keeps the "(peak RSS)" entry exact with unchanged %M semantics (validated within 1 MB) The series ships delta-encoded + gzipped + base64 in the entry's extra (-50% vs raw JSON, with a raw fallback so it never loses); each run also records a [p0,p25,p50,p75,p100] summary of its tree-total RSS. The tracked value is the median across runs of the run's median tree-total. Single commands now share the step-sequence runner, gaining per-run peak RSS and per-run CPU statistics; "(cpu)" entries carry the same times/min/max/median/mean extra as wall-clock entries. CI no longer installs hyperfine or time.
Render memory-over-time data from regression reports and bench exports into a self-contained HTML page: one canvas chart per scenario+command with the representative run's per-process breakdown, or one tree-total line per file when comparing up to 24 reports (8 colorblind-validated hues × 3 line styles). Per-run stats (duration, exact peak, five-number summary) are listed in a table under each chart.
b5e6270 to
73e892f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (3)
scripts/benchmark/helpers/runner.ts:79
runMeasuredstarts aMemorySamplerinterval but only stops it on the success path. Ifexecute()rejects (command fails andignoreFailureis false, or spawn errors), the interval remains active (it’sunref()’d but will still keep sampling until process exit). Ensure the sampler is stopped in the error path as well.
const sampler = procSamplingAvailable() ? new MemorySampler() : undefined;
const { wallSeconds } = await execute(
wrapWithCpuTiming(command, timingPath),
options,
sampler,
scripts/benchmark/render-mem-series.ts:140
resolveArgsdoesn’t validate that--output/--scenarios/--benchmarksare followed by a value. If the flag is last (or followed by another flag),argv[++i]becomesundefinedand the tool silently falls back to defaults, which is hard to diagnose. Consider erroring out when an option value is missing.
case "--output":
output = argv[++i];
break;
case "--scenarios":
scenarios = argv[++i];
scripts/benchmark/helpers/mem-series.ts:220
encodeSeriesTablecomparesseriesGz.lengthtoraw.length, but those are different payload shapes (base64 string vs JSON of the table). This can pickseriesGzeven when the final serializedextrabecomes larger than the raw table, contradicting the “never larger than uncompressed” intent. Compare the serialized{seriesGz: ...}vs{series: ...}payload sizes instead.
const raw = JSON.stringify(table);
const seriesGz = gzipSync(
JSON.stringify({
tMs: deltaEncode(table.tMs),
byProcess: Object.fromEntries(
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated no new comments.
Suppressed comments (4)
scripts/benchmark/helpers/runner.ts:83
- If the measured command fails,
execute()rejects andrunMeasured()exits before callingsampler.stop(). That leaves theMemorySamplerinterval running for the rest of the process, continuing/procpolling and potentially affecting subsequent measurements.
const calibration = await shellSpawnOverheadSeconds();
const sampler = procSamplingAvailable() ? new MemorySampler() : undefined;
const { wallSeconds } = await execute(
wrapWithCpuTiming(command, timingPath),
options,
sampler,
);
const memory = sampler?.stop();
const cpu = parseCpuTiming(readFileSync(timingPath, "utf-8"), timingPath);
scripts/benchmark/main.ts:126
mkdtempSync()creates a new temp directory per benchmark run, but the directory is never removed. Over time this can leak manyhardhat-bench-*directories under the system temp dir (especially on CI/self-hosted runners).
const timingPath = path.join(
mkdtempSync(path.join(tmpdir(), "hardhat-bench-")),
"cpu.txt",
);
scripts/benchmark/main.ts:1
- The benchmark temp-dir cleanup uses
rmSync(...), but it's not currently imported in this file.
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
scripts/benchmark/regression.ts:566
- This block declares
const runstwice (outer run count for logging, then the array of measured runs). The shadowing is easy to misread and makes future edits riskier.
const runs = await runSeries(
cfg.command,
path.join(scenarioTmpDir, `${slugify(name)}-cpu.txt`),
{ cwd: workingDir, env, runs: cfg.runs, prepare: cfg.prepare },
);
return measuredRunsToEntries(scenarioId, name, runs);
4c84dd4 to
73e892f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (4)
scripts/benchmark/main.ts:126
mkdtempSync(path.join(tmpdir(), "hardhat-bench-"))creates a new temp directory for everypnpm benchinvocation, but it’s never cleaned up. Over time this can clutter /tmp on developer machines or CI runners. Consider using atry/finallyto remove the directory (or reuse a stable temp path).
const timingPath = path.join(
mkdtempSync(path.join(tmpdir(), "hardhat-bench-")),
"cpu.txt",
);
scripts/benchmark/regression.ts:834
representativeRunis documented as “the run with the median peak”, but the representative index is currently chosen from the tree-total p100 values (summaries.map((s) => s[4])), not from the run’s actual peak RSS (MemorySeries.peakRssMb). These can diverge, which would make the stored series not match the stated selection rule.
const summaries = defined.map((memory) =>
fiveNumberSummary(memory.samples.map(treeTotalMb)).map(Math.round),
);
const representative = pickRepresentativeRun(summaries.map((s) => s[4]));
const p50Stats = computeStats(summaries.map((s) => s[2]));
scripts/benchmark/regression.ts:566
runsis declared twice in the same function (const runs = …for the count, thenconst runs = await runSeries(…)for the measured runs array). This shadowing makes the code harder to follow and is easy to trip over during future edits.
const runs = await runSeries(
cfg.command,
path.join(scenarioTmpDir, `${slugify(name)}-cpu.txt`),
{ cwd: workingDir, env, runs: cfg.runs, prepare: cfg.prepare },
);
scripts/benchmark/helpers/mem-series.ts:220
- This compression decision compares
seriesGz.lengthagainstJSON.stringify(table).length, but the actual payload size also includes the wrapper key ({seriesGz:...}vs{series:...}). This can pickseriesGzeven when the wrapped JSON ends up larger (or vice versa). Comparing the wrapped JSON lengths avoids this edge case.
return seriesGz.length < raw.length ? { seriesGz } : { series: table };
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (3)
scripts/benchmark/main.ts:126
mkdtempSynccreates a new temp directory for the CPU timing file, but it’s never cleaned up. Repeatedpnpm benchruns will leave manyhardhat-bench-*directories under the OS temp dir.
const timingPath = path.join(
mkdtempSync(path.join(tmpdir(), "hardhat-bench-")),
"cpu.txt",
);
scripts/benchmark/main.ts:1
- The cleanup fix for the temp timing directory needs
rmSync, but it isn’t imported in this file right now.
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
scripts/benchmark/render-mem-series.ts:140
resolveArgsdoesn’t validate that--output,--scenarios, and--benchmarksare followed by a value. If any of these options are the last argument, the script will proceed withundefinedand fail later with a less helpful error.
case "--output":
output = argv[++i];
break;
case "--scenarios":
scenarios = argv[++i];
break;
case "--benchmarks":
benchmarks = argv[++i];
break;
Replace the "(peak RSS)" and "(mem over time)" entries with a single "<name> (memory)" entry tracking the tree-total RSS (per-sample sum across the process tree). The old headline — max single-process VmHWM — was misleading for parallel workloads: Lido's parallel Mocha suite peaks at 2.2 GB in one process while the tree holds 14.5 GB.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
scripts/benchmark/main.ts:127
mkdtempSynccreates a new temp directory for the CPU timing file, but it is never removed. Repeatedpnpm benchruns will leave manyhardhat-bench-*directories in the system temp dir. Consider removing the temp directory in afinallyblock (also on error) afterrunSeriescompletes.
const timingPath = path.join(
mkdtempSync(path.join(tmpdir(), "hardhat-bench-")),
"cpu.txt",
);
| return [ | ||
| { | ||
| name: `${scenarioId} / ${phaseLabel} (memory)`, | ||
| unit: "MB", | ||
| value: Math.round(pooled.mean), | ||
| range: `± ${computeStats(runMeans).stddev}`, |
The e2e regression benchmarks previously captured only peak memory via GNU
time -f %M. GNU time reads the kernel'sru_maxrssonce atwait4()— an exact peak, but fundamentally incapable of producing a time series, and its%Mis the max of any single process in the tree, not an aggregate.This PR samples each measured run's process tree from
/procevery 100 ms, producing a memory-over-time series per process (hardhat vs. solc vs. wrappers) alongside the existing metrics, and unifies all benchmark execution (single commands and step sequences,pnpm benchandpnpm bench:regression) onto one shared in-process runner — dropping both hyperfine and GNU time as dependencies.How measurement works now
One
bashspawn per measured run captures everything in a single pass (scripts/benchmark/helpers/runner.ts):performance.now()around the child, minus a shell-spawn calibration offset (the wrapped no-op:timed 20× at startup, mean subtracted — the same calibration hyperfine applies). Measured offset: ~1.9 ms.timebuiltin (%U %S) — a reserved word, not a process: zero fork/exec, and it reads the exact child rusage the kernel reports atwait().scripts/benchmark/helpers/mem-series.ts): every 100 ms, walk/proc/<pid>/task/*/childrenand read each process's/proc/<pid>/status. One sample costs ~50–200 µs in the otherwise-idle driver process.VmRSSper process, aggregated by label (hardhat,solc-linux-amd64-v0.8.28+…,npx, …; generic script names likecli.jsresolve to their npm package, including through.binshims) → the series.VmHWM— the kernel's exact per-process peak-RSS high-water mark (the same counter behind%M) → the peak, for free in the same read. It must be tracked during sampling:/proc/<pid>vanishes at process exit, and post-mortem rusage is only available to the parent (bash), not the driver.On non-Linux (no
/proc) memory entries are skipped with a warning; timing still works.Report format
Every measured name now emits four entries (previously three):
<scenario> / <name>times, min/max/median/mean,peakRssMb… (cpu)user/systemsplit… (peak RSS)VmHWM,%Msemantics)… (mem over time)(new)The
(mem over time)value ismedian_r( median_s( Σ_p RSS[r][s][p] ) )— a duration-weighted "typical working set", robust to spikes (which(peak RSS)tracks) and outlier runs, suitable for the 110% drift alerting. Note it aggregates the sum of the tree, so it can legitimately exceed(peak RSS)(max of a single process).Its
extra:{ "representativeRun": 1, "seriesGz": "H4sIAAAA…", // raw series of the run with the median peak "runs": [ { "durationMs": 4139, "peakRssMb": 315, "total": [3, 250, 383, 425, 569] }, { "durationMs": 4174, "peakRssMb": 315, "total": [1, 247, 367, 424, 565] } ] }seriesGz= base64(gzip(JSON of{ tMs, byProcess: { label: MB[] } }with every array delta-encoded)). Decode: gunzip (browsers:DecompressionStream("gzip")),JSON.parse, cumulative sum per array. When compression wouldn't help (tiny runs) the raw table ships asseriesinstead, so the payload is never larger than uncompressed.totalis the[p0,p25,p50,p75,p100]summary of each run's tree-total samples.Validation
GNU time equivalence (gate for deleting the
%Mpath):Hyperfine equivalence (gate for dropping hyperfine; 10 runs each, calibrated runner):
sleep 0.5Compression (real
ens-verifiable-factorydata, six(mem over time)entries, ~40-sample series):It won on every entry even at these unfavorably short series lengths; long
test solidityruns compress much better. The raw fallback guarantees it can never lose.Also in this PR
pnpm benchkeeps its full CLI (--warmup,--prepare,--show-output,--ignore-failure) with hyperfine-compatible--export-jsonoutput, extended with per-run memory series.scripts/setup.shno longer installhyperfine/time.Expected one-time effects on the dashboard
(cpu)entries gain a real±range and per-run statistics inextra.Follow-up (hardhat-benchmark-results repo)
Consume the new entry: the scalar chart comes free from the existing renderer; add the commit-overlay view (one line per commit, gradient by age) and the commit × time-in-run heatmap — both read the same
seriesGzpayload. Interactive heatmap mockup shared separately.🤖 Generated with Claude Code