x86エミュレーションの弊害
The Scourge of x86 Emulation (fex-emu.com)
要約
この記事は、ARMのような弱順序メモリモデル上でx86のTotal Store Ordering (TSO) メモリモデルをエミュレートする際の複雑な問題について解説しています。x86-TSOの厳格なメモリ順序付けをARMの緩和されたモデルで再現するには、特別なロード・ストア命令が必要となり、これがパフォーマンスの低下を引き起こす原因となります。ARMv8.3以降で導入されたLRCPC (Load-Reserved/Conditional-Store-Conditional) 命令は、この問題に対する有望な解決策を提供し、エミュレーションのパフォーマンスを大幅に向上させる可能性を示唆しています。
全文翻訳
x86エミュレーションの苦境
当サイト初の特集記事へようこそ。今回は、エミュレートするすべてのアプリケーションに影響を与える、x86エミュレーションにおける継続的な問題について解説します。これは、広範な影響を持つ単一の包括的な用語、すなわちx86 Total Store Orderingメモリモデル (x86-TSO)に集約されます。
ARMが定義する弱順序メモリモデル上でこのメモリモデルをエミュレートする際の問題は多岐にわたり、複数の課題を網羅しています。この記事では、遭遇する可能性のあるすべての問題と、それらを解決する方法(あるいは、場合によっては解決できない方法)について説明します。スナックとお気に入りの温かい飲み物を用意して、ゆっくりお楽しみください。これは長くなるでしょう。
- x86-TSOとは何か?
- ARMv8.0-aの謙虚な始まり
- メモリへのアクセスは簡単な部分だと思っていたのに?
- ああ、これらのアトミック命令は何だ?
- スプリットロックが必須だとはどういうことですか?
- キャッシュされないメモリが機能する必要があるとは?
- より明るい未来に向けて
x86-TSOとは何か?
x86メモリモデルの問題を回避する方法に入る前に、それが正確に何であるかをまず議論する必要があります。メモリモデルとは、システム内のメモリアクセスが互いにどのように振る舞うかについてのルールのセットです。これらのルールは、シングルスレッドまたはマルチスレッド環境でのロードとストアの相互作用を決定します。さまざまな形態のハードウェアに実装されているメモリモデルはいくつかありますが、今日私たちが気にするのは、ARMのリラックス(または弱い)一貫性モデルと、x86のTotal-Store-Ordering一貫性モデルのバリアントです。これら2つのモデルは、スペクトルの両極端に位置すると言えます。ARMは最もリラックスしており、大幅なハードウェア最適化を可能にしますが、x86は最も厳格で、最適化の余地をほとんど与えない非常に強力なコヒーレンシモデルを強制します。メモリモデルについて議論する際に注意すべき点は、一貫性とアトミシティの違いです。これらは関連していますが、同じではなく、すべてのケースで保証されるわけでもありません。
メモリモデルの違いがどのように機能するかを説明する最良の方法は、x86がこれをどのように処理するかから始めることです。TSOは非常に厳格に動作するため、プログラマはメモリへのストアが発生した場合、それがシステム内の他のすべてのプロセッサに対して一貫して可視であると想定できます。これはさらに、メモリからのロードが発生した場合、それ以前のすべてのストアが「論理的に」完了したか、少なくとも可視であることを意味します。これはプログラマの期待と一致します。メモリに書き込むと、それが書き込み時点で見えるようになります。これはプログラミングにおいて直感的です。ストアは実質的にロードの可視性を順序付けるため、モデルの名前が付けられています。これがどのように機能するかにはニュアンスがありますが、理解するために厳密に必要ではありません。
ARMが持つ弱いメモリモデルは、その動作について少し直感的ではありません。デフォルトでは、ARMが使用する通常のメモリロードとストアは、システム内のプロセッサ間で厳密に一貫しているわけではなく、CPUがほとんどの時間、より効率的に動作することを可能にします。ストア命令が実行されると、そのメモリ(キャッシュライン)はシステム内の他のプロセッサにすぐに可視になるわけではありません。キャッシュラインを無効化したり、他のプロセッサのキャッシュをスヌープさせたりするのはハードウェアにとってコストが高いため、貴重な電力と効率を節約します。同様に、プロセッサが他のプロセッサが書き込んだメモリからデータをロードしている場合、このロードが更新されたメモリを見ることは保証されていません。これはマルチスレッドアプリケーションで重大な問題を引き起こすように聞こえますか?古いバージョンのARM(ARMv7以前)はメモリバリア命令を使用して順序付けを保証していましたが、これはパフォーマンスに大きな影響を与えました。
この一貫性の制限を回避するために、ARMはロード・アキュア(load-acquire)およびストア・リリース(store-release)メモリ命令も導入しました。C++の専門用語では、これはそれぞれstd::atomicのmemory_order_acquireおよびmemory_order_release定義にマッピングされます。ARMの用語では、これらの命令も技術的にアトミック操作とは見なされませんが、プログラマは両者を混同します。FEXは、アトミックロードとアトミックストアという用語を同じ意味で使用しています!通常、この区別は問題になりませんが、これらのトピックについて議論する際には、厳密に言及した方が良いかもしれません。
これらの命令の主なユースケースは、これらのクラスの命令間のメモリ順序を強制することです。ARMはこのモデルを「Release Consistency sequentially consistent (RCsc)」モデルと呼んでいます。このモデルがどのように動作するかについて深入りせずに、基本的な要点は、ロード・アキュア命令は再順序付けなしで順次観測されなければならず、ストア・リリース命令も「barrier-ordered-before」セマンティクスを満たしながら順次観測されなければならないということです。古いARMアーキテクチャバージョンで必要だったコストのかかるメモリバリア命令を削除します。
ARMv8.0-aの謙虚な始まり
これが、x86-TSOメモリモデルをエミュレートする際にARMv8.0-aで開始する前提です。すべてのx86メモリロードをARMのロード・アキュア命令に、x86メモリストアをストア・リリース命令に変換します。これにより、FEXは実質的にx86と同じメモリセマンティクスを得ますが、実際には必要以上に厳格になっています。なぜなら、正確に一致する中間的な方法がなかったからです。想像できるように、これらの命令でTSOをエミュレートするのは非常にコストがかかり、それを示すマイクロベンチマークがあります。ARM CPUは、これらの比較的まれなアキュア/リリース命令が突然実行される命令の大多数になるようには設計されていませんでした。
まず簡単なものから始め、ハードウェアに比較的優しいマイクロベンチマークを使用しましょう。トリッキーなエッジケースはなく、一般的なケースでのメモリへのアクセスです。これにより、最良の状況がどのようなものかについてのベースライン数値が得られます。
このグラフを分解すると、いくつかの興味深いストーリーが語られます。各マシンのロードとストアの列は、ハードウェアが達成しようとすべきベースラインパフォーマンス数値を表しています。これらは各システムのメモリ帯域幅を最大化しようとしているわけではありませんが、各操作タイプで同じ量の作業を行います。アキュアロードの結果に目を向けると、テストされた5つのCPUのうち3つで、アキュアロードの使用によってパフォーマンスがかなり低下していることがわかります!さらに、AmpereOne CPUのリリースストア命令は、他の結果と比較して著しく低く、M1のアキュア/LRCPCロード命令もベースラインよりかなり低いことがわかります。
特にAmpereOneの結果は、このレガシーパスがどれほど悪くなるかを示しています。これらの命令は、このように使用されるようには設計されていませんでした。x86エミュレーションのためにすべてのアキュア/リリースセマンティクスを使用することは、ARM CPUに非常に厳しい制限を課します。なぜなら、ロード命令は互いに順序付けできなくなるからです。したがって、毎秒数百万もの命令が実行される場合、パフォーマンスが良好であるとは期待できません。しかし、ARMv8.0-aで利用できる命令はこれしかなかったため、これを使用するしかありませんでした。Cortex-X4とCortex-X925はこれらの命令で驚異的なパフォーマンスを発揮しますが、Oryon-3がいかにそれらの重要性を優先順位を下げているかがわかります。
これからどこへ向かうのか?
ARMv8.3以降で必須となったLRCPCロード命令を詳しく見てみましょう。この拡張機能は、ARM ISAに多くの新しいロード命令を追加し、以前のARMのRCscモデルの上に新しいメモリモデルを追加します。この新しい「Release Consistency processor consistent (RCpc)」メモリモデルこそ、私たちが求めていたものです!この拡張機能は、x86エミュレーションが必要とする要件を中心に設計されており、それを実装するハードウェアでヘビーに利用されることが期待されています。グラフからわかるように、ほとんどのプラットフォームでLRCPCロードのパフォーマンスは通常のロードと同等です。
新しいARMバージョンで必須となったこの新しい拡張機能により、メモリパフォーマンスは実質的に解決されます。少なくともこのマイクロベンチマークによれば、そのようです。FEXがこの拡張機能を検出すると、アキュアロード命令の使用を完全に停止し、代わりにLRCPCロードに切り替えます。しかし、あのApple M1の結果はどうなっているのでしょうか?
ここで、この問題解決に向けたAppleのアプローチを称賛する必要があります。Apple Siliconプロセッサでは、x86-TSOメモリモデルを直接ハードウェアに追加しました。CPU機能が切り替えられると、通常のロード/ストアARM命令の動作がx86の要件に一致するように変化します。彼らは、ARMエコシステムに完全に移行する際に、自社ハードウェアに高性能なソリューションが必要になることを知っていたため、このルートを選択しました。だからこそ、彼らのハードウェアではLRCPCロード命令は実際にはアキュアロード命令のエイリアスなのです。なぜなら、彼らのx86エミュレータはこれらの命令をまったく使用しないからです!x86メモリモデルを実装しているため、通常のロード/ストア命令を使用するだけで、マイクロベンチマーク結果ではパフォーマンスのオーバーヘッドはほとんど認識できません。他のプラットフォームに対して公平に言うと、このスレッド全体でのTSOモードトグルにはパフォーマンスへの影響がありますが、ここではそれが見えません。FEXがAsahi LinuxからこのCPU機能を検出すると、これも有効にし、「無料」のパフォーマンス向上を得ることができます。懸念点としては、x86エミュレーションとARMコード間を切り替える際に、ARMコードがすべてのアクセスがTSOになるため、不必要なオーバーヘッドを支払うことになる可能性があることです。これはもっともな懸念ですが、実行されるネイティブARMコードの量はほぼ0%に近づきます。開発者としては、1%のメモリアクセスが10%遅くなることよりも、99%のアクセスが「理想」の15%になること(AmpereOneの結果で示されている)の方が重要です。
注記として、TSOモードはARMプラットフォームでの高性能x86エミュレーションを確保するための最良の道であると考えています。なぜなら、これによりすべてのメモリアクセス命令が期待通りに動作することが保証されるからです。これは、公式のFEAT_LRCPC拡張機能が、実装にそれぞれ包帯を巻く3つのバージョンを持っていることからもわかります。
- FEAT_LRCPC - 基本的なGPR TSOロード命令を追加
- FEAT_LRCPC2 - TSOロード命令に小さなオフセット即値を追加
- FEAT_LRCPC3 - 基本的なベクトルおよびスタックベースのTSOロード&ストア命令を追加
これらの3つの拡張機能があっても、ハードウェアトグルがあればよりきれいにエミュレートできるほどではないエッジケースの動作が存在します。記事の後半で議論する追加の問題の一部を修正しようとする、さらなる拡張機能バージョンが登場すると予想しています。
メモリへのアクセスは簡単な部分だと思っていたのに?
前のセクションでは、ARMハードウェアに優しく、ハードウェアの配置要件に従ってパフォーマンスのベースラインを取得しました。しかし、x86をエミュレートする際には、最初から明白な問題に直面します。お気に入りのx86アプリケーションは配置(アライメント)を気にしません!それらは望むようにメモリにアクセスし、キャッシュラインの境界を越え、アライメントされていないアトミック操作を行います。配置の問題を考えると、これらのゲームはそれを行っています。この問題は非常に深刻なため、それに付随する用語としてスプリットロック(split-locks)があります。これらは非常に重要であるため、Linuxカーネルでさえ発生時にキャプチャし、ゲームの速度を低下させます!多くのゲーマーが速度低下を避けるためにカーネルオプションをいじる原因となっています。
しかし、ここではまだ完全なスプリットロックについては話しません。配置を気にしない環境でのロード・ストア命令から始めましょう。x86はプログラマにある種の保証を与えます。ロード・ストアがキャッシュライン内にある場合、そのロード・ストアはアトミックであり、前述の一貫性モデルに一致します。しかし、ハードウェア開発者に少し優しくするために、ロード・ストアがキャッシュラインを越える場合、データはアトミックではなく、他のスレッドはそれを分割して見ることができます。したがって、プログラマは注意する必要があります。なぜなら、基本的なロード・ストアはスプリットロックではないからです。
これらの基本的なアクセスをロード・アキュア/ストア・リリースでエミュレートする問題は、ARMv8.0が自然アライメント(natural alignment)と呼ばれるものを必要とすることです。これは、アクセスされるデータのサイズに対して、メモリ内のオフセットがサイズと一致しなければならないことを意味します。したがって、8バイトのアクセスの場合、オフセットは0、8、16、24などである必要があります。これはネイティブARMアプリケーションにはうまく機能しますが、自然アライメント要件に従わない場合はどうなるでしょうか?ARMの場合、これは命令がアライメントフォルト(alignment fault)を発生させることを意味します。ハードウェアはアライメント要件が満たされているか検証し、満たされていない場合はCPUがフォルトを起こします。これは通常クラッシュにつながりますが、FEXは特別な処理を行います。
FEXのJITメカニズム内では、x86のロード・ストアをエミュレートするメモリロード・ストア命令を追跡しています。ロード・ストアがアライメントフォルトを引き起こす可能性があることがわかっている場合、コード内にパッチポイント(patchpoint)と呼ばれるものがあります。ロード・ストア命令の場合、これはロード・ストアの前または後にNOP命令として現れます。これらのパッチポイントでアライメントフォルトが発生すると、FEXはフォルトをキャプチャし、コードをロード・アキュア/ストア・リリース命令から基本的な同等のロード・ストアにパッチし、命令をデータメモリバリアでラップします。そして実行を続行します!
[画像: 前とパッチ適用後]
以前のARMv8.0-aがこれらの新しい派手なロード・アキュア、ストア・リリース命令を追加したことについての、あの議論全体を覚えていますか?アライメントの動作が一致しない場合、私たちはすぐに古典的なメモリバリア命令にフォールバックします。以前のグラフではこの悪いケースを示していなかったので、新しいデータを持ってきましょう。
ああ、これは分析するには多くのデータです。ハードウェアが「最適」なパスからどれだけ離れているかを示すのに役立ちますが、ここではそれが重要ではありません。このマイクロベンチマークでは、通常のロード/ストアの整列(aligned)と非整列(unaligned)の間であまり差が見られないため、両者の平均を計算しました。x86 CPUと通常のロード・ストアのデータはARM列から削除します。これらは一般的なFEXパスではないためです。これにより、エミュレーション下での非整列メモリアクセスの悪影響について、より的を絞ったビューが得られます。
より合理的なデータグラフが得られたので、左から右へ歩きながら何が起こっているかを議論しましょう。
AmpereOne
これは非常に興味深い結果です。整列ロード命令と非整列ロード命令はほぼ同等であり、ノイズの範囲内です。これは、非整列ロードがデータメモリバリアのペナルティを受けているにもかかわらず、CPUがそれを処理していることを意味します。パフォーマンスが他のプラットフォームと比較して非常に低いため、ベンチマークが他の要因でボトルネックになっている可能性があります。
一方、ストア側は非整列でなくても良い状態ではありません。グラフにはほとんど見えません!非整列ストアに当たると約8.5%のパフォーマンス低下が見られますが、すでに非常に低い値から始まっているため、気付きにくいです。これは、通常のストア命令がこのベンチで約28GB/sを達成していることとは stark contrast です。
私たちが到達できる唯一の結論は、Ampereがサーバークラスのワークロードのために最適化されており、コンシューマーハードウェアの動作とはあまり一致しないということです。興味深いデータポイントですが、ユーザーは通常このクラスのハードウェアでゲームを実行しません。
Cortex-X4
これはQualcomm Snapdragon 8 Gen 3に搭載されている非常に人気のあるCPUコアです。チャートをデータで圧倒しないように、SoCからこのコアのみをテストしました。多くのハンドヘルドデバイスに搭載されているため、興味深いターゲットです。このCPUは、リストの中で唯一の携帯電話SoCであることを考えると、驚くほどうまく機能します。全体として、このコアは期待されるものに沿っており、グラフの傾向は次世代Cortexのチャートに沿っています。
このCPUの主なトピックは、整列ロードとストアが比較的強力で、それぞれ約11.5GB/sと6.7GB/sを達成していることです。興味深いのは、非整列ロードストアを処理する必要がある場合のパフォーマンス低下です。DMB命令に当たると、このベンチマークではロードとストアの両方で約50%のペナルティが発生します。
これは、CPUがかなりの数のLRCPCリリースロードストアをインフライトで保持できることを示唆しており、DMB命令に遭遇するとより大きな影響を与えますが、世界を終わらせるようなパフォーマンス低下を引き起こしているわけではありません。単に、配置によって50%のパフォーマンス低下が発生するのは素晴らしい結果ではありません。
Cortex-X925
X4に続いて、DGX SparkとそのX925コアを見てみましょう。これはARMの新しいCPUコアであるだけでなく、劇的にメモリ帯域幅が増加したシステムで動作しています。プラットフォームでは273GB/sですが、以前は76.8GB/sでした。これは、X4と同様の結果が得られることを意味しますが、グラフのスケールが少し高くなります。興味深いことに、非整列アクセスによるパフォーマンスペナルティは、X4とほぼ同等です。ストアは少し速く回復するようですが、おそらく高速なメモリが役立っているのでしょう。ここには驚きはありません。世代を超えて一貫したパフォーマンスです。
Oryon-3
このCPUコア設計は、Qualcommから最新のものです。Linuxサポートはまだ開発中ですが、すでに強力なパフォーマンスを示しています。この中で最も興味深い結果は、整列LRCPCロード命令が通常のロードのパフォーマンスに匹敵することです!これは、正常に動作するアプリケーションの場合、通常はフルパフォーマンスが期待できることを意味します。これはリリースストア命令にも当てはまり、非常に強力ですが、帯域幅の68%しか達成できず、通常のストアには及びません。決して悪い結果ではありません。
このCPUも、非整列LRCPCリリースロードストアのペナルティから逃れることはできません。ロード側はCortex-X925の約70%のパフォーマンスペナルティに匹敵しますが、おそらくSnapdragon X2 Eliteも大量の帯域幅を持っているためでしょう。しかし、ストア側はパフォーマンスの約43%と、さらに悪化します。これらの非整列アクセスのパフォーマンスヒットにもかかわらず、このプラットフォームはCortex製品の整列アクセスよりも高速です。
このプラットフォームの奇妙な点の1つは、「完全にコヒーレントな96KB 6ウェイL1キャッシュと64Bコヒーレンシグラニュール」を搭載していると宣伝されていたことです。これは、私たちの読み方では、非整列アクセスがパフォーマンスに劇的な影響を与えるべきではないことを意味していました。興味深いですね。覚えておいてください。
Apple M1
これは私たちが話す必要がある大きなポイントです。これはゲームチェンジャーであり、「Appleの瞬間」でした。ARMが単に実行可能であるだけでなく、より高速になる可能性があることをすべての人に示しました。このグラフの数値は驚異的であり、Appleがx86-TSOメモリモデルをハードウェアに直接組み込んだ結果です。このためにLRCPCリリースアクセスを使用する代わりに、TSO機能を有効にしただけで、整列バージョンは非整列バージョンとほぼ同等です。ストアで約5%のパフォーマンス低下があるかもしれませんか?このグラフの他のすべてのデバイスと比較すると、実質的に何もありません。これは主に、非整列アクセスがもはやコードにバックパッチされるDMB命令を必要としないことに起因します。ハードウェアが直接処理するためです。
私たちにとって、これはARMでx86エミュレーションを真剣に受け止めることを意味し、Appleが顧客にネイティブおよびエミュレートされたソフトウェアの両方を実行する際に優れた体験を提供することを気にかけていたことを示しています。彼らは問題を見て、それを「解決」し、消し去りました。とはいえ、TSOモードが有効になると、パフォーマンスへの影響があります。前のグラフと比較すると、通常のストアパフォーマンスの76%しか得られず、ロードパフォーマンスはほぼ同等です。すべてがはるかに高速であるため、これははるかに耐えやすいです。
非整列LRCPC/リリースアクセスのまとめ
このセクションをまとめるにあたり、これらのベンダーが実際にサポートしているパフォーマンス改善の1つについて話す必要があります。これはARMが考案したFEAT_LSE2と呼ばれる拡張機能であり、テストされたすべてのプラットフォームがこれを実装しています。以前に、アキュア/LRCPC/リリースメモリアクセスが、CPUがアライメントフォルトを発生させるという怒りを招かないように、自然アライメントを必要とすることを話しました。ARMはこの問題について考え、この拡張機能を実装しました。これはx86エミュレーション(およびおそらく他のワークロード)を支援します。この拡張機能は、アキュア/LRCPC/リリースロードストア命令だけでなく、読み取り・変更・書き込みアトミック(read-modify-write atomics)のアライメント要件も緩和します!
それはすべて順調に聞こえますが、歯に食い込むような点は次のとおりです。これはx86エミュレーションにわずかなパフォーマンス向上しか提供しないということです。この拡張機能は、16バイトのグラニュール内の非整列メモリアクセスを許可するようにアライメント要件を緩和するだけです。その16バイトグラニュールを越えるアクセスは、依然としてアライメントフォルトを受け取ります。x86アプリケーションはメモリアクセスの配置をあまり気にしないため、キャッシュライン全体にわたる非整列アクセスが発生します。キャッシュラインを越えることを避けようとするのは、読み取り・変更・書き込みアトミックだけです!
試みはありがとう、見るのは良いですが、針を動かすほどではありません。すでに話しているので、アトミック命令に飛び込みましょう。
ああ、これらのアトミック命令は何だ?
ほとんどの最新の命令セットと同様に、x86はアトミックメモリ操作をサポートしています。これらは、メモリ内のデータに対してアトミックにALU操作を実行する命令であり、中間状態を可視にしません。x86の用語では、これはアトミックかつコヒーレントなメモリに対して動作しますが、ARMではアトミックまたはアトミックかつコヒーレントのいずれかを選択できます。以前にこれに briefly 触れましたが、アトミックにデータを操作することと、そのデータのコヒーレンシの間には実際には違いがあります。その違いは何でしょうか?
これまでのすべてのx86メモリモデルの議論では、システム内の他のプロセッサからロードとストアがどのように見えるかというコヒーレンシへの影響について話してきました。私たちが完全に省略していたのは、これらのメモリアクセスのアトミシティ要件です。x86の世界では、ロードまたはストアは、非整列の場合でも通常アトミックに完了します。これは、8バイトのデータをストアしていて、別のスレッドがレースコンディションでその8バイトをロードしている場合、ストアの前後のデータが混ざった状態を突然見ることは決してないということです。ARMでは、これらのアトミシティ保証は著しく弱いです。つまり、非整列ストア命令を実行した場合、ISAの仕様ではデータがティア(tear、分割された状態)になることを読むことについて何の保証もありません。幸いなことに、自然に整列されたロード・ストア命令については、ARMには「シングルコピーアトミシティ」(single-copy atomicity)と呼ばれる仕様があり、これらのアクセスでティアが発生しないことが保証されています。また良いニュースとして、前述のFEAT_LSE2拡張機能は、16バイトグラニュール内のあらゆる非整列アクセスにシングルコピーアトミシティ保証を拡張します!欠点は、x86はキャッシュライン全体にわたってシングルコピーアトミシティ保証を持っているため、ここでも拡張機能は何も完全に解決せず、発生回数を減らしただけということです。
アトミシティとコヒーレンシの違いについてはもう十分です。実際のアトミック命令はどこにあるのでしょうか?それらは何をするのでしょうか?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エミュレーションの面白いところです。これらの命令があるからといって、問題なくそれらを配線できるわけではありません。非整列アクセスが通常のロードとストアのパフォーマンスをどれだけ損なうかについてこれほど時間を費やしてきましたが、この同じ問題がRMWアトミックにも適用されます!
このグラフでは、単一のアトミック命令とそのメモリアドレスがキャッシュライン内に収まる場合を見ています。19のアトミック操作すべてのデータを含めると、このデータはすでに圧倒的なものよりもさらに圧倒的になります。これらのアトミック操作はすべてほぼ同等に動作するため、冗長であり、議論していることには関係ありません。また、この投稿で最初にログスケールを使用しているグラフでもあります。したがって、読み取る際には、最も速い結果から最も遅い結果までのパフォーマンスの違いが約1000倍のスケールであることを理解してください。
x86 Zenプロセッサから始まるこのグラフ。これらは、私たちのエミュレーションが達成を目指すべき結果です。アクセスがキャッシュライン内に完全に収まる場合、命令のレイテンシは1.44nsで同じであることがわかります。これは、x86が「アトミックキャッシュライン」または「コヒーレントキャッシュライン」を持っているため、非整列アトミック操作がキャッシュライン内に収まる限り、コストはほぼ同じであると説明できます。これはx86の非常に強力な機能であり、数十年もサポートされているため、ゲームはそれに気づかずに大きく依存しています。x86の際立った結果は、64バイトグラニュールを越えて約660nsかかる最後の結果です!これは約458倍遅い驚異的な結果です。なぜなら、これはついにハードウェアがスプリットロックを使用しているからです。
私たちが記事を準備している間に、Chips and Cheeseが書いた記事に言及する時間を取りましょう。彼らは、これらのスプリットロックがなぜ劇的に遅いのかについて深く掘り下げており、それらがどのように機能するかを知らない場合は読む価値があります。特に、x86スプリットロックはx86-TSOのアトミシティとコヒーレンシ要件を維持し、キャッシュラインを越える場合でもデータが決してティアしないことを言及する必要があります。これはかなりクレイジーで、後で詳しく説明します。
次にARMプロセッサについてですが、まず自然アライメントのレイテンシ数値から始めましょう。見るとおり、すべてのプラットフォームはかなりうまく機能しますが、最新のコアでさえx86には遠く及びません。最も速いARMプラットフォームでも、x86と比較してレイテンシが約3倍です。これはゲームのパフォーマンスに直接影響しますが、通常は直接のボトルネックではないため、正確にどれだけ影響するかを測定するのは困難です。次のデータポイントに進むと、ほとんどのARMプラットフォームで16バイトグラニュールと64バイトグラニュールを越える結果を組み合わせることができます。ARM仕様が非整列アトミックの動作をどのように定義しているかにより、これら両方の結果はほぼ同等であり、FEXはx86のスプリットロック問題と同じように扱います。
スプリットロックの問題を繰り返し取り上げていますが、FEXはそれをどのようにエミュレートし、なぜそれほど遅いのでしょうか?「Apple M1はハードウェアにx86-TSOサポートを追加したと言ったのに、なぜまだ遅いのですか?」以前にFEAT_LSE2が16バイトグラニュール内の非整列メモリアクセスをサポートしたことを思い出してください。これらのスプリットロック操作は、以前と同じアライメント問題に遭遇しますが、劇的に遅くなります。FEXはこれらの命令のいずれかをDMB操作を実行するようにバックパッチすることはできないため、実行されるたびにアライメントフォルトを発生させます。これは、カーネル -> ユーザー空間シグナルハンドラ -> カーネル -> 元のコードのダンスを実行することを意味します。毎回これらのスプリットロック操作が実行されます。カーネルスペースとユーザー空間の間を移動するのはどのプラットフォームでも遅く、毎秒数千回実行するとすぐに積み重なります。これが、これらの機能のエミュレーションがARMでこれほどひどく遅い理由です。
今日、1つのARMプラットフォームがこの問題を部分的に解決しました。Oryon-3 CPUコアは、「コヒーレントキャッシュライン」と宣伝されているものを導入し、ここでマイクロベンチマーク結果でそれを見ることができます。x86と同様に、アトミックメモリアクセスが64バイトキャッシュライン内のどこにあっても、パフォーマンスは自然アライメントバージョンに匹敵します!これは、キャッシュラインを越えようとする時点まで、CPUが機能サポートにおいてx86と同等であることを意味する素晴らしい改善です。Qualcommがこの機能を実装したことを称賛する必要があります。なぜなら、x86エミュレーションにおけるスプリットロックに関する主要なパフォーマンスと正確性の問題を解決するからです。ハードウェアはまだ64バイトのスプリットロックをサポートしていないため、そのインスタンスでは依然としてFEXエミュレートパスにフォールバックします。
Appleの結果を続けると、x86-TSOメモリアクセスをハードウェアに追加したにもかかわらず、Oryonが行ったような完全なキャッシュライン非整列アトミックを実装しなかったのは残念です。彼らはこのエッジケースが発生することを予期して実装すべきだったと思いますが、それは推測にすぎません。だからこそ、TSOハードウェアトグルが有効になっていても、16バイトグラニュールを越える動作が他のプラットフォームと同じように見えるのです。
グラフの別の小さなデータ的な奇妙さにも気づいたかもしれません。Cortex-X4の結果にアスタリスクが付いており、非整列アトミックのパフォーマンスが大幅に新しいCPUよりも劇的に高速です。それは、レイテンシが約209nsしかないという、X925のレイテンシが1060nsであることと比較して、5倍のパフォーマンス向上を達成しています!これはどのようにして可能なのでしょうか?これは実際、テストしているプラットフォーム、つまりValve Steam Frameで提供されている楽しい「特別なソース」です。Valveは既存のゲームカタログのパフォーマンスを気にしているため、FEX開発者の1人が作成したカーネルパッチを提供しています。これにより、Linuxカーネル自体が、FEXとユーザー空間との遅いダンスなしで非整列アトミックを処理できるようになり、劇的に高速化されます。他のプラットフォームがこのパッチをカーネルに提供したい場合は、それを取り込むことをお勧めします。FEXは自動的にそれを使用し始めます。
カーネル介入といえば、スプリットロックエミュレーションがハードウェアの制限によりFEX下で実際には完全に正確ではないことについて話す必要があります。このx86の必須機能を正しく実装するために、16バイトまたは64バイトのスプリットロックを実行するたびに、カーネルがその機能を実装する必要があります。現在、FEXはこれを「最善努力」の試みとして実装しており、実際にはデータがティアする可能性があります。以前、x86のスプリットロックは決してティアしないと言ったことを思い出してくださいか?Oryon-3でさえ、「コヒーレントキャッシュライン」でこの問題を解決していません。
スプリットロックが必須だとはどういうことですか?
今日のARMハードウェアでスプリットロックエミュレーションをパフォーマンス良く実装することは、実際には非常に困難です。単純な実装は、グローバルミューテックスを使用し、スプリットロックが発生したときに操作を実行する前にミューテックスを取得することを保証することです。これは、参加するスプリットロック操作がこのミューテックスを通過することを意味します。これは、整列アトミック操作はスプリットロックではなく、参加しないという問題を除けば正しいです。スプリットロックエミュレーションコードは2つの64ビット比較交換操作として実装する必要があり、各半分がグラニュール境界をまたぐため、非参加アトミックでティアが発生する可能性があります。簡単な例は、あるスレッドがキャッシュラインの中央にあるアトミックを常に変更しており、別のスレッドが片方の整数のみを変更している場合です。これは最初は人工的な例のように聞こえるかもしれませんが、ロックフリーの連結リスト実装はまさにこのように動作します!整列スレッドが変更している半分に応じて、スプリットロックコードの最初のCASまたは2番目のCASのいずれかが失敗します。最初のCASが失敗した場合、それは安全であり、コードはリトライできます。2番目のCASが失敗した場合、データがティアしたことを意味し、データが破損してクラッシュしないことを願うしかありません。これはゲストアプリケーションが使用するアルゴリズムに完全に依存するため、私たちはそれを制御できません。
完全に実行不可能な代替アプローチは、カーネルが互いにメモリを共有しているすべてのプロセスとスレッドを追跡し、スレッドがスプリットロックをエミュレートする必要があるときに、カーネルがそのプロセスとメモリを共有しているすべてのプロセスを停止し、スプリットロックを分離して実行し、その後すべてを再起動することです。このアプローチのパフォーマンスへの影響は実行可能ではありません。アプリケーションやゲームは、毎秒数千回以上スプリットロックを実行する可能性があり、世界を停止させることは、x86ネイティブよりもはるかに悪い、実行不可能なパフォーマンスヒットをもたらします。
スプリットロックのエミュレーションを正しく行うためには、FEXはこれらの機能をサポートするために何らかの形のハードウェアサポートが必要です。すべての原子操作がスプリットロックをサポートする必要があるとは言いませんが、それも実行可能ではありません。良いニュースは、ARMにはまさに私たちが望むことを行う拡張機能があることです。ARMには、問題を解決できるトランザクショナルメモリ拡張機能と呼ばれる拡張機能があります。この拡張機能により、コードはトランザクション領域内でいくつかの操作を実行し、その後その作業をアトミックにコミットできます。コミット操作が失敗した場合、単純にリトライできます。この拡張機能の欠点は?ARMは公式にこの拡張機能を非推奨にし、誰も出荷しませんでした。これはおそらく最善であり、x86バージョンの拡張機能には多くの問題があり、多くのプラットフォームで無効にされていました。
したがって、スプリットロックを正しくエミュレートするには別の方法が必要です。FEXのニーズとARMベンダーのニーズの両方に適していると思われる解決策として、128ビットのCASP命令に、CASPの各半分がアトミックグラニュール境界を完全にまたぐ能力(下半分に64ビット、上半分に64ビット)を与えるというアイデアを思いつきました。その場合のみ、命令はアライメントフォルトを発生させず、CAS操作を実行しようとします。これは、x86は最大64ビットの非整列アトミック操作しか持たないため、操作の両半分が常に単一の操作で完全に囲むことができるため機能します。
しかし、あなたは「これはハードウェアがスプリットロックをサポートするのとどう違うのか?」と尋ねるかもしれません。それは良い考えであり、操作の記述方法には注意が必要です。x86では、アトミック操作は常にティアなしで成功しなければなりません。エミュレートされたアプローチでは、このARMCASP命令が安全に失敗し、その後リトライできます。CASの利点の1つは、操作がどのような理由でも失敗する可能性があり、リトライする必要があることです。命令は、その時点でロードしたデータを返し、プログラムは最新のメモリを取得します。これは重要な区別です。なぜなら、FEXはCAS操作を無限回リトライできるため、最終的に成功するからです!これはARM LL/SCアーキテクチャの利点であり、基本的にこれを機能させます。ハードウェアはいつか順方向の進行を保証する必要がありますが、他の理由で既にサポートされているため、完全に実行可能です!CAS命令に追加された唯一の新しい失敗モードは、操作全体を実行する前に、2つのキャッシュラインのいずれかが別のコアに取得された場合のみです。ハードウェアが順方向の進行を保証するために最大数千サイクルを必要とする場合でも、これは基本的にx86の動作と一致します。
これはスプリットロックのx86エミュレーションにとって最良の道だと考えていますが、私たちはハードウェアアーキテクトではないため、不平を言って誰かが解決してくれることを願うことしかできません。スプリットロックの議論はここで終え、別の興味深い問題に移りましょう。
キャッシュされないメモリが機能する必要があるとは?
このトピックに入る前に、「キャッシュされない」(uncached)という用語について話す必要があります。なぜなら、世界のあなたの見方によっては、それはいくつかの異なる意味を持つ可能性があるからです。この記事の目的では、Vulkanの用語を使用します。なぜなら、私たちは主にゲームを気にしているからです。Vulkanの用語では、VK_MEMORY_HOST_CACHED_BITがあり、これはホストCPUがこのメモリをキャッシュすることを意味します。このビットの欠如が私たちが気にかけていることであり、「キャッシュされない」と呼ぶものです。メモリサブシステムにとって何を意味するかというと、あなたが思うよりも少し複雑になります。特に、メモリがGPU上にあり、おそらくPCIeを介している場合、「キャッシュされない」メモリは通常(常にではありませんが!)VK_MEMORY_HOST_COHERENTフラグも取得します。これは、メモリのキャッシュされないプロパティのため、CPUとGPUは常に互いにコヒーレントなワールドメモリビューを持っていることを意味します。
CPUにとって、これは通常、メモリが最大3つの方法でマッピングできることを意味します。「キャッシュされる」(cached)メモリを要求する場合、通常はライトバックのメモリタイプであり、通常のメモリマッピングタイプもこれです。「キャッシュされない」(uncached)マッピングは、ライトコンバインまたは「ストロング・アンキャッシュド」(Strong Uncacheable)のいずれかです。「ストロング・アンキャッシュド」の実装は、ユーザー空間アプリケーションでは基本的に存在しないため、今日の議論では無視します。これにより、実質的にWB(キャッシュされる)とWC(キャッシュされない)のメモリタイプに限定されます。キャッシュされるメモリは、ゲームが通常ステージングバッファに使用するもので、キャッシュされないメモリは、データを直接GPUに渡す際に使用します。
多くのゲームエンジンでは、キャッシュされないバッファタイプのサポートを公開しないと一部が動作しないようにコード化されています。これは、APUのようなUMAシステムとPCIe GPUの違いに関する動作の詳細に起因します。UMAシステムは通常、キャッシュされ、コヒーレントで、GPUから見えるメモリを割り当てることができます。PCIe GPUはそれを保証できないため、ゲーム開発者はステージングバッファと非同期コピーを使用してGPUにデータを転送するか、「キャッシュされない」メモリを使用してPCIe経由でデータをGPUに慎重に移動する必要があります。PCIeはPCゲームで非常に一般的であるため、一部のエンジンはUMA固有のコードパスを使用せず、どちらの場合でもキャッシュされないアプローチを使用します!
キャッシュされないメモリが私たちにとって何を意味するかの簡単な紹介はさておき、UMA Snapdragonシステムでのキャッシュメモリの速度を示すベンチマークを持ってきましょう。これにより、パフォーマンスが通常どおりであるべきかどうかのベースラインが得られます。
Steam FrameとSnapdragon X2 Eliteの両方にとって、これらは本当に良い結果です。予想どおり、Oryon-3プラットフォームはより多くのメモリ帯域幅を持っているため、グラフでより高くスケールできますが、両方とも数十ギガバイト/秒の結果を達成しています。このグラフは、「通常のライトバック」メモリが達成できるベースラインを設定します。次にキャッシュされない結果を示して、パフォーマンスの違いを見てみましょう。
ここでは奇妙なことがいくつか起こっているため、このグラフでもログスケールを使用する必要がありました。まず良い点から話しましょう。キャッシュされないメモリバッファはライトコンバインであるため、ARMプラットフォームの通常のストアはキャッシュされたベンチマーク結果と同等であることがわかります。これは、ライトコンバインメモリが、ライトコンバインが一度に1キャッシュラインのメモリをバーストできるように、一時的にデータのキャッシュラインを保持するライトコンバインバッファと呼ばれるものを使用していることに起因します。興味深いことに、Zen 4のWCBはキャッシュされたものに追いつけないようですが、PCIeバスを介して通信されることを考えると、おそらく問題ないでしょう。
さて、ここでの本当に醜い結果に入りましょう。説明しやすいものから始めると、キャッシュされないメモリからのロード帯域幅は、テストされたすべてのプラットフォームで悲惨です。Zenをパフォーマンスのベースラインとして使用している場合、ARMの通常のロード命令は優れていますが、LRCPCロードはさらに悪いです。何が起こっているのでしょうか?これはライトコンバインメモリの動作の奇妙さです。キャッシュされないため、セマンティクスを維持するために、すべてのアクセスに対してシステムメモリにアクセスする必要があります。次に、それに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セマンティクスで解決しません。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 GPUを取得することは別の話です。Radeon GPUがARMプラットフォームでライトコンバインメモリをすべてライトバックとして隠すという面白い奇妙さもありますが、それは別の機会に話しましょう。
より明るい未来に向けて
このマラソン記事の後、x86-TSOメモリモデルのエミュレーションがもたらす課題のいくつかをよりよく理解していただけたことを願っています。ARMv8.0を最小スペックとして開始した場所から、ハードウェアが長年にわたって劇的な改善を提供してきたことは驚くべきことです。まだアーキテクチャレベルで解決されていないエッジケースはすべてではありませんが、エコシステム全体で最悪のケースを改善しようとする真のコミットメントがあるようです。さまざまなベンダーが問題の一部を解決し、互換性を向上させるために針を動かしています。おそらく、この時代を振り返る10年後には、ARMに移植されることのない質の高いx86ゲームを楽しみながら、現在遭遇している問題を笑っているでしょう。PCゲームエコシステムのレガシーを、どこでプレイすることになっても、生き続けさせます。