眼鏡止水

FPGA、ネットワーク機器、料理、そしてメガネ女子。

【RISC-V】命令の書き換えとライトバックキャッシュとFENCE.I

命令の書き換え

近年の一般的なプロセッサが採用するノイマン型アーキテクチャでは、あるプログラムがメインメモリ内の自分自身 (または他のプログラム) の命令を書き換えるシチュエーションがあります。 典型的な例として、JITコンパイルを用いる自己書き換えコードが挙げられます。 また、OSがファイルシステムからメインメモリにプログラムを展開したり、スワップ領域から命令のページを復元したりすることも、広い意味で命令の書き換えと考えられます。

一方で、多くのプロセッサは命令フェッチとロード・ストアにそれぞれ専用のキャッシュを持ち、メモリアクセスの競合による性能低下を抑えています。 図に、そのような構成の概念を示します。 ここでは、命令フェッチにL1I$ (Level 1 Instruction Cache)、ロード・ストアにL1D$ (L1 Data Cache) があります。

命令フェッチとロード・ストアにそれぞれ専用のキャッシュを持つプロセッサコア

この構成のL1キャッシュ同士は、マルチコアと同様に、キャッシュコヒーレンシの問題に対処する必要があります。

プログラムの動的ローディング(英: Dynamic loading)、動的コンパイル、機械語レベルの自己書き換えコードおよびそれのトランポリン(英: Trampoline)による実行など(後に挙げたものほど深刻である)、ノイマン型アーキテクチャの、プログラムの命令自身をプログラム自身により書き換え可能である、という性質を利用した技術がある。これらの実行において、単純に命令用とデータ用のキャッシュが分離されているのは、キャッシュコヒーレンシがおかしい状態にあるのと同じことである。よって何らかの対処がされている。

ja.wikipedia.org

キャッシュコヒーレンシの問題

キャッシュはメインメモリの内容を部分的に複製してプロセッサコアに高速に提供するバッファです。 キャッシュを複数持つ構成で片方のキャッシュのデータを更新すると、一時的に双方のデータが異なります。

二つのキャッシュが同じアドレスの複製を持っているときに、片方が更新される様子

キャッシュのデータはあくまでメインメモリの複製ですから、複数のキャッシュが異なる値を持ち続け、古い値を使ってしまうことは好ましくありません。 これにより生じる種々の問題をキャッシュコヒーレンシの問題と呼びます。 例えば、プログラムAをスワップアウトし、同じ物理ページにプログラムBをスワップインしたあとに、命令キャッシュがプログラムAの命令を保持したままだと、所望の動作を得られません。

同じアドレスの複製を持つ二つのキャッシュのうち、片方が古いデータを持ち続けている状態

この問題に対処するため、キャッシュ同士を同期する機構が必要となります。 つまり、何らかの手法によって、キャッシュは古いデータを破棄し、最新のデータを確保しなければなりません。 マルチコアのプロセッサは大抵、キャッシュコヒーレンス・プロトコルに基づいてコア間で通信し、古いデータを無効化したり、最新のデータを直接共有したりします*1。 一方で、ひとつのコア内のL1キャッシュ同士では、しばしば異なる手法による同期が行われます

命令フェッチフェンス

ここで、ロード・ストアによる命令の書き換えを考えます。 L1キャッシュ同士はキャッシュコヒーレンス・プロトコルに基づく通信を行わないものとします。 このとき、L1I$の内容が古くなっている可能性があります。

いくつかの命令セットアーキテクチャは、命令フェッチフェンスと呼ばれる専用の命令により、命令キャッシュを同期します。 例えば、RISC-VはZifencei拡張FENCE.Iという命令を定義しています。

FENCE.Iの実装にはいくつかの選択肢があります。 シンプルな実装では、命令キャッシュと命令パイプラインをフラッシュします*2。 命令キャッシュをフラッシュすることで、それ以降の命令フェッチは最新のデータが利用できることを期待します。 パイプラインをフラッシュするのは、書き換え対象の命令がフェッチ済みで、古いまま利用されることを防ぐためです。

MAステージで命令の書き換えを行うが、IDステージに古い命令が入っている様子

RISC-Vはまた、キャッシュコヒーレンシをきちんと維持する実装では、命令パイプラインをフラッシュすればいいともコメントしています*3。 ただし、多くのオープンソースの実装はそのようになっていません。 これはPattersonの原則に基づくものかもしれません。

Making the common case fast will tend to enhance performance better than optimizing the rare case.

www.elsevier.com

いずれにせよ、FENCE.Iの本質は、書き換えられた最新の命令をフェッチできるようにすることです。

落とし穴: ライトバックキャッシュ

シンプルな実装ではL1I$と命令パイプラインをフラッシュするだけでよいことを述べました。 では、この「シンプルな実装」とは何でしょう。

繰り返しになりますが、FENCE.Iの本質は、書き換えられた最新の命令をフェッチできるようにすることです。 専用のコヒーレンシ維持機構を持たずとも、L1I$とパイプラインをフラッシュするだけで、最新の命令をフェッチできるのは、書き換えられた命令がすでにメインメモリ (または共有L2キャッシュなど) に書き込まれている場合です。 つまり、命令キャッシュにミスが起こり、再充填する前に、メインメモリに書き換えが反映されている必要があります。

したがって、「シンプルな実装」は以下のいずれかであると考えられます。

  • L1D$がない
  • L1D$がライトスルー型

これらは、ストアと同時にメインメモリのデータも更新します。

