wasm-opt
Run binaryen's wasm-opt over the core Wasm that Wado generates, and read what it changes. Use for any question about wasm-opt or binaryen, or to find where Wado's codegen leaves size or speed on the t
SKILL.md.claude/skills/wasm-opt/SKILL.md
GitHubwasm-opt over Wado output
wasm-opt reads a core module, not a component. jco splits a Wado component and hands back its core module, so the pipeline runs through jco. What wasm-opt then removes shows what Wado's own codegen could remove.
Setup
binaryen is not in the mise registry, and mise's github: backend gets a 403
from the GitHub API in a cloud session. Download the release tarball instead:
curl -sSL https://github.com/WebAssembly/binaryen/releases/download/version_133/binaryen-version_133-x86_64-linux.tar.gz | tar xz -C scratchpad
Pipeline
B=scratchpad/binaryen-version_133/bin
S=scratchpad/wasm-opt
FEATURES=(--mvp-features --enable-sign-ext --enable-mutable-globals
--enable-nontrapping-float-to-int --enable-simd --enable-relaxed-simd
--enable-bulk-memory --enable-bulk-memory-opt --enable-call-indirect-overlong
--enable-exception-handling --enable-tail-call --enable-reference-types
--enable-multivalue --enable-gc --enable-extended-const --enable-multimemory)
mkdir -p $S
wado compile -Os -f no-wide-arithmetic prog.wado -o $S/prog.wasm
node scripts/jco/transpile-released.mjs $S/prog.wasm $S/jco # → $S/jco/prog.core.wasm
$B/wasm-opt "${FEATURES[@]}" $S/jco/prog.core.wasm -o $S/none.wasm
$B/wasm-opt "${FEATURES[@]}" -O3 $S/jco/prog.core.wasm -o $S/O3.wasm
- Make
-Osthe baseline. It is-O2with the name section stripped and assert messages dropped. An-O2baseline counts about 9KB of names that wasm-opt drops by default. -f no-wide-arithmeticis required, since the module is run on V8 (see thejcoskill).- Name the features explicitly, as an array because zsh does not word-split a
string. Never pass
-all: it lets wasm-opt emit proposals V8 cannot load, compact imports among them. - The run with no pass (
none.wasm) re-encodes the module and nothing more, so it separates encoding waste from optimization. - Add
-gto keep function names for a diff. Measure size without it.
Reading the difference
wado-dev-tools stands in for the wasm-tools CLI (scripts/dev-bin.sh wado-dev-tools <command> <file.wasm>).
objdumpgives the size and count of each section.print --offsetsputs each instruction's byte offset beside it. Use it for encoding questions: an instruction takes the bytes up to the next offset.printgives WAT to diff. A whole-module diff is too large to read, so compare per-function instruction counts and opcode histograms with a small Node script instead.-O2 --skip-pass=NAMEmeasures what one pass is worth: the bytes-O2grows by without it. Passes overlap, so these do not add up to the whole gap. Running a pass alone misleads for the-optimizingvariants, which rerun the whole function pipeline on what they touch.
To run the result, copy the jco output directory and replace its
<name>.core.wasm. Then run it as the jco skill describes. Keep a median of
at least 7 runs per variant, since the spread on a cloud VM is ±15%.
Findings: zlib benchmark (binaryen 133, 2026-10)
Sizes of the zlib benchmark's core module:
| Variant | Bytes |
|---|---|
Wado -Os |
76,476 |
| wasm-opt, no pass | 76,943 |
wasm-opt -O2 |
70,073 |
wasm-opt -O3 |
67,979 |
wasm-opt -Oz |
67,853 |
Re-encoding alone makes this module 467 bytes larger, so binaryen's encoding finds nothing to save in Wado's.
In 2026-09, on Node 26, decompression ran faster under wasm-opt -O2 and
-Os: the median rose 10–40% across two sessions. Compression, and everything
under -O3, stayed within the noise.
What each -O2 pass is worth, by --skip-pass:
| Pass | Bytes | What it does to Wado's output |
|---|---|---|
coalesce-locals |
2,230 | Mostly cleans up after inlining: 337 run alone |
inlining-optimizing |
1,799 | Takes 114 functions to 53 |
optimize-instructions |
1,164 | Peepholes, ref.as_non_null among them |
remove-unused-brs |
1,062 | Turns block/br_if/return into if/else |
precompute-propagate |
782 | Constant propagation |
dae-optimizing |
758 | Drops unused and always-constant parameters |
heap2local |
664 | Turns non-escaping structs into locals |
vacuum, merge-blocks |
824 | Removes the blocks the passes above leave behind |
What wasm-opt removes, as open codegen targets:
- Local declarations: the emitter wrote one entry per local, 2,942 entries
and 7.1KB in all. Grouped by type, they take 346 entries and 1.0KB.
wir_optimize/local_layout.rsgroups them, and puts the most used first. - Duplicate function types: 136 types, of which
-O2keeps 69. Most of the rest go with the functions it inlines. -
ref.as_non_null:-O2takes the count from 388 to 16. Codegen added most of them, after reads of nullable global slots. A global promoted to a non-null constant now has a non-null slot (wir_optimize/const_global.rs). The object of a GC access, which traps on null by itself, is not narrowed (wir_optimize/cleanup.rs). The-Osbuild keeps 17. - Set-then-get: a
local.set Xfollowed at once bylocal.get Xoccurred 1,191 times. wasm-opt turns it into alocal.teeor removes it. The last WIR pass fuses each pair into a tee (fuse_remaining_local_tees), and copy propagation removes a copy of a tee'd local. The-Osbuild keeps 39 pairs. - Set sinking:
simplify-localsmoves alocal.setinto the one read of it, so the value stays on the stack.-O2removes 565local.setand 725local.get, and adds 188local.tee. - Local coalescing: wasm-opt merges locals whose live ranges never overlap,
as a register allocator would.
wir_optimize/local_coalesce.rsdoes the same per Wasm type. On the-Osbuild it takes 2,752 locals to 1,020. - Control-flow shape:
-O2takesblockfrom 229 to 57,br_iffrom 191 to 54, andreturnfrom 184 to 44, whileifgrows from 1,278 to 1,385. - Single-caller inlining:
-O2inlines functions with one caller whatever their size,inflate_fastamong them. - Identical functions: the
-Osbuild has none. The-O2build keeps 9 copies of the outlined$cold0bounds-check path ofList<T>::index_valueandindex_assign, one per element type, andi32'sDisplay::fmtequals itsInspect::inspect.--duplicate-function-eliminationsaves 4,722 bytes there. Twoparam_specclones whose bindings differ can fold to one body (fmt_float_special$spec0and$spec1in json_twitter). - Empty functions: a module whose globals all become constants keeps an
empty
$initialize_module.wir_optimize/empty_work.rsremoves the calls to it and the once-flag that guarded them, and DCE drops both.