Skip to content

[RFC] Add construction-time hash-consing for Expr (hashConsExplore) - #1085

Open
gustavo-grieco wants to merge 2 commits into
mainfrom
expr-hash-consing
Open

[RFC] Add construction-time hash-consing for Expr (hashConsExplore)#1085
gustavo-grieco wants to merge 2 commits into
mainfrom
expr-hash-consing

Conversation

@gustavo-grieco

@gustavo-grieco gustavo-grieco commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Description

The symbolic interpreter rebuilds structurally-identical subterms as separate heap objects (e.g. a mapping-slot keccak recomputed on every storage access), so a computation that reuses an intermediate at k nesting levels materializes a 2^k tree with no sharing. On storage-heavy targets this exhausts memory: in one observed case a 914-node DAG materialized as a 601,664-node tree with a 22 GB peak, and every structural traversal or comparison pays the logical (not the shared) size.

This PR adds an experimental, off-by-default fix:

  • New EVM.HashCons moduleinternExpr returns a canonical representative per structural equivalence class, so the heap holds the DAG, not the tree. It is exact (never merges structurally-different terms) and O(1) amortized per node: a StableName table shortcuts already-canonical nodes, and the structural tables are keyed by shallow keys (constructor tag, scalar payload, child ids), so lookups never compare whole subterms. Interning hooks sit at the term-construction sites: stack ops, the SLOAD/keccak paths, and mapExpr rebuilds.
  • Pointer-equality fast pathsExpr's Eq/Ord instances are now hand-written with a ptrEq shortcut at every recursive step. Semantics are identical to the previously-derived instances (constructor declaration order, fields left-to-right); on shared terms comparisons drop from O(logical size) to O(size of the differing part), which removes the dominant cost of simplifier guards (a == b) and normalization compares.
  • Memoized simplifier passesuntilFixpoint detects convergence by pointer identity, and the repeatedly-applied passes (litToKeccak, simplifyNoLitToKeccak, concKeccakSimpExpr via mapExprShared/memoFixTraverse) remember per-node fixpoint status per pass, skipping already-simplified shared subterms in O(1).
  • readStorage fast path — two distinct literal slots can never alias, so reads skip down concrete SStore chains without invoking the simplifier through surelyEqual/surelyNotEqual.

Everything is gated behind the new hashConsExplore config flag (default False): when disabled, every hook is a single IORef read returning its argument unchanged, so existing behavior is untouched. The intern tables are module-global in the style of GHC's FastString table and are reset at the start of each symbolic exploration; the id counter survives resets, so overlapping explorations (parallel test cases) can only lose sharing, never correctness.

internExpr e is always structurally equal to e — it only shares memory — so enabling the flag cannot change verification results, only their cost.

CLI & tests: the flag is exposed as --hash-cons, and all test suites (test, cli, BlockchainTests, rpc, and the forge-symbolic-tests runner) run with hash-consing enabled, so the sharing machinery is exercised by the entire suite while remaining opt-in for users.

Checklist

  • tested the changes locally (builds clean; exercised via echidna verification mode on storage-heavy targets)
  • added changelog entry

🤖 Generated with Claude Code

The symbolic interpreter rebuilds structurally-identical subterms as
separate heap objects, so a computation that reuses an intermediate at k
nesting levels materializes a 2^k tree with no sharing. On storage-heavy
targets this exhausts memory: observed, a 914-node DAG materialized as a
601,664-node tree with a 22 GB peak.

New EVM.HashCons module: internExpr returns a canonical representative
per structural equivalence class (StableName shortcut for already-
canonical nodes, shallow structural keys otherwise), so the heap holds
the DAG, not the tree. Interning hooks at the term-construction sites
(stack ops, SLOAD/keccak paths, mapExpr rebuilds). Gated by the new
hashConsExplore config flag, off by default; when disabled every hook is
a single IORef read.

Canonicalization makes physical identity a cheap witness for structural
equality, exploited in two places:

* Expr's Eq/Ord instances are now hand-written with a ptrEq fast path
  at every recursive step (semantics identical to the derived ones), so
  comparisons on shared terms cost O(differing part), not O(logical
  tree).
