Wasmバイナリサイズの最適化
バイナリサイズが重要な理由
.wasmファイルのすべてのバイトは、アプリケーションが起動する前にブラウザによってダウンロード、解析、コンパイルされる必要があります。JavaScriptとは異なり、Wasmはコンパクトなバイナリ形式ですが、素朴なRustビルドでは数メガバイトのファイルが生成されることもあります。
ダウンロード 解析 コンパイル
┌──────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐
│ .wasm│──────>│ ネットワーク │───>│ デコーダ │───>│ JIT/AOT │──> 実行
│ file │ │ 転送 │ │ │ │ コンパイル│
└──────┘ └──────────┘ └─────────┘ └─────────┘
^ ^
│ │
小さいファイル = どこでも高速な起動| ファイルサイズ | 3G (2 Mbps) | 4G (20 Mbps) | Wi-Fi (50 Mbps) |
|---|---|---|---|
| 100 KB | 0.4 s | 0.04 s | 0.02 s |
| 500 KB | 2.0 s | 0.2 s | 0.08 s |
| 2 MB | 8.0 s | 0.8 s | 0.32 s |
| 10 MB | 40.0 s | 4.0 s | 1.6 s |
Cargo.tomlのリリースプロファイル
最も効果的な変更は、[profile.release]セクションの調整です:
[profile.release]
opt-level = "z" # サイズを最適化 (s = サイズ、z = さらに小さく)
lto = true # リンク時最適化 — プログラム全体の解析
codegen-units = 1 # 単一コード生成ユニット = インライン化/デッドコード除去の改善
strip = true # デバッグシンボルの除去
panic = "abort" # アンワインド機構なし(約10-20 KB節約)各設定の効果
opt-level = "z"
┌──────────────────────────────────────────────────────┐
│ "0" — 最適化なし (デバッグビルド) │
│ "1" — 基本的な最適化 │
│ "2" — 標準的な最適化 (デフォルトリリース) │
│ "3" — 積極的な速度最適化 │
│ "s" — バイナリサイズを最適化 │
│ "z" — サイズをさらに強力に最適化 │
└──────────────────────────────────────────────────────┘
lto = true
┌────────────────────────────────────────┐
│ LTOなし: │
│ crate_a.o ──┐ │
│ crate_b.o ──┼── リンク ── バイナリ │
│ crate_c.o ──┘ (デッドコードが残る) │
│ │
│ LTOあり: │
│ crate_a.o ──┐ │
│ crate_b.o ──┼── 全体を ── 最適化 │
│ crate_c.o ──┘ 解析 ── バイナリ │
│ (より小さい!)│
└────────────────────────────────────────┘
codegen-units = 1
┌─────────────────────────────────────────────────┐
│ デフォルト: 16ユニット — コンパイルは速いが │
│ 最適化は少ない。 │
│ 1に設定: 単一ユニット — コンパイルは遅いが、 │
│ コンパイラがすべてを把握し、インライン化と │
│ デッドコード除去がより効果的になる。 │
└─────────────────────────────────────────────────┘wasm-opt: ビルド後オプティマイザ
Binaryenのwasm-optは、Rustコンパイラでは行えないWasm固有の最適化を実行します:
# インストール
cargo install wasm-opt
# または: npm install -g binaryen
# サイズ最適化 (-Oz) または速度最適化 (-O3)
wasm-opt -Oz -o output.wasm input.wasmwasm-opt -Ozによる一般的な削減効果:
| 段階 | サイズ |
|---|---|
cargo build後 |
180 KB |
wasm-opt後 |
120 KB |
| gzip後 | 45 KB |
| brotli後 | 38 KB |
ツリーシェイキングとデッドコード除去
Rustの単相化(モノモーフィゼーション)は、ジェネリック関数の多くのコピーを生成する可能性があります。異なるTに対するVec<T>の各インスタンス化が、個別のマシンコードを生成します。
コードが使用: 単相化の結果:
Vec<u8> ──────> Vec_u8_push, Vec_u8_pop, Vec_u8_len ...
Vec<String> ──────> Vec_String_push, Vec_String_pop ...
Vec<(i32,i32)> ──────> Vec_tuple_push, Vec_tuple_pop ...単相化による肥大化を減らす戦略:
- パフォーマンスが重要でない場合はトレイトオブジェクトを使用する:
// 型ごとにコードを生成:
fn process<T: Display>(items: &[T]) { ... }
// 単一の関数を生成:
fn process(items: &[&dyn Display]) { ... }- 非ジェネリックコードをジェネリック関数から分離する:
// 悪い例 — 関数全体がTごとに複製される
fn insert<T: Ord>(vec: &mut Vec<T>, item: T) {
let idx = vec.binary_search(&item).unwrap_or_else(|i| i);
vec.insert(idx, item);
log_size(vec.len()); // この行はTを使わない
}
// 良い例 — log_sizeは一度だけコンパイルされる
fn log_size(n: usize) { /* ... */ }
fn insert<T: Ord>(vec: &mut Vec<T>, item: T) {
let idx = vec.binary_search(&item).unwrap_or_else(|i| i);
vec.insert(idx, item);
log_size(vec.len());
}隠れた肥大化の回避
format!とpanic!の問題
すべてのformat!()、panic!()、unwrap()、expect()呼び出しは、Rustのフォーマット機構を引き込みます — おおよそ10〜20 KBのコードです。
// 悪い例 — 範囲外アクセス時にDisplay + フォーマットをパニック文字列で引き込む
fn get(idx: usize) -> u8 {
DATA[idx] // 範囲外でフルフォーマットのパニック
}
// 良い例 — get() + 小さなabortを使用
fn get(idx: usize) -> u8 {
match DATA.get(idx) {
Some(&v) => v,
None => abort_with_code(1),
}
}文字列肥大化チェックリスト
| パターン | バイナリに追加? | 代替手段 |
|---|---|---|
format!("x = {}", x) |
はい — フォーマット基盤 | 手動変換または &str |
panic!("bad index {}", i) |
はい — panic + format | unreachable_unchecked()* |
.unwrap() |
はい — パニック文字列 | .unwrap_or(default) |
.expect("msg") |
はい — panic + 文字列 | match + abort |
#[derive(Debug)] 型に対して |
はい — Debug実装 | 必要な場合のみderive |
数値のto_string() |
はい — Displayトレイト | itoaクレートまたは手動バッファ |
*unreachable_uncheckedはunsafeであり、パスが到達不能であることを保証できる場合にのみ使用すべきです。
wee_alloc: 小さなアロケータ
デフォルトのRustアロケータ(Wasmではdlmalloc)は約10 KBを追加します。wee_allocはパフォーマンスとサイズをトレードオフします(約1 KB):
// lib.rsに記述
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;[dependencies]
wee_alloc = "0.4"トレードオフ:
| 特性 | dlmalloc | wee_alloc |
|---|---|---|
| バイナリオーバーヘッド | ~10 KB | ~1 KB |
| アロケーション速度 | 高速 | 低速 |
| フラグメンテーション | 良好 | 不良 |
| 解放で回収? | はい | 部分的 |
| 最適な用途 | 汎用 | 小さなWasm |
注意:
wee_allocは現在メンテナンスされていません。本番環境ではlol_allocやtalcクレート、または独自のバンプアロケータの実装を検討してください(レッスン35参照)。
twiggyによる計測
twiggyは.wasmバイナリを解析し、何がスペースを占めているかを表示します:
# インストール
cargo install twiggy
# 上位20の最大アイテムを表示
twiggy top -n 20 my_app_bg.wasm
# 何が何を保持しているかのコールグラフを表示
twiggy dominators my_app_bg.wasm
# 2つのビルドを比較
twiggy diff old.wasm new.wasmtwiggy topの出力例:
Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────
12480 │ 8.31% │ data[0]
6204 │ 4.13% │ "function names" subsection
3120 │ 2.08% │ core::fmt::write
2800 │ 1.86% │ dlmalloc::dlmalloc::Dlmalloc::malloc
2476 │ 1.65% │ core::fmt::Formatter::pad圧縮: 最後のステップ
すべての最適化の後、HTTP圧縮を適用します。ほとんどのサーバーはgzipとBrotliをサポートしています:
生の .wasm gzip (-9) Brotli (-11)
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 120 KB │ ────────> │ 45 KB │ ────────> │ 38 KB │
└─────────┘ ~62% └─────────┘ ~15% └─────────┘
削減 さらにWebサーバーの設定:
# Nginx
location ~ \.wasm$ {
gzip on;
gzip_types application/wasm;
# または事前圧縮ファイルを配信:
gzip_static on;
brotli_static on;
}完全な最適化パイプライン
cargo build --release ← Cargo.tomlプロファイル設定
│
▼
wasm-opt -Oz ← Binaryenによる後処理
│
▼
wasm-strip ← namesセクションの除去(オプション)
│
▼
brotli / gzip ← HTTP転送圧縮
│
▼
✓ 最小限の .wasm をブラウザに配信クイックリファレンス: テクニック別の効果
| テクニック | 一般的な削減効果 |
|---|---|
opt-level = "z" |
10〜25% |
lto = true |
10〜20% |
codegen-units = 1 |
5〜10% |
panic = "abort" |
5〜15% |
strip = true |
5〜15% |
wasm-opt -Oz |
15〜30% |
wee_allocの置き換え |
約10 KB固定 |
format! / Debug deriveの除去 |
大きく変動 |
| Brotli圧縮 | 60〜70% |
まとめ
バイナリサイズの最適化はスペクトラムです — すべてのプロジェクトにすべてのテクニックが必要なわけではありません。まずCargo.tomlの設定とwasm-opt(80/20の法則での勝利)から始め、次にtwiggyを使って残りの肥大化を見つけましょう。50 KB未満を目指す場合は、フォーマット基盤の回避とカスタムアロケータの検討が必要になります。