はじめに
ブラウザで高速なバイナリ処理や計算ロジックを動かしたいとき、WebAssembly(Wasm)の導入を検討したことのあるWebエンジニアは多いはずです。しかし、いざ手を出そうとすると、常に過剰なトレードオフに突き当たります。
- C/C++(Emscripten): 気づけばPOSIX互換レイヤーや仮想ファイルシステムまで引き連れてきて、巨大なグルーコードと格闘させられる。
- Rust(wasm-bindgen / wasm-pack): 性能もサイズも素晴らしいが、学習曲線が急峻で、多段のビルドパイプラインが必要。
- Go(GOOS=js GOARCH=wasm): スライスや文字列の扱いやすさは最高だが、GCとスケジューラが丸ごと同梱されるため、最小構成でも2〜3MBに膨らむ。
「Goの直感的な書き味のまま、数KBでサクッと動く超軽量Wasmは作れないのか?」
その疑問を突き詰めて自作プログラミング言語「Hike(ハイク)」のコンパイラを開発していたところ、いつの間にか「Goライクな構文」「完全なlibcフリー」「JSグルーコード完全自動生成」「Wasmバイナリわずか2.56KB」という、Webエンジニアにとって理想的なWasmコンパイル環境が完成してしまいました。
2.56KBの衝撃:既存ツールチェーンとの比較
実際に文字列スライス処理、DOM書き換え、再帰フィボナッチ計算を含むコードをコンパイルした際のサイズとビルド体験の比較です。
| 言語 / ツールチェーン | バイナリサイズ | 外部依存・ツール | 構文の手軽さ |
|---|---|---|---|
標準Go (GOOS=js) |
約 2.5 MB 〜 3.0 MB | なし | ◎(直感的で書きやすい) |
| TinyGo | 約 30 KB 〜 100 KB | TinyGo専用コンパイラ | ◯(一部Go標準機能に制限) |
Rust (no_std) |
約 5 KB 〜 20 KB | Cargo + wasm-bindgen
|
△(借用やライフタイムの学習コスト) |
| Hike (今回開発) | 2.56 KB | hikec 単一コマンドのみ |
◎(Go完全互換のスライス・構文) |
TCPの初期ウィンドウサイズ(MTU 1500バイト基準)なら、最初の1〜2パケットでブラウザへ完全に届きます。ネットワーク遅延は事実上ゼロ、V8などのエンジンによるJITコンパイルも一瞬で終わります。

動作デモはこんな感じです。ブラウザのDOMを操作するJavaScript側のコードも、WASM側のプログラムに一緒にかけてしまいます。
なぜここまで削ぎ落とせたのか?
1. libc(C標準ライブラリ)の徹底的なセルフホスト化
多くのWasmコンパイラが肥大化する最大の要因は、言語ランタイムが暗黙のうちに要求するC標準ライブラリ(printf、memcpy、strlen、strcmp など)にあります。
Hikeでは、外部リソースに触れないメモリ・文字列操作をすべて自前のLLVM IR内部関数(define internal)として実装しました。
; LLVM IR による純粋な memcpy 実装(外部 libc 呼び出しゼロ)
define internal i8* @memcpy(i8* %dst, i8* %src, i64 %n) {
entry:
%cmp = icmp eq i64 %n, 0
br i1 %cmp, label %exit, label %loop.body
loop.body:
%i = phi i64 [ 0, %entry ], [ %i.next, %loop.body ]
%p_src = getelementptr inbounds i8, i8* %src, i64 %i
%val = load i8, i8* %p_src, align 1
%p_dst = getelementptr inbounds i8, i8* %dst, i64 %i
store i8 %val, i8* %p_dst, align 1
%i.next = add i64 %i, 1
%cont = icmp ult i64 %i.next, %n
br i1 %cont, label %loop.body, label %exit
exit:
ret i8* %dst
}
外部宣言(declare)された標準関数はLLVMにとってブラックボックスですが、IR内部関数として定義することで、Clangの最適化パス(-O2)によって自動でインライン展開やSIMDベクトル化が行われます。
ランタイムの外部依存は、JavaScript側から渡す極小のバンプアロケータ(malloc / free)のみ。これにより、POSIXエミュレーションを一切含まない完全なフリースタンディングWasmを出力できるようになりました。
2. スライス構造による $O(1)$ 文字列処理
C言語のようなnull終端文字列を採用すると、終端探索(strlen)が頻発し、部分文字列を作るたびにメモリ確保が発生します。
HikeではGo言語と同様のバイトスライス構造({ ptr, len, cap })をネイティブ採用しています。部分文字列の切り出し(s[low:high])はポインタのオフセット加算と長さの減算のみで完結するため、アロケーション回数が劇的に減り、バイナリサイズと実行速度の双方に寄与しています。
開発体験:JS境界の配管コードが消滅する
Wasm開発で最も開発者を疲弊させるのは、「Wasmのリニアメモリのポインタと長さをどうやってJavaScript側とやり取りするか」という境界コードの記述です。
Hikeでは、extern func や cfunc 宣言によって言語自身がこのブリッジを標準化しています。
Hikeコード (main.hike)
package main
// JavaScript 側が提供する DOM / システム API
extern func js_log(msg string, len int)
extern func js_set_text(id string, id_len int, text string, text_len int)
extern func js_append_text(id string, id_len int, text string, text_len int)
extern func js_set_badge_color(id string, id_len int, color string, color_len int)
func Log(msg string) {
js_log(msg, len(msg))
}
func SetText(elementId string, text string) {
js_set_text(elementId, len(elementId), text, len(text))
}
// 性能検証用: フィボナッチ再帰計算
func Fib(n int) int {
if n <= 1 {
return n
}
return Fib(n-1) + Fib(n-2)
}
// ブラウザへエクスポートする関数群 (cfunc)
cfunc InitApp() {
Log("Hike WebAssembly core initialized.")
SetText("status-badge", "Running (Wasm Active)")
js_set_badge_color("status-badge", 12, "#10b981", 7)
}
cfunc RunComputation(n int) int {
return Fib(n)
}
ビルドコマンドは1回叩くだけ
別のジェネレータやNode.js製ツールチェーンを挟む必要はありません。
hikec build -target wasm32 main.hike -o main.wasm
このコマンドを実行すると、main.wasm(2.56KB)の生成と同時に、同一ディレクトリへブラウザ連携用の runtime.js が自動生成されます。
自動生成される runtime.js
Wasm内のリニアメモリからのUTF-8文字列デコード、64bit整数(BigInt)とNumberの自動変換、バンプアロケータ、DOMブリッジ関数があらかじめ整った状態で書き出されます。
/**
* Hike Language WebAssembly Runtime Bridge
* Auto-generated by hikec
*/
class HikeRuntime {
constructor() {
this.wasmInstance = null;
this.memory = null;
this.heapPtr = 65536;
this.textDecoder = new TextDecoder('utf-8');
}
getString(ptr, len) {
return this.textDecoder.decode(new Uint8Array(this.memory.buffer, Number(ptr), Number(len)));
}
getImportObject() {
return {
env: {
malloc: (size) => {
const s = Number(size);
const allocated = this.heapPtr;
this.heapPtr += (s + 7) & ~7;
if (this.heapPtr > this.memory.buffer.byteLength) {
this.memory.grow(Math.ceil((this.heapPtr - this.memory.buffer.byteLength) / 65536));
}
return allocated;
},
free: (ptr) => {},
js_log: (ptr, len) => {
console.log(`%c[Hike Wasm]%c ${this.getString(ptr, len)}`, "color: #3b82f6; font-weight: bold;", "color: inherit;");
},
js_set_text: (idPtr, idLen, textPtr, textLen) => {
const el = document.getElementById(this.getString(idPtr, idLen));
if (el) el.innerText = this.getString(textPtr, textLen);
},
// ... 各種 DOM / システム API
}
};
}
async load(wasmPath) {
const response = await fetch(wasmPath);
const { instance } = await WebAssembly.instantiateStreaming(response, this.getImportObject());
this.wasmInstance = instance;
this.memory = instance.exports.memory;
if (typeof instance.exports.InitApp === 'function') {
instance.exports.InitApp();
}
return instance.exports;
}
}
あとはHTML側でインスタンス化するだけで、Wasm内のコードが即座に起動します。
<script src="runtime.js"></script>
<script>
const runtime = new HikeRuntime();
runtime.load('main.wasm').then(wasm => {
// Wasm 内のフィボナッチ関数を実行 (3000万回の再帰がわずか22msで完了)
const result = wasm.RunComputation(35n);
console.log("Fib(35) =", result);
});
</script>
「マイクロWasm」という新しい設計パターン
これまでWebAssemblyは「FigmaやGoogle Earth、ゲームエンジンのような巨大なデスクトップ級アプリをブラウザへ移植するための重量級技術」という文脈で語られがちでした。
しかし、わずか2KB台でGoのような記述性を持つバイナリが吐き出せるとなれば、Wasmの活用範囲は劇的に変わります。
- フォームの超複雑なバリデーションロジック
- クライアントサイドでのMarkdown / JSON / CSVの高速パーサー
- Canvas 2D / WebGLにおけるリアルタイムの幾何計算や物理シミュレーション
- 画像や音声データのバイナリ加工・波形解析
こうした「JavaScriptだとプロファイラと睨めっこになるが、巨大なWasmランタイムを持ち込むのは躊躇われる」という日常的なフロントエンドのホットパスに対して、カジュアルにピンポイントでWasmを差し込めるようになります。
低レイヤの依存関係をセルフホスト化で徹底的に削ぎ落とした結果、偶然にもWebフロントエンドにとって最も小回りの利くコンパイラ環境を手に入れることが
できました。
なんと早くも実務実績ができました。
- 新言語「Hike」をシステムに採用したらバイナリ99.8%削減・ネットワークも実働処理も爆速になった
すごい威力ですね。こちらもぜひご覧ください。