WASM composition: what's inside nanopython.wasm¶
A snapshot of the wasm binary's layout, what each subsystem contributes, and where the remaining size budget is hiding. Numbers below were measured at the project state of this commit. Re-running python3 scripts/wasm_composition.py regenerates the report against the current binary; the snapshot below is for explanation rather than for tracking.
The companion script scripts/wasm_composition.py is the canonical tool for this analysis. It has no external dependencies and reads web/nanopython.wasm by default; pass an alternate path to inspect a different build.
Snapshot¶
web/nanopython.wasm: 343,714 bytes raw (~336 KB), ~140 KB compressed. Built with zig build -Dtarget=wasm32-wasi -Doptimize=ReleaseSmall against the nano config + -Dfeature-js=true. No name section (ReleaseSmall strips it), no debug info, no sourcemaps.
Section-level breakdown¶
| Section | Bytes | % | Notes |
|---|---|---|---|
| Code (function bodies) | 260,153 | 75.7% | 1,453 defined Zig functions |
| Data (initial memory) | 52,394 | 15.2% | qstr strings + ROM tables + error messages |
| Export (symbol names) | 23,347 | 6.8% | 1,026 exported names (the strangler's C-ABI surface) |
| Global (mutable slots) | 3,271 | 1.0% | 408 globals (qstr table base, builtin singletons, ROM addresses) |
| Import (host functions) | 1,571 | 0.5% | 46 imports: 38 WASI shims + 8 J5 JS bridge externs |
| Function (signatures) | 1,460 | 0.4% | 1,453 type indices |
| Element (indirect-call table) | 1,088 | 0.3% | vtable entries for type slots |
| Type (signatures dedup) | 423 | 0.1% | 55 unique function signatures |
| Table + Memory | 15 | 0.0% | declarations |
Function-size distribution¶
| Bucket | # funcs | Total bytes | % of Code |
|---|---|---|---|
| ≤32 bytes | 400 | 6,439 | 2% |
| 32–128 | 536 | 38,884 | 15% |
| 128–512 | 427 | 106,006 | 41% |
| 512–2,048 | 81 | 66,658 | 25% |
| 2,048–8,192 | 8 | 30,002 | 11% |
| 8,192+ | 1 | 10,185 | 4% |
Median function: 74 bytes. Average: 177 bytes. The single largest is npy_zig_compile_to_mpy at 10 KB (the parse-tree → bytecode pipeline as one big switched dispatch).
Code section by inferred subsystem¶
604 of the 1,453 functions have export names; the other 849 (68% of the code section) are internal Zig static helpers and slot dispatchers that ReleaseSmall stripped names for. Classifying by export-name prefix:
| Subsystem | Bytes | % | # funcs | Notes |
|---|---|---|---|---|
| Unnamed internal Zig | 177,971 | 68% | 849 | static helpers, closure bodies, the bulk of VM dispatch, parser internals, GC mark/sweep |
| Compiler driver | 10,266 | 3% | 3 | npy_zig_compile_to_mpy parse→emit |
Misc mp_* utilities |
8,236 | 3% | 86 | hash, qstr lookup, type predicates |
| Object: str | 7,991 | 3% | 97 | str slot ops, vstr, mpprint (97 named functions) |
| Object: list/tuple/array | 6,861 | 2% | 44 | seq ops, indexing, slicing |
| WASI startup | 6,701 | 2% | 3 | _start, __main_argc_argv + Zig std runtime bootstrap |
Strangler bridges (npy_*) |
4,389 | 1% | 46 | C-ABI shim wrappers around pyerr.bridge(...) |
| Object: int/bool/bignum | 4,279 | 1% | 32 | int slot ops, bignum (compiler track) |
| Object: dict/map/set | 4,251 | 1% | 46 | hash maps + set ops |
| Object: iter | 3,449 | 1% | 31 | generator/enumerate/filter/zip/reversed |
| Modules: builtins | 2,828 | 1% | 36 | print, len, abs, … |
| Object: type/instance/module | 2,807 | 1% | 19 | metaclass machinery |
| Runtime dispatch | 2,724 | 1% | 25 | mp_binary_op, mp_call_*, mp_load_*, mp_subscr |
| qstr pool | 1,899 | 1% | 12 | interning + hash table |
| VM bytecode | 1,883 | 1% | 3 | mp_execute_bytecode (most VM body is inside the unnamed-internal pool) |
| Exception machinery | 1,782 | 1% | 22 | mp_raise_* + polled-sentinel bridge |
| Print / format | 1,088 | 0% | 1 | mp_vprintf |
| Object: fun/closure | 1,420 | 0% | 13 | callable machinery |
| Parser | 980 | 0% | 7 | most parser is inside the unnamed pool |
| Other (deque, GC, m_*, range, stream, modules) | <1 KB each | ~4% | scattered | small surfaces |
Source-LOC → wasm bytes ratio¶
Total Zig: 34,103 LOC → 258 KB Code section = ~7.6 bytes per source line after -Doptimize=ReleaseSmall. Roughly traceable to source-tree subdirectories:
src/ subdir |
LOC | Estimated wasm contribution |
|---|---|---|
runtime/ (VM + dispatch + builtins-host) |
~10,000 | ~75 KB |
objects/ (all Python types) |
~8,000 | ~60 KB |
compiler/ (lexer + parser + emit + scope + mpy) |
~7,000 | ~50 KB |
modules/ (sys, math, struct, gc, collections, …) |
~3,500 | ~25 KB |
mp_mirrors.zig + pyerr.zig + mp_layout_check.zig + mp_type_roms.zig + entry/ |
~2,000 | ~15 KB |
memory/ (gc, alloc, pairheap, ringbuf) |
~1,500 | ~10 KB |
support/ (smallint, unicode, stream, readline) |
~1,500 | ~10 KB |
main.zig, main_vm.zig, vendor_stubs.zig, glue |
~600 | ~5 KB |
Data section composition (51 KB)¶
| Bytes | Content | |
|---|---|---|
| Printable ASCII strings | ~12,500 | 967 distinct strings: error messages, version banner, qstr-pool seed strings, builtin module names, ROM type names |
| Binary tables | ~39,500 | qstr hash table + qstr length/offset arrays, builtin module registry, ROM type structs, slot vtables, dispatch tables |
Two data segments: one for .rodata (constants), one for the initial .data (mutable globals starting state).
Imports: the host surface¶
| WASI shims | 38: full WASI preview1 surface (fd_*, path_*, clock_*, proc_exit, random_get, poll_oneoff, …) |
| J5 JS bridge | 8: js_call, js_get_global, js_get_attr, js_set_attr, js_new, js_to_string_len, js_from_string_len, js_dispatch_callback |
If you build with -Dfeature-js=false, the 8 JS imports disappear and any js.py-only Python disappears from the qstr pool.
Opportunities for fat loss¶
Roughly ordered by cost-to-implement vs. expected payoff. Theme F's per-feature flags already produced an 8.95% reduction (286 KB → 262 KB); the techniques below extend beyond that.
Easy wins (mechanical; estimated 10–25% reduction combined)¶
-
Run
binaryen wasm-opt -Ozpost-link. Mentioned in ADR-001 Theme F. Typically 5–15% on a Zig-produced wasm binary with no source changes: peephole optimization, function-body simplification, dead-instruction removal. Plug into the build as amake wasm-opttarget afterzig build -Dtarget=wasm32-wasi. -
Strip the Export section more aggressively. We currently export 1,026 names (23 KB). Many
npy_*strangler bridges were exported so vendor C could call them; with vendor C gone under nano, only the bridges still consumed by Zig-from-Zig or by the JS bridge actually need export. An audit pass that finds unreferencedpub export fnand demotes them topub fncould potentially halve this section (~10 KB savings + the function body itself becoming DCE-eligible). The audit is mechanical: dump exports, grep for callers outside the strangler, demote the rest. Risk: anything called from them_layout_check_*runtime path, themp_state_ctxaccessors, or the WASI startup must remain exported. -
Cull unused WASI imports. We import 38 WASI functions; the runtime path used by
web/*demos calls perhaps 6 (fd_write,proc_exit,clock_time_get,random_get, the fd-related ones for stdin). The other ~32 (filesystem path operations, polling, environment access) come in because Zig's std library declares them in case any path through std happens to use them. Forcing--export=fooflags +--no-importfor unused WASI names (or providing trivial stub Zig code that doesn't reach those) would cut the Import section by perhaps 1 KB and let the linker DCE the std-library code paths that reference them. Possibly a few hundred bytes inside Code too. -
Shorten error message strings. The 12.5 KB of strings in Data includes verbose user-facing messages like
"unsupported value type for js bridge (must be str, int, bool, callable, or JsProxy)"(84 bytes). Replacing with error codes + a small lookup table in user space would compact this, though at the cost of message quality. A middle ground: a-Dterse-errors=trueflag that swaps the verbose strings for short codes; tools/tests look up the codes for human display. Potential savings: 5–8 KB.
Medium wins (architectural; another 5–15%)¶
-
Sub-component carveouts. The Theme F notes mention an experimental
nanopython-vm.wasmat 217 KB that strips the compiler and ships only the VM (loads.mpyfiles at startup). A formalmake vm-onlytarget would produce this artifact: the compiler track moves to a separate compile-time toolchain; the runtime wasm gets ~50 KB lighter. Trade-off: users can't compile Python at runtime; they pre-compile viamake build-mpy yourfile.py. Good for "ship a fixed Python program" workflows like the ci-build-monitor extension, where the source is known at deploy time. -
Combine printf-family code paths.
mp_vprintfis 1 KB, and the wrapping helpers (mp_print_str,mp_print_int,mp_print_mp_int,mp_printfwith different signatures) collectively account for several KB across Print/format. A singlemp_formatentry point with type-tag-dispatch (instead of N specialized formatters) could save ~2 KB. Architectural cost: changing the Print protocol breaks the strangler's exact-mirror discipline. Defer until the strangler discipline no longer applies. -
Object slot dispatcher consolidation. Each Python type owns a
mp_obj_type_tstruct with print/binary_op/unary_op/subscr/getiter/iternext/buffer_p/protocol slots: ~30 fields per type, many of which are null for any given type. With ~25 types in nano, the slot tables are 25 × 30 × 8 bytes = ~6 KB of mostly-null ROM. A compact format (bitmap + non-null entries) could halve this. Trade-off: an extra indirection on every slot dispatch; the per-call cost is small (one cmp + one load) but it changes the hot path. -
More per-feature flags. Theme F gates 8 modules; the same mechanism can extend to language features (generators, comprehensions, f-strings, decorators) where the compile + runtime cost can be skipped per-build. Each gate is small (1–4 KB) but additive. Risk: combinatorial explosion in CI matrix; the per-feature build registry from ADR-002 Path C addresses this.
Harder wins (research-grade; 10%+ each but with significant cost)¶
-
Move the compiler to a separate wasm. Same idea as #5 but stricter: split into
nanopython-compile.wasm(lexer + parser + emit, runs only when the user changes source) andnanopython-vm.wasm(runtime, loads.mpyfrom preopen). A loader page coordinates. Two-binary architecture is more complex to deploy but lets users pay ~217 KB instead of 336 KB for the "run mode," and the compile pipeline becomes optional. The playground demo would still ship both; the production-extension use case would ship just the VM. -
Aggressive
--gc-sectionscuration. The Zig build already enables-fno-stack-checkand-fLLVMSize=4. Adding explicit--export=listings +--no-entry-style aggressive DCE might find unreachable code in the std library and the strangled-but-still-linked vendor stubs. Cost: each new--export=is a manual decision; if anything is forgotten the runtime traps. Worth ~3–8 KB if done carefully. -
Compress / dedup the qstr table. The qstr pool has the ~12.5 KB of string content + several KB of length+offset arrays. Many qstrs are short (3–10 bytes); the per-entry metadata is significant relative to the entry. A more compact format (4-bit-length prefix + variable-byte offsets) could save ~3 KB. Cost: the qstr hot path becomes more complex; the strangler convention assumes the existing format.
What probably won't help¶
- Switching to ReleaseFast. Larger by ~20% (~50 KB) for marginal runtime gains. We're already on ReleaseSmall for a reason.
- Switching to a different Zig version. Zig 0.16 → 0.17 is unlikely to give >5% size delta on its own; Zig is already aggressive with LLVM size optimization.
- Manual hand-coded assembly. wasm32's text format isn't friendlier to size-tuning than Zig's IR is. The pipeline is already wasm-friendly.
- Compressing the wasm in transit. Already done via standard gzip/brotli; the 140 KB compressed figure is the practical download cost.
How to reproduce¶
For a wasm-tiny-built binary:
The script's output format is stable; diffing two runs against different builds is a fair proxy for "did my change make the binary smaller?"
See also¶
- ADR-001 §Theme F: per-feature flag mechanism + wasm-tiny target
- ADR-002: per-feature
-Dflags; oracle role shift post-convergence notes/03-nano-python-cheatsheet.md: what subsystems are reachable from Python code under nanonotes/04-code-layout.md: source-tree → subsystem mappingnotes/tech-reports/TR-001.md§4.1: binary size measurements over time