では、L1D$がライトバック型の場合はどうでしょう。 ライトバック型はメインメモリの更新を必要最低限に抑え、メモリの遅延の影響を緩和したり、インターコネクトの帯域を節約したりします。 その性質上、ストアによる命令の書き換えがメインメモリに反映されるまで時間があります。 もしメインメモリのデータも古い状態で、L1I$とパイプラインをフラッシュしたとしても、フェッチされる命令は更新前のものの可能性があります。 その結果、キャッシュコヒーレンシの問題が顕在化します。

キャッシュAがメインメモリから古い値を読み出す様子

この問題を防ぐため、いくつかの実装はFENCE.Iの実行時に、L1I$とパイプラインをフラッシュすることに加え (またはその前に)、L1D$のdirtyなブロックをメインメモリに書き込むようです*4。 こうすることで、フラッシュ後にメインメモリからL1I$へ再補充されるデータは最新のものとなります。

riscv-testsによる実験

ライトバック型L1D$を持つ構成で、L1I$とパイプラインのフラッシュだけでは不十分であることを確認します。 筆者が自作しているoffnariscvの以下のバージョンは、L1I$とライトバック型のL1D$と持ちますが、それらの同期機構は不十分です。 このプロセッサでriscv-testsのFENCE.Iのテストを実行します。

github.com

以下に、テストの実行ファイルを逆アセンブルした結果の一部を示します。

# a3 に 111 をロード
8000018c:   06f00693            li  a3,111
# a0, a1 に 0x80002000 の4バイトをロード
80000190:   00002517            auipc   a0,0x2
80000194:   e7051503            lh  a0,-400(a0) # 80002000 <begin_signature>
80000198:   00002597            auipc   a1,0x2
8000019c:   e6a59583            lh  a1,-406(a1) # 80002002 <begin_signature+0x2>
800001a0:   00000013            nop
800001a4:   00000013            nop
800001a8:   00000013            nop
800001ac:   00000013            nop
800001b0:   00000013            nop
800001b4:   00000013            nop
800001b8:   00000013            nop
800001bc:   00000013            nop
# 0x80002004 に a0, a1 をストア (0x80002000 を 0x80002004 に複製)
800001c0:   00002297            auipc   t0,0x2
800001c4:   e4a29223            sh  a0,-444(t0) # 80002004 <begin_signature+0x4>
800001c8:   00002297            auipc   t0,0x2
800001cc:   e2b29f23            sh  a1,-450(t0) # 80002006 <begin_signature+0x6>
# 命令フェッチフェンス
800001d0:   0000100f            fence.i
# 0x80002004 にジャンプ
800001d4:   00002797            auipc   a5,0x2
800001d8:   e3078793            addi    a5,a5,-464 # 80002004 <begin_signature+0x4>
800001dc:   00078367            jalr    t1,a5
...
800001e0 <test_2>:
800001e0:   00200193            li  gp,2
800001e4:   00000013            nop
# a3 が 444 でなければ失敗
800001e8:   1bc00393            li  t2,444
800001ec:   06769a63            bne a3,t2,80000260 <fail>
...
80002000 <begin_signature>:
80002000:   14d68693            addi    a3,a3,333
# a3 に 333 を加算 (0x80002000 を複製したため)
80002004:   0de68693            addi    a3,a3,222
# 0x800001e0 へ
80002008:   000307e7            jalr    a5,t1

このテストの流れは以下です。

  1. a3111をロード
  2. a0, a10x80002000の4バイトをロード
  3. 0x80002004a0, a1をストア (0x800020000x80002004に複製)
  4. 命令フェッチフェンス
  5. 0x80002004にジャンプ
  6. a3333を加算 (0x80002000を複製したため)
  7. 0x800001e0
  8. a3444でなければ失敗

命令の書き換えにより、0x80002004にはaddi a3,a3,333が書かれているはずです。 この書き換えが反映されてなければ、addi a3,a3,222であり、a3の最終的な値は333になって失敗します。

図に、命令フェッチフェンス後に古い命令をフェッチする様子を示します。 手順③で0x80002004の命令はaddi a3,a3,333となります。 また、0x80002004の命令フェッチがL1I$にミスし、メインメモリからの再補充が行われていますが、古い命令0x0de68693 (addi a3,a3,222) をフェッチしてしまっています。 結果として、このテストに失敗します。

命令フェッチフェンス後に古い命令をフェッチする様子

まとめと今後の課題

本記事では、命令の書き換えとライトバックキャッシュとFENCE.Iの関係を確認しました。 そして、ライトバック型のL1D$を持つ構成では、L1I$とパイプラインをフラッシュすることが不十分であることを確認しました。

今後の課題として、offnariscvが命令の書き換えを適切に行えるよう改善することが挙げられます。 その手法として、典型的な「L1D$のdirtyブロックの書き戻し」と、「キャッシュコヒーレンス・プロトコルに基づく専用の機構でL1キャッシュ同士を動的に同期する」という選択肢があります。

*1:実装上の都合や消費電力の観点から、データの直接共有を行わず、単に無効化する方式が主流といわれています。無効化さえすれば、キャッシュは自発的に最新の値を取得するよう試みます。

*2:A simple implementation can flush the local instruction cache and the instruction pipeline when the FENCE.I is executed.

*3:A more complex implementation might snoop the instruction (data) cache on every data (instruction) cache miss, or use an inclusive unified private L2 cache to invalidate lines from the primary instruction cache when they are being written by a local store instruction. If instruction and data caches are kept coherent in this way, or if the memory system consists of only uncached RAMs, then just the fetch pipeline needs to be flushed at a FENCE.I.

*4:VexiiRiscv, BlackParrot, MicroBlaze V, Nios Vなどがこの手法を採用しているように見えます。