🙌

RISCVにおける処理の高速化 RISCV原典読書録

に公開

はじめに

RISCV原典を読みました。
指令の定義の仕方だけでここまでパフォーマンスが変わるんだなというところがとても勉強になった一方で、詳細な説明がちょいちょい省かれていて結構追加で調べたり考えたりしないと理解できない部分もあったので、そのような個所をここにまとめておきます。
主に性能向上の説明部分に焦点を当て、「なぜそれで性能が上がるのか」を深堀りしてみました。

条件コードの削除

RISCVでは条件コードを削ったことで性能向上になると書かれています。

条件コードとは

x86-32のアセンブラで、2つの数の大小を比較して結果に応じて分岐(if (a > b){...})する場合、

  1. cmpで大小を比較
  2. 条件分岐命令(jgなど)
    という2ステップが必要になります。1と2の間には、1の結果がフラグレジスタに保存され、2ではフラグレジスタの値に応じて分岐するか判定します。
cmp eax, ebx
jg a_greater_than_b

一方RISCVの場合は

bge rs1, rs2, offset    //if (rs1 >= rs2) pc += sext(offset)

と1命令で済みます

なぜ性能向上するのか

まずもちろん「実行する命令数が少なくなるから」というのはあります。

さらに大きいのは、「条件コードがあるとアウトオブオーダ実行しにくくなるから」ということです。

アウトオブオーダ実行においては、結果が変わらない範囲内で指令の順序を入れ替え、並列性を上げます。
条件分岐という1動作が2指令にまたがっていると順番の入れ替えがしにくくなります。連続しているならいいですが間に別の命令が入る可能性だってあります。

RISCVのように1命令ならば入れ替えもしやすいです。

PCの取得

次はPC(プログラムカウンタ)を取得する部分です。

arm-32の場合

本書内(p20)ではarmではPCが汎用レジスタの一つであり、レジスタを変更する命令はいずれも分岐命令になる可能性がある、とあります。これはどういうことでしょうか。

上記の通りpcは汎用レジスタの一つPCに格納されており、これは簡単に書き換え可能です。つまり

MOV PC, R0

のように明示的に分岐命令を使わなくても簡単に分岐ができてしまいます。
しかも移動先(R0)の決定方法は複雑で、何行にも渡って依存関係ができてしまいます。
これではアウトオブオーダ実行のための並び替えや分岐予測もかなりやりにくいです。

x86の場合

x86ではpcは専用レジスタEIPに格納されていますが、armのように直接値を変更できません。
つまり

mov eip, eax

のようなコードは禁止されています。

ただし、PCの取得の方も難しくなってしまったという問題があります。
本書内p25に記述がありますが、関数を呼び出す必要があります。

section .data
    eip_value dd 0  ; Reserve space to store the EIP value

...

    ; Call the function to get EIP
    call get_eip

    ; Use EIP value
    add eax, 10
    mov [eip_value], eax

...

get_eip:
    ; Get the current EIP value
    pop eax         ; Pop the return address (which is the current EIP value) into EAX
    mov [eip_value], eax  ; Store the EIP value into the eip_value variable
    ret

関数呼び出しの際に戻り先(=現在のpc)がスタックにpushされるのを利用して、呼び出された関数内でそれを取得し、保存しておきます。
ただこの方法だとpushで1ストア、popと格納したpcの取り出しで2ロード、関数の呼び出し/戻りで2ジャンプが必要になります。

RISCVの場合

RISCVの場合はpcをレジスタには格納せず、auipcなどの命令でpcを取得します。
これにより上述のarmとx86の両問題を解決します。
また、レジスタを1つ節約できています。

命令フォーマットでレジスタの位置が固定

これが「単純性」なのは分かるが、なぜ「性能向上」になるのか直感的には分かりませんでしたが、以下のような理由です。
x86のようにレジスタの位置が指令の種類によって変わる場合、デコーダは

  1. 指令の最後まで読み指令を確定
  2. その指令の場合のレジスタの位置を特定する
    ということをしなければなりません。

RISCVの場合は指令は固定長かつレジスタの位置も指令の種類によら固定なのでデコーダの処理数が減り、処理時間も短くなります。

3つのレジスタ

本書内に記述があるように、x86では1指令につきレジスタを2つしか指定できません。
addのような指令の場合、srcとdstが同じレジスタになります。

add eax, ebx  ;eax = eax + ebx

srcとdstが異なっていなければならない場合にはムーブを余分に挿入しなければならない、と書かれています。
どんな場合だよと思いましたが、以下のようなケースが考えられます。

  • srcの両方のレジスタをそれ以降のプログラムでもまだ参照する

上記のコード例でいえば、(ebxを足す前の)eaxをまだこの先でも参照する場合は、eax + ebxをeaxに入れてはいけないので

mov ecx, eax
add eax, ebx

というようにaddの前にeaxの値を退避しておく必要があります。
この処理分処理時間が延び、性能の悪化になります。

遅延分岐

これは性能向上ではなく「実装からのアーキテクチャの分離」の項目として挙げられています。

遅延分岐とは

条件分岐処理の場合、分岐するかしないかはEXステージで条件式を実行してはじめて判明します。
パイプライン処理の場合、条件分岐の直後の行(=条件が成立しなかった場合に実行される指令)はこの時点でEXの1つ前のステージまで来てしまっていますが、条件が成立した場合はこれらの処理は無駄になってしまいます。
そこで、条件分岐の行と条件未成立の際に実行される行との間に「条件成立に関わらずどのみち実行される処理」を入れておくことでこの無駄を回避するテクニックが遅延分岐です。

cmp eax, ebx
jg L
mov ecx, edx  ;←これ
addi eax, 2   ;eax<=ebxの時に実行される

実装とアーキテクチャの関係

ではなぜ遅延分岐が「実装とアーキテクチャの結合」になってしまうかというと、上述の例では挿入するのは1行でしたが、これはIFとEXの間に1段(ID)しかないパイプラインの場合です。
パイプラインの段数が増え、IFとEXの間に2段、3段と入るようになった場合、挿入しなければならない行数もどんどん増えていきます。
つまり「パイプラインの構造」に「プログラムの作り方」が依存してしまっているのです。

最後に

ただ指令フォーマットが解説されるだけでなく、他のアーキテクチャとの比較があるのがとても分かりやすかったです。
また、本書内にある
アーキテクトは、組み込んだ仕様だけでなく省いた仕様によっても腕前を示せる
はなるほどと感じました。

Discussion