Skip to content

perf: reduce call-trace memory retention in Solidity test runner - #1612

Draft
Wodann wants to merge 15 commits into
mainfrom
perf/reduce-memory
Draft

perf: reduce call-trace memory retention in Solidity test runner#1612
Wodann wants to merge 15 commits into
mainfrom
perf/reduce-memory

Conversation

@Wodann

@Wodann Wodann commented Aug 11, 2026

Copy link
Copy Markdown
Member

Problem

Hardhat 3 runs out of memory in EDR at verbosity -vvv and above. Hardhat maps verbosity to two EDR options: -vvvcollectStackTraces: Always + includeTraces: Failing, -vvvv+ → Always + All (default -vv is OnFailure + None). In Always mode the runner records EVM steps (one entry per executed opcode, ~150–200 bytes each) for every call of every test, and every resulting trace arena stayed resident in TestResult.execution_traces until the entire test suite finished — regardless of whether anything would ever consume it. On a 16 GiB machine, solady at -vvvv needs over 10 GiB and gets OOM-killed; with unbounded parallelism it already dies at -vvv.

The fix is a per-test retention policy: the moment a test finishes (and its stack trace, the only consumer of EVM steps, has been computed), every arena — or part of one — that has no remaining consumer is freed.

Benchmark methodology

New pnpm soltestsMemory benchmark (first commit): runs each (repo × verbosity) cell in a fresh child process and records peak RSS via /usr/bin/time -v, cross-checked against process.resourceUsage(). Children are capped at 4 rayon threads because the unoptimized baseline OOMs the 16 GiB benchmark machine with unbounded parallelism, which would leave nothing to compare against. Repos: solady (1557 tests, fuzz-heavy), uniswap-v4-core (598 tests), morpho-blue (145 tests incl. 6 invariant functions, runs=16 × depth=256). All benchmark tests pass, which matters for reading the tables: -vvv (Failing, nothing surfaced) exercises the "drop everything" path, -vvvv (All) the "keep call traces" path.

Each commit was benchmarked stacked on the previous ones (the later commits depend on the earlier ones), so the Δ columns below are vs. baseline including all prior commits; a commit's own contribution is the change relative to the previous table. Run-to-run noise on step-recording cells is ±10–15 %, so single-digit percentages are not meaningful.

Baseline (peak RSS / wall-clock)

repo -vv -vvv -vvvv
solady 510 MiB / 3.1s 4750 MiB / 17.7s OOM (killed ≥10.3 GiB)
uniswap-v4-core 387 MiB / 1.6s 2308 MiB / 1.8s 3652 MiB / 1.9s
morpho-blue 283 MiB / 2.4s 1681 MiB / 10.9s 1900 MiB / 10.8s

Per-commit changes and results

refactor: name the failing arena explicitly in get_stack_trace

Previously get_stack_trace folded over an arena iterator and converted whichever came last ("assumes last trace is the error one"); every earlier arena was walked only for CREATE nodes to map contract addresses to code. The signature now says this: failing_trace (converted and analysed) vs. code_sources (walked for contract code only, therefore never need recorded steps) vs. executed_code (pre-computed code maps). No behavioural change; the point was to verify runtime neutrality and to establish the invariant — only the failing arena's steps are ever read — that makes the step-stripping commits sound by construction.

repo -vv -vvv -vvvv
solady 508 MiB (−0%) 5314 MiB (+12%) OOM
uniswap-v4-core 390 MiB (+1%) 2685 MiB (+16%) 3310 MiB (−9%)
morpho-blue 284 MiB (+1%) 1637 MiB (−3%) 1863 MiB (−2%)

Neutral, as required (the +12/+16 % readings are within the noise band of these cells and reverse sign in later runs).

perf: free unconsumed trace arenas as soon as each test finishes (O1)

Introduces TraceRetentionPolicy, applied once per finished test. Frees execution_traces when includeTraces will never surface them (always at None; passing tests at Failing), counterexample arenas (no consumer at all after a test finishes — not decoded, not exposed over napi), and gas_report_traces when no gas report was requested (also fixing a leak where passing tests at Failing skipped the existing clear).

repo -vv -vvv -vvvv
solady 506 MiB (−1%) 4596 MiB (−3%) OOM
uniswap-v4-core 389 MiB (+1%) 2454 MiB (+6%) 3440 MiB (−6%)
morpho-blue 277 MiB (−2%) 1585 MiB (−6%) 1920 MiB (+1%)

