RISC-Vで関数ポインタ呼び出しにjalr t0を使ってはいけない

概要

RISC-Vでは、jalr t0という命令には特別な意味が割り当てられているので、関数ポインタを用いた関数呼び出しのために使うと一部のプロセッサで性能低下を引き起こします。 t0以外のレジスタを使う場合は問題なく動作するので、関数ポインタの格納にはt0以外のレジスタを使いましょう。

詳細

高性能プロセッサには、分岐命令を実行せずに分岐先を予測する、分岐予測といった仕組みが実装されています。 分岐命令のうち、関数からの復帰命令は分岐先が毎回同じとは限らないので、「前回の分岐先を記憶しておく」などの方法では予測ができないことがあります。 これに対しては、関数呼び出し命令の次の番地を記録しておくスタックを用意することで簡単に解決が可能です。 このような分岐予測に使うスタックをreturn address stack (RAS)と呼びます。

ところで、「これは関数呼び出し命令」「これは関数からの復帰命令」というのは高級言語からコンパイルするときの約束(呼び出し規約)です。 これを守らずに書かれた手書きアセンブリコードは、RASが搭載されたプロセッサでは正しく動作しないのでしょうか。

もちろん、そんな風にはなっていません。 RASが搭載されたプロセッサであっても、プログラムはRASが搭載されていないプロセッサと同じように動作します。 これは、実行ステージで必ず分岐予測が正しいかを確かめるためです。 したがって、分岐予測が間違っていたとしても、プログラムが誤動作することはありません。 とはいえ、分岐予測の失敗は一般に性能低下につながるため、なるべく避けたいものです。

分岐予測の失敗を防ぐためには、RASへのpush, popを正しく行う(RASを適切に動作させる)必要があります。 そのためには、pushすべき命令(関数呼び出し命令)やpopすべき命令(関数からの復帰命令)を正しく見分ける必要があります。 従来の命令セット設計では、専用の関数呼び出し命令や関数からの復帰命令を設けることが普通でした。 こうすることで、RASの適切な動作を自明に判断することができます。

これに対して、RISC-Vでは、専用の命令を設けることはせず、ソースレジスタ・デスティネーションレジスタとして何が指定されているかを根拠にRASの動作を決定します。 RASの動かし方の指南はRISC-V Unprivileged ISA V20191213のp. 22に書いてあります。

このマニュアルを見ると、関数ポインタ経由の関数呼び出しの時に使うjalr命令で、ソースレジスタx5、デスティネーションレジスタx1としたjalr x1, x5はpop, then pushと書かれています。 関数呼び出し命令ではpushのみすべきですから、関数呼び出しのつもりでjalr x1, x5を使うと分岐予測の失敗につながります。 なお、jalr x1, x5jalr t0と略記されることがあるので、本記事のタイトルということになります。

なぜRASの動かし方が変則的なのか

RISC-Vで、関数呼び出し専用の命令、などを設けていない理由は、命令数の削減にあります。 ソースレジスタやデスティネーションレジスタに何が指定されているかを根拠にRASの動作を決める、というのは実際可能です。

しかし、RISC-Vにalternate link registerが存在することを加味すると、話が一気にややこしくなります。 alternate link registerというのは「第二のリンクレジスタ」くらいの意味で、millicode呼び出しというコード圧縮のテクを実現するためのもので、それ自体は問題の原因ではありません。 問題の原因は、RISC-Vではこれを「二つのコルーチンを行き来するという状況は、リンクレジスタが二つあればリンクレジスタの退避が不要である」という思い付きを実現するために転用していることです。

確かに二つのコルーチンを行き来する状況はRASを適切に動作させる(popしてpushする)ことで分岐予測を成功させることができますが、無理やり感が否めません。 これのせいでjalr x1, x5はpop, then pushという動作をすると決められています。

落とし穴:アセンブリ言語で書かないから関係ないよ

コンパイラが間違ってjalr t0を出力することがあります。 少なくとも、LLVM9のRISC-Vコンパイラはこのコードを出力します。 gccはこのコードを避ける工夫が行われています。

LLVMRISC-Vコンパイラ(LLVM9で確認)は、引数が0個の関数ポインタ呼び出しの場合は関数ポインタの格納にa0を使います。 以下、引数が1個、2個、……、7個の場合、関数ポインタの格納にa1a2、……、a7を使います(要するに、使われていない最も若い引数用レジスタを使うという戦略のようです)。 しかし、引数が8個以上の場合、関数ポインタの格納にt0を使うため、jalr t0となって性能低下を引き起こすコードを出力します。

gccRISC-Vコンパイラ(gcc7で確認)の場合、引数が5個以下であればa5、6個であればa6、7個であればa7、8個以上であればt1を使います。 gccのコードを確認したところ、明示的にjalr t0を避ける工夫が行われていることがわかりました。 gcc/constraints.md at master · gcc-mirror/gcc · GitHub

一般的な場合を高速化?

二つのコルーチンを行き来するという限定的な状況でしか役に立たないこの機能は、「一般的な場合を高速化」という原則に反しているような気がします。 現状、この機能を活用するコンパイラはおそらく無いので、ハードウェアが複雑化するだけの仕様です。 というか誤爆するコンパイラがあるせいで、性能低下の原因にしかなっていません。 コンパイラの出すコードが正しいと思ってハードウェア側を直すプログラマもいそうです。

今後のコンパイラの進展に期待する場合、後方互換性のために事前に定義しておかないといけない、ということかもしれません。 あるいは、ハードウェアが提供している面白機能として見ると楽しいですが、具体的に役立つ例は思いつきません。

なお、三つ以上のコルーチンを行き来する状況では、この機能はほぼ役に立ちません。 対称コルーチンで三つ以上のコルーチンを巡回する場合は明らかに役に立ちません。 非対称コルーチンでMain→Croutine A→Main→Coroutine B→Mainのように行き来する場合も無駄です(Main側ではRAS pushする命令を、Coroutine側ではRAS popをする命令を使えば十分です)。