moxzi
Docs / Concepts / Resumability

Resumabilityalpha

Why is every stage checkpointed, and why is on-chain compile time message count rather than instructions?

Resumability means every stage of the compile can stop mid-way, store what it has on the job record, return, and be resumed by the next message with identical results. It is not an optimization; without it the on-chain compiler cannot exist at all.

The constraint that forces it: the Internet Computer kills any update message that exceeds 40 billion instructionsCanister exceeded the limit of 40000000000 instructions for single message execution, error IC0522 — and a compile is far larger than that. A 7.6 MB compile is roughly 451 billion reported instructions. It has to be at least a dozen messages by arithmetic, and in practice thousands.

Checkpointing between stages is not enough#

The obvious design gives each pipeline stage its own message. That fails for two separate reasons, both measured:

So checkpointing happens at three depths.

DepthMechanismWhere
Between phasesThe job record holds all state; step returns #more with no calls outstandingstepCore in bin/compiler-canister/Main.mo
Inside a passEach IR pass is a pull-driven { step; finish } machine stored on the job; one declaration per step()mops/mo-ir-passes/*, staged twins
Inside one buildFuel: the sync descent counts entries, latches exhaustion, then snapshot / rollback / replaybuildWithFuel in CompileEnhanced.mo

The third is the interesting one. Codegen's expression descent cannot yield — colouring it async* overflows the engine's wasm stack on the compiler's giant functions — so instead compileExpH increments a counter on entry, reads the real performance counter every 64 entries, and latches fuelExhausted without aborting. The build completes (the bytes are correct; the message is merely over budget). At the next async* seam, buildWithFuel takes an O(1) environment snapshot, and if fuel was exhausted it restores the environment, replays an undo journal LIFO, crosses a real message boundary, and re-runs the build deterministically — identical reservations, identical pool order, identical bytes.

That determinism was proven rather than argued: a forced-rollback mode made every wrapped build snapshot, roll back, replay and re-run — 78 and 80 retries on two probe programs, and both came out byte-identical to the reference. The forcing was then removed, and setFuelForceRollback(b) retained as an audit hook. A build that still exceeds budget after a fresh-budget replay is accepted and logged as FUEL-OVERRUN <name>, so any such build names itself instead of hiding.

The governors#

These are the knobs that decide where a message ends. Every one of them is a measured number, and every one of them was set against a specific failure.

ConstantValueWhat it bounds
onchainStepUnitBudget24Resumable phase-units drained per message
onchainStepInstrBudget6 BInstructions spent before the batch loop stops starting new units
CONCLUDE_FRESH_START2 BA conclude unit only starts on a message this shallow — one unit can cost ~28 B alone
cgConcludeBatchHeap32 MiBHeap growth allowed while batching conclude sub-steps
cgConcludeBatchInstr3 BInstruction ceiling for the same batch
batchGarbageLimit1 GiBEstimated garbage (rts_heap_size - rts_max_live_size) that cuts a batch short
Sched.defaultHeapGrowthLimit4 MiBHeap growth since the last boundary that forces one
Sched.defaultStackBudget1000tick calls between forced real-await yields, bounding native stack depth
Sched.bigJumpReport384 MiBA round that grows past this prints SCHED-BIGJUMP — proof of an ungated region

Two of these have counter-intuitive shapes worth stating. Tightening the heap valve does not monotonically help: at 384 MiB the peak was 2,496 MB, at 128 MiB it was 2,304 MB, at 64 MiB it was 2,773 MB — because every boundary retains continuation state, so past the optimum more collection costs more memory than it frees. And onchainStepUnitBudget saturates: 8 units gives 2,817 steps, 24 gives 1,868, 64 gives 1,877. Past 24 the heap-growth valve binds instead.

Why message count is the clock#

On-chain wall-clock time is rounds × block time, and rounds are set by our own message count, not by instructions. The other candidate — the 75 MiB-per-round heap-delta rate limit — sets only a floor, and that floor is far below where a real compile sits.

For evm_engine (671 files):

RoundsWall clock at ~1 s/round
Heap-dirty floor (27.87 GB ÷ 75 MiB)354~6 min
Before the message-count work8,297~2.3 h
After (byte-identical output)4,964~1.38 h

Three changes got that, none of them about the IC's limits: the codegen "baseline beat" ladder was triggering on an absolute heap size that sat below the live set, so 3,658 of ~7,000 messages did nothing at all; the conclude driver returned to the dispatcher after every sub-step, each using ~0.14 % of the message cap, and now batches; and that batching was only safe after stripping the async* colour from codegen, because await* in a loop nests a CPS continuation per iteration and overflows the on-chain stack while passing every off-chain gate.

The planning rule: compile time ≈ max(dirty floor, message count) × block time, and message count is yours to control — bounded by the fact that the collector only runs between messages, so messages cannot grow so fat that garbage threatens the 6 GiB ceiling.

What you cannot measure this way#

instructionsUsed is not a price. performance_counter reports only what executes inside the canister's own wasm; the IC also bills GC, memory operations and scheduling it cannot see. Measured against real spend it under-reports by 2.3×–3.4×, and the ratio is not constant — heavier phases bill more per reported instruction. Storage and per-message overhead were both ruled out arithmetically as explanations.

Two further traps: performance_counter(1) resets on every inner await async {}, so a budget measured from a function's entry silently fails to accumulate across any unit that yields internally; and the off-chain performance counter is a stub that always grows, so an off-chain probe about instruction budgets returns a confident, meaningless answer. Numbers that matter here were measured on a replica.

Things you cannot do#

Not possibleWhy
Force a garbage collectionMotoko has no manual GC. Increments at message ends are the only lever; the compiler spends deliberate empty messages ("baseline beats") to buy collector slices.
Resume inside a single synchronous codegen buildFuel latches and the build finishes; the recovery is rollback and deterministic replay, not suspension.
Price a build from instructionsUsedSee above. Price from measured cycles, and from the worst case rather than the average.
Assume local timings predict on-chain onesLocal wasmtime fuel charges one unit per instruction; the IC charges bulk-memory operations roughly per byte, and wasm64 costs 2 cycles per instruction. A local worst message under 15 B landed near 40 B on-chain.
Raise a governor without measuring both axesEvery one of them trades messages against committed memory, and the memory ceiling is hard.

Next#

On this pageCheckpointing between stages is not enoughThe governorsWhy message count is the clockWhat you cannot measure this wayThings you cannot doNext