Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions docs/typecheck-cache-poc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Typecheck cache — proof of concept

## 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:

1. the module's own source,
2. the public interfaces of the modules it references, and
3. the compile flags that reach the type-checker.

So we give every module a **content-addressed Merkle key**:

```
key(M) = keccak( contentHash(M)
++ sorted [ key(R) | R <- references(M) ]
++ flagComponent(M) )
```

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.dispatch` and only `std.dispatch` + its dependents are rechecked; `std` and
`std.opcodes` are 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

| File | Role |
|------|------|
| `src/Solcore/Pipeline/TypecheckCache.hs` | Merkle cache keys; precise-invalidation helper (`transitiveDependents`) |
| `src/Solcore/Pipeline/TcCacheSerialize.hs` | `binary` serialization of a checked module; magic + version header |
| `src/Solcore/Pipeline/SolcorePipeline.hs` | `compileGraphWithCache` — reuse cached modules instead of re-checking |
| `src/Solcore/Util/Keccak.hs` | pure-Haskell keccak-256 used for the content hashes |
| `poc/Main.hs` | `tc-cache-poc` harness that demonstrates and checks all of the above |

### Cache keys (`TypecheckCache.hs`)

- `contentHash` is taken over the **parsed** compilation unit, so it's
insensitive to comments/whitespace for free.
- Reference keys are folded in dependency order and sorted, so the key is
independent of import order.
- `flagComponent` records only the flags that affect type-checking
(`--no-desugar-calls`); `-g`/dispatch generation is folded in **only for
modules that contain contracts**, so library keys stay stable as the UI
toggles `-g`.

### Serialization (`TcCacheSerialize.hs`)

- A cached module carries only what the assembly step reads back from a
non-entry module: its typed `CompUnit` and the `typeTable` of 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.
- Every blob is prefixed with a **magic number + format version**. A dump
written by an incompatible build (or a corrupted/foreign file) is rejected and
degrades to a cache miss (recompute) — never a wrong result.
- We use `binary` (a GHC boot library, pure Haskell) rather than a JSON/aeson
stack: compact, no dependency tail, and the format is confined behind
`encodeCache` / `decodeCache` so it can be swapped without touching the rest.

### Pipeline seam (`SolcorePipeline.hs`)

`compileGraphWithCache opts graph cache` runs the normal pipeline but reuses any
module present in `cache` (keyed by `ModuleId`) instead of type-checking it, and
returns the full set of checked modules so a caller can seed the next run. The
existing `compile` path is unchanged (it calls this with an empty cache).

## Results

`cabal run tc-cache-poc -- test/examples/dispatch/counter.solc` demonstrates, on
a 4-module graph (`std`, `std.opcodes`, `std.dispatch`, `counter`):

- **Merkle keys** printed per module.
- **Invalidation property** (checked as an assertion): editing `std.opcodes`
rechecks all 4; `std` → 3; `std.dispatch` → 2; `counter` → 1 — exactly the
transitive-dependent set.
- **Determinism**: keys are stable across a reload of the same sources.
- **On-disk round-trip**: the cold-checked modules are written to
`solcore-tc-cache.bin`, read back, decoded, and used to compile again.
- **Format guard**: a blob with a corrupted header is rejected.
- **Correctness**: the in-process warm/hit runs and the on-disk run all produce
**byte-identical hull** to the cold run.

Representative timing (type-check phase is the part the cache elides):

```
objects: 1 | cold 3.74s | warm 1.05s | hit 0.20s | disk 0.19s
```

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)

- **Type-check only.** Specialization is whole-program (monomorphization across
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.
- **No cyclic import groups yet.** Modules in an import cycle share an SCC;
SCC-group keying is not implemented, and the PoC fails loudly if it hits one.
- **Harness, not CLI integration.** The disk cache is exercised by `tc-cache-poc`
to validate the concept; wiring it into the `sol-core` CLI (cache directory,
eviction, invalidation on flag change) is the natural next step.
- **Native only.** A browser/session variant (in-memory + IndexedDB) exists on a
separate branch and is out of scope here.
- **Blob size.** The typed AST now carries a source location on every node, so
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

```bash
cabal run tc-cache-poc -- test/examples/dispatch/counter.solc
```
174 changes: 174 additions & 0 deletions poc/Main.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
-- | PoC harness for the typecheck cache.
--
-- Phase 1 (caching seam): reusing a previously-computed 'CheckedModule' instead
-- of re-typechecking yields byte-identical downstream output, and saves the
-- typecheck time. No serialization yet — the cache is an in-process 'Map'.
--
-- Phase 2 (Merkle keying): a content-addressed key per module drives cache
-- reuse. We check keying determinism, the precise-invalidation property (editing
-- a module invalidates exactly it plus its transitive dependents), and use the
-- keys to select which modules to reuse when the entry module is "edited".
module Main where

import Control.Monad (forM_, unless)
import Control.Monad.Except (ExceptT (..), runExceptT)
import Control.Monad.IO.Class (liftIO)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Maybe (isNothing)
import Data.Set qualified as Set
import Language.Hull qualified as Hull
import Solcore.Frontend.Module.Identity qualified as Mod
import Solcore.Frontend.Module.Loader (LoadedModule (..), ModuleGraph (..), loadModuleGraph)
import Solcore.Frontend.TypeInference.TcModule (CheckedModule)
import Solcore.Pipeline.Options (Option (..), emptyOption)
import Solcore.Pipeline.SolcorePipeline (compileDiagnosticsText, compileGraphWithCache, parseExternalLibSpecs, parseStdRoot)
import Solcore.Pipeline.TcCacheSerialize (decodeCache, encodeCache, fromCachedModule, toCachedModule)
import Solcore.Pipeline.TypecheckCache
( TcCacheKey (..),
moduleCacheKeys,
moduleCacheKeysWith,
moduleHasContracts,
transitiveDependents,
)
import System.Directory (getTemporaryDirectory, makeAbsolute)
import System.Environment (getArgs)
import System.Exit (exitFailure)
import System.FilePath ((</>))
import System.TimeIt (timeItT)
import Text.Printf (printf)

main :: IO ()
main = do
args <- getArgs
let file = case args of
(f : _) -> f
[] -> "test/examples/dispatch/counter.solc"
opts = (emptyOption file) {optTiming = True}
putStrLn ("PoC file: " ++ file)
graph <- loadGraph opts file
let order = moduleOrder graph
entry = entryModule graph
display = Mod.moduleIdDisplay
hasContracts m = moduleHasContracts (loadedCompUnit (modules graph Map.! m))
printf "modules in graph: %d (entry: %s)\n" (length order) (display entry)

keys <- orDie (moduleCacheKeys opts graph)

-- Cache keys ----------------------------------------------------------------
section "cache keys (Merkle: source + reference keys + flags)"
forM_ order $ \m ->
printf " %-42s key=%s contracts=%s\n" (display m) (shortKey (keys Map.! m)) (show (hasContracts m))

-- Keying is deterministic across a fresh load of the same sources.
graph2 <- loadGraph opts file
keys2 <- orDie (moduleCacheKeys opts graph2)
assert "keys are stable across a reload" (keys == keys2)

-- Precise invalidation: editing a module changes the key of exactly that
-- module plus every module that (transitively) references it.
section "invalidation property (edit M -> which modules must be rechecked)"
forM_ order $ \m -> do
edited <- orDie (moduleCacheKeysWith (bump m) opts graph)
let changed = Set.fromList [x | x <- order, Map.lookup x keys /= Map.lookup x edited]
expected = transitiveDependents graph m
printf
" edit %-42s -> %d recheck(s): %s\n"
(display m)
(Set.size changed)
(unwords (map display (Set.toList changed)))
assert ("invalidation set matches dependents for " ++ display m) (changed == expected)

-- Correctness + timing ------------------------------------------------------
section "compile runs (cold / key-driven warm / full hit)"
(hull0, checked0, t0) <- runPipeline "cold (empty cache)" opts graph Map.empty

-- Simulate the IDE editing the entry module: reuse every module whose key is
-- unchanged by that edit. That set is computed purely from the keys.
editedKeys <- orDie (moduleCacheKeysWith (bump entry) opts graph)
let reusable = Set.fromList [m | m <- order, Map.lookup m keys == Map.lookup m editedKeys]
stdCache = Map.restrictKeys checked0 reusable
printf "entry edit reuses %d/%d modules by key\n" (Set.size reusable) (length order)
(hull1, _, t1) <- runPipeline "warm (key-driven: entry edited)" opts graph stdCache

(hull2, _, t2) <- runPipeline "hit (all modules cached)" opts graph checked0

-- Phase 3: serialize the cold-checked modules (keyed by Merkle key), round
-- trip through disk with binary, and compile again reusing the decoded cache.
section "serialization round-trip (binary, on disk)"
tmp <- getTemporaryDirectory
let cacheFile = tmp </> "solcore-tc-cache.bin"
keyed = Map.fromList [(keys Map.! m, toCachedModule cm) | (m, cm) <- Map.toList checked0]
BL.writeFile cacheFile (encodeCache keyed)
blob <- BL.readFile cacheFile
decoded <-
maybe
(putStrLn "cache decode failed (bad header/version)" >> exitFailure)
pure
(decodeCache blob)
let fromDisk =
Map.fromList
[ (m, fromCachedModule opts cm)
| m <- order,
Just cm <- [Map.lookup (keys Map.! m) decoded]
]
printf "encoded %d modules -> %s (%d bytes)\n" (Map.size decoded) cacheFile (BL.length blob)
-- Format guard: a blob with a corrupted header is rejected (miss, not crash).
assert
"tampered/foreign dump is rejected by the version guard"
(isNothing (decodeCache (BL.cons 0xff (BL.drop 1 blob))))
putStrLn "format guard: tampered dump correctly rejected (would recompute)"
(hullD, _, tD) <- runPipeline "disk (all modules from on-disk cache)" opts graph fromDisk

let render = map show :: [Hull.Object] -> [String]
ok = all (== render hull0) [render hull1, render hull2, render hullD]
printf
"\nobjects: %d | cold %.2fs | warm %.2fs | hit %.2fs | disk %.2fs\n"
(length hull0)
t0
t1
t2
tD
if ok
then putStrLn "RESULT: OK — in-process and on-disk caches both reproduce the cold run"
else putStrLn "RESULT: MISMATCH — a cached run differs from the cold run" >> exitFailure

-- | Perturb one module's content hash, to simulate an edit to it.
bump :: Mod.ModuleId -> Mod.ModuleId -> BS.ByteString -> BS.ByteString
bump target moduleId h = if moduleId == target then h <> "EDIT" else h

shortKey :: TcCacheKey -> String
shortKey (TcCacheKey bs) = concatMap (printf "%02x") (BS.unpack (BS.take 6 bs))

section :: String -> IO ()
section title = putStrLn ("\n== " ++ title ++ " ==")

assert :: String -> Bool -> IO ()
assert msg cond = unless cond (putStrLn ("FAIL: " ++ msg) >> exitFailure)

orDie :: Either String a -> IO a
orDie = either (\err -> putStrLn err >> exitFailure) pure

loadGraph :: Option -> FilePath -> IO ModuleGraph
loadGraph opts file = do
result <- runExceptT $ do
mainRoot <- liftIO (makeAbsolute (optRootDir opts))
stdRoot <- ExceptT (pure (parseStdRoot (optImportDirs opts)))
externalLibs <- ExceptT (pure (parseExternalLibSpecs (optExternalLibs opts)))
ExceptT (loadModuleGraph mainRoot stdRoot externalLibs file)
orDie result

runPipeline ::
String ->
Option ->
ModuleGraph ->
Map Mod.ModuleId CheckedModule ->
IO ([Hull.Object], Map Mod.ModuleId CheckedModule, Double)
runPipeline label opts graph cache = do
printf "\n-- %s: %d cached --\n" label (Map.size cache)
(elapsed, result) <- timeItT (runExceptT (compileGraphWithCache opts graph cache))
case result of
Left err -> putStrLn ("compile error: " ++ compileDiagnosticsText err) >> exitFailure
Right (hull, checked) -> pure (hull, checked, elapsed)
15 changes: 15 additions & 0 deletions sol-core.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ common common-opts
build-depends:
base >= 4.19.0.0
, mtl
, binary
, bytestring
, containers
, cryptonite
Expand Down Expand Up @@ -114,6 +115,9 @@ library
Solcore.Frontend.TypeInference.TcUnify
Solcore.Pipeline.Options
Solcore.Pipeline.SolcorePipeline
Solcore.Pipeline.TypecheckCache
Solcore.Pipeline.TcCacheSerialize
Solcore.Util.Keccak
Solcore.Primitives.Primitives
Language.Hull
Language.Hull.Compress
Expand Down Expand Up @@ -151,6 +155,16 @@ executable sol-core
ghc-options:
-O1 -rtsopts

-- Typecheck-cache PoC harness: demonstrates Merkle cache keys, precise
-- invalidation, and binary dump + on-disk reuse (see poc/Main.hs).
executable tc-cache-poc
import: common-opts
main-is: Main.hs
hs-source-dirs: poc
build-depends: sol-core, timeit
ghc-options:
-O1 -rtsopts

executable yule
import: common-opts
main-is: Main.hs
Expand Down Expand Up @@ -189,6 +203,7 @@ test-suite sol-core-tests
DiagnosticCliTests
DiagnosticTests
HullCases
KeccakTests
LocationTests
MatchCompilerTests
ModuleTypeCheckTests
Expand Down
Loading
Loading