* untilFixpoint detects convergence by pointer identity, and the
  repeatedly-applied simplifier passes (mapExprShared/memoFixTraverse)
  memoize per-node fixpoint status per pass, skipping already-simplified
  shared subterms in O(1).

Also a readStorage fast path: two distinct literal slots can never
alias, so reads skip down concrete SStore chains without invoking the
simplifier via surelyEqual/surelyNotEqual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New --hash-cons flag wiring hashConsExplore into the CLI config (still
off by default). All test suites (test, clitest, BlockchainTests, rpc,
and the forge-symbolic-tests runner via --hash-cons) now run with
hash-consing enabled so the sharing machinery is exercised by the whole
suite while remaining opt-in for users.

resetHashCons now preserves the id counter across resets: ids are never
reused, so when parallel test cases trigger overlapping explorations, a
mid-run reset can only lose sharing, never correctness (stale memo
entries reference ids no new node can be assigned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gustavo-grieco gustavo-grieco changed the title Add construction-time hash-consing for Expr (hashConsExplore) [RFC] Add construction-time hash-consing for Expr (hashConsExplore) Jul 25, 2026
@msooseth
msooseth self-requested a review July 30, 2026 13:24
@MavenRain

Copy link
Copy Markdown

Thanks for the invitation to look at this, @gustavo-grieco.

Short version: I did not find anything that changes a verification result. The central claim, that internExpr e is structurally equal to e so the flag can only move cost around, holds up everywhere that I could see. The two things I would want resolved before this comes off RFC are that the retention discipline can invert the memory goal on workloads that are not the motivating one, and that the "enabled across the test suites" story does not currently hold.

Findings

1. The intern tables retain every node ever built, not the live set. setHashConsEnabled and resetHashCons have exactly one call site between them, src/EVM/SymExec.hs:392-393, on the way in. Neither is called on the way out. Since verify enters interpret once, every stack-op result, every SLOAD result and every simplifier intermediate produced across a whole multi-branch exploration lands in hcState (src/EVM/HashCons.hs:83) and passMemos (:132) and stays strongly reachable long after its branch has been solved, pruned or abandoned. Peak memory becomes O(all nodes ever constructed) rather than O(live nodes).

On the motivating case the 601664:914 sharing factor pays for that easily. On a workload whose sharing factor is below roughly 10, turning the flag on should raise peak memory, which is the opposite of what the flag is for. Compounding it: because the flag is never written back to False, everything downstream in the same process keeps growing the tables with no exploration in scope at all, including the counterexample model substitutions at src/EVM/SymExec.hs:1388, :1401 and :1436. A library embedder like echidna never gets the memory back.

Consider disabling and clearing on exploration exit as well as entry, and consider a size or node-count ceiling above which interning quietly stops rather than continuing to accumulate.

2. ConcreteStore and ConcreteBuf put unbounded payloads in the key. src/EVM/HashCons.hs:342 builds PStore (M.toList m), the entire storage map as an association list, and :330 embeds the whole ByteString. Both become part of Key, which is Ord-compared on every Map probe and retained in two maps until reset. That contradicts the module's own docstring at :19 and :74 ("All shallow", "a lookup never compares whole subterms").

Concretely, a contract doing N concrete SSTOREs to distinct slots produces N distinct store snapshots, each interned under a fresh key holding a roughly N-element list, so O(N^2) retained bytes and O(N) comparison work per probe, where the persistent Map previously shared structure in O(N log N). This reproduces the blowup the PR exists to remove. My guess is these two constructors are not worth interning at all, or should be keyed by a hash of the payload rather than the payload.

3. The flag is a process-global with last-writer-wins semantics, and the suites run concurrently. hashConsExplore is per-Env in src/EVM/Effects.hs:52, but enablement is a single process-wide IORef written on every entry to interpret. Both test suites build with -threaded "-with-rtsopts=-N" (hevm.cabal:207 and :273) and I do not see a NumThreads 1 anywhere, so tasty runs cases concurrently. A False-config case entering interpret will call resetHashCons and wipe the tables of a live True-config exploration on another thread. Today that costs only sharing, per the reset argument above, but it does mean which tests actually exercise the feature is nondeterministic.

4. Three of the four suites that set the flag do not actually exercise it.

  • test/EVM/Test/FuzzSymExec.hs:659 builds its own Env from plain defaultConfig, so hashConsExplore is False (src/EVM/Effects.hs:77). Those cases set the global back to off, and given item 3 every pure Expr.simplify/mapExpr test that happens to run afterwards inherits it.
  • test/clitest.hs:38 sets the flag, but the symbolic cases shell out through readProcessWithExitCode "cabal" ["run", "exe:hevm", ...] at :52-53 without passing --hash-cons, so the parent process's flag is irrelevant to what actually executes.
  • test/BlockchainTests.hs:8 sets the flag, but src/EVM/Test/BlockchainTests.hs contains no reference to Stepper, SymExec or interpret at all, so src/EVM/SymExec.hs:392 is never reached and the flip is inert.

That leaves test/rpc.hs:31 and the forge runner as the only places genuinely covering the feature. Separately, and maybe more importantly, the flag-off default that every user gets is now covered by no suite, since test/test.hs:87 flipped it on wholesale. I would keep at least one suite running with it off.

5. There is no direct test for the 556-line module. No property test for internExpr e == e, no idempotence check, nothing asserting that sharing actually happens, and nothing pinning the 263 new hand-written Eq/Ord lines. That last one raises an eyebrow, as those instances are always on. A QuickCheck comparing the hand-written instances against a deriving-generated shadow instance on a newtype wrapper is roughly ten lines and would make the riskiest part of the diff self-guarding against future constructor additions, which is exactly when a hand-maintained 72-arm rank table goes wrong.

6. mapExpr was changed unconditionally rather than gated. src/EVM/Traversals.hs:260 is now mapExpr f expr = runIdentity (mapExprM (Identity . internExpr . f) expr), which splices a NOINLINE unsafePerformIO plus an IORef read into every node of every pre-existing caller: all five arms of mapProp, rewriteFresh (src/EVM/SymExec.hs:1011), the three counterexample substitutions, and mapTerm. It is semantically identity when disabled, but unlike mapExprShared (src/EVM/Traversals.hs:266-267) it has no gated fallback, so the default path pays an uncosted tax. Giving it the same hashConsEnabled guard as mapExprShared costs nothing and shrinks the diff's blast radius considerably.

One thing I couldn't settle

Does internExpr preserve definedness, and not merely structure?

go reaches lookupSN, which is makeStableName $! e (src/EVM/HashCons.hs:184 and :199), and goBuild recurses into every child, so interning forces every node of the subterm to WHNF at construction time. Before this PR, src/EVM.hs:3282 read let !y = f x and forced only the root; it now reads let !y = HashCons.internExpr (f x). Same shape at :3297, :3311, and the readStorage' result at :799-800 which was previously consumed lazily.

Expr fields do hold thunks built from partial operations (unsafeInto, forceLit, internalError). If any such thunk sits in a field that would never have been demanded on a pruned or unqueried path, then with the flag on it is now evaluated eagerly, and a would-be verdict becomes a crash. That would break the "cannot change results" claim in the one direction that is invisible to structural-equality reasoning, since internExpr e == e can hold perfectly while definedness does not. This is the classic hash-consing pitfall and I could not rule it in or out statically. A sweep of thunk-valued Expr fields reachable from stackOp1/2/3 and readStorage' would settle it, or a run of the suite under a build with internalError made noisy.

Smaller notes

  • src/EVM/Expr.hs:1195 and :1200 intern the argument before the fixpoint, and :2073 composes two memoized passes. The per-slot memo keying looks right to me: litToKeccak's go, simplifyNoLitToKeccak's go and concKeccakOnePass are all top-level and pure, so slots 0, 1 and 2 cannot alias. It's worth a comment in memoFixTraverse stating that invariant, since a future caller passing a closure over local state would silently poison the memo.
  • The docstring at src/EVM/HashCons.hs:19 should be reconciled with finding 2 either way, since it currently describes a property the code does not have.

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.

2 participants