Small on these all-passing benchmarks — per-test retained arenas turn out not to dominate them — but it is the mechanism the following commits act through, and its target scenarios (failing tests, the Failing-mode leak) aren't exercised here.

perf: strip recorded EVM steps from arenas retained for call traces (O2b)

The -vvvv fix. After stack-trace generation nothing reads steps: trace decoding, the gas report and the napi conversion only use the call tree, logs and ordering (the napi conversion explicitly discards step entries). New SparsedTraceArena::strip_steps() (resolving pause/resume-tracing bookkeeping first, since it refers to step indices) is applied to every arena the policy keeps.

repo -vv -vvv -vvvv
solady 504 MiB (−1%) 5089 MiB (+7%) 4540 MiB (was OOM)
uniswap-v4-core 396 MiB (+2%) 2118 MiB (−8%) 2485 MiB (−32%)
morpho-blue 280 MiB (−1%) 1617 MiB (−4%) 1743 MiB (−8%)

perf: strip EVM steps from displaced arenas as traces accumulate (O2a)

Same idea applied during a test: when a new arena is pushed onto a collection, the one it displaces as most-recent can no longer be a failing trace, so its steps are stripped immediately. Bounds the worst case within a test (beforeTestSetup chains, invariant replay sequences) to one step-laden arena at a time.

repo -vv -vvv -vvvv
solady 492 MiB (−4%) 4893 MiB (+3%) 4405 MiB (was OOM)
uniswap-v4-core 394 MiB (+2%) 2664 MiB (+15%) 2427 MiB (−34%)
morpho-blue 279 MiB (−1%) 1537 MiB (−9%) 1623 MiB (−15%)

Within noise vs. O2b on these repos (multi-arena tests are rare there); kept because it bounds the worst case at zero cost.

perf: don't collect invariant run traces past the gas-sample budget (O3a)

The invariant fix. During an invariant campaign every run accumulated up to depth arenas in run_traces — with steps, under Always — only for end_run to throw them away unless gas-report samples were still needed (the napi layer always sets gas_report_samples = 0). The same condition is now applied at collection time.

repo -vv -vvv -vvvv
solady 512 MiB (+0%) 4462 MiB (−6%) 4785 MiB (was OOM)
uniswap-v4-core 394 MiB (+2%) 3277 MiB (+42%)¹ 2493 MiB (−32%)
morpho-blue 278 MiB (−2%) 420 MiB (−75%) 448 MiB (−76%)

morpho-blue also gets ~15 % faster (10.9s → 8.8s). ¹ The uniswap -vvv cell is this benchmark's noisiest (2.1–3.3 GiB across all builds); the +42 % is an outlier reading, not an effect of this commit — it returns to −4 % two builds later.

perf: don't retain invariant replay arenas that nothing will consume (O3b)

A passing invariant test replays its last run purely to collect logs and identify created contracts, but also pushed one arena per replayed call (up to depth+2) into the test result. Pushes are now gated on the retention policy; arenas are still kept whenever stack-trace generation needs them as code sources.

repo -vv -vvv -vvvv
solady 512 MiB (+0%) 4403 MiB (−7%) 5775 MiB (was OOM)²
uniswap-v4-core 394 MiB (+2%) 2394 MiB (+4%) 2263 MiB (−38%)
morpho-blue 276 MiB (−2%) 407 MiB (−76%) 414 MiB (−78%)

Marginal trim on morpho vs. O3a; mainly removes a per-test transient. ² solady -vvvv noise (4.4–5.8 GiB band).

perf: share setup trace arenas between a suite's napi test results (O4b)

The napi conversion cloned the suite's setup arenas into every included test result (N copies for an N-test suite at All). The surfaced subset is now materialized once per suite (moved, not cloned) and shared via Arc; callTraces() output is unchanged.

repo -vv -vvv -vvvv
solady 508 MiB (−0%) 4394 MiB (−7%) 4431 MiB (was OOM)
uniswap-v4-core 391 MiB (+1%) 2226 MiB (−4%) 2265 MiB (−38%)
morpho-blue 278 MiB (−2%) 427 MiB (−75%) 438 MiB (−77%)

Within noise here — these repos' setup arenas are step-free and small. Kept because it removes the pathological case for heavyweight setUps at no cost.

perf: derive a suite-level deployed-code map instead of retaining setup arenas (O4c)

