How a build runsalpha
Why does a build take minutes, and what is happening in each step?
A build is a state machine advanced one update message at a time: each call to step(id) runs as much of the compile as fits in one message and returns #more, #done, #err, or #needClass. The constraint that produces every other property on this page is the IC's per-message instruction cap — 40 billion instructions, IC0522 when exceeded — so no amount of engineering makes a compile one call.
Wall clock is message count, not instructions#
The intuition that a compile is slow because it is a lot of computation is wrong here. A compile step uses a small fraction of the per-message budget; measured on evm_engine, per-message instruction use ran 0.14%–0.35% of the 40 B cap. What costs time is that every message must be scheduled into a block.
wall clock ~= messages x rounds-per-message x block time
Measured under rate-limited PocketIC, rounds tracked messages at about 1.36 rounds per message. That is why moxzi build --timings prints an on-chain round estimate from the local step count — and prints it at both 1 s and 0.4 s per round, because block time is the one term in that product we do not control. Reducing message count was therefore the entire performance campaign for the on-chain path.
quantity, evm_engine | value |
|---|---|
| heap-dirty budget floor (27.87 GB ÷ 75 MiB/round) | 354 rounds ≈ 6 min |
| baseline | 6,967 messages / 8,297 rounds ≈ 2.3 h at 1 s/round |
after removing no-op "beat" messages and stripping async* from codegen | 5,447 messages / 6,779 rounds |
| after batching the conclude phase | 3,639 messages / 4,964 rounds, 672 s |
The dirty-page budget is not the binding constraint — it was 23x below the real cost. Our own message count was.
The two drivers#
There are two ways the same stepCore gets advanced, and they exist for different failure modes.
| driver | who calls it | shape |
|---|---|---|
step(id) | an external client, in a loop | one ingress per step; this is what moxzi build --remote uses |
startSelfDriven(opts) | the canister itself | each timer fire runs a batch of steps in a fresh system-scheduled message and re-arms |
The step(id) driver's failure mode is its own death — a network blip 90 minutes into a three-hour build. That loses nothing on-chain: between steps the job is simply parked state, so the CLI retries transport errors and, if the driver dies anyway, moxzi build --remote --resume-job <id> picks the same job up where it stopped (measured: a build stranded at message 3,034 finished with 1,104 more rather than starting over).
The self-driven path exists because between steps there is no live call context at all — nothing for a replica to lose, and every boundary is a real message end where the incremental GC can run. Its batch loop runs up to 8 steps per timer fire, stops early when the heap passes 2 GB (and then re-arms with a 3-second GC window instead of immediately), and never batches two conclude/emit phases into one call context. sdStatus() reports (active, steps, last); sdPoke(id) is a watchdog re-arm for the case where a timer expiration is dropped, and it is owner-or-operator only because it force-releases the re-entrancy guard.
What each phase is doing#
status(id) returns a phase string; on the EOP backend the detail also carries a stage number. The stages are a pipeline, and each one is itself resumable so no single stage becomes one enormous message.
| stage | phase | work |
|---|---|---|
| — | parse / parse-entry | lex and parse the entry file, incrementally, with a per-message parse window |
| — | scan-lib-deps, resolve, parse-libs | walk imports, resolve them against the VFS, parse each library |
| 0 | parse-internals | parse the compiler's own internals module |
| 1–2 | int-gather, int-tc-per-dec | gather then typecheck the internals, one declaration per step |
| 3–4 | user-gather, user-tc-per-dec | the same for your code |
| 5–6 | desugar (user, then internals) | lower the AST, one declaration at a time |
| 7 | assemble | build the single IR program |
| 8–14 | IR passes | each pass is a pull-driven { step; finish } machine that returns from the message and resumes on the next one |
| 15 | compile / compile-enhanced | codegen, then a stepped conclude machine — one sub-phase per message |
| 15 | emit | three chunks, each in its own fresh ingress: module bytes, name section, then metadata + join |
| — | done | artifact and candid text retained; everything else freed |
(The compile-lower / compile-batch / compile-link phases belong to the older non-EOP path, which is not what a moxzi build produces.)
The resumption shape matters: passes yield by returning from the message, with state left on the heap, rather than by awaiting. There is no in-flight call context to lose between steps.
Two step budgets bound a message. onchainStepInstrBudget is 6 billion instructions — deliberately far under the 40 B cap, because mainnet was measured to price roughly 3x wasmtime fuel for this workload, so a 12 B local target lands near 36 B on-chain. A conclude unit can cost ~28 B by itself, so it only starts on a message that has spent under 2 B (CONCLUDE_FRESH_START).
The whole-build cache#
An exact repeat build returns the artifact for the price of a lookup. The key has three parts, and each defends a different way of serving a stale artifact:
| key part | what it covers |
|---|---|
debug_show of the whole Options record | every build flag, structurally — a field added later is covered automatically |
| every file in the VFS, hashed and path-sorted | the source closure; over-keys when unrelated files are present, which costs hit rate, never correctness |
cacheVersion | the compiler build, set by the operator to the installed module hash |
Until an operator calls setCacheVersion, the cache is inert — forgetting it means no caching, never stale artifacts. buildCacheStats() reports enabled, entries, bytes, hits, misses. Storage is capped at 200 MiB; past the cap, inserts simply stop.
Retention#
Finished jobs keep only their deliverables — the wasm, the candid text, the diagnostics, and the counters status reports. Everything else (the lowered IR, both parse caches, the typechecker environment, the codegen module) is freed at completion.
Retained artifacts are capped at 100 MiB in total. When a new job starts, the oldest finished jobs are dropped until the total fits — except any job whose settlement is still open, which is never evicted, because settlement reads the job to learn whether it finished and evicting it would strand the customer's ICP.
Proving what was built#
A build that finishes leaves a record, and the record is stable — it outlives the job, the artifact's eviction, and a canister upgrade. That ordering is the point: the evidence has to outlast the thing it is evidence of.
| Field | What it commits to |
|---|---|
sourcesDigest | sha256 over the path-sorted path:sha256n closure, so VFS iteration order cannot change it. The same material the build cache keys on. |
optionsDigest | the flags. Two builds of one tree under different flags are different artifacts and must not claim to be the same one. |
compilerVersion | what the artifact carries in motoko:compiler. |
cacheVersion | the operator-set module hash of the compiler canister. Not self-reported trust: read the real hash from the IC and compare. |
artifactSha256, artifactBytes | the unlinked module this job produced. |
buildRecord : (JobId) -> opt BuildRecord query
buildSources : (JobId) -> opt vec (text, text) query -- the file list, recent builds only
recentBuilds : (nat) -> vec (JobId, BuildRecord) query
Verification is a comparison anyone can perform, and scripts/mainnet/verify-attestation.mjs performs it: pull the file list, recompute sourcesDigest locally, and check each file hash against your own tree. Nothing in the chain asks you to trust the canister's word.
The artifact hash is the unlinked module because the canister compiles and does not link. Linking is deterministic — measured, three separate processes, one hash — so the deployed (linked) bytes follow from it, and moxzi build --remote does that link locally. See .plan/build-provenance.plan.md for the full manifest design this is step one of.
Records are kept in bounded rings (the newest few hundred; file lists for fewer). An evicted digest is not lost information — it is reproducible by rebuilding, which is the property that makes eviction acceptable.
Things you cannot do#
| Why | |
|---|---|
| Speed a build up by giving it more cycles | The limit is rounds, not instructions. Cycles buy the messages; they do not compress them. |
| Run two jobs concurrently on one canister | Builds serialize. Settlement also measures a cycle-balance delta, which is only correct for a serial queue. |
| Resume a job across a canister upgrade | The Job record is transient; it holds parser cursors and closures Motoko cannot persist. Nothing about a build survives an upgrade — the codegen-pause snapshot is transient too, deliberately: when it was stable, a stable ?Ir.Prog pinned the whole canister's upgradeability to the structural stability of the compiler's own IR, and blocked a deploy. What DOES survive is the build record and the money. |
Rely on #done carrying the bytes | A reply over ~1.9 MB omits the blob; page it out with jobWasmSize and jobWasmChunk. |
Next#
- What a build costs — messages and instructions are the billing basis.
- Sources and packages — how the closure gets into the VFS.
- Linking and installing — why the emit phase stops short of a deployable module.