moxzi
Docs / On-chain builds / How a build runs

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_enginevalue
heap-dirty budget floor (27.87 GB ÷ 75 MiB/round)354 rounds ≈ 6 min
baseline6,967 messages / 8,297 rounds ≈ 2.3 h at 1 s/round
after removing no-op "beat" messages and stripping async* from codegen5,447 messages / 6,779 rounds
after batching the conclude phase3,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.

driverwho calls itshape
step(id)an external client, in a loopone ingress per step; this is what moxzi build --remote uses
startSelfDriven(opts)the canister itselfeach 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.

stagephasework
parse / parse-entrylex and parse the entry file, incrementally, with a per-message parse window
scan-lib-deps, resolve, parse-libswalk imports, resolve them against the VFS, parse each library
0parse-internalsparse the compiler's own internals module
1–2int-gather, int-tc-per-decgather then typecheck the internals, one declaration per step
3–4user-gather, user-tc-per-decthe same for your code
5–6desugar (user, then internals)lower the AST, one declaration at a time
7assemblebuild the single IR program
8–14IR passeseach pass is a pull-driven { step; finish } machine that returns from the message and resumes on the next one
15compile / compile-enhancedcodegen, then a stepped conclude machine — one sub-phase per message
15emitthree chunks, each in its own fresh ingress: module bytes, name section, then metadata + join
doneartifact 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 partwhat it covers
debug_show of the whole Options recordevery build flag, structurally — a field added later is covered automatically
every file in the VFS, hashed and path-sortedthe source closure; over-keys when unrelated files are present, which costs hit rate, never correctness
cacheVersionthe 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.

FieldWhat it commits to
sourcesDigestsha256 over the path-sorted path:sha256n closure, so VFS iteration order cannot change it. The same material the build cache keys on.
optionsDigestthe flags. Two builds of one tree under different flags are different artifacts and must not claim to be the same one.
compilerVersionwhat the artifact carries in motoko:compiler.
cacheVersionthe operator-set module hash of the compiler canister. Not self-reported trust: read the real hash from the IC and compare.
artifactSha256, artifactBytesthe 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 cyclesThe limit is rounds, not instructions. Cycles buy the messages; they do not compress them.
Run two jobs concurrently on one canisterBuilds serialize. Settlement also measures a cycle-balance delta, which is only correct for a serial queue.
Resume a job across a canister upgradeThe 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 bytesA reply over ~1.9 MB omits the blob; page it out with jobWasmSize and jobWasmChunk.

Next#

On this pageWall clock is message count, not instructionsThe two driversWhat each phase is doingThe whole-build cacheRetentionProving what was builtThings you cannot doNext