Typecheck cache PoC - #519
Draft
mbenke wants to merge 2 commits into
Draft
Conversation
A proof of concept for content-addressed caching of per-module typecheck results, so unchanged modules (notably std) are reused across compiles instead of being re-typechecked. - TypecheckCache: a Merkle cache key per module = keccak(content hash ++ sorted reference keys ++ typecheck-relevant flags), folded in dependency order. Editing a module changes exactly its key and its transitive dependents' (transitiveDependents), giving precise invalidation. -g is folded in only for contract-bearing modules, so library keys stay stable as -g toggles. - TcCacheSerialize: binary serialization of the checked module (typed CompUnit + its typeTable; other env fields restored as loud error thunks, proven unread). A magic + version header makes a stale or foreign dump degrade to a cache miss rather than a wrong result. Location instances added for current main's AST. - SolcorePipeline: compileGraphWithCache / typeCheckLoadedModulesWithCache reuse any cached module instead of re-checking it; the existing compile path is unchanged (empty cache). - Keccak: pure-Haskell keccak-256 for the content hashes. - poc/Main.hs (exe tc-cache-poc): demonstrates and asserts the Merkle keys, the invalidation property, deterministic keying, the on-disk binary round-trip and its format guard, and that warm/hit/disk runs reproduce the cold hull exactly. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com>
Conceptual description for review: motivation, Merkle keying, binary dump + on-disk reuse, results, and scope/limitations. Assisted-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Every compile re-typechecks the whole module graph from scratch, including the
standard library. For the edit–recompile loop (and especially the browser IDE)
the std typecheck dominates wall-clock time even though std never changes. This
PoC shows we can type-check each module once, persist the result to disk, and
reuse it on later compiles whenever the module (and everything it depends on)
is unchanged — without affecting the compiler's output.
The idea
Type-checking a module is a pure function of:
So we give every module a content-addressed Merkle key:
folded over the graph in dependency order. Two modules with equal keys are
guaranteed to type-check to the same result, so a cache indexed by key is sound.
Editing a module changes its key and, transitively, the key of every module that
references it — and nothing else. That gives precise invalidation: edit
std.dispatchand onlystd.dispatch+ its dependents are rechecked;stdandstd.opcodesare reused.The type-checked result of each module is serialized to a binary blob on
disk, keyed by its Merkle key. On the next compile we recompute the keys
(cheap — parsing + hashing) and, for every key already on disk, load the checked
module instead of re-type-checking it.
What's in this PR
src/Solcore/Pipeline/TypecheckCache.hstransitiveDependents)src/Solcore/Pipeline/TcCacheSerialize.hsbinaryserialization of a checked module; magic + version headersrc/Solcore/Pipeline/SolcorePipeline.hscompileGraphWithCache— reuse cached modules instead of re-checkingsrc/Solcore/Util/Keccak.hspoc/Main.hstc-cache-pocharness that demonstrates and checks all of the aboveCache keys (
TypecheckCache.hs)contentHashis taken over the parsed compilation unit, so it'sinsensitive to comments/whitespace for free.
independent of import order.
flagComponentrecords only the flags that affect type-checking(
--no-desugar-calls);-g/dispatch generation is folded in only formodules that contain contracts, so library keys stay stable as the UI
toggles
-g.Serialization (
TcCacheSerialize.hs)non-entry module: its typed
CompUnitand thetypeTableof its environment.The other environment fields are restored as loud error thunks — proven
unread, so never forced, but failing clearly if that assumption is violated.
written by an incompatible build (or a corrupted/foreign file) is rejected and
degrades to a cache miss (recompute) — never a wrong result.
binary(a GHC boot library, pure Haskell) rather than a JSON/aesonstack: compact, no dependency tail, and the format is confined behind
encodeCache/decodeCacheso it can be swapped without touching the rest.Pipeline seam (
SolcorePipeline.hs)compileGraphWithCache opts graph cacheruns the normal pipeline but reuses anymodule present in
cache(keyed byModuleId) instead of type-checking it, andreturns the full set of checked modules so a caller can seed the next run. The
existing
compilepath is unchanged (it calls this with an empty cache).Results
cabal run tc-cache-poc -- test/examples/dispatch/counter.solcdemonstrates, ona 4-module graph (
std,std.opcodes,std.dispatch,counter):std.opcodesrechecks all 4;
std→ 3;std.dispatch→ 2;counter→ 1 — exactly thetransitive-dependent set.
solcore-tc-cache.bin, read back, decoded, and used to compile again.byte-identical hull to the cold run.
Representative timing (type-check phase is the part the cache elides):
i.e. reusing the std subset from disk turns a ~3.7s cold compile into ~0.2s,
reproducing the cold output exactly. The on-disk dump for this 4-module graph is
~3.3 MB (see the note on location data below).
Scope / limitations (deliberate, for review)
the entire program) and is not cached — it re-runs every compile. The cache
elides the per-module type-check, which is the dominant cost for std.
SCC-group keying is not implemented, and the PoC fails loudly if it hits one.
tc-cache-pocto validate the concept; wiring it into the
sol-coreCLI (cache directory,eviction, invalidation on flag change) is the natural next step.
separate branch and is out of scope here.
the serialized modules are larger than they need to be (~3.3 MB for the 4
above). Since node equality already ignores locations, a real integration
would likely drop or normalize them before serializing.
How to run