Shipping Tiny WebAssembly Builds
Alon Zakai / @kripken
Code Size Matters!
In particular on the Web, but also in some other places.
�smaller code
faster download, faster startup,�less mobile data usage
(But Not Always!)
Sometimes code size is negligible compared to other factors, like asset sizes.
Sometimes the magic ability to run an app on the Web at all is worth a large code size (ship a framework, VM, etc.).
WebAssembly: An Opportunity For Small Code!
WebAssembly is a binary format and can be smaller than JavaScript.
WebAssembly is often compiled from statically-typed languages where powerful dead code elimination (DCE) is possible, like C, C++, Rust, and Go.
...And A Risk
WebAssembly is often compiled from languages that were not designed for small binaries (they even have FAQ entries on that), nor for the Web, like… those same C, C++, Rust, and Go.
This talk will describe how to benefit from wasm’s strengths, and try to avoid those risks.
Advice For All Toolchains
1 Slide Of Obvious Stuff
Enable compression on the server!
Minify your JavaScript too!
General Advice: Run Binaryen’s wasm-opt
wasm-opt optimizes WebAssembly files:
No matter what toolchain emitted your wasm, wasm-opt can help!
large wasm
wasm-opt
small wasm
wasm-opt: Expected Benefit
wasm-opt: What It Does (1)
One set of optimizations are standard compiler passes, for example:
These can help since:
wasm-opt: What It Does (2)
Another set of optimizations are WebAssembly-specific, for example:
An example optimization from RemoveUnusedBrs, saves one byte:
A lot of other small things in that pass add up to saving 1.5% on e.g. zlib. And a lot of passes add up to that 20%!
(block $x (br_if $x (X)) (Y)) | | (if (i32.eqz (X)) (Y)) |
Using wasm-opt
Some tools run wasm-opt for you, like Emscripten, wasm-pack, and AssemblyScript.
If running manually, use something like:
wasm-opt input.wasm -o output.wasm -O
(can also try -Oz, -O3, -O4)
Or, get binary releases from the WebAssembly/binaryen repo, or JS builds from npm install binaryen or the binaryen.js buildbot.
Using wasm-opt On The Web
Advanced wasm-opt Usage
Some flags you may want to set:
Some optimizations that cannot be run by default:
Optimizing A Load Offset..?
This is not safe in general as the add can overflow, but not the offset!
(i32.load (i32.add (X) (i32.const 16))) | | (i32.load offset=16 (X)) |
--low-memory-unused
If a low region of memory is never used, then a small constant offset that overflows to low memory would be invalid anyhow.
Saves 1.8% on e.g. Poppler! (Helps with speed too.)
pointer
invalid address
offset
General Advice: Investigate Your Code
Various tools focus on size profiling of wasm binaries:
Advice For Specific Languages & Toolchains
General C/C++
Use Web APIs directly!
Even better than printf, call a Web API, e.g. using EM_JS:
#include <emscripten.h>
EM_JS(void, log_int, (int value), {
console.log(”log_int:”, value);
});
int main() {
log_int(42);
}
Build with -O3, -Os, or -Oz. Those levels enable the maximum optimizations for size.
Emscripten/Binaryen Integration
emcc drives wasm-opt for you, doing all the useful stuff mentioned earlier, automatically!
While doing so it does things like use --low-memory-unused.
Emscripten emits an optimized combination of WebAssembly and JavaScript, which lets it do things like meta-DCE (DCE JavaScript and WebAssembly as a whole).
Example: Minifying Import Names
As with meta-DCE, this requires close coordination between the WebAssembly binary and the JavaScript that provides those imports.
This optimization saves space in both those files!
(import “env” “glDrawArrays” ..) | | (import “a” “q” ..) |
malloc() / free()
Emscripten by default uses dlmalloc for malloc/free, which is quite fast.
emmalloc is a compact alternative - about ⅓ the size - which is often fast enough (unless your app stresses lots of small variable-sized allocations).
-s MALLOC=emmalloc
Emscripten: Miscellaneous
LLVM Link Time Optimizations (LTO) including system libraries: compile with -s WASM_OBJECT_FILES=0 and link with --llvm-lto 1
JavaScript tips:
Rust
Consider optimizing for size and using LLVM LTO:�
[profile.release]
opt-level = 's'
lto = true
Consider using wee_alloc (similar concept as emmalloc).
Rust
Like C++, common things can increase code size due to library support (e.g. format! and to_string), generics may duplicate code, dynamic dispatch may inhibit DCE, etc.
Consider no_std. As the FAQ says,
Using #![no_std] can result in smaller binaries, but will also usually result in substantial changes to the sort of Rust code you’re writing.
Rust: wasm-pack
Go (gc) ships a full runtime. Very powerful when you need 100% compatibility! But it’s around 2MB (in Go 1.13).
TinyGo is a relatively new Go compiler, “for small places” - microcontrollers and WebAssembly. “Hello world” is less than 1K!
As with C++ and Rust, various things may increase code size, e.g., interfaces may inhibit DCE, as may packages like reflect, etc.
Be careful and do size profiling!
A new language designed with WebAssembly and�code size in mind! (just look at the name and logo ;)
The Future
The Big Thing: Indirect Calls
virtual, dynamic dispatch, interfaces, etc.: indirect calls hurt DCE :(
LLVM’s Devirtualization and Control Flow Integrity data can perhaps be encoded with wasm multi-table. Help wanted!
(table 100 funcref) (elem (i32.const 0) $a $b $c $d ..) | | (table $foo 2 (type $X)) (elem $foo (i32.const 0) $a $b) .. |
Many Small Things: More Optimizations!
Binaryen has a lot of infrastructure to make it easy to write optimizations, like control flow graph analysis, local analysis, etc. Contributions welcome!
I suspect a lot more can be done with toolchain-specific optimizations. Binaryen currently has PostEmscripten and PostAssemblyScript passes that help a lot. Let’s add more!
Thank you!