Stack-trace decoding only needs setup arenas for the code of contracts deployed during setup. That's now captured into ExecutedCodeMaps as setup runs, used everywhere the arenas were previously walked, letting setup arenas be dropped at suite end under None and letting failure re-runs skip re-tracing setup. Subtlety: at Hardhat's default (OnFailure + None) setup runs untraced, so the re-run path still re-traces setup to rebuild the map — covered by a new regression test that fails without the conditional.

repo -vv -vvv -vvvv
solady 520 MiB (+2%) 4490 MiB (−5%) 4752 MiB (was OOM)
uniswap-v4-core 397 MiB (+3%) 2432 MiB (+5%) 2716 MiB (−26%)
morpho-blue 276 MiB (−2%) 430 MiB (−74%) 423 MiB (−78%)

Within noise here; its wins need failing tests or heavyweight setUps, which these all-passing benchmarks don't have.

refactor!: rename IncludeTraces to IncludeCallTraces

IncludeCallTraces controls whether call trace arenas appear in test results (mirroring the provider's includeCallTraces option); CollectStackTraces separately controls stack-trace computation. The old name suggested it governed trace collection as a whole. Breaking for the napi API (includeCallTraces property); the in-repo Hardhat patch is updated, and Hardhat needs the same rename when adopting this version.

What's not fixed (follow-up)

solady still peaks ~4.5 GiB at -vvv+: that is the transient cost of step recording itself while a test executes, not retention. The stack-trace inferrer only reads step.pc and step.op, while CallTraceStep stores far more per opcode — a slimmer step record in the tracer (revm-inspectors) is the natural next step.

Testing

  • cargo test -p edr_solidity_tests (123 passing, incl. new regression tests for Always-mode stack traces and the untraced-setup re-run path)
  • JS integration tests: 83 passing, incl. IncludeCallTraces.All/.Failing call-trace suites and OnFailure/Always stack-trace guardrails
  • Gas-report tests unaffected (gas report forces All, and the policy keeps what it needs)

Wodann added 15 commits June 18, 2026 18:57
Replace the per-request `spawn_blocking` + blocking channel round trip
with a completion callback: `handle_request` now deserializes the
request on the calling thread, enqueues it on the provider's background
thread, and settles the napi deferred from that thread once the
response is available. This cuts the thread handoffs per request from
four to two (~7x fewer futex syscalls), making the provider benchmark
suite ~2x faster than main.

Queued requests are settled with the new
`ProviderError::UnexpectedTermination` when the provider shuts down, so
pending promises always resolve.
…bosity

Runs each (repo, verbosity) pair in a fresh child process, records peak
RSS via /usr/bin/time -v cross-checked with process.resourceUsage(), and
reports a markdown table. Repo setup now also initializes submodules
pinned by the benchmark commit and tolerates tool-dependent npm lifecycle
scripts (morpho-blue's prepare step requires forge).

Children are capped at 4 rayon threads: the unoptimized runner OOMs a
16 GiB machine at verbosity >= 3 on solady with unbounded parallelism,
which would leave nothing to compare optimisations against.
Previously the failing arena was implicit: get_stack_trace folded over an
iterator and converted whichever arena came last, with a doc comment
('assumes last trace is the error one') carrying the whole contract.
Every other arena was walked only to seed the creation/runtime code maps.

Make that split explicit in the signature:
- failing_trace: the arena that is converted and analysed,
- code_sources: arenas walked only for their CREATE nodes, which
  therefore never need recorded EVM steps,
- executed_code: pre-computed creation/runtime code mappings, seeded
  before the arenas are walked (replaces the runtime-code-only third
  parameter; the provider now passes ExecutedCode { runtime: Some(..) }).

The stronger invariant - only the failing arena's steps are ever read -
is what allows later commits to strip steps from retained arenas.

No behavioural change.
Previously every test's SparsedTraceArenas stayed resident in
TestResult.execution_traces until the entire suite completed and the
napi conversion filtered them - regardless of include_traces. With
CollectStackTraces::Always (Hardhat -vvv and above) each arena carries
per-opcode EVM steps, which is the difference between megabytes and
gigabytes: solady at -vvv peaks at 4.7 GiB and at -vvvv OOMs a 16 GiB
machine.

Introduce TraceRetentionPolicy, applied at the single point where each
finished test's result is produced (after stack-trace generation, the
only consumer of EVM steps). It frees:
- execution_traces, when include_traces will never surface them
  (always at None; for passing tests at Failing),
- counterexample arenas, which have no consumer at all after the test
  finishes (not decoded, not exposed over napi),
- gas_report_traces, when no gas report was requested (also fixes the
  leak where the decode loop's continue skipped the clear for passing
  tests at IncludeTraces::Failing).
Recorded steps (one entry per executed opcode) are only consumed by
stack-trace generation, which has already run by the time a test
finishes; the remaining consumers - trace decoding, the gas report and
the napi conversion - only read the call tree, logs and their ordering
(the napi conversion explicitly discards TraceMemberOrder::Step).

Add SparsedTraceArena::strip_steps, resolving ignored ranges first
because their bookkeeping refers to step indices, and apply it to every
arena the retention policy keeps. This is the fix that matters at
IncludeTraces::All (Hardhat -vvvv), where every test's arenas survive
until the suite completes.
Only the most recent arena in a collection can still be named as the
failing trace of a stack-trace computation; every earlier one is only
walked for its CREATE nodes, which don't involve steps. Roll the steps
off on push, so at most one step-laden arena is alive per test at any
time - this bounds the transient peak within a test (beforeTestSetup
chains, invariant replay sequences) rather than waiting for the
retention policy at test end.
InvariantTestRun::run_traces is only ever consumed by end_run, which
samples it into gas_report_traces while fewer than gas_report_samples
entries exist and discards it otherwise. Apply the same condition at
collection time: without a gas report (gas_report_samples = 0, as the
napi layer configures) an invariant run no longer accumulates up to
'depth' arenas per run only to throw them away.
A passing invariant test replays its last run purely to collect logs
and identify created contracts, but replay_run also pushed one arena
per replayed call (up to 'depth', plus the invariant and afterInvariant
calls) into TestResult.execution_traces. Those arenas are only ever
surfaced as call traces, so gate the pushes on the retention policy;
they are still kept whenever stack-trace generation needs them as code
sources (generate_stack_trace).

The per-call failure stack trace inside replay_run is now only computed
when the caller asked for one - the success-path caller discards the
ReplayResult, and without retained arenas the computation would have
nothing to work from.
The napi conversion cloned the suite's setup arenas into every included
test result, so a suite with N tests at IncludeTraces::All materialized
N copies. Materialize the surfaced subset once per suite (moving it out
of the suite result instead of cloning) and share it as Arc<[_]>; the
call_traces() output is unchanged.
…up arenas

Stack-trace decoding only needs the setup arenas for the creation and
runtime code of the contracts deployed during setup. Capture that into
ExecutedCodeMaps as setup traces are recorded, and use it everywhere the
setup arenas were previously walked as code sources:

- collect_stack_trace seeds get_stack_trace with the map instead of
  chaining the setup arenas;
- the invariant replay paths take ExecutedCode instead of borrowing the
  setup traces;
- the re-run-on-failure helpers no longer re-trace setup when the
  original setup was traced. When it wasn't (OnFailure with
  IncludeTraces::None, Hardhat's default, traces nothing during setup),
  they keep re-tracing to rebuild the mapping - covered by a new
  regression test that fails without the conditional.

With the arenas' last in-run consumer gone, a suite's setup traces are
dropped at suite end when include_traces is None instead of surviving
until the napi conversion.
IncludeCallTraces controls whether call trace arenas are included in
test results, mirroring the provider's includeCallTraces observability
option; CollectStackTraces separately controls whether a stack trace is
computed for failing tests. The old name suggested it governed trace
collection as a whole.

BREAKING: the napi enum is now IncludeCallTraces and the
SolidityTestRunnerConfigArgs property is now includeCallTraces. The
in-repo Hardhat patch is updated accordingly; the same rename is needed
in Hardhat when it adopts this EDR version. The benchmark keeps a
temporary shim mapping the old property until then.
@Wodann Wodann self-assigned this Aug 11, 2026
@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a178475

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@nomicfoundation/edr Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Wodann
Wodann temporarily deployed to github-action-benchmark August 11, 2026 14:42 — with GitHub Actions Inactive
@Wodann Wodann changed the title perf: reduce memory consumption of call traces perf: reduce call-trace memory retention in Solidity test runner Aug 11, 2026
@Wodann
Wodann temporarily deployed to github-action-benchmark August 11, 2026 15:38 — with GitHub Actions Inactive
@Wodann
Wodann temporarily deployed to github-action-benchmark August 11, 2026 15:38 — with GitHub Actions Inactive
Base automatically changed from refactor/provider-on-background-thread to main August 31, 2026 20:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant