FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

RISC-V spec 20210922でPublic Reviewになったいくつかの新規命令について

RISC-Vの仕様において、20210922でいくつかの新規拡張命令がPublic Reviewになっている。 これらの命令について、存在は知っていたし概要は知っていたのだけれども、そろそろマニュアルを眺めてどういうものなのか確認することにした。

既存のRISC-Vの仕様には単精度、倍精度の浮動小数点命令は入っていたものの、半精度は入っていなかった。Zfhは半精度浮動小数点命令をサポートする。 サポートしている範囲は単精度、倍精度と変わらない。ロード・ストア、加減算、乗算除算、データ型変換命令など。

これはまだドラフト仕様ではあるが、最小機能の半精度命令をサポートする。Zfhminは半精度対応のロード・ストア命令に加え、レジスタ間移動、他の精度の浮動小数点への変換のみをサポートする。 この目的は、データを半精度で格納するだけで内部処理する場合は単精度・倍精度に展開する、という「メモリの節約」的な意味があるのだと思う。

4種類の拡張命令は、それぞれ

にそれぞれ相当する。これらは専用の浮動小数レジスタを持たずに、演算のオペランドと結果を整数レジスタから取り出す。これらは整数・浮動小数レジスタ移動命令とロードストア命令を除いて通常の浮動小数点命令と同じものがサポートされる。

自作RISC-V OoOコアの性能解析用Performance Monitorの作成

ちまちまと自作RISC-Vコアを実装している。DhrystoneがPASSできるようになったが、まだまだ性能的にはひどいもんだ。

一つの要因としては分岐予測を全く実装していないこと。これでは分岐が成立すると殆どの命令を破棄することになってしまう。まずは分岐予測を実装するところからだろう。

その前に、まずは性能について定量的なデータを取れるようにならなければならない。そのために、パフォーマンスモニタを実装することにした。これはシミュレーションにのみ使用する機能だ。

取得したい情報としては、

  • 一定期間内にどれだけの命令がコミットしているのか。有効なコミット数はいくつか。逆にいくつのコミットがパイプラインフラッシュにより無効化されたか。
  • 命令キャッシュのヒット率。どれくらいのアクセスがありどれくらいヒットしたか。
  • データキャッシュのヒット率。どのポートからどれくらいのアクセスがあり、どれくらいヒットしたか。
  • 分岐命令。どのくらいの分岐命令でフラッシュが発生したか。

これくらいを収集できればよかろう。1000サイクル毎に統計を出力する様にしてみる。

どういうフォーマットに仕様かと思ったが、とりあえずJSONで良かろう。各モジュールに1000サイクル毎にこれらの情報を蓄積するロガーを設ける。

logic [63: 0] r_cycle_count;
logic [63: 0] r_commit_count;
logic [63: 0] r_inst_count;
logic [63: 0] r_dead_count;

always_ff @ (negedge i_clk, negedge i_reset_n) begin
  if (!i_reset_n) begin
    r_commit_count <= 'h0;
    r_inst_count   <= 'h0;
    r_dead_count   <= 'h0;
    r_cycle_count  <= 'h0;
  end else begin
    r_cycle_count <= r_cycle_count + 'h1;
    if (r_cycle_count % sim_pkg::COUNT_UNIT == sim_pkg::COUNT_UNIT-1) begin
      r_commit_count <= 'h0;
      r_inst_count   <= 'h0;
      r_dead_count   <= 'h0;
    end else begin
      if (o_commit.commit) begin
        if (!o_commit.all_dead) begin
          r_commit_count <= r_commit_count + 'h1;
          r_inst_count <= r_inst_count + $countones(o_commit.grp_id & ~o_commit.dead_id);
        end else begin
          r_dead_count <= r_dead_count   + 'h1;
        end
      end
    end
  end
end


function void dump_perf (int fp);
  $fwrite(fp, "  \"commit\" : {");
  $fwrite(fp, "  \"cmt\" : %5d, ", r_commit_count);
  $fwrite(fp, "  \"inst\" : %5d, ", r_inst_count);
  $fwrite(fp, "  \"dead\" : %5d", r_dead_count);
  $fwrite(fp, "  },\n");
endfunction

で、これらを同じファイルにダンプするようにする。

always_ff @ (negedge w_clk, negedge w_msrh_reset_n) begin
  if (!w_msrh_reset_n) begin
    r_cycle_count <= 'h0;
  end else begin
    r_cycle_count <= r_cycle_count + 'h1;

    if (r_cycle_count % sim_pkg::COUNT_UNIT == sim_pkg::COUNT_UNIT-1) begin
      $fwrite(perf_fp, "\"%t\" : {\n", $time);

      // Commit Rate
      u_msrh_tile_wrapper.u_msrh_tile.u_rob.dump_perf(perf_fp);
      // ICache
      u_msrh_tile_wrapper.u_msrh_tile.u_frontend.u_msrh_icache.dump_perf(perf_fp);
      // DCache
      u_msrh_tile_wrapper.u_msrh_tile.u_msrh_lsu_top.u_msrh_dcache.dump_perf(perf_fp);
      // Branch
      u_msrh_tile_wrapper.u_msrh_tile.u_msrh_bru.u_bru_pipe.dump_perf(perf_fp);

      $fwrite(perf_fp, "\}\n");
    end
  end // else: !if(!w_msrh_reset_n)
end // always_ff @ (negedge w_clk, negedge w_msrh_reset_n)

これで再びDhrystoneを走らせてみた。以下のようなログが1000サイクル毎に採れる。

"               56386" : {
  "commit" : {  "cmt" :   132,   "inst" :   434,   "dead" :   278  },
  "icache" : {  "request" :   601,   "hit" :   601,   "miss" :     0  },
  "dcache" : {
    "port[0]" : {    "req" :     0,     "hit" :     0,     "miss" :     0,     "conflict" :     0    "}
    "port[1]" : {    "req" :     0,     "hit" :     0,     "miss" :     0,     "conflict" :     0    "}
    "port[2]" : {    "req" :     0,     "hit" :     0,     "miss" :     0,     "conflict" :     0    "}
    "port[3]" : {    "req" :    54,     "hit" :    42,     "miss" :     0,     "conflict" :    12    "}
    "port[4]" : {    "req" :   130,     "hit" :   121,     "miss" :     0,     "conflict" :     9    "}
    "port[5]" : {    "req" :    92,     "hit" :    60,     "miss" :     0,     "conflict" :    32    "}
  },
  "branch" : {    "execute" :   195,     "hit" :    42   },
}

うーん、ほらね。132回のコミットが有効で、死んだコミットが278回。つまり倍以上フラッシュで死んでるじゃん。 その要因はやはり分岐命令。 分岐命令195回のうち42回のみが「分岐不成立」つまりフラッシュする必要なかったタイプで、それ以外は全部パイプラインフラッシュが発生。こりゃこれだけ遅くもなるわ。 まずは分岐予測器を作ってみるところから。

一方で命令キャッシュのヒット率は悪くない。まあDhrystoneくらいの大きさだったら普通に命令キャッシュの中に全部収まっちゃって毎回ヒットするでしょ見たいになっている。 データキャッシュも結構しっかりヒットしている。問題はいろんなポートでコンフリクトが起きていること(2つのLSUパイプラインが同時に別のアドレスに対してリクエストを出すと片方をコンフリクトとしている)。これも必要に応じてさらにバンク分けした方が性能は上がるかもしれない。

LLVMのPassの作り方について学ぶ(ドキュメントを読む4)

LLVMについて、バックエンドの部分はある程度勉強したけど、そういえばPassの作り方をまじめに勉強したことが無かった。

LLVMと言えばPassだろう!ということでPassの作り方について勉強することにした。これはそのうちLLVMの拡張や最適化をしたいときに役に立つはずだ。

次にpassの登録方法について。相互依存関係についてなどの話。

llvm.org


LLVMのPassを書く

Passの登録

Hello Worldの例ではどのようにpassの登録が動作するのかについて紹介し、これがどのように使用されどのように動作するのかを見ました。ここでは、passがどのようにして、そしてなぜ登録されるのかについて議論します。

サンプルを見たように、passはRegisterPassテンプレートを使用して登録され案す。テンプレートパラメータはpassの名前で、これはコマンドラインでプログラム(例えば、optbugprintなど)に追加されるべきpassの名前を指定するときに使用されます。最初の引数はpassの名前で、-helpによってプログラムから出力される時に使用され、同様に-debug-passオプションによって生成されるメッセージにも使用されます。

passを簡単にダンプできるようにしたい場合には、以下の仮想printメソッドを実装する必要があります。

printメソッド

virtual void print(llvm::raw_ostream &O, const Module *M) const;

printメソッドは、解析の結果を人間が読める形式で出力するために"analyses"によって実装されなければなりません。これは解析の結果をデバッグするために有効で、他の人にどのように解析が動作するのかを示すのに便利です。-analyze引数によりこのメソッドが起動します。

llvm::raw_ostreamパラメータにより結果を出力するストリームを指定し、Moduleパラメータは解析されるプログラムのトップレベルのモジュールへのポインタを示しています。特定の環境下においてこのポインタがNULLであった場合には(例えば、デバッガからのPass::dump()が呼び出された場合)、このメソッドはデバッグ出力を向上させるためだけにつ合われるべきであり、それに依存されるべきではありません。

pass間での相互作用の指定

PassManagerの責任の内の重要なものの一つに、passが互いに正しく相互作用することを保証することが上げられます。PassManagerは"passの実行を最適化"しようとするため、passがどのように相互作用し既存のいくつかのpassにどのように依存するかについて知る必要があります。これを追いかけるために、各passは現在のpassを実行する前に実行されておくべき必要なpassや、現在のpassによって無欧化されるべきpassの一覧を宣言しておく必要があります。

典型的に、この機能は、あなたのpassが実行される前に解析の結果を計算しておく必要がある場合に使用されます。何らかのpassが無効化passの一覧を設定した場合に、すべての変換passが計算した解析の結果を無効化すうrことができます。もしpassがgetAnalysisUsageメソッドを実装しない場合、このような事前に必要なpassが存在しないとされ、他のすべてのpassが無効化されます。

getAnalysisUsageメソッド

virtual void getAnalysisUsage(AnalysisUsage &Info) const;

getAnalysisUsageメソッドを使うことによって、あなたの変換に対して必要なpassと無効化すべきpassの一覧を指定します。この実装では、必要なpassと無効化すべきpassの一覧をAnalysisUsageオブジェクトに埋める必要があります。これにより、passはAnalysiUsageオブジェクトに含まれるかの関数を呼び出します:

AnalysisUsage::addRequired<>AnalysisUsage::addRequiredTransitive<>メソッド

もしあなたのpassが(例えば解析など)別のpassを事前に実行しておく必要がある場合、これらのメソッドを使用してpassを実行するようにアレンジすることができます。LLVMDominatorSetからBreakCriticalEdgeまで様々な異なるタイプの解析とpassを持っています。例えばBreakCriticalEdgeは、あなたのpassが実行された時にはクリティカルなエッジ(依存関係)が無いことを保証します。

いくつかの解析はそのジョブを実行するために他の解析に対してチェインを持っています。例えば、AliasAnaLysis<AliasAnaLysis>実装は他のエイリアス解析パスと接続することが必要です。解析チェインが存在している場合には、addRequiredメソッドの代わりにaddRequiredTransitiveメソッドを使用する必要があります。これは、必要なパスが存在する限り、一時的に必要なパスも有効であることをPassManagerに知らせるものです。

AnalysisUsage::addPreserved<>メソッド

PassManagerのジョブの一つにいつどのように解析が実行されるのかを最適化するというものがあります。特に、必要ないのにデータの再計算が発生するのを避ける飛鳥があり案す。このため、passは既存の解析で利用可能なものがあれば(例えば、無効化されない)それを予約することを宣言することが許されていまうs。たとえあb、単純な定数の折り畳みpassはCFGを変更しないので、dominator解析の結果に影響を与えません。デフォルトでは、すべてのpassはそれ以外のpassを無効化することを仮定しています。

AnalysisUsageクラスはaddPreservedに関連する特定の環境において便利ないくつかのメソッドを提供しています。特に、setPreservesAllメソッドはLLVMのプログラムに全く影響を与えないことを宣言するためのメソッドで、setPreservesCFGは変換いよりプログラム中の命令を変更するがCFGもしくは終了命令には変更を行わないことを示すためのものです。

addPreservedメソッドはBreakCriticalEdgeのように特に便利なメソッドです。このpassは小さなループのセットとdominatorに関連する解析をどのようにアップデートするかを知っています。したがって実際にはCFGでハックするにもかかわらずこれを保存することができます。

getAnalysisUsageの実装例

// この例ではプログラムを変更するが、CFGは変更しない
void LICM::getAnalysisUsage(AnalysisUsage &AU) const {
  AU.setPreservesCFG();
  AU.addRequired<LoopInfoWrapperPass>();
}

getAnalysis<>getAnalysisIfAvailable<>メソッド

Pass::getAnalysis<>メソッドはあなたのクラスから自動的に継承され、getAnalysisUsageメソッドによりあなたが必要だと宣言したpassに対するアクセスを提供します。このメソッドはシンプルなテンプレートの引数を取り、必要なpassを指定し、そのpassへの参照を返します。例えば:

bool LICM::runOnFunction(Function &F) {
  LoopInfo &LI = getAnalysis<LoopInfoWrapperPass>().getLoopInfo();
  //...

このメソッドは所望のpassへの参照を返します。getAnalysisUsageの実装で宣言しなかttあものにアクセスしようとすると、ランタイムアサーションエラーが発生します。このメソッドはrun*メソッドのじっそうで呼び出すことができ、run*メソッドから呼び出される別のローカルメソッドからも呼び出すことができます。

モジュールレベルのpassは関数レベルの解析情報をインタフェースを通じて使用することができます。例えば:

bool ModuleLevelPass::runOnModule(Module &M) {
  //...
  DominatorTree &DT = getAnalysis<DominatorTree>(Func);
  //...
}

上記の例では、DominatorTreerunOnFunctionは、所望のpassの参照から戻ってくる前にPass Managerによって呼び出されます。

passに解析が存在する場合に更新する機能がある場合(前述のBreakCriticalEdgesなど)、getAnalysisIfAvailableメソッドを使用することができます。このメソッドは、分析がアクティブな場合にその分析へのポインタを返します。例えば、以下のようになります。

if (DominatorSet *DS = getAnalysisIfAvailable<DominatorSet>()) {
  // A DominatorSet is active.  This code will update it.
}

LLVMのPassの作り方について学ぶ(ドキュメントを読む3)

LLVMについて、バックエンドの部分はある程度勉強したけど、そういえばPassの作り方をまじめに勉強したことが無かった。

LLVMと言えばPassだろう!ということでPassの作り方について勉強することにした。これはそのうちLLVMの拡張や最適化をしたいときに役に立つはずだ。

いくつかのPassについて。CallGraphSCCPassクラス、FunctionPassクラス、LoopPassクラス。

llvm.org


LLVMのPassを書く

CallGraphSCCPassクラス

CallGraphSCCPassはpassがプログラムの呼び出しグラフを下から上にする必要があるときに使用する(呼び出し先から呼び出し元へ)。 CallGraphSCCPassCallGraphを作成するメカニズムを提供するが、システムがCallGraphSCCPassの最適化実行も許す。 あなたのpassが以下のアウトラインに示す要件に合致し、FunctionPassの要件に合致しないならば、CallGraphSCCPassから派生させるべきです。

TODO: SCCとは何か、Tarjanのアルゴリズム、B-U meanについて説明する。

CallGraphSCCPassサブクラスについてその役割を明確にすると:

  1. 現在のSCCおよびSCCの直接呼び出し元と直接呼び出し先以外の関数を検査したり変更したりすることはできません。
  2. 現在のCallGraphオブジェクトを保存し、プログラムに加えられた変更を反映して更新することが求められる。
  3. SCC の内容を変更することはできても、現在のモジュールに SCC を追加したり削除したりすることはできない。
  4. 現在のモジュールにグローバル変数を追加・削除することができる。
  5. runOnSCC の起動に関わらず、状態を維持することができる(グローバルデータを含む)。

Note: SCCって何? --> "Strongly Connected Component"

SCCを1つ以上のノードで管理しなければならないため、CallGraphSCCPassを実装するのは、いくつかの場合において非常にトリッキーです。 以下で説明するすべてのバーチャルメソッドでは、プログラムを変更した場合にはtrueを、そうでない場合はfalseを返す必要があります。

doInitialization(CallGraph &)メソッド

virtual bool doInitialization(CallGraph &CG);

doInitiazilationメソッドはCallGraphSCCPassが許可していないことのほとんどを許可するメソッドである。関数の追加や削除、関数のポインタの取得などが行えます。doInitializatoinメソッドは処理されるSCCに依存しない単純な初期化などに使用されます。doInitilizationメソッドは他のpassの実行とオーバラップしないようにスケジュールされます(従ってこのメソッドは非常に高速であるべきです)。

runOnSCCメソッド

virtual bool runOnSCC(CallGraphSCC &SCC) = 0;

runOnSCCメソッドはpassの最も興味のある動作を行う場所です。モジュールが変換により変換された場合はtrueを返し、そうでない場合はfalseを返すべきです。

doFinalization(CallGraph &)メソッド

virtual bool doFinalization(CallGraph &CG);

doFinalizationメソッドは、passのフレームワークコンパイルされるプログラムのすべてのSCCに対してrunOnSCCを呼び終わった後に呼び出されます。

FunctionPassクラス

ModulePassサブクラスと対比して、FunctionPassサブクラスは予測可能な、システムによって予測可能な動作を持っている。すべてのFunctionPassはプログラム中の関数を独立に実行する。FunctionPassは特定の順序で動作する必要はなく、FunctionPassは外部の関数を変更しない。

明確化するために、FunctionPassサブクラスは以下を許可しない:

  • 現在処理されているFunction以外の関数を検査や変更すること
  • 現在のModuleから関数を追加もしくは削除すること
  • 現在のModuleからグローバル変数を追加または削除すること
  • runOnFunctionの起動の状態を操作すること(グローバルデータを含む)

FunctionPassの実装は通常は単純です("Hello World"passの例を参照のこと)。FunctionPassは3つのバーチャルメソッドをオーバロードする。すべてのメソッドは、プログラムを変更した場合にはtrueを返す必要があり、そうでない場合はfalseを返す。

doInitialization(Module &) メソッド

virtual bool doInitialization(Module &M);

このdoInitializationメソッドはFunctionPassが許可しない殆どの操作を行うことができます。関数の追加削除や、関数のポインタの取得などが行えます。このdoInitializationメソッドは処理する関数に依存しないタイプの様々なことを簡単に初期化するために設計されています。 doInitializationメソッドは他のpassとオーバラップして呼び出されるようにはスケジュールされていません(従って非常に高速であるべきです)。

このメソッドの使い方の良い例としてLowerAllocationpassがあります。 このpassはmallocfree命令をプラットフォームに依存するmalloc()free()関数呼び出しに置き換えます。 doInitializationメソッドではmallocfree関数への参照を取得し、必要ならばモジュールにプロトタイプを追加します。

runOnFunctionメソッド

virtual bool runOnFunction(Function &F) = 0;

runOnFunctionメソッドはpassによる変換や解析処理を行うために実装する必要があります。 通常は、関数が変更されたらtrueを返します。

doFinalization(Module &)メソッド

virtual bool doFinalization(Module &M);

doFinalizationメソッドはあまり使われませんが、passのフレームワークが、コンパイルされるプログラムに対してすべてのrunOnFunctionの呼び終えた後に呼び出されます。

LoopPassクラス

LoopPassクラスは関数内の独立したループに対して適用されます。LoopPassはネストされた順番に実行され、つまり最も上位のループが最後に実行されます。

LoopPassサブクラスはLPPassManagerインタフェースを通じてループネストをアップデートすることが許可されています。loop passを実装することは通常は単純です。LoopPassは3つバーチャルメソッドをオーバロードする必要があります。すべてのこれらのメソッドは、プログラムを変更した場合はtrueを返し、そうでない場合はfalseを返すべきです。

LoopPassサブクラスは、同じ関数の解析を実行するメインのループパスパイプラインの一部として実行するように意図されています。これを簡単にするために、LoopUtils.hにはgeTLoopAnalysisUsage関数が提供されています。これはサブクラスのgeTAnalysisUsageのオーバライドの中で呼び出すことが出来、一貫性と正しい動作を行うために必要です。同様に、INITIALIZE_PASS_DEPENDENCY(LoopPass)はいくつかの関数の解析を初期化します。

doInitialization(Loop *, LPPassManager &)メソッド

virtual bool doInitialization(Loop *, LPPassManager &LPM);

doInitializationメソッドは処理する関数に依存しない簡単な初期化を実行します。doInitializationメソッドは他のpassと実行がオーバラップしないようにスケジュールされます(従って、この関数は素早く処理を終える必要があります)。LPPassManagerインタフェースはFuncitonModuleレベルの解析情報にアクセスするために使用します。

runOnLoopメソッド

virtual bool runOnLoop(Loop *, LPPassManager &LPM) = 0;

runOnLoopメソッドは、サブクラス内、変換及び解析を行うために実装しなければならないメソッドです。通常、関数が変更された場合にはtrueを返す必要があります。LPPassManagerインタフェースはループネストをアップデートするために必要です。

doFinalizationメソッド

virtual bool doFinalization();

doFinalizationメソッドはあまり使われることのないメソッドですが、passフレームワークが、コンパイルされるプログラム内のすべてのループに対してrunOnLoopの呼び出しを終了すると呼び出されます。

RegionPassクラス

RegionPassLoopPassと似ていますが、関数内のエントリから終了までを実行します。RegionPassはその領域をネスト順に実行し、もっとも外側の領域が最後に処理されます。

RegionPassサブクラスはRGPassManagerインタフェースを使って領域ツリーを更新することが許されています。RegionPass内の3つのバーチャルメソッドをオーバロードして独自のリージョンpassを実装することができます。これらのメソッドは、プログラムを変更した場合にはtrueを返す必要があります。

doInitialization(Region *, RGPassManager &)メソッド

virtual bool doInitialization(Region *, RGPassManager &RGM);

doInitializationメソッドは処理される関数に依存しない簡単な初期化などを行うためのものです。doInitializationメソッドは他のpassの実行とオーバラップしないようにスケジュールされます(したがって高速に動作する必要があります)。RPPassManagerインタフェースはFunctionもしくはModuleレベルの解析情報にアクセスするために必要となります。

runOnRegionメソッド

virtual bool runOnRegion(Region *, RGPassManager &RGM) = 0;

runOnRegionメソッドは、変換や解析などの実行するために必要な関数です。通常、その領域を変更した場合にはtrueを返します。RGPassManagerは領域ツリーをアップデートするために必要です。

doFinalization()メソッド

virtual bool doFinalization();

doFinalizationメソッドはあまり使われることのないメソッドで、コンパイルされるプログラムのすべての領域に対してrunOnRegionが実行された後に呼び出されます。

MachineFunctionPassクラス

MachineFunctionPassLLVMコードジェネレータの一部で、プログラム中のマシンに依存する表現に対して実行されるものです。

コードジェネレータpassはTargetMachine::addPassToEmitFileにより特別に登録され初期化されます。従ってこれらは一般的にoptbugpointコマンドによって実行することはできません。

MachineFunctionPassFunctionPassでもあります。したがってFunctionPassのすべての制約はMachineFunctionPassにも当てはまります。MachineFunctionPassは以下のことが許可されていません:

  1. LLVM IRInstruction, BasciBlock, Argument, Function, GlobalVariables, GlobalAlias, Moduleの作成または変更
  2. 現在処理しているMachineFunction以外のMachineFunctionの変更
  3. runOnMachineFunctionの起動を促すステートの調整 (グローバルデータも含む)

runOnMachineFunction(MachineFunction &MF) メソッド

virtual bool runOnMachineFunction(MachineFunction &MF) = 0;

runOnMachineFunctionMachineFunctionPassのメインのエントリポイントと考えることができます; つまり、このメソッドをオーバライドしてMachineFunctionPassを実装することができます。

TerosHDL for Visual Studio Code を試してみる

ネットで回ってきたVisual Studio Code用のVerilog/VHDLの総合開発環境プラグインTerosHDLが気になったので少し試してみた。

github.com

インストールはVisual Studio Codeプラグイン環境で簡単にインストールすることができた。 私はWSL2の環境(とリポジトリはWSL2上に存在しているので)にVerilatorとVUnitをインストールしておく。

pip install vunit_hdl

インストールできるまではいいのだが、どうも思い通りに動いてくれない。ステートマシンを試しに作ってみて表示させてみようとしたのだが上手く認識してくれない。

f:id:msyksphinz:20210920190227p:plain

Module declaration map はちゃんと出てくれるようだ。

f:id:msyksphinz:20210920190719p:plain

Dependencies viewerも動いてくれない。警告表示で"Install Python3 and configure the binary path in TerosHDL plugin configuration. Install VUnit: sudo pip3 install vunit_hdl"と出るのだが、ちゃんとWSL2上にこれらはインストールしてるんだけどなあ?何がダメなんだろう?

f:id:msyksphinz:20210920190938p:plain

LLVMのPassの作り方について学ぶ(ドキュメントを読む2)

LLVMについて、バックエンドの部分はある程度勉強したけど、そういえばPassの作り方をまじめに勉強したことが無かった。

LLVMと言えばPassだろう!ということでPassの作り方について勉強することにした。これはそのうちLLVMの拡張や最適化をしたいときに役に立つはずだ。

今回は少し進んで、LLVMのPassのクラスについて。いくつかのPassを構成するクラスがあり、最適なものを選択する必要があるということ。

llvm.org


LLVMのPassを書く

passのクラスと必要事項

新しいpassをデザインするときにまず考えなければならないのは、そのpassがどのクラスのサブクラスなのかということです。 "Hello World"の例ではFunctionPassを使用しましたが、なぜこのpassを使ったのか、他にどのようなpassがあるのかについては特に議論しませんでした。 ここではどのようなクラスが利用なのか、最も一般的なものから専門的なものまで紹介します。

Passのスーパークラスを選択する際、可能ならばその要求に適した最も専門的なクラスを選択すべきです。 これにより、LLVM Passインフラストラクチャはpassの実行方法を最適化するために必要な情報を提供し、 結果としてコンパイラが不必要に遅くならないようにします。

ImmutablePassクラス

もっとも一般的なタイプのpassはImmutablePassクラスです。 このpass型は実行する必要のない、状態を変更しない、更新する必要のないpassに対して使用します。 これは変換もしくは解析の普通の型ではありませんが、現在のコンパイラのコンフィグレーションについての情報を提供することができます。

このpassクラスは殆ど使用されることはありませんが、コンパイルされている現在のターゲットマシンについての情報や、 いくつかの変換に影響を与える他の静的な情報を提供するために重要です。

ImmutablePassは他の変換を決して無効化せず、また決して無効化されず、また決して「実行」されることもありません。

ModulePassクラス

ModulePassクラスはすべてのスーパークラスのもっとも一般的なものです。ModulePassから派生したものは、 このpassはプログラム全体を使用することを意味し、単体として、predictable順ではなく関数の本体を参照し、関数を追加または削除します。 ModulePassサブクラスについて何も知らないため、このpassの実行では最適化は実行できません。

モジュールpassは、任意のモジュールまたはimmutable passを使用しないとき、getAnalysisインタフェースgetAnalysis<DominatorTree>(llvm::Function *)を使用する関数レベルのpass(例: dominatorなど)を使用することができ、関数に対して全体的な解析の結果を提供します。 これは解析の行われた関数に対してのみ行われます。例えば、dominatorの場合はDominatorTreeの関数定義に対してのみ問い合わせるべきであり、宣言に対しては問い合わせるべきではありません。

正しいModulePassサブクラスを実装するためには、ModulePassから派生してrunOnModuleメソッドを以下のようにして実装する必要があります:

runOnModuleメソッド

virtual bool runOnModule(Module &M) = 0;

runOnModuleメソッドはpass乗で面白い動作をします。 変換処理によりモジュールを変更した場合、trueを返す必要があり、そうでない場合はfalseを返します。

LLVMのPassの作り方について学ぶ(ドキュメントを読む1)

LLVMについて、バックエンドの部分はある程度勉強したけど、そういえばPassの作り方をまじめに勉強したことが無かった。

LLVMと言えばPassだろう!ということでPassの作り方について勉強することにした。これはそのうちLLVMの拡張や最適化をしたいときに役に立つはずだ。

参考にしたのはWriting LLVM Passというドキュメント。読み進めていくとこのPass Managerは古いそうなのだが、まあまずは古い方のドキュメントを読んで、新しい方の差分を確認していけばよかろう。

llvm.org


LLVMのPassを書く

Passとは何か?

LLVMのPassフレームワークLLVMシステムで重要な部分です。 LLVM Passはコンパイラ内に存在する面白い部分殆どに存在しています。 Passは、コンパイラを構成する変換や最適化を行い、これらの変換で使用される解析結果を構築し、そして何よりもコンパイラコードの構造化技術です。

LLVMのすべてのPassはPassクラスのサブクラスで、仮想メソッドをPassクラスから継承してオーバライドすることによって機能を実装します。 Passがどのように動作するかに依存して、ModulePass, CallGraphSCCPass, FunctionPass, LoopPass, RegionPassクラスのどれから継承するかを選択する必要があります。 それぞれのクラスがpassがどのように動作するかの情報のシステムを提供し、どのように他のpassと組み合わせられるのかについての情報を提供します。 LLVM Passフレームワークの大きな特徴の一つに、あなたのpassの要件(どのクラスから継承されたか、を示す)をもとに効率的なpassの実行方法のスケジューリングがあります。

まずはどのようにpassを構築するのか、コードをセットアップしてコンパイルし、ロードして実行することですべてを見ていきましょう。 基本を押さえた後で、より高度な機能について議論します。

注意: このドキュメントでは、レガシーなpassマネージャを扱っています。 LLVMは最適化パイプラインのために、新しいpassマネージャをデフォルトで使用しています(codegenパイプラインはまだレガシーパスマネージャを使用しています)。 詳細については、Writing an LLVM PassUsing the New Pass Managerを参照のこと。 optでレガシーpassマネージャを使用するには、すべてのoptの呼び出しに-enable-new-pm=0フラグを渡してください。

クイックスタート: hello worldを書く

ここでは、"hello world" passを書く手順について説明します。 "Hello" passは単純にコンパイルされたプログラムのうち、外部関数ではないものの名前をプリントアウトするだけのものです。 これはプログラムを全く変更せずに、単純に調査するだけです。 このpassのソースコードとファイルはLLVMのソースツリー内のlib/Transform/Helloディレクトリに置かれています。

ビルド環境を構築する

まず、LLVMを校正してビルドします。次にLLVMのソースベースに対して新しいディレクトうぃろ作成する必要があります。 この例では、lib/Transforms/Helloに新しいディレクトリを作成したものとします。 最後に、新しいpassのためのソースコードコンパイルするためのビルドスクリプトを用意する必要があります。 このために、以下のコードをコピーしてCMakeLists.txtとして保存してください。

add_llvm_library( LLVMHello MODULE
  Hello.cpp

  PLUGIN_TOOL
  opt
  )

そして以下のコードをlib/Transforms/CMakeLists.txtに挿入します。

add_subdirectory(Hello)

(すでにHelloディレクトリが作成されており、"Hello"サンプルpassが実装されている場合、すぐにそれを試すことができます。 この場合はCMakeLists.txtファイルを変更する必要はありません。 すべてを一からから作りたい場合、別の名前を使用して試すことができます)

ビルドスクリプトは当該ディレクトリのHello.cppを指定しており、これがコンパイルされて共有オブジェクト$(LEVEL)/lib/LLVMHello.soとしてリンクされます。 このオブジェクトはopt-loadオプションによって動的んいロードされます。 使用しているオペレーティングシステム.so以外の接尾子を使っている場合(WindowsMacOSなど)、適切な拡張子が使用されます。

ビルドスクリプトの構築が完了したら、次にpassを書いていきます。

必須の基本コード

ここまでで新しいpassをコンパイルするための準備が整いました。次にコードを書いていきます。まず以下から始めましょう。

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"

Pass.hは私たちは今Passを書いているので必要なもの、 何かをプリントするのでFunctionが必要です。

次に以下を追加します。

using namespace llvm;

これは、インクルードファイルに機能はllvm名前空間に存在しているからです。

次に、

namespace {

これは無名の名前空間から始まるという意味です。無名の名前空間C++ではstaticキーワードでC言語から(グローバルスコープで)使用できます。 無名の名前空間で定義したものは、このファイルでのみ参照できます。 もしこの機能にあまり詳しくなければ、まともなC++の本を読んで参考にしてください。

次に、私たちのpassを宣言します:

static Hello : public FunctionPass {

これはFunctionPassのサブクラスとして"Hello"クラスを提起します。 ビルトインpassのサブクラスの違いは以降で説明しますが、 今はFunctionPassは関数を一度に処理するということだけ知っておいてください。

static char ID;
Hello() : FunctionPass(ID) {}

これはLLVM内でpassを識別するための識別子を定義します。 これによりLLVMC++の高価なランタイム情報を使用することが回避できます。

  bool runOnFunction(Function &F) override {
    errs() << "Hello: ";
    errs().write_escaped(F.getName()) << '\n';
    return false;
  }
}; // end of struct Hello
}  // end of anonymous namespace

runOnFunction()メソッドを定義します。 これは私たちのやりたいことを実現する関数で、各関数の名前をメッセージとして出力します。

ここでpassのIDを初期化します。LLVMはpassを識別するためにIDアドレスを使用しますので、この初期化はあまり重要ではありません。

static RegisterPass<Hello> X("hello", "Hello World Pass",
                             false /* Only looks at CFG */,
                             false /* Analysis Pass */);

最後に、Helloクラスを登録しますし、 コマンドライン引数として"hello"を使えるようにし、名前を"Hello World Pass"とします。 最後の2つの引数はこのクラスの動作を示しています: もしpassがCFG中を探索し、それを変更しない場合、3つ目の引数をtrueに設定します; もしそのpassが解析pass、例えばdominator tree passの場合、4番目の引数をtrueに設定します。

もしpassを既存のパイプラインの1ステップとして登録したい場合は、いくつかの拡張ポイントが提供されます。 例えば、PassManagerBuilder::EP_EarlyAsPossble は私たちのpassを最適化の前に適用し、 PassManagerBuilder::EP_FullLinkTimeOptimizationLastではリンク時最適化の後に挿入します。

static llvm::RegisterStandardPasses Y(
    llvm::PassManagerBuilder::EP_EarlyAsPossible,
    [](const llvm::PassManagerBuilder &Builder,
       llvm::legacy::PassManagerBase &PM) { PM.add(new Hello()); });

全体として、.cppファイルは以下のようになります。

#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/Support/raw_ostream.h"

#include "llvm/IR/LegacyPassManager.h"
#include "llvm/Transforms/IPO/PassManagerBuilder.h"

using namespace llvm;

namespace {
struct Hello : public FunctionPass {
  static char ID;
  Hello() : FunctionPass(ID) {}

  bool runOnFunction(Function &F) override {
    errs() << "Hello: ";
    errs().write_escaped(F.getName()) << '\n';
    return false;
  }
}; // end of struct Hello
}  // end of anonymous namespace

char Hello::ID = 0;
static RegisterPass<Hello> X("hello", "Hello World Pass",
                             false /* Only looks at CFG */,
                             false /* Analysis Pass */);

static RegisterStandardPasses Y(
    PassManagerBuilder::EP_EarlyAsPossible,
    [](const PassManagerBuilder &Builder,
       legacy::PassManagerBase &PM) { PM.add(new Hello()); });

すべてを組み合わせて、ビルドディレクトリのトップで単にgmakeコマンドを入力すればlib/LLVMHello.soファイルが生成されているはずです。 このファイルに含まれているものすべてが無名名前空間に含まれています - これはpass自体が自身を内蔵しているユニットであり、 外部のインタフェースを必要としない(しかし必要とあれば持つこともできます)ということを意味します。

optを使ったpassの実行

私たちは新しく作った、輝かしい共有オブジェクトを手に入れました。optコマンドを使ってLLVM実行中にpassを渡すことができます。 RegisterPassを使ってpassを登録したので、optツールによってそれにアクセスすることができます。

テストのために、Getting Started with the LLVM System の最後に使った例を使って "Hello World"をLLVMコンパイルしてみましょう。 ビットコードファイル(hello.bc)のプログラム全体を以下のようにして変換します(もちろん、どのようなビットコードでも問題ありません)。

$ opt -load lib/LLVMHello.so -hello < hello.bc > /dev/null
Hello: __main
Hello: puts
Hello: main

-loadオプションによってoptがどのような共有オブジェクトをpassとしてロードすべきなのかを指定しており、 これにより-hello引数が有効となります。(これが[passの登録]の必要な理由です)。 Hello passはどのような興味深い方法でもプログラムを全く変更しないので、私たちはoptの結果を単純に捨てています(結果を/dev/nullに渡しています)。

他にどのような文字列を登録したのか、opt-helpオプションで見てみましょう:

$ opt -load lib/LLVMHello.so -help
OVERVIEW: llvm .bc -> .bc modular optimizer and analysis printer

USAGE: opt [subcommand] [options] <input bitcode file>

OPTIONS:
  Optimizations available:
...
    -guard-widening           - Widen guards
    -gvn                      - Global Value Numbering
    -gvn-hoist                - Early GVN Hoisting of Expressions
    -hello                    - Hello World Pass
    -indvars                  - Induction Variable Simplification
    -inferattrs               - Infer set function attributes
...

passの名前に、あなたのpassの情報文字列が追加され、optのいくつかのドキュメントに追加されています。 これで動作するpassを手に入れることができましたので、次によりクールな変換用のpassの実装に移ることができます。 一度動作を確認しテストすると、このpassがどれくらいの速度なのかをチェックできると便利です。 PassManagerは便利なコマンドラインオプション(-time-passes)を提供しており、他のpassと一緒にあなたのpassが どれくらいの実行時間だったのかを知ることができます。例えば:

$ opt -load lib/LLVMHello.so -hello -time-passes < hello.bc > /dev/null
Hello: __main
Hello: puts
Hello: main
===-------------------------------------------------------------------------===
                      ... Pass execution timing report ...
===-------------------------------------------------------------------------===
  Total Execution Time: 0.0007 seconds (0.0005 wall clock)

   ---User Time---   --User+System--   ---Wall Time---  --- Name ---
   0.0004 ( 55.3%)   0.0004 ( 55.3%)   0.0004 ( 75.7%)  Bitcode Writer
   0.0003 ( 44.7%)   0.0003 ( 44.7%)   0.0001 ( 13.6%)  Hello World Pass
   0.0000 (  0.0%)   0.0000 (  0.0%)   0.0001 ( 10.7%)  Module Verifier
   0.0007 (100.0%)   0.0007 (100.0%)   0.0005 (100.0%)  Total

見てわかる通り私たちの実装はかなり高速です。他のpassはoptによって自動的に挿入されたもので、 あなたのpassによってLLVMから出力されたものが有効でLLVMにより正しく構成されているもの確認し、 何も壊されていないことを確認するためのものです。

ここまでで、passの基本とpassの裏側にあるメカニズムについてみてきました。 これらがどのように動作するのかと、どのように使用すればよいのかについてより詳細に見ていきましょう。