WebAssemblyで最大4GBのメモリ使用を実現
はじめに
ChromeとEmscriptenによる最近の作業により、WebAssemblyアプリケーションで最大4GBのメモリを使用できるようになりました。以前の制限は2GBでした。そもそも制限があるのは奇妙に思えるかもしれませんが(結局512MBや1GBのメモリ使用を許可するために特別な作業は必要ありませんでした)、実際には2GBから4GBにジャンプする際にはブラウザでもツールチェーンでも特別なことが発生します。この投稿ではそれについて説明します。
32ビット
詳細に入る前の背景説明:新しい4GBの制限は、WebAssemblyが現在対応している32ビットポインタで可能な最大量のメモリで、LLVMなどで「wasm32」として知られています。「wasm64」(wasm仕様では「memory64」として知られています)への作業が進行しており、ポインタが64ビットになると1600万テラバイト以上のメモリを活用できるようになりますが、それまでは最大で4GBがアクセス可能な限度です。
32ビットポインタが使用できるなら4GBにアクセスできるべきだと思われますが、なぜ2GBに制限されてきたのでしょうか?その理由は、ブラウザ側とツールチェーン側の双方に複数存在します。まずブラウザ側から説明します。
Chrome/V8の作業
原則として、V8の変更は簡単に聞こえるかもしれません:WebAssembly関数用に生成されたすべてのコード、ならびにメモリ管理コードがメモリインデックスと長さに対して符号なし32ビット整数を使用することを確実にするだけで、完了です。しかし実際には、それ以上の変更が必要です!WebAssemblyメモリはJavaScriptにArrayBufferとしてエクスポートできるため、JavaScriptのArrayBuffers、TypedArrays、およびWeb Audio、WebGPU、WebUSBなどのArrayBuffersおよびTypedArraysを使用するすべてのWeb APIの実装を変更する必要がありました。
最初に解決する必要があった問題は、V8がTypedArrayのインデックスと長さにSmis(つまり31ビット符号付き整数)を使用していたため、最大サイズは実際には230-1、約1GBでした。さらに、すべてを32ビット整数に切り替えるだけでは充分ではありませんでした。なぜなら4GBのメモリの長さ自体は32ビット整数に収まらないためです。例を挙げると:10進法で2桁の数は100個あります(0から99まで)が、「100」自体は3桁の数です。同様に、4GBは32ビットアドレスでアドレス指定できますが、4GB自体は33ビットの数値です。少し低い制限に妥協することもできましたが、すべてのTypedArrayコードに触れるついでに、将来のさらに大きな制限にも対応できるよう準備することを目指しました。そのため、TypedArrayインデックスや長さを処理するすべてのコードを64ビット幅の整数型またはJavaScript Numbers(JavaScriptを介する必要がある場合)を使用するよう変更しました。そのおかげで、より大きなメモリをサポートするwasm64対応が比較的簡単になるという付加的なメリットも得られます!
第2の課題は、JavaScriptのArray要素に対する特殊な扱いを通常の名前付きプロパティと比較した場合の差異に対応することでした。これはオブジェクトの実装に反映されています。(これはJavaScript仕様に関する非常に技術的な問題なので、詳細をすべて理解する必要はありません。)以下の例を考えてみてください:
console.log(array[5_000_000_000]);
array
が通常のJavaScriptオブジェクトまたは配列の場合、array[5_000_000_000]
は文字列ベースのプロパティ検索として処理されます。実行時に「5000000000」という名前のプロパティを探します。このプロパティが見つからない場合、プロトタイプチェーンを辿り、そのプロパティを探し、最終的にはチェーンの末尾でundefined
を返します。しかし、array
自体またはそのプロトタイプチェーン上のオブジェクトがTypedArrayである場合、実行時はインデックス5,000,000,000の要素を探し、このインデックスが範囲外の場合はundefined
を即座に返す必要があります。
言い換えれば、TypedArraysに関するルールは通常のArraysとはかなり異なります。この差異は主に巨大なインデックスに対して表れます。そのため、より小さなTypedArraysのみが許可されている間は、実装を比較的単純にすることができました。具体的には、プロパティキーを一度だけ見ることで、「インデックス」検索経路または「名前付き」検索経路のどちらを取るべきかを判断することができました。より大きなTypedArraysを許可するには、この区別をプロトタイプチェーンを辿るたびに繰り返し行う必要があり、繰り返される作業とオーバーヘッドによって既存のJavaScriptコードを遅くしないよう慎重なキャッシングが必要です。
ツールチェーンの作業
ツールチェーン側でも作業が必要でしたが、そのほとんどはJavaScriptのサポートコードに関するものであり、WebAssemblyでのコンパイル済みコードではありませんでした。主な問題は、Emscriptenが常に次の形式でメモリーアクセスを書いていたことです:
HEAP32[(ptr + offset) >> 2]
これは、ptr + offset
アドレスから32ビット(4バイト)の符号付き整数を読み取るためのものです。この動作の仕組みは、HEAP32
が Int32Array であり、配列の各インデックスが4バイトであることに基づいています。そのため、バイトアドレス (ptr + offset
) を4で割る必要があり、それを >> 2
が実現します。
問題は、>>
が符号付き演算であることです!アドレスが2GBマーク以上の場合、入力が負の数にオーバーフローしてしまいます:
// 2GB以下は問題ありません。これは536870911を出力します。
console.log((2 * 1024 * 1024 * 1024 - 4) >> 2);
// 2GBを超えるとオーバーフローし、-536870912になります :(
console.log((2 * 1024 * 1024 * 1024) >> 2);
解決策は符号なしシフト >>>
を使用することです:
// これにより希望通り536870912が得られます!
console.log((2 * 1024 * 1024 * 1024) >>> 2);
Emscripten はコンパイル時に、使用されるメモリーが2GB以上になるかどうかを(使用するフラグに依存して)判断できます。もしあなたのフラグが2GB以上のアドレスを可能にする場合、コンパイラはすべてのメモリーアクセスコードを自動的に >>>
に書き換えます。これには、上記の例で示した HEAP32
などのアクセスだけでなく、.subarray()
や .copyWithin()
のような操作も含まれます。つまり、コンパイラは符号付きポインターの代わりに符号なしポインターを使用するよう切り替えるのです。
この変換はコードサイズを少し増加させます - シフト操作で文字が1つ増えるため - そのため、2GB以上のアドレスを使用しない場合にはこれを行いません。差異は通常1%未満ですが、これは不必要であり簡単に回避できます - 多くの小さな最適化は積み重なります!
JavaScriptのサポートコードには他にも稀な問題が発生する可能性があります。通常のメモリーアクセスは前述の通り自動的に処理されますが、符号付きポインターと符号なしポインターを手動で比較すると、(アドレスが2GB以上の場合)falseが返されます。このような問題を見つけるために、EmscriptenのJavaScriptコードを監査し、すべてをアドレス2GB以上に配置する特別なモードでテストスイートを実行しました。(注意:独自のJavaScriptサポートコードを書く場合は、通常のメモリーアクセス以外でポインターを手動で操作する場合、それを修正する必要があるかもしれません。)
試してみる
これをテストするには、最新のEmscriptenリリース(または少なくともバージョン1.39.15)を入手してください。その後、以下のようなフラグを使用してビルドします。
emcc -s ALLOW_MEMORY_GROWTH -s MAXIMUM_MEMORY=4GB
これらはメモリーの増加を可能にし、プログラムが最大4GBまでメモリーを割り当てることを許可します。デフォルトでは最大2GBまでしか割り当てられません - 明示的に2GB〜4GBを使用することを選択する必要があります(これにより、前述のように >>>
の代わりに >>
を出力することで、よりコンパクトなコードを生成できます)。
Chrome M83(現在ベータ版)以降でテストすることを確認してください。不具合があれば問題を報告してください!
結論
4GBメモリー対応は、ネイティブプラットフォームと同様にウェブをより強力なものにするためのもう一つのステップであり、32ビットプログラムが通常のように多くのメモリーを使用できるようにします。これだけでは完全に新しい種類のアプリケーションを可能にすることはありませんが、より高い品質の体験を可能にします。例えば、ゲーム内の非常に大きなレベルやグラフィックエディタでの大規模なコンテンツ操作などです。
前述の通り、64ビットメモリーの対応も予定されています。これにより4GBを超えるメモリーへのアクセスが可能になります。しかし、64ビットネイティブプラットフォームと同じ欠点(ポインターが2倍のメモリーを占有すること)が発生します。このため、wasm32での4GB対応は非常に重要です。以前の2倍のメモリーにアクセスできる一方で、コードサイズは従来通りコンパクトなままです!
いつものように、コードを複数のブラウザでテストし、また2〜4GBは大量のメモリーであることを忘れないでください!それが必要なら使用すべきですが、不要な場合は避けてください。多くのユーザーのマシンでは十分な空きメモリーがない場合があるからです。最初は可能な限り小さな初期メモリーから始め、必要があれば増加させることを推奨します。また、増加を許可する場合、malloc()
失敗時のケースを優雅に処理してください。