perf(wasm): Shrink the WebAssembly module by 81% - #1
Merged
Conversation
The optimised module drops from 25,059 to 4,833 bytes, or 9,659 to 2,933 gzipped. Attributing the code section by symbol showed where the weight sat: the AES implementation was 548 bytes, around 3% of the binary, while the default dlmalloc allocator took 7,125 bytes and panic formatting a further 4,746. A separate wasm-release profile carries opt-level "z", full link-time optimisation and a single codegen unit. It is deliberately kept out of profile.release, because opt-level "z" costs native AES throughput that tiber-bench measures. wasm-pack bundles a binaryen too old to validate the memory.copy that opt-level "z" makes LLVM emit, and it ignores package.metadata wasm-opt settings for custom profile names. The Makefile therefore passes --no-opt and invokes wasm-opt directly, mirroring the target_features section that binaryen does not read. -O is deliberate: -Oz measures 70 bytes larger on this module. talc replaces dlmalloc behind a cfg guard, leaving host builds on the system allocator. Rebuilding std with -Cpanic=immediate-abort removes panic formatting and its location strings, which also drops an absolute build path that was leaking into the shipped data section. Only build-wasm needs nightly; every other target stays on stable, and flake.nix now installs rust-src for -Zbuild-std. The trade-off is that panics become bare traps, surfacing as RuntimeError: unreachable with no message. main.js rejects malformed keys and plaintext before calling in, so the site cannot reach one. Note that -Cpanic=immediate-abort is unstable and was spelled -Zbuild-std-features=panic_immediate_abort until recently, so a nightly bump may rename it. Verified against all four NIST SP 800-38A AES-128 vectors in both directions, plus 50,000 encrypt/decrypt pairs to exercise the new allocator under churn.
clap and its subtree were compiled for wasm32 on every WebAssembly build, despite no binary target existing there, because Cargo resolves dependencies per package rather than per target. Link-time optimisation already stripped the result, so the module is byte-identical at 4,833 bytes; the cost was build time alone, measured at 12.9 seconds of CPU and 2.0 seconds of wall clock. The gap between those two figures is parallelism on an 8-core machine, so a 2-core CI runner sits closer to the CPU number. The cli feature is on by default, leaving native builds, CI and the binaries unchanged. tiber-wasm sets default-features = false and compiles 10 rlibs where it previously compiled 30. required-features on the binaries makes cargo build --bin tiber fail without the feature, and the expect test's fallback build path relied on that command. It now passes --features cli explicitly. Left alone, that test would report success whenever a stale binary happened to exist, which is a silently skipped test rather than a failing one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The optimised WebAssembly module drops from 25,059 to 4,833 bytes, or 9,659 to 2,933 gzipped. Two commits: the size work, and an independent build-time change that moves zero bytes.
Where the weight was
Attributing the code section by symbol, before any change:
The AES implementation was 3% of its own binary. That reframed the problem: the wins were in what the module pulls in, not in how the AES code is compiled.
Steps
wasm-releaseprofile + explicitwasm-opt-Cpanic=immediate-abortThree details that are not obvious:
opt-level = "z"is confined to a separate profile. Inprofile.releaseit would also apply totiberandtiber-bench, costing native AES throughput.wasm-optis invoked directly. wasm-pack bundles a binaryen too old to validate thememory.copythatopt-level = "z"emits, and it ignorespackage.metadatawasm-opt settings for custom profile names, so--no-optplus an explicit call is the only route.-Obeats-Ozhere.-Ozmeasures 70 bytes larger on this module. Likewisepanic = "abort"alone measures 68 bytes larger, so it is not set.Verification
cargo test --all-targets(62) and--no-default-features(44),make fmt-ci,make lint,make build-native,make site.Trade-offs worth a reviewer's attention
build-wasmnow needs nightly.-Cpanic=immediate-abortis unstable and was spelled-Zbuild-std-features=panic_immediate_abortuntil recently.flake.nixtracks a floating nightly, so a toolchain bump can rename it and break this target. Pinning a dated nightly would fix that, but the fuzz targets also use+nightly, so it belongs in its own change. Every other target stays on stable.Panics are now bare traps, surfacing as
RuntimeError: unreachablewith no message.main.js:164already rejects malformed keys and plaintext before calling in, so the site cannot reach one — but this is a debugging cost when extending the wasm API.talc is a new dependency under key material. A hand-written bump allocator measured ~1 KB smaller and passed every functional test, and was rejected: resetting the bump pointer per call depends on no allocation outliving an exported call, which nothing enforces. Caching a key in a
static— a change a reviewer would wave through — silently overwrote live key bytes with round-key material while the AES vectors kept passing. lol_alloc measured 679 bytes smaller than talc and clean, but its README opens with "I wrotelol_allocto learn about allocators (I hadn't written one before)"; talc is actively maintained and ships wasm-specific size tuning.Also fixed
tests/expect.rsshelled out tocargo build --bin tiber, whichrequired-featuresbreaks under--no-default-features. It now passes--features cli. Untouched, that test would have reported success whenever a stale binary happened to exist.Rejected, with numbers
Vec<u8>— 18,497 bytes (−7.1%) and −47% on the JS glue gzipped, but it makes the 176-byte layout an implicit contract betweenlib.rsandmain.js.trim-paths/--remap-path-prefix— −132 bytes, fully subsumed by immediate-abort, which removes every path string anyway.