GHCのバックエンドについて

先日リリースされたGHC 9.2.1で、64ビットArm(AArch64)向けのネイティブコード生成器(Native Code Generator; NCG)が実装された。これを機会にGHCのバックエンドについて簡単にまとめてみる。

概略

GHCでHaskellプログラムをコンパイルすると、いくつかの中間言語を経て最終的には機械語が出力される。

この工程の最後の部分を「バックエンド」と呼ぶ。

GHCには

  • Native Code Generator (-fasm)
  • LLVM backend (-fllvm)
  • unregisterised via-C backend

の3種類のバックエンドが存在する。このほか、バイトコードインタープリターと-fno-codeもデータ型的にはバックエンドの一種として扱われている。(参照:compiler/GHC/Driver/Backend.hs

Native Code Generatorは最もポピュラーなもので、GHC自身がアセンブリーの出力を行う。対応しているターゲットの場合はデフォルトで有効になる。

LLVM backendはその名の通りLLVMを使って更なる最適化とアセンブリーの出力を行う。NCGが対応していないターゲットの場合はデフォルトで使用される。

NCGとLLVM backendは特定のレジスターの使い方(GHC独自の呼び出し規約)をするので、registerisedと呼ばれる。GHC独自の呼び出し規約へはLLVM側での対応も必要である。

unregisterised via-C backendはポータブルなCコードを出力し、Cコンパイラーでオブジェクトコードを出力する。このバックエンドの使用の有無はGHCオプションの -fvia-C とは無関係で、GHC自体がunregisterised via-C backend用にビルドされている必要がある。

かつてはこれら以外にregisterised via-C backend (-fvia-C)というものもあった。これについても後で触れる。

GHC本体とは別に、GHCを利用してHaskellプログラムを処理するコンパイラーもいくつか存在する(した)。それらは必ずしもCmmを利用しているわけではなく、

となっているようだ。

Native Code Generator

一番よく使われているのがNCGだろう。今回AArch64への対応も果たした。NCGが対応しているアーキテクチャーは

  • x86/x86_64
  • PowerPC (32/64/64LE)
  • SPARC
  • AArch64 (GHC 9.2 or later)

の4種である。

SPARCは多分32ビットしか対応していない。メンテできる人がいないので削除しようかという提案が上がっている。

かつてはDEC Alpha用のNCGも存在したようだが、削除されている。

LLVM backend

LLVM backendはGHC 7.0(2010年11月リリース)で登場した、比較的新しいバックエンドである。

LLVMが対応しているアーキテクチャーならなんでもアリ、というわけではなく、LLVM側でGHCの呼び出し規約に関する知識が必要となる。現在対応しているアーキテクチャーは

  • ARM/AArch64
  • RISC-V (32/64) (LLVM 12 or later)
  • System z
  • x86/x86_64

である。

LLVMの呼び出し方だが、C++で書かれたLLVMとGHCは直接リンクしているわけではなく、LLVMのoptコマンドとllcコマンドを呼び出すという形で連携している。

かつてはGHCのバージョンごとに特定のLLVMのバージョンにしか対応していなかった(例:GHC 8.10.1にはLLVM 9が必要)が、Apple Silicon対応で不都合があるということである程度幅のあるバージョンに対応するようになった(例:GHC 8.10.7はLLVM 9〜12に対応する)。

registerised via-C backend

かつてはregisterised via-C backendなるものも存在した。これは特定のレジスターの使い方をする特定のアーキテクチャー・GCC依存なCコードを出力するモードである。

「特定のレジスターの使い方」というのはGCC拡張を使って

register StgRegTable *BaseReg __asm__("%ebx");
register P_ Sp __asm__("%ebp");
register P_ Hp __asm__("%edi");

というレジスターに紐づいたグローバル変数を宣言し、それに対して操作を行うことである。また、C言語には末尾呼び出し最適化がないので、GCCのcomputed gotoを使って

void *__target = (void *)target_fn;
goto *__target;

という風に末尾呼び出しもといジャンプを行う(このcomputed gotoの使い方はClangでコンパイルできるとは限らない)。

NCGに比べた利点は、GHC側にターゲットアーキテクチャーの知識は最低限(レジスターの使い方等)しか必要ないという点である。

かつてのMachRegs.hを見るに、お馴染みのメンツの他にはHP-PA, m68k, MIPS, IA64がこのバックエンドに対応していたようだ。

「ターゲットアーキテクチャーの知識が少なくて済むバックエンド」としてはLLVM backendに役目を譲り、registerised via-C backendはGHC 7.0でdeprecatedとなり、GHC 7.2で廃止された。

余談だが、2021年現在の計算機環境でregisterised via-C backendの出力するコードを見てみたい、という場合はGHC 7.0あるいはそれ以前のWindows版のバイナリーを落としてくるのが一番手っ取り早い。Linux向けバイナリーはlibgmp.so.3という古い共有ライブラリーに依存していて大変だし、新しいGHCで古いGHCのソースをビルドするのは無理ゲーである(ソースの書き方が古い、とかなら気合いで直せるが、ghc-pkg dumpの出力の解釈に失敗する、とか色々地獄がある)。

unregisterised via-C backend

こちらはポータブルなCコードを出力するバックエンドである。レジスターの代わりに普通のグローバル変数を使い、末尾呼び出しの代わりに呼び出し先の関数ポインターを返す(トランポリン)。

StgFunPtr foo(void) {
    ...
    return (StgFunPtr)target_fn;
}

ランタイムのどこかに、返した関数ポインターをそのまま呼び出してくれるコードがあるので、これでスタックを消費せずに末尾呼び出しができる。

    StgFunPtr f = ...;
    while (f) {
        f = f();
    }

unregisterised via-C backendを使うには、GHC自体を ./configure --enable-unregisterised という風にビルドしなくてはならない。GHC自体がunregisterised via-C backend向けにビルドされるので、使用時に -fvia-C を指定する必要はない。というか意味がない。

unregisterised via-C backendはポータブルなCコードを経由するので新しいアーキテクチャーへの移植が楽、ということになっている。

余談:RISC-V

LLVM 12以降とGHC 9.2以降の組み合わせで原理的にはRV64向けのコードを出力できるはずだが、Issuesを見ると色々問題があるようである。筆者は試していないのでよくわからない。


コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です