moxzi
Docs / CLI / Compiling and linking

Compiling and linkingalpha

What do build, link and validate actually do, and when do I need each?

moxzi build is two steps glued together: the compiler turns Motoko into an unlinked wasm module, and the linker adds the runtime system that makes it a canister. The constraint worth internalising is that the compiler's own output is not deployable — it has no runtime system and still carries link-only exports — so linking is part of producing a canister, not an optional extra.

The pipeline#

StepInputOutputWho runs it
resolveentry .mothe upload closure, VFS-namedmoxzi (see moxzi deps)
compilethe closureunlinked wasm + candidcompiler.wasm, driven message by message
linkunlinked wasmdeployable wasmlinker.wasm
validatedeployable wasmpass/failan external wasm validator, not moxzi

moxzi build does the first three by default. --no-link stops after the second. moxzi link runs the third on its own. Nothing in moxzi does the fourth.

Build#

moxzi build src/Main.mo -o main.wasm

Writes main.wasm and, unless --no-idl, main.did beside it. The .did is taken from the module's own candid:service metadata section, falling back to what the compiler reported. moc puts this behind --idl; here it is free and always wanted, because a canister without its interface file is not deliverable.

The metadata defaults follow dfx rather than moc: candid:service and candid:args are emitted public, so a deployed canister's interface is readable by dashboards and agents without controller rights. moc's raw default is all-private.

Two flags change how the compile runs without changing a byte of what it produces:

FlagWhat it changesWhen to use it
--stack-budget <N>how often the compiler's scheduler yields (0 = the canister default, 200)large projects — off-chain every yield is a self-call round trip, so a low ceiling costs minutes to hours
--force-gccollect garbage at every message boundarywhen a long build's heap climbs; expensive in instructions, which does not matter locally

Yields are checkpoints, so raising --stack-budget cannot change the output. The reason the default is low at all is the IC's small fixed stack, which does not apply locally.

moxzi build hello.mo -o u.wasm --no-link
moxzi link u.wasm -o u.linked.wasm
hello.mo -> u.wasm + u.did (66825 bytes, 1 file(s), 58 steps, 0.22s)
u.wasm -> u.linked.wasm (66825 -> 223251 bytes, 0.40s)

The size jump is the runtime system. Without -o, moxzi link writes <input>.linked.wasm.

You need moxzi link in exactly two situations:

  1. You compiled with --no-link — usually to inspect the compiler's raw output.
  2. You have an artifact from an on-chain compile. The compiler canister emits the same unlinked module; --remote links it locally for you, and moxzi link is how you do it by hand.

Linking is deterministic and mechanical, which is why the on-chain path leaves it on the client without weakening its provenance claim. Moving it on-chain too is separate work.

The linker is found by the same search order as the compiler: --linker, then MOXZI_LINKER, then beside the binary, then ../lib/moxzi/linker.wasm, then bin/linker-canister/linker.wasm. moxzi info reports which one is loaded and its hash.

Note that a build containing an imported actor class needs the linker even with --no-link: each class library is compiled and linked as its own canister module before the entry starts, because the entry's compile consumes the linked bytes.

Validate#

moxzi has no validate subcommand. Validation is a wasm-level check, and the repo's gate does it with wasm-validate from wabt:

wasm-validate --enable-memory64 main.wasm

--enable-memory64 is required: the modules are memory64. scripts/moxzi_gate.sh runs exactly this after every gate build and reports ok build: … B, validates, or skips loudly when wasm-validate is not installed.

The trap to avoid: an unlinked module does not validate, and that is correct. If you validate a --no-link artifact and it fails, you have confirmed the flag worked, not found a bug. Validate the linked module.

Where the time goes#

moxzi build hello.mo -o hello.wasm --timings
rts: allocated 0.07 GB, reclaimed 0.06 GB, peak live 5 MB, heap 13 MB, committed 201 MB
on-chain estimate: ~79 rounds (~0.02 h at 1s/round, ~0.01 h at 0.4s) | dirty-budget floor 1 rounds
timings: module 0.033s (cached), sources 0.001s, instantiate 0.001s, compile 0.208s, link 0.358s (66825 -> 223251 bytes)
PhaseWhat it is
moduleloading compiler.wasm; (cached) is the AOT cache, (JIT) is a cold ~1 s compile
sourcesresolving the closure from disk
instantiatecreating the compiler instance
compilethe compiler's own driver loop
linkthe linker canister over the unlinked module

On a hello world the link genuinely costs more than the compile. That is the runtime system's fixed cost, and it does not grow with your program the way the compile does.

Watching a long build#

FlagEffect
--progress <N>report every N steps (0 = quiet, the default)
--status-secs <S>additionally name the compiler's current phase, at most every S seconds
--max-steps <N>give up after N steps (default 5,000,000)

--status-secs costs a query per sample, and a query costs a state snapshot, so keep it coarse.

Things you cannot do#

Why
Deploy a --no-link artifactno runtime system, link-only exports still present — unlinked by design
Link without linker.wasmmoxzi build fails up front and names every path it tried; --no-link is the escape only when no actor class libraries are involved
Type-check without emittingno --check equivalent
Use the classical (non-EOP) backend--no-eop is retired; its IR is never closed and it traps in ConstFold on any program, hello world included
Change the output by tuning the scheduler--stack-budget and --force-gc change cost, never bytes — that is what makes them safe

Next#

On this pageThe pipelineBuildLinkValidateWhere the time goesWatching a long buildThings you cannot doNext