x86エミュレーションの厄災
日本語
コピー
x86 エミュレーションの苦悩
本サイト最初の特集記事へようこそ。今回取り上げるのは、x86 エミュレーションに長く存在する問題で、私たちがエミュレートするあらゆるアプリに影響します。突き詰めれば、すべては一つの影響力の大きい概念に行き着きます。それは x86 全ストア順序メモリモデル(x86-TSO)のエミュレーションです。
ARM が定義する弱順序メモリモデルの上でこのメモリモデルをエミュレートするとなると、問題は一つの側面にとどまりません。細部が山ほど絡んできます。本記事では、私たちが遭遇しうる問題を一つずつ整理し、それをどう解決しているか(場合によっては解決できていないか)を見ていきます。お菓子と温かい飲み物を用意してください。長い記事になります。
- x86-TSO とは一体何なのか?
- ARMv8.0-a という素朴な出発点
- メモリへのアクセスは一番簡単な部分ではないのか?
- 待って、これらのアトミック命令とは何だ?
- split-lock が必須とはどういうことだ?
- おい、非キャッシュメモリも動かないといけないのか?
- より明るい未来に向けて
x86-TSO とは一体何なのか?
x86 メモリモデルの問題をどう回避するかを掘り下げる前に、それが一体何なのかをはっきりさせておく必要があります。メモリモデルとは、システム内の各メモリアクセスが互いにどう影響し合うかを定めるルールの集合です。このルールが、シングルスレッド環境とマルチスレッド環境の両方でロードとストアがどう相互作用するかを決めます。ハードウェアに実装されたメモリモデルにはいくつか種類があり、それぞれ形が異なりますが、今回注目するのは二つだけです。ARM の緩い(弱い)一貫性モデルと、x86 のこの全ストア順序一貫性モデルです。この二つはほぼスペクトルの両端に位置します。ARM は最も緩く、多くのハードウェア最適化を許します。x86 は最も厳しく、非常に強い一貫性を強制し、最適化の余地をほとんど残しません。メモリモデルを論じる際に注意すべき点が一つあります。一貫性とアトミック性の違いです。両者は関連していますが同一ではなく、すべての状況で等号で結べるものでもありません。
メモリモデルの違いがどう現れるかを説明するには、x86 のやり方から入るのが一番です。TSO は動作が極めて厳格で、プログラマは次のように仮定できます。あるメモリストアが起きたとき、システム内の他のすべてのプロセッサが一貫してそれを観測できる。これはまた、あるメモリロードが起きたとき、それより前のすべてのストアが「論理的には」完了している、少なくとも可視になっていることを意味します。これはプログラマの期待に沿います。メモリに書き込めば、書き込んだ瞬間に可視になる。そう考えるのが最も直感的だからです。ストアが実際にロードの可視性の順序を決める。このモデルの名前はそこから来ています。動作にはもう少し細かい点がありますが、理解するのに必須ではありません。
ARM の弱いメモリモデルは、動作がそこまで直感的ではありません。デフォルトでは、ARM が使う通常メモリのロードとストアは、システム内でプロセッサ間の一貫性を厳密には保証せず、そのおかげで CPU はほとんどの場面でより効率よく動けます。ストア命令が実行されても、そのメモリ(cacheline)はシステム内の他のプロセッサに即座には可視になりません。これにより貴重な電力を節約し効率が上がります。ハードウェアレベルでは、他のコアの cacheline を無効化したり、別のプロセッサのキャッシュを覗かせたりするコストが高いからです。これに関連して、あるプロセッサが別のプロセッサの書き込んだデータをメモリからロードしている場合、そのロードが更新後のメモリを観測することすら保証されません。マルチスレッドアプリで深刻な問題を引き起こしそうに聞こえますよね? 旧バージョンの ARM(ARMv7 以前)はメモリバリア命令で順序を保証していましたが、これには無視できない性能オーバーヘッドが伴いました。
この一貫性の制約を回避するため、ARM は load-acquire と store-release というメモリ命令も導入しました。C++ の言葉で言えば、それぞれ std::atomic の memory_order_acquire と memory_order_release の定義に対応します。ARM の用語では、これらの命令は 厳密には アトミック操作でもありませんが、プログラマはしばしば両者を混同します。FEX も同じ意味で atomic-load と atomic-store という言葉を使い続けてきました! この区別は たいてい どうでもいいことですが、こうした話題を論じるなら細かく見た方がよいかもしれません。
これらの命令の主な用途は、この種の命令の間にメモリ順序を強制することです。ARM はこれを「Release Consistency sequentially consistent(RCsc)」モデルと呼びます。このモデルがどう動くかの詳細に立ち入る必要はありませんが、要点はこうです。load-acquire 命令は順序どおりに観測されなければならず、並べ替えは許されません。store-release 命令も同様で、「barrier-ordered-before」セマンティクスを満たします。これにより、旧 ARM アーキテクチャのコストの高いメモリバリア命令が不要になりました。
ARMv8.0-a という素朴な出発点
x86-TSO メモリモデルをエミュレートするとき、ARMv8.0-a が出発点の前提になります。私たちは x86 のメモリロードをすべて ARM の load-acquire 命令に、x86 のメモリストアをすべて store-release 命令に変換しました。これで FEX は x86 と実質的に同じメモリセマンティクスを得られます。ただし実際には必要以上に厳格です。その挙動にちょうど一致する中間の選択肢がなかったからです。言うまでもなく、これらの命令で TSO をエミュレートするコストは_極めて_高く、それを示すマイクロベンチマークがあります。ARM CPU は、比較的珍しいはずの acquire/release 命令が実行される命令の大半を突然占めるようには設計されていません。
まずは単純な、ハードウェアにかなり優しいマイクロベンチマークから始めましょう。厄介な境界ケースはなく、よくある状況でメモリにアクセスするだけです。これで、最良の場合がどうなるかを表すベースラインの数値が得られます。
このグラフを分解すると、いくつか面白い話が見えてくる。各マシンの Load と Store の列は、私たちのハードウェアが目指すべきベースライン性能の数字だ。これは各システムのメモリ帯域を飽和させるという話ではなく、どの操作についても同じ量の仕事をするという話だ。acquire-load の結果に目を向けると、テストした 5 つの CPU のうち 3 つが acquire-load を使うことでかなり大きな性能低下を起こしていることがわかる。さらに、AmpereOne CPU の release-store 命令は他の結果と比べて驚くほど低く、M1 の Acquire/LRCPC ロード命令もベースラインを明らかに下回っている。
AmpereOne の結果は、このレガシーな経路がどれほど酷くなり得るかをよく示している。これらの命令はそもそもこういう使われ方をするために設計されたものではない。x86 のエミュレーションでは、すべての load に acquire-release セマンティクスを使うことで、実質的に ARM CPU に非常に厳しい制約を課すことになる。load 命令同士をまったく並べ替えられなくなるのだ。毎秒何百万もの命令が飛び交う状況で、性能がまともであるはずがない。だが ARMv8.0-a では使える命令がこれしかなく、これを使うしかない。Cortex-X4 と Cortex-X925 はこれらの命令を非常に高い性能で実行し、Oryon-3 は明らかに優先度を低くしている。
次はどこへ向かうのか?
LRCPC-load 命令をよく見てみよう。ARMv8.3 以降では必須だ。この拡張は ARM ISA に新しい load 命令群を追加し、ARM の以前の RCsc モデルの上に新しいメモリモデルを導入した。この新しい「Release Consistency processor consistent(RCpc)」メモリモデルこそ、私たちがずっと求めていたものだ。この拡張はまさに x86 エミュレーションの要求を中心に設計され、実装したハードウェアでは大量に使われることが想定されている。グラフを見ると、ほぼすべてのプラットフォームで LRCPC-load の性能は通常の load と同等だ。
新しい ARM で必須となったこの拡張があれば、メモリ性能の問題はほぼ解決したと言っていい。少なくともこのマイクロベンチマークでは。FEX はこの拡張を検出すると、Acquire-Load 命令を完全に無効化し、LRCPC-Load に切り替える。では Apple M1 のあの結果は何なのか……?
ここは Apple がこの問題をどう解いたかを褒めるべきところだ。Apple Silicon のプロセッサでは、彼らは x86-TSO メモリモデルのサポートを直接組み込んだ。この CPU 機能を有効にすると、通常の ARM load/store 命令の挙動が変わり、x86 の要求に一致するようになる。彼らがこの道を選んだのは、ARM エコシステムへ全面的に移行した後も自社ハードウェアには高性能な解決策が必要だと理解していたからだ。だから彼らのハードウェアでは、LRCPC-load 命令は実質的に acquire-load 命令の別名にすぎない。彼らの x86 エミュレータはこれらの命令を使わないからだ。x86 メモリモデルを実装している以上、通常の load/store 命令をそのまま使えばよく、それが私たちのマイクロベンチマークでは判別不能な性能オーバーヘッドとして現れている。公平に言えば、このスレッド単位の TSO モード切り替えには確かにいくらかの性能影響がある。ここでは見えないだけだ。FEX が Asahi Linux からこの CPU 機能を検出すると、私たちもこれを有効にし、この性能向上をただで手に入れる。潜在的な懸念は、x86 エミュレーションのコードと ARM のコードを行き来するとき、ARM のコードがすべてのアクセスを TSO にすることで不要なオーバーヘッドを払うことだ。この懸念はもっともだが、エミュレーション下で実行される ARM ネイティブコードの量はほぼ 0% だ。開発者として気にするのは、1% のメモリアクセスが 10% 遅くなることではなく、99% のアクセスが「理想」性能の 15% になることだ(AmpereOne の結果が示すとおり)。
念のため言っておくと、私たちは TSO モードがこのプラットフォームで高性能な x86 エミュレーションを実現する最良の道だと考える。すべてのメモリアクセス命令の挙動が私たちの期待どおりになることを保証してくれるからだ。公式の FEAT_LRCPC 拡張には実は 3 つのバージョンがあり、そのたびに実装にパッチを当てている。このこと自体が問題の在りかを物語っている。
- FEAT_LRCPC —— GPR に基本的な TSO load 命令を追加
- FEAT_LRCPC2 —— TSO load 命令に小さなオフセット即値を追加
- FEAT_LRCPC3 —— 基本的なベクタおよびスタックベースの TSO load & store 命令を追加
これら 3 つの拡張があっても、TSO のハードウェアスイッチがあるかのようにエレガントにエミュレートできないエッジケースがまだ残っている。私たちは今後もさらに拡張バージョンが出て、後述するいくつかのレガシーな問題を修正しようとすると予想している。
メモリへのアクセスが一番簡単な部分だと思ってた?
前節では ARM ハードウェアにかなり寛容で、下層ハードウェアのアラインメント要求に従って性能ベースラインを得た。だが x86 をエミュレートすると、いきなり目に飛び込んでくる厄介な問題がある。あなたの大好きな x86 アプリはアラインメントなど気にしない! メモリに好き勝手にアクセスし、cacheline 境界をまたぎ、未アラインメントのアトミック操作を実行する。アラインメントの問題として思いつくものは、これらのゲームが全部やっている。この問題はあまりに深刻なので、私たちは split-locks という名前までつけた。その影響は大きく、Linux カーネルでさえこうした操作を捕捉し、発生時にゲームを遅くする! 多くのプレイヤーがこの性能低下を避けようとカーネルオプションをいじる羽目になっている!
まだ完全な split-lock の話をするつもりはない。まずはアラインメントを気にしない環境での load-store 命令から始めよう。x86 はプログラマに対していくつかの保証をしている。同じ cacheline 内に収まる load-store を実行するなら、その load-store はアトミックであり、前述の一貫性モデルにも従う。ただし、ハードウェア開発者に少しだけ優しくするため、その load-store が cacheline を_またいで_しまった場合、データはもはやアトミックではなくなり、他のスレッドは tearing を目にすることになる。つまり、普通の load-store は split-lock ではないので、プログラマは注意しなければならない。
これらの基本アクセスを load-acquire/store-release でエミュレートする場合、問題になるのは ARMv8.0 が要求する自然アラインメントだ。つまり、アクセスするデータのサイズが何であれ、アドレス内のメモリのオフセットはそのサイズと一致しなければならない。8 バイトアクセスならオフセット 0、8、16、24 などに落ちなければならない。ネイティブの ARM アプリケーションならこれで問題ないが、自然アラインメントの要件を満たさないとどうなるか? ARM では、その命令がアラインメントエラーを引き起こす。ハードウェアがアラインメント要件を満たしているか検証し、満たしていなければ CPU が例外を発生させる。通常はクラッシュにつながるが、FEX は特別な処理を行う。
FEX の JIT の仕組みでは、x86 の load-store をエミュレートするために使われるメモリ load-store 命令を追跡している。ある load-store がアラインメントエラーを起こしうると分かっている場合、コード中にpatchpointと呼ばれるものが現れる。load-store 命令の場合、それは命令の前か後ろに置かれた NOP として現れる。これらの patchpoint の一つでアラインメントエラーが起きると、FEX はその例外を捕捉し、コードを load-acquire/store-release 命令から基本の等価な load-store にパッチし、その命令をデータメモリバリアで包む。あとは実行を続けるだけだ!


パッチ前とパッチ後
ARMv8.0-a がこうした洒落た load-acquire、store-release 命令をどう追加したかという、あの長い話はどこへ行ったのか? アラインメントの挙動が一致しなくなった時点で、我々は即座に古典的なメモリバリア命令へと退避する。先ほどのグラフはこの厄介なケースを示していなかったので、新しいデータをいくつか持ち込もう。
ああ、整理すべきデータが本当に多い。ハードウェアが TSO をエミュレートする際に「最適」な経路からどれだけ離れているかが改めて見えて面白いが、ここで関心があるのはそこではない。興味深いのは、このマイクロベンチマークが通常の load/store におけるアライン済みと未アラインの差をほとんど示していないことだ。だから両者の平均を取ることにした。ARM の列から x86 CPU と通常の load-store のデータは外す。これらは FEX の一般的な経路ではないからだ。こうすることで、未アラインのメモリアクセスがエミュレーション下でどれほどの損害を与えるかを、より的を絞って見られる。
だいぶまともなグラフになったので、左から右へ順に見ていき、何が起きているのかを説明しよう。
AmpereOne
これはなかなか面白い。アライン済みと未アラインのロード命令はほぼ同等の性能で、差はノイズの範囲内だ。つまり、未アラインのロードがデータメモリバリアのペナルティを食らうにもかかわらず、CPU は耐えている。他のプラットフォームと比べて性能がかなり低いことを考えると、このベンチマーク自体が別の何かでボトルネックになっている可能性もある。
ストアの方は芳しくない。未アラインでも同じだ。グラフ上ではほとんど見えない! 未アラインのストアに当たると性能損失はおよそ 8.5% だが、そもそもの出発点が低いので気づきにくい。このテストで通常のストア命令が約 28GB/s を出せるのとは対照的だ。
ここから言える唯一の結論は、Ampere は何らかのサーバー向けワークロードに最適化されており、コンシューマー向けハードウェアの挙動とはあまり合っていないということだ。面白いデータポイントではあるが、我々のユーザーは普通こうしたハードウェアでゲームを動かしたりはしない。
Cortex-X4
これは非常によく使われる CPU コアで、Qualcomm Snapdragon 8 Gen 3 に搭載されている。グラフがデータで溢れかえるのを避けるため、この SoC のコアはこれ一つだけを測定した。多くのハンドヘルドがこれを使っているので、興味深いテスト対象だ。このリストで唯一のスマートフォン SoC であることを踏まえると、その性能は実に意外なほど良い。全体として、このコアはおおむね我々の予想に沿っており、グラフ上の傾向も図中の次世代 Cortex と一致している。
この CPU の主な状況はこうだ。アライン済みのロードとストアはまずまず健闘しており、それぞれ約 11.5GB/s と 6.7GB/s を出せる。興味深いのは未アラインの load/store を処理する際の性能低下で、DMB 命令にぶつかると、このベンチマークではロードもストアもほぼ同程度、50% 前後のペナルティを受ける。
これは、CPU がかなりの数の LRCPC-release loadstore を in-flight に保てることを意味するようだ。そのため DMB 命令は遭遇したときの影響が大きいが、致命的な性能損失にまでは至らない。ただ、アラインメント問題による 50% の性能低下は良い結果とは言えない。
Cortex-X925
X4 に続いて、DGX Spark とその X925 コアを見ていきます。これは単に新しい ARM CPU コアというだけではなく、システムのメモリ帯域もはるかに広い。プラットフォームの帯域は 273GB/s で、以前は 76.8GB/s でした。つまり、結果が X4 とかなり近くても、グラフ全体が少し底上げされるだけです。興味深いのは、非アライメントアクセスのペナルティも X4 とほぼ同じであること。ただし store はやや早く回復しているようで、これは高速なメモリのおかげかもしれません。ここに意外な点はなく、世代間で性能は一貫しています。
Oryon-3
Qualcomm が発表したばかりの CPU コア設計です。Linux サポートはまだ進行中ですが、すでに強い実力を見せています。最も興味深い結果はアライメントされた LRCPC-load 命令から得られました。性能は通常の load と同等です。つまり、行儀の良いアプリケーションなら通常はフル性能が期待できます。release-store 命令もかなり健闘しており、帯域は通常の store の 68% に過ぎませんが、決して悪い数字ではありません。
この CPU も非アライメントの LRCPC-release loadstore のペナルティを避けられません。load 側は Cortex-X925 とほぼ同等の約 70% の性能損失で、Snapdragon X2 Elite の帯域が潤沢なためでしょう。しかし store 側はむしろ悪く、性能は約 43% です。これらの非アライメントアクセスのペナルティがあっても、このプラットフォームは実際には Cortex シリーズのアライメントアクセスより高速です。
このプラットフォームには奇妙な点が一つあります。「Fully coherent 96KB 6-way L1 cache with 64B coherency granules」を謳っているのです。我々の理解では、これは非アライメントアクセスの性能影響がはるかに小さくなるはずだという意味になります。興味深い……これは覚えておきましょう。
Apple M1
これは重点的に議論しなければならないものです。真の転換点であり、「Apple の瞬間」です。ARM が実行可能なだけでなく、より高速になり得ることをすべての人に示しました。このグラフの数字は驚異的で、これは Apple が TSO メモリモデルをハードウェアに直接組み込んだ成果です。このモデルは LRCPC-release アクセスを使わず、TSO 機能を有効にしただけで、アライメント版はほぼ非アライメント版に追いついています。store の性能損失はおよそ 5% でしょうか? グラフ上の他のどのデバイスと比べても、実質ゼロです。根本的な理由は、非アライメントアクセスで DMB 命令をコードに埋め戻す必要がなくなり、ハードウェアが直接処理するからです。
我々にとって、これが ARM 上で真剣に x86 エミュレーションに取り組む意味であり、Apple がユーザーのネイティブ・エミュレーション両方のソフトウェア体験を気にかけている証拠でもあります。彼らは問題を見つけ、それを直接「解決」し、消し去りました。 とはいえ、TSO モードを_有効_にすると性能は確かに低下します。前のグラフと比べて、store 性能は通常の 76% にとどまり、load 性能はほぼ横ばいです。すべてがはるかに高速なときには、この程度のコストははるかに受け入れやすくなります。
非アライメント LRCPC/release アクセスのまとめ
この節の締めくくりとして、これらすべてのベンダーが実際にサポートしている性能改善について話します。ARM が作った拡張で FEAT_LSE2 と呼ばれ、すべてのテストプラットフォームが実装しています。先に述べたように、acquire/LRCPC/release メモリアクセスは自然アライメントが必要で、そうでなければ CPU のアライメント例外が発生します。ARM はこの問題を考慮してこの拡張を実装し、x86 エミュレーション(おそらく他のワークロードにも)に役立ちます。この拡張は acquire/LRCPC/release load/store 命令のアライメント要件を緩和するだけでなく、read-modify-write アトミック操作のアライメント要件_も_緩和します!
話は良さそうですが、問題があります。つまり、x86 エミュレーションに限界的な性能向上しかもたらさないということです。この拡張はアライメント要件を緩和し、16 バイト粒度内での非アライメントメモリアクセスを許可するだけです。その 16 バイト粒度をまたぐアクセスは依然としてアライメント例外を引き起こします。x86 アプリケーションはメモリアクセスのアライメントをまったく気にしないので、キャッシュライン全体をまたぐ非アライメントアクセスに遭遇します。x86 では、read-modify-write アトミック操作だけがキャッシュラインをまたぐのを避けようとします!
なので、この試みには感謝しますが、見た目は良くても実際にはあまり役に立ちません。ここまで来たので、これらの RMW アトミック操作を見てみましょうか?
おっと、このアトミック命令は何だ?
ほとんどの現代の命令セットと同様、x86 はアトミックメモリ操作をサポートしています。これらの命令はメモリ内のデータに対して ALU 演算をアトミックに実行し、中間状態を外部に可視化しません。x86 の言葉では、これはアトミックかつ一貫性のあるメモリに対する操作であり、ARM ではアトミック性のみを保証するか、_あるいは_アトミック性と一貫性の両方を保証するかを選べます。先に簡単に触れましたが、データに対するアトミック操作とそのデータの一貫性の間には実際に違いがあります。何が違うのでしょうか?
x86 メモリモデルに関する以前の議論では、load と store が他のプロセッサに可視化される際の一貫性の問題を扱ってきました。完全に無視していたのは、これらのメモリアクセスのアトミック性要件です。x86 の世界では、load や store は_通常_、非アライメントであってもアトミックに完了します。つまり、8 バイトのデータを store し、別のスレッドが競合条件下でこの 8 バイトを load した場合、store 前と store 後のデータが混ざったものを突然見ることは決してありません。ARM では、これらのアトミック性保証は明らかに弱く、非アライメント store 命令を実行した場合、ISA 仕様は読み取ったデータの撕裂について何も保証しません。幸いなことに、自然アライメントされた load-store 命令については、ARM には 「single-copy atomicity」 と呼ばれる仕様があり、これらのアクセスで撕裂が発生しないことを保証します。さらに良い知らせもあります。先に触れた FEAT_LSE2 拡張は? 実は single-copy atomicity 保証を 16 バイト粒度内の_あらゆる_非アライメントアクセスに拡張します! 悪い知らせは、x86 の single-copy atomicity 保証がキャッシュライン全体をカバーするため、この拡張でも根本的に問題は解決せず、発生回数を減らすだけだということです。
原子性と一貫性の違いについては、もう十分に語られてきた。では実際のアトミック命令はどこにあり、何ができるのか。ARMv8.1-a 以降、私たちの ISA には x86 のアトミック命令とほぼ一対一で対応する命令群が追加された。ここで完全なリストを出し、私たちの JIT で一つ一つどう対応しているか見てみよう。
| x86 | ARMv8.1-a |
|---|---|
| LOCK DEC | ldaddal |
| LOCK INC | ldaddal |
| LOCK NEG | ??? |
| LOCK NOT | ldeoral |
| LOCK ADC | ldaddal |
| LOCK ADD | ldaddal |
| LOCK AND | ldclral |
| LOCK OR | ldsetal |
| LOCK SBB | ldaddal |
| LOCK SUB | ldaddal |
| LOCK XADD | ldaddal |
| LOCK XOR | ldeoral |
| LOCK BTC | ldclralb |
| LOCK BTR | ldeoralb |
| LOCK BTS | ldsetalb |
| XCHG | swpal |
| LOCK CMPXCHG | casal |
| CMPXCHG8B | caspal |
| CMPXCHG16B | caspal |
ほら、19 個のアトミック RMW 操作がすべてここにあり、しかもほぼすべてが何らかの ARM 命令に直接対応している。怪しいものは無視していい。実際のワークロードでは使われないし、掘り下げても話がどんどん脱線するだけだ。両アーキテクチャの間にはかなり明快な 1:1 の対応がある。これで一件落着、だろう?x86 エミュレーションの面白さはここにある。命令が揃っているからといって、すんなり繋ぎ込めるわけではない。非アラインドアクセスが通常の load と store の性能を大きく落とすことは既にたっぷり書いたが、同じ問題は RMW アトミック操作にも当てはまる。
この図で見ているのは単一のアトミック命令で、そのメモリアドレスはある cacheline 内のどこかに位置している。19 個のアトミック操作のデータを全部描いたら、この図は今よりずっと見づらくなる。これらのアトミック操作の挙動は_おおむね_同じで、全部描いても冗長なうえ、ここで議論したい内容とは関係がない。また、これは本記事で初めて本当に対数スケールを使った図なので、見るときははっきり意識してほしい。最速から最遅までの性能差はおよそ 1000 倍のオーダーになる。
図の x86 Zen プロセッサから始めよう。この結果こそ、私たちのシミュレーションが目指すべきものだ。見てのとおり、アクセスが単一の cacheline 内に完全に収まっていれば、命令レイテンシは一律 1.44ns になる。理由は x86 が「アトミック cacheline」、別名「コヒーレント cacheline」を持っているからだ。非アラインドのアトミック操作が cacheline 境界をまたがない限り、コストはほぼ変わらない。これは何十年も前からサポートされている x86 の極めて強力な機能で、ゲームはそれと気づかないままこれに大きく依存するようになっている。x86 の結果で最も目を引くのは最後のものだ。64 バイトの粒度をまたぐと約 660ns かかる!これは他の結果よりおよそ 458 倍遅く、驚くべきことだ。この時点でハードウェアはついに split-lock に頼るしかなくなる。
本記事の準備中に公開された Chips and Cheese の記事を挙げておきたい。split-lock がなぜこれほど劇的に遅いのかを深く掘り下げており、その仕組みがわからないなら読む価値がある。特に、x86 の split-lock は cacheline をまたいでも x86-TSO の原子性とコヒーレンスの保証を維持し続ける。データを 決して 引き裂かない。これは少しばかげていて、後でさらに説明する。
次にARMプロセッサを見ていきます。まずは自然アラインメントのレイテンシデータからです。ご覧のとおり、どのプラットフォームもそれなりの性能は出ていますが、最新のコアでもx86には遠く及びません。最速のARMプラットフォームでもレイテンシはx86の約3倍で、これはゲーム性能に直結します。ただし通常は直接のボトルネックにはならないため、影響を正確に測るのは困難です。次のデータポイントに移ります。ほとんどのARMプラットフォームでは、16バイト境界をまたぐ場合と64バイト境界をまたぐ場合の結果は実質的に統合できます。ARMの仕様が非アラインメントなアトミック操作をどう定義しているかにより、この2つの結果はほぼ等価であり、FEXはこれをx86のsplit-lock問題と同じものとして扱います。
このsplit-lock問題については以前から触れてきましたが、FEXは具体的にどうエミュレートしているのか、そしてなぜそんなに遅いのか。「Apple M1はx86-TSOをハードウェアレベルでサポートしていたはずだが、それでもなぜ遅いのか」。先ほど触れたことを思い出してください。FEAT_LSE2は16バイト粒度内の非アラインメントメモリアクセスをサポートしました。これらのsplit-lock操作も結局は同じアラインメント問題に行き着きますが、はるかに遅くなります。FEXはこれらの命令を単純なDMB操作で代替できないため、実行されるたびにalignment-faultが発生します。つまり、split-lock操作は毎回、カーネル -> ユーザースペースのシグナルハンドラ -> カーネル -> 元のコードというサイクルをたどることになります。カーネルモードとユーザーモードの往復はどのプラットフォームでも遅く、それが毎秒何千回も発生すればオーバーヘッドはすぐに積み上がります。ARMでのこれらの機能のエミュレーションが異常に遅いのはこれが理由です。
しかし、今日のARMプラットフォームの1つがこの問題を部分的に解決しています。Oryon-3 CPUコアは「coherent cachelines」として売り込まれている機能を導入しており、その結果はこのマイクロベンチマークで確認できます。x86と同様、アトミックメモリアクセスが64バイトのキャッシュライン内に収まっていれば、性能は自然アラインメント版と同等です。これは大きな改善であり、このCPUが機能サポートにおいてx86と同等であることを意味します。ただしキャッシュラインをまたごうとした場合は別です。Qualcommがこの機能を実装したことは称賛に値します。x86エミュレーションにおけるsplit-lockの主要な性能問題と正確性の問題を解決しています。とはいえ、ハードウェアは64バイトのsplit-lockをまだサポートしていないため、その場合は依然としてFEXのエミュレーションパスにフォールバックします。
続いて Apple の結果です。x86-TSO のメモリアクセスに対するハードウェアサポートを追加したにもかかわらず、なぜか Oryon のようにキャッシュラインをまたぐ非アラインのアトミック操作は完全には実装されませんでした。このエッジケースを予見して実装しているはずだと思うかもしれませんが、それはただの推測です。そのため、TSO のハードウェアスイッチを有効にしても、16 バイト粒度での性能は他のプラットフォームと同じになります。
グラフのもう一つの小さな異常にも気づいたかもしれません。このベンチマークでは Cortex-X4 の結果にアスタリスクが付いており、その非アラインのアトミック操作性能は、はるかに新しい CPU よりも荒唐無稽なほど高速です。どういうわけかレイテンシは約 209ns しかなく、X925 のレイテンシは 1060ns ですから、5 倍の性能向上です。そんなことがあり得るのでしょうか? 実はこれ、私たちがテストしたプラットフォームに載っていたちょっと面白い「秘密のレシピ」によるものです。もちろん Valve Steam Frame です。Valve は既存のゲームライブラリの性能を気にかけていたため、とある FEX 開発者がさっと書いたカーネルパッチを配信しました。これにより Linux カーネル自体が非アラインのアトミック操作を処理できるようになり、FEX やユーザー空間とやり取りする必要がなくなったため、速度が大幅に向上しました。他のプラットフォームもこのパッチをカーネルに取り込みたいなら、そのまま採用することを勧めます。FEX が自動的に使い始めます。
カーネルが関与する話が出たので、split-lock のエミュレーションが FEX では実は完全には正しくないこと、その理由がハードウェアの制約にあることにも触れておきます。x86 のこの必須機能を正しく実装するには、16 バイトまたは 64 バイトの split-lock が発生するたびに、カーネルが処理するしかありません。現状 FEX は「ベストエフォート」で処理しており、場合によってはデータが実際に引き裂かれます。x86 では split-lock が決して引き裂かれないと以前説明したのを覚えているでしょう。たとえ「コヒーレントなキャッシュライン」を持つ Oryon-3 であっても、この問題は今のところ解決できていません。
split-lock は必須だって、どういう意味?
今日の ARM ハードウェアで split-lock のエミュレーションを高性能に実装するのは、実は非常に困難です。最も素朴な方法はグローバルミューテックスを使い、split-lock が起きるたびにミューテックスを取得してから操作を実行するものです。つまり、_関与する_すべての split-lock 操作がこのロックを通り抜けることになります。これ自体は正しいのですが、問題はアラインされたアトミック操作は split-lock ではないため、関与しないことです。split-lock のエミュレーションコードは 2 回の 64 ビット compare-exchange として実装せざるを得ず、その各半分が粒度の境界をまたぐため、関与しないアトミック操作があっても引き裂きが起こり得ます。簡単な例を挙げましょう。あるスレッドがキャッシュラインの中央にあるアトミック変数をひたすら変更し、別のスレッドがその半分にある整数_だけ_を変更するとします。わざと作った例のように聞こえますが、まさにその通りの動作をするロックフリー連結リストの実装が実在します。アラインされたスレッドがどちらの半分を変更するかによって、split-lock コードの 1 回目か 2 回目の CAS が失敗します。1 回目の CAS が失敗した場合は安全で、コードはリトライできます。_2 回目_の CAS が失敗した場合は、データがすでに引き裂かれたということであり、データが壊れたりクラッシュしたりしないことを祈る以外に手はありません。これは完全に顧客アプリケーションが使うアルゴリズム次第で、私たちには制御できません。
もう一つのまったく実現不可能な案は、カーネルがメモリを共有するすべてのプロセスとスレッドを追跡し、あるスレッドが split-lock をエミュレートする必要が生じたときに、そのプロセスとメモリを共有する すべての プロセスを停止し、その split-lock を単独で完了させてから世界全体を再開させるというものです。この案の性能への影響は到底受け入れられません。アプリやゲームは毎秒数千回、それ以上の場合もある split-lock を実行する可能性があり、世界全体を止めることによる性能損失は耐え難く、x86 ネイティブよりもはるかに悪くなります。
split-lock のエミュレーションで正しさを保証するなら、FEX には何らかの形のハードウェアサポートが必要です。だからといって、今すべてのアトミック操作が x86 のように split-lock をサポートすべきだという話ではありません。それも同じく実現不可能です。幸いなことに、ARM にはまさに私たちがやりたいことを実現する拡張があります。ARM には Transactional Memory Extension という拡張があり、この問題を解決できます。この拡張を使えば、コードはトランザクション領域内でいくつかの操作を実行し、その作業をアトミックにコミットできます。コミットに失敗したら、そのままリトライすればよいのです。この拡張の欠点は何か? ARM はすでに公式に非推奨としており、実際にリリースしたベンダーは一社もありません。おそらくこれが最良の結果です。この拡張の x86 版は問題が続出し、多くのプラットフォームで無効化される原因になったからです。
そこで split-lock を正しくエミュレートするには、別の仕組みが要る。FEX の要件と ARM ベンダーの要件の両方を満たせる案として考えたのが、128 ビットの CASP 命令の 2 つの半分を、原子粒度の境界にちょうどまたがせる、というものだ。下位 64 ビットと上位 64 ビットをそれぞれ境界の両側に置く。この場合に限り、その命令はアラインメントエラーを起こさず、CAS を試みる。うまくいく理由は、x86 の非アラインメント原子操作は最大でも 64 ビットなので、操作の両半分は必ず我々の 1 命令で丸ごと覆えるからだ。
だが「ハードウェアが split-lock を直接サポートするより、これのどこが良いのか」と思うかもしれない。もっともな疑問なので、この操作が実際にどう動くかを慎重に説明しよう。x86 では、原子操作は_常に_成功しなければならず、引き裂かれてはいけない。我々のエミュレーションでは、この ARM の CASP 命令を安全に失敗させ、あとで再試行できる。これは CAS の利点のひとつで、操作は_どんな_理由でも失敗してよく、失敗したらもう一度やればいい。この命令はそのときメモリから読んだデータも同時に返すので、プログラムは最新のメモリ状態を手に入れられる。この違いは大きい。つまり FEX は CAS 操作が必ず成功するまで無限に再試行できる! ARM の LL/SC アーキテクチャのおかげで、これで話が成り立つ。厄介なのは、ハードウェアが_いつか_は前方への進行を保証しなければならない点だが、それは別の理由ですでにサポートされているので、まったく問題ない。CAS 命令で新たに増える失敗ケースは、2 本の cacheline のどちらかが、操作全体が完了する前に別のコアに持っていかれた場合だけだ。ハードウェアが前方への進行を保証するのに最大で数千サイクルかかるとしても、x86 の挙動とほぼ一致する。
ARM で x86 の split-lock をエミュレートするには、これが最良の方向性だと考えている。ただ我々はハードウェアアーキテクトではないので、文句を言って誰かが直してくれるのを待つことしかできない。split-lock の話はここまでにして、次は別の面白い問題を見ていこう。
待って、uncached メモリも動かないと?
この話題に入る前に「uncached」という言葉を説明しておきたい。見る角度によって意味が何通りにもなるからだ。本記事では Vulkan の用語を使う。主に関心があるのはゲームだからだ。Vulkan には VK_MEMORY_HOST_CACHED_BIT があり、これはホスト CPU がそのメモリをキャッシュすることを示す。ここで問題にするのはこのフラグが付かない場合、つまり我々が「uncached」と呼んでいるものだ。これがメモリサブシステムにとって何を意味するかは、思っているよりややこしい。特にメモリが GPU 上にあり、PCIe を経由することもある場合、メモリが「uncached」である状態は通常(ただし常にではない!)VK_MEMORY_HOST_COHERENT フラグも伴う。つまりこのメモリはキャッシュされないので、CPU と GPU の間で常に一貫したメモリの見え方が得られる。
CPU 側では、これは通常、メモリのマッピングが最大 3 通りあり得ることを意味する。「cached」メモリを確保すると、そのメモリタイプは通常 Write-back になり、通常のメモリマップト型も同じだ。「uncached」マッピングは Write-Combine か「Strong Uncacheable」になり得る。「Strong Uncacheable」の 実装はユーザー空間アプリにはまず存在しないので、今日は無視してよい。すると実際に使えるのは WB (cached)と WC(uncached)の 2 種類だけになる。ゲームは通常、staging buffer に cached メモリを使い、データを GPU に直接渡すときには uncached を使う。
多くのゲームエンジンはこれをコードに織り込んでいる。uncached buffer タイプを提供しないと動かないエンジンもある。背景には UMA システム(APU など)と PCIe GPU の挙動の違いがある。UMA システムは通常、cached で coherent、かつ GPU から見えるメモリ割り当てを提供できる。PCIe GPU ではそれが保証できないので、 ゲーム開発者は staging buffer でデータを非同期に GPU へコピーするか、「uncached」メモリを使って PCIe 経由で慎重にデータを GPU へ運ぶかのどちらかを選ぶ。PC ゲームでは PCIe が一般的すぎるため、 UMA 専用のコードパスを書かず、一律で uncached 方式にするエンジンもある。
uncached が我々にとって何を意味するかの前置きはこのくらいにして、次は UMA の Snapdragon システム数台で cached メモリがどれだけ速いかを測ったベンチマークを見よう。通常時の性能のベースラインが得られる。
Steam Frame と Snapdragon X2 Elite はどちらもかなり良い結果だ。予想どおり Oryon-3 プラットフォームはメモリ 帯域が広いのでグラフ上でも上まで伸びるが、どちらも毎秒数十 GB に達している。この図は「普通の」write-back メモリがどの程度の水準になるかを示す良いベースラインだ。次は uncached の結果を見て、どれだけ性能が落ちるか確かめよう。
ここで奇妙なことが起きている。このグラフでも対数スケールを使わざるを得なかった。まずは明らかになった良い知らせから。
未キャッシュのメモリバッファが write-combine 方式を採るため、ARM プラットフォームでも通常のストア操作がキャッシュのベンチマーク結果に匹敵することがわかる。write-combine メモリは write combine buffers と呼ばれる仕組みを使い、これらのバッファが cacheline 1 個分のデータを ごく 短時間だけ保持することで、write-combine が cacheline 単位でメモリへバースト書き込みできるからだ。面白いことに、Zen 4 の WCB はキャッシュの速度に追いつけないようだが、PCIe バス経由であることを考えればおそらく問題にはならない。
ここからが本当にまずい結果だ。説明しやすいものから始めよう。すべてのテストプラットフォームで、write-combine メモリからの読み出し帯域が壊滅的だった。Zen を性能の基準にすると、ARM では通常のロード命令のほうが勝っており、LRCPC ロードはさらに悪い。何が起きているのか。これは write-combine メモリの動作上の癖だ。キャッシュされないため、ロード命令はセマンティクスを保つために毎回システムメモリまで取りに行かねばならない。LRCPC ロードが加わると、問題はさらに深刻になる。だが最悪なのはストア性能で、Zen のストア性能と比べるとほぼ致命的だ。帯域差はなんと 816 倍。Hollow Knight: Silksong や Subnautica 2 のようなゲームは、この性能の崖のせいで 1FPS 未満でしか動かない。
前述のとおり、PCIe GPU を使う場合、ゲームは GPU へデータを渡すために未キャッシュメモリを使う必要がある。独立した PCIe GPU を搭載したプラットフォームで x86 ゲームをエミュレートすると、勝ち目のない状況に追い込まれ、極端に遅い動作が避けられない。ARM が x86 メモリモデルのエミュレーションを改善するために FEAT_LRCPC1/2/3 系の拡張を追加したのを覚えているだろうか。これは未対応のエッジケースに遭遇したときに起きることだ。これらの拡張はすべて、x86-TSO メモリモデルのセマンティクスでメモリロードを処理する新しい命令を追加したが、x86-TSO セマンティクスで write-combine メモリへストアする問題を解決したものは一つもない。ARMv8.0-a 以降、ストア命令はバッキングメモリの種類にかかわらず通常の store-release 命令を使う。FEX がこの問題を解決する唯一の方法は、問題が発生したときに TSO エミュレーションを選択的に無効化することであり、そのため PCIe GPU を搭載した x86 エミュレーションプラットフォームの体験は永遠に UMA に劣る。少なくとも、これを解決する別の FEAT_LRCPC4 か同種の拡張が登場するまでは。
UMA システムを使っている人たちは喜んでほしい。性能を引き上げるために使っている一連のゲーム向け回避策は、あなたたちにもそのまま効く。CPU と GPU のキャッシュコヒーレントな組み合わせをサポートするプラットフォームだとわかれば、グラフィックスドライバに 常に キャッシュ付きバッファを使わせることができ、この問題を完全に回避できる。NVIDIA は自社の Tegra プラットフォームで既にそうしており、Snapdragon は少なくとも Adreno 600 クラスの GPU 以来ずっと対応してきたし、多くの Mali プラットフォームも同様だ。FEX の実行中、プラットフォームが対応している限り未キャッシュメモリに絶対に触れないようにする Adreno Turnip パッチがある。面白いのは、Asahi のユーザーはハードウェアに TSO ビットが備わっているため現実にはこの問題に遭遇しないことだが、そのプラットフォームで PCIe グラフィックスカードを使うとなると話はまったく別だ。もう一つ興味深い癖として、ARM プラットフォームの Radeon カードは write-combine メモリをすべて隠して write-back に変えてしまう。これについてはまた今度。
より明るい未来へ
このマラソン記事を読み終えた今、x86-TSO メモリモデルのエミュレーションがもたらす課題のいくつかについて、より深く理解してもらえたはずだ。ARMv8.0 を最低スペックとして出発し、その後のハードウェアの進歩は驚くべきものだった。すべてのエッジケースがアーキテクチャレベルで解決されたわけではないが、エコシステム全体に最悪のケースを改善しようという意志は確かにある。各社がそれぞれ問題の一部を解決し、互換性を前へ進めている。おそらく 10 年後、今を振り返って当時の問題を笑い飛ばしながら、ARM ハードウェアには決して移植されない優れた x86 ゲームを楽しんでいることだろう。最終的にどこでゲームを遊ぶことになっても、PC ゲームエコシステムの遺産は受け継がれていく。
2026 年 9 月 17 日執筆