POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook

本記事は、原著者の許諾のもとに翻訳・掲載しております。

LLVM上で、LuaからCライブラリを呼び出し、コンパイラを使わずにソフトウェアを書く

私は、ある プログラミング言語 の開発に取り組んできました。私はよく ビデオゲーム を作りますが、ゲーム開発に利用できる既存の言語には、それぞれ私のやり方に合わない欠点がありました。そこで、自分で新しい言語を作ることにしたのです。私はインタプリタを実装し、ちゃんと動きます。素晴らしい!しかし、あまりに遅いのです。自分がやりたいことを実現するには、私は決めたのです、インタプリタではなく、コンパイラを書くべきだと。ところがそのように決めるとたちまち、このプロジェクトは行き詰ってしまいました。なぜなら、本当はコンパイラを書きたくなかったからです。作業量は多いし、今までやったことがないことも多く、どこから始めればいいかも本当に分かりませんでした。しかも、私はパーサを書くのが本当に嫌いなのです。

そして先週、次のような妙案を思い付きました。

コンパイラに関する本を見てみると、必ず次のような図に行き着くと思います。


この図は、大学でもらった『Modern Compiler Implementation in Java』という本からの引用です。コンパイラがソースコードを実行形式のプログラムに変換する過程の全ステップを示しています。ですがこの図も、もう古いですね。今は、次のようになります。


LLVMはコンパイラの「バックエンド」です。この用語は定義があいまいですが、最初の図を2つに分け、3つ目までのボックスを「フロントエンド」、残りを「バックエンド」とすることが多いです。LLVMは、 Clang のバックエンドですが、LLVMはClangより前から存在しており、スタンドアロンのライブラリとして使用することができます。これにより自家製のプログラミング言語の隆盛が生まれています。今や自分でコンパイラを書く必要がないからです。フロントエンドだけを書いて、LLVMに接続するだけで良いのです。

そして、ますます、誰もがそうするようになっています。自分でバックエンドを書くこともできますが、LLVMは すばらしい バックエンドです。大変多くのターゲットを持っており、また、AppleやGoogle(現在では、マイクロソフトも)の技術的改善努力が結集されているので、自分でこれ以上良いものを書くことは実際問題として不可能でしょう。特定のユースケースに、より適したバックエンドなら書くことができるかもしれませんが、 汎用的な バックエンドでは無理でしょう。実際に私たちは今、バックエンドの オプション として少なくともLLVMをサポートすることが必須の世の中に、突入しています。Appleは、自分でコンパイラを書くことを許さない方式へ積極的に移行しています。特定のプラットフォームでは、事実、ソフトウェアを自分で作成することはできません(Apple Watchですとか、他にもAppleTVでも、しばらくの間これを強制しようとしていました)。自分でできるのは、bitcode(図の中の「AST – 抽象構文木」のバイナリ表現)の作成だけで、そのbitcodeをAppleが自分たちのLLVMバックエンドでコンパイルします。これは、とにかくAppleが市場の独占を急激に推し進めようとする、恐ろしい例と言えます。

私が言いたいのは次のようなことです。

何年も前、私はよくオーディオソフトを書いていました。音楽を製作していて、音楽を作るための小さなプログラムを作っていました。ごく小さな楽器のようなものなどです。長い間、私の大きな夢は、最終的にはシーケンサを書くことでした。私は小さな自作のソフトウェア楽器をよく演奏しました。また、本物の楽器もよく演奏しました。しかし、それらを組み合わせて曲にしたいと思ったら、他の誰かが書いたシーケンサソフトを使わなければなりませんでした。これでは、どのように拍子を合わせたり、ミキシングしたりするかを思いどおりに制御することができないので、自分のシーケンサを書きたかったのです。しかし、それは難しいことが分かりました。UXを数多く設計し、GUIのコードを何行も書かなくてはなりませんでした。私の試みは全て失敗しました。結局、代わりにGUIの無い1つの曲用の小さなシーケンサプログラムを書くことから始めました。曲ごとに小さなCプログラムを書いたのです。C言語で思った通りに記述していった私のプログラムは、そのディレクトリ内のWAVファイルから記録されたトラックとサンプルをロードし、正確にそれらを一緒にミキシングして、最後にWAVファイルを吐き出しました。これは本当にうまく動きました! は良い音を出し、すごく楽しかったです。シーケンサプログラムを介さずに、”手動で”ミキシングしていたので、GUIで表現するのが途方もなく難しいと思われる複雑なことを、自分のコードに思い付きで指示することができました。「トラック3がこの音量を2回超えるまで待ってから、すぐにトラック4をカットインさせる」ようなことができたのです。

今、Emily(私のプログラミング言語プロジェクト)で、同様の問題を抱えているような気がします。ゲームでやりたいことがあるのですが、自分が持っているツールに対して簡潔に表現するのに苦労しています。だから、新しいツールを作りたいのですが、これは結局、複雑なアイデアを表現できる言語を設計し、その言語をLLVMのAST(抽象構文木)に変えるコンパイラを書くことを意味します。その全てを完成させて 初めて 、私が設計した言語に対して自分のアイデアを表現することができるようになるのです。しかし、最初の2つのステップが難しくて行き詰っています。2つのステップとは、コンパイラのフロントエンドに自分の考えを伝えるのに最適な中間手段を見つけることと、次にフロントエンドにその考えをLLVMへ翻訳させることです。いわゆる”UX”にあたる部分ですね。

私の妙案は次のようなものです。音楽を作っていた時にやっていたようなことだけをやって、Emilyとそのコンパイラを完全に切り捨てて、LLVMのASTを自分で書いたらどうだろう? 私には、「LLVMのASTを構築する関数を呼び出すような、もはやプログラムと呼ぶ以前のレベルのプログラムを書き、EXEファイルを出力するようLLVMに指示する」というのが簡単に想像できました。基本的にコンパイラなしでソフトウェアを書くことになります。つまり、多分、別の見方をすれば、プログラミング言語を使わずに、非常に小さなコンパイラをたくさん書くということです。各コンパイラは1つの一定のプログラムだけを出力します。いきなり、図は次のようになります。


これは最悪のアイデアのように思えます。ひどく滑稽にも思えますが、試してみることにします。

LLVM-C

コンピュータサイエンスの世界において「メタプログラミング」が話題に挙がることがあります。これは、他のコードを生成するコードを書くことができる機能が言語に備わっていることを示す緩い表現です。時には文字どおりに機能します。例えば、C言語の定義やC++のテンプレート、LISPのマクロを見てみると、第二のプログラミング言語のような役割を果たしていて、これらはいくらかぎこちない形で半分ほどが第一言語に埋め込まれていて、コンパイラやインタプリタが実行しようとする前にコードを生成します。今回のアイデアについてはどういうことになるかというと、独自のメタ言語で書くことになり、言語自体が存在しないことになる(というか、実際はLLVM IR(中間表現)になる)と考えました。基礎となる言語は、意味のある便利なものを書くにしては単純すぎるため、使用するメタ言語を決める際の考え方としては、通常ソフトウェアを書く上で自分が望む全ての抽象的概念をビルドさせてくれる程度に十分に表現的であるものがいいと考えました。

そのため、Luaを選びました。Luaは世界で一番気に入っているプログラミング言語で、今まで使用した言語の中で柔軟性において勝てたものがありません。Luaのプログラムを実行すると実用的な問題にあってしまうため、最近ではあまり使用していません(FFI/libraryのサポートが思ったより使い勝手が悪く、ガーベジコレクションが必須になり、スレッドがうまく動作しません)。しかし、実際にここではLuaを 実行しない ので問題にはなりません。Luaはある種のビルドスクリプトのような位置づけだからです。

Luaのいいところの1つがLuaJITで、かなり特殊なFFI(例:C言語で書かれたソフトウェアとの通信)を持つインタプリタ実装です。Luaのような高水準言語でCのライブラリを使う場合、通常はラッパースクリプトを使って、呼び出したいC言語関数全てのコピーを生成しなければいけないという大変な作業が伴います。この「ラッパー関数」は、使用している言語が考えるデータと関数からC言語が考えるものに変換し、また、C言語の考えから使用言語の考えに変換するという面倒な作業を行います。これは不自然でバグを生む恐れがあり、時にはC言語でのデータの使用方法が、使用している言語では処理できず動かなくなってしまう場合が生じます。しかし、LuaJITでは全く異なる、ある種ワイルドな方法で処理します。実行時に直接C言語ヘッダファイルを構文解析することで、C言語の構造体と呼び出し規約を複製するのにメモリをどのように使用すればいいのか理解するのです。主にCライブラリの関数を呼び出すスクリプトを書く場合は、LuaJITは便利です。LuaJITはCコンパイラのように、Cデータ項目をメモリに作成し、C言語関数を呼び出すことができるため、C言語でLLVMライブラリを使うのと同じように、LuaJITでLLVMライブラリを使うのは簡単なはずです。

しかし、LLVMライブラリがC言語で書かれていないというちょっとした問題があります。C++で書かれています。これは問題です。C++は言語の互換性という観点から基本的には害になります。C++には「ABI」が定義されていないため、ライブラリを使用した際に関数を呼ぶ基準がありません。コンパイラにはそれぞれ独自のものを備えてしまっているような状態です。つまり、C++ではない言語でC++コードを呼び出すことが不可能なだけでなく、逆にC++がC++ではない言語のコードを呼び出すことが不可能になります。同じコンパイラを使用したC++ライブラリ同士なら通信ができますが、使用したいライブラリが異なるコンパイラを使用している場合(Windowsを使用している場合はよくあるケースです)、なすすべはありません。

LLVM関係者が問題だと気付いたためだとは思いますが、 結局 LLVMでもLLVM-Cというライブラリが提供されています。これは、C++ APIのCラッパーです。やったー!って思ったでしょう。しかし残念なことにフルにはサポートされていません。LLVM-Cドキュメントには、「このモジュールはLLVMライブラリの一部をC APIとして公開」すると説明されています。「一部」とはどういうことなのでしょう。IRC Channelで聞いた説明によると、LLVM-Cは過去13年間に誰かが必要とした、あるいは必要になった時にLLVM-Cに追加した断片で構成されているとのことです。欠けている部分もあり、その欠けている部分は不規則に欠けているとのことです。見たところでは、LLVM-Cインターフェイスを使用した主要プロジェクトが進行しているようなので、使用を早々に諦める理由にはなりません。しかし、使用の際は、フルにサポートされていないことは念頭に置いておいた方がいいでしょう。(面白いことにRustではLLVM-Cのようなものを導入しています。その理由はLLVM-Cが理解しにくいからだそうです。しかし、Rustプロジェクト でも このLLVM-Cバインディングは行き当たりばったりで、Rustで必要とされたLLVMの部分だけに対応しています。LLVM-Cとは異なる部分なので、ここでは役に立ちません)

いずれにせよ、LLVM-Cを使用します。LLVMバージョン3.9(最新)をインストールし、 llvm-c-kaleidoscope をダウンロードします。C言語に移植したLLVMチュートリアルのプログラムです。llvm-c-kaleidoscope自体をコンパイルはしませんが問題ありません。これはリファレンスのみに使用します。

これでLuaを書き始める準備ができました!小さなプロジェクトをセットアップします。

config.lua 
generate.lua 
lib-ffi/     
     import.lua 
     llvm/ 
          3_9/ 
               (LLVM headers in here) 
lib-lua/ 
     import.lua 
     pl/ 
        (Penlight in here)

残念ながら、Luaにはライブラリにまつわるいい話がありません。例えばPythonにはコンピュータに既にインストールされている細かなもののバラエティに適合するランタイムを持ちますが、Luaはそれと異なり、大きなプログラムに組み込まれるように設計されています(例えばゲーム)。組み込みを行っている人が必要なライブラリをセットアップすることが想定されています。Luaのスクリプトを単体で実行する場合(ここで私が実行しているように)、基本ライブラリを使用した環境をセットアップする必要が生じます。基本となるライブラリは Penlight で提供されています。これは、クラスなどの基本的なものをたくさん提供してくれるサードパーティのLua用「標準ライブラリ」です(そう、Luaの世界ではオブジェクト指向プログラミング自体もライブラリから得ることができる一つの機能なのです。前述したように、柔軟性がありますよね)。Penlightをプログラムに入れると不格好になってしまうかもしれません。 lib-lua/import.lua には次が含まれます。

-- Lets internal references in Penlight work 
package.path = package.path .. ";./lib-lua/?.lua"  

class = require("pl/class") 
pretty = require("pl/pretty")

うーん。

もっと面白いのが lib-ffi ディレクトリです。これもちょっと変かもしれません。前述のとおり、LuaJITはCヘッダファイルを消費しますが、コンパイラがヘッダファイルを扱うのとは異なる扱い方をします。インクルードパスのようなものはありません。その代わりに、ヘッダファイルの内容を文字列として渡して、 ffi.cdef と呼ばれる関数を呼び出します。しかし、ヘッダファイルを一語一句そのままでは、渡すことはできません。LuaJITはCプリプロセッサディレクティブを理解しないため、渡す前に外す必要があります。前述のとおり、これが最近Luaを使用しなくなった理由です。新しいライブラリを使おうとするたびにLuaと通信できるようにファイルの内容に手を加える労力が伴います。ここでは、この作業はLLVMライブラリのための1回のみなので、さほど大変ではありません(ゆくゆくはClanglibにもする必要があるかもしれません)。これ以降に使用するライブラリは生成した実行ファイルと通信することになり、Luaスクリプトとではなくなります。

どのヘッダが必要か判断し lib-ffi/import.lua に次を入れます。

ffi = require( "ffi" ) 
 -- Libraries 

local libExt = ({ 
   OSX     = "dylib", 
   Windows = "dll", 
   Linux   = "so", BSD = "so", POSIX = "so", Other = "so" 
})[ffi.os] 

local function libPath(name) 
    return config.llvmLibPath .. "/" .. name .. "." .. libExt 
end  

LLVM = ffi.load( libPath("libLLVM") ) 

-- Headers  

local llvmDir = "lib-ffi/llvm/" .. config.llvmVersion .. "/"  

require(llvmDir .. "Types") 
require(llvmDir .. "Target")            -- Requires Types 
require(llvmDir .. "TargetMachine")     -- Requires Types, Target 
require(llvmDir .. "ExecutionEngine")   -- Requires Types, Target, TargetMachine 
require(llvmDir .. "ErrorHandling") 
require(llvmDir .. "Core")              -- Requires ErrorHandling, Types 
require(llvmDir .. "Transforms/Scalar") -- Requires Types

ここでは複数のことが行われています。

C言語のプログラムをコンパイルする場合、コンパイル段階(ライブラリを使用する場合、ヘッダをインタープリトするタイミング)とリンク段階(ライブラリを使用する場合、ライブラリのバイナリを接続するタイミング)があります。LuaJITではこの両方を実行時に処理しますが、その際にヘッダとライブラリが必要となります。ヘッダを文字列として渡せば、どのメモリレイアウトを使用すればいいのか分かり、ライブラリを動的に読み込めるダイレクトパスを与えることができます。全て手作業で、実行中のリンカのように自動でできるところないので、ダイナミックライブラリ用のファイルの拡張子が実行するOSでどのようになっているかなどを調べるなど細かい作業をする必要があります。LLVMライブラリがどこにインストールされているかを知るすべも私にはないので、スクリプトの実行者が実行前にパスを config.lua ファイルで教えてくれることを期待することにします。

次にヘッダに行きます。ヘッダにバージョン付けして、使いたいバージョンをユーザに config.lua で指定してもらいます。

目標はLLVMの「AST(抽象構文木)」の作成です。私の把握してる限りでは、LLVMにはASTを指定する方法が3つあります。1つ目はLLVM IRとして指定する方法で、ASTのテキスト表現で、LLVMのバージョンのごとに変わります。2つ目はbitcodeで指定する方法で、バイナリ表現です。bitcodeはLLVMの異なるバージョンの間で標準化されていて、互換性があります。3つ目の方法はLLVMやLLVM-C APIから関数を呼び出す方法です。私はLLVM-C APIが安定していると考えています。LLVM 3.6を対象にプログラムを書いた場合、LLVM 3.9で再コンパイルでき、実行できるはずです(もしこの理解が間違っているのであれば、どなたか教えてください)。しかし、3.6と3.9のAPIのソースに互換性があったとしても、3.9のヘッダを3.6のライブラリに使用できるわけではありません。ここでちょっと変わったことをします。3.9のヘッダをソースリポジトリに直接埋め込みます。ここでは、ディスク上のものではなく、手を加えたヘッダのバージョンを読み込ませたいのでこの作業が必要となります。

ここで私が試しているのは全て変わった実験です。ここでは、他の人にとっての使い勝手を考慮しているわけではありません。しかし、このようにデザインしたことで、今使用しているコンピュータ上のこのバージョンのLLVMでしか実行できなくなってしまうようでは問題です。変な実験であれ、自分以外の人でも実行できるようにして、失敗した時にその失敗から学べるようにしておく必要があります。これを可能にすべく、複数の「手を加えた」LLVMヘッダのバージョンをこのリポジトリに入れておきます。3.9と互換性を持つヘッダ( 3_9 という変な名前のフォルダにあります。Luaではドットをディレクトリ名に含むことができないので、アンダーバーになっています)を入れておきます。将来他の人が異なるバージョンのLLVMを使用した際に変換作業をしたヘッダをチェックインした新たなフォルダが 3_9 フォルダと並ぶようにしておきます。

これができたところで、全ての3.9 LLVM-Cヘッダを 3_9 フォルダにコピーしてから変更せずリポジトリにチェックインします。使用しませんが、将来のリファレンスのために入れておきます。そして、必要なものを import.lua から取り出しLuaに変換します。簡単な作業です。 .lua に名前を変更し、インクルードガードの #if #include を頭と末尾から外し、代わりに ffi.cdef [[ を頭に、 ]] を末尾に付けます(二重カッコを付けることでマルチラインの文字列を作成できます)。簡単!ただし、次のことに注意してください。

  • #include を外したので、依存性管理がされません。つまり、依存性ツリーを手作業で作成し、正しい順番で全てが含まれるようにしなければなりません。そのため、上の import.lua の中で、「Requires Types」や「Requires Types, Target,」といった変なコメントが書かれています。

  • Core.h には #define を使った小技が含まれています。ちょっとしたCメタプログラミングです。そのため、このファイルにCプリプロセッサを手動で実行する必要があります(必ず #include を頭から外した後に実行しないと、stdlib全体をファイルに引き込むことになってしまいます)。

  • Target.h はさらに最悪で、変なプリプロセッサ構文の繰り返しが含まれています。

/* Declare all of the target-initialization functions that are available. */ 
#define LLVM_TARGET(TargetName) \  
 void LLVMInitialize##TargetName##TargetInfo(void); 
#include "llvm/Config/Targets.def" 
#undef LLVM_TARGET  /* Explicit undef to make SWIG happier */

これはなんでしょう?見る限り明らかに、LLVMライブラリが私のコンピュータにインストールされた際に構築された .def ファイルのようで、システムごとの設定情報が含まれているようです。主要なLLVMヘッダの中にある Targets.def を見ると次のような行があると思います。

LLVM_TARGET(AArch64) 
LLVM_TARGET(AMDGPU) 
LLVM_TARGET(ARM) ...

これは問題です。「手を加えた」3.9ヘッダを別のコンピュータに移して、ローカルのダイナミックライブラリでも実行できると考える理由は、1つのLLVMバージョンのどの2つのコンパイルコピーでも構造のレイアウトは変わらないだろうと推測するからです。しかし、 .def の内容はコンピュータによって異なります。対応策としては、メタプログラミングの不要なマクロを Target.lua に残し、このコードをコンパイルをしようとする人には、プリプロセッサを独自に実行するように伝えるしかありません( .def の内容をそれぞれのパソコンから持ってくることになります)。このプロジェクトのREADMEを作成し、このコンパイラではない部分を実行する前に次のひどい作業をするように書いておきます。

gcc -I/opt/local/libexec/llvm-3.9/include -E -P -C -xc-header ./lib-ffi/llvm/3_9/Target.source.lua > ./lib-ffi/llvm/3_9/Target.lua

これでは、、、あまりにひどいので、このまま実験を続けるなら今後ビルドスクリプトのようなものが必要になるかもしれません。

generate.lua

本記事を書き始めて英語で3,135単語になりますが、ようやくコードを書く準備が整いました!まず、llvm-c-kaleidoscopeプロジェクトの メインファイル の最も小さな塊をLuaのスタンドアロンなファイルに変換しました。

require("config")
require("lib-lua/import")
require("lib-ffi/import")

class.Builder()
function Builder:_init(moduleName)
    self.llvm = {
        module = LLVM.LLVMModuleCreateWithName(moduleName),
        builder = LLVM.LLVMCreateBuilder()
    }

    local enginePtr = ffi.new("LLVMExecutionEngineRef[1]")
    local msgPtr = ffi.new("char *[1]")
    if LLVM.LLVMCreateExecutionEngineForModule(enginePtr, self.llvm.module, msg) == 1 then
        local err = ffi.string(msgPtr[0])
        LLVM.LLVMDisposeMessage(msgPtr[0]);
        error("LLVMCreateExecutionEngineForModule error: " + err)
    end
    self.llvm.engine = enginePtr[0]

    self.llvm.passManager = ,LLVM.LLVMCreateFunctionPassManagerForModule(self.llvm.module)
    LLVM.LLVMAddTargetData(LLVM.LLVMGetExecutionEngineTargetData(self.llvm.engine), self.llvm.passManager);
    LLVM.LLVMAddPromoteMemoryToRegisterPass(self.llvm.passManager);
    LLVM.LLVMAddInstructionCombiningPass(self.llvm.passManager);
    LLVM.LLVMAddReassociatePass(self.llvm.passManager);
    LLVM.LLVMAddGVNPass(self.llvm.passManager);
    LLVM.LLVMAddCFGSimplificationPass(self.llvm.passManager);
    LLVM.LLVMInitializeFunctionPassManager(self.llvm.passManager);
end

function Builder:dump()
    LLVM.LLVMDumpModule(self.llvm.module)
end

function Builder:dispose()
    LLVM.LLVMDisposePassManager(self.llvm.pass_manager);
    LLVM.LLVMDisposeBuilder(self.llvm.builder);
    LLVM.LLVMDisposeModule(self.llvm.module);
end

builder = Builder("main")
builder:dump()

ここで、私は単一の空の「モジュール」を作り、それに付随するLLVM IRをダンプします。そして、これと以下を実行します。

; ModuleID = 'main'

うまくいきました!モジュールが何なのかはまだ分かりません。オブジェクトファイルに相当するものと考えますが、少なくともLLVMとやり取りでき、クラッシュすることもないということを証明できました。

ここに、私がこれまでに書いてきたプロジェクトがあります 。しかし、これよりもう少し先に進みたいと思います。実行可能なものにしたいのです。

ファイルへの出力

モジュールがオブジェクトファイルだとすると、ここからやるべきことは2つあります。「main」関数をエントリポイントとして追加すること、そしてオブジェクトファイルをディスクに書き出すことです。私はまず、簡単そうに思われた後者から取り組むことにしました。しかし、その考えは間違いでした。

Kaleidoscopeのサンプルプログラムは、ファイルを出力しません。JITを使うからです。したがって、これは私が自分でやることになった最初のLLVMの作業でした。調べたところ、モジュールをオブジェクトファイルとして出力するには、 LLVMTargetMachineEmitToFile() を使うように思われました。ただ、これはちょっと化け物のような関数です。設定の引数を2つ取るのですが、1つはTargetMachineと呼ばれる複雑な構造となっています。そのコンストラクタが取る 7つの引数 の多くが、意味のよく分からない”マジックワード”なのです。

この時点で、LLVMのドキュメントに限界を感じ始めました。LLVM-C自体にはドキュメントがありませんから、何もかも2回ずつ調べる羽目になりました。まず対応するC++の関数が何であるか知るためにLLVM-Cリファレンスを調べ、次にその関数の働きを知るためにC++のドキュメントを調べるのです。ですが、必要な関数を見つけても、決まって関数の厳密な定義しか説明されていません。「ところで、この関数をいつ呼び出すのか?」とか、「では、このTargetMachineはどこから来るのか?」といった質問は、リファレンスマニュアルの範囲外なんですね。といっても、リファレンスが 唯一の ドキュメントですから、この時はStackOverflowを検索するしかありませんでした。しかし、StackOverflowは、私のやりたいことが一般的である場合にしか役に立ちません。オブジェクトファイルを出力するというのは、初心者レベルのLLVMユーザはまずやらないほど変わった試みでしょうし、少なくともパワーユーザ的な作業ではあるでしょう。他の人たちは、LLVM IRをオブジェクトファイルに変換するのに、 llc というコマンドラインツールを使っているようなのです。

LlvmCreateTargetMachine() の引数が複雑なのは、LLVMがクロスコンパイラであり、目の前にある自分のマシンや他の不特定多数のマシン向けにコンパイルできるようになっているからです。では、 自分の マシン向けのスペックは、どうやって入手したらいいのでしょうか? 私は、 llc ソースコード を引っ張り出して、その動作を確認することにしました。すると、まさに現在のマシンに対応するデフォルトを返してくれる関数がたくさんあると分かりました。例えば、 getDefaultTargetTriple() getFeaturesStr() などです。しかし、このような関数は、 sys.h CommandFlags.h の中にあります。C++のヘッダです。Cからはアクセスできません。うーん。

8つのものを指定する必要が出てきました。「target」、「triple」、「CPU」、「features」のリスト、そして最適化と「何をビルドするか」(ライブラリ、実行ファイルなど)に関連する4つのオプションです。最後の4つは幸いにも列挙型ですから、ヘッダを見るだけで簡単に分かります。そして「triple」から「target」を得られる関数も見つけたので、残る謎は3つとなりました。自分のマシンの「triple」を調べる方法は知っていますが、やはりこのソフトウェアが1台のコンピュータに縛られる形にはしたくありません。そこでIRCとTwitterで情報を得ようと約48時間取り組んだ結果、同じ経験をした人たちが助けてくれました。分かったことは以下のとおりです。

  • 「triple」は、あらゆるコンパイラで使う「コンパイルするのは何?」を指す文字列と同じである。 clang -version gcc -dumpmachine を使えば、所定のマシン上でどのコンパイラからでもサンプルを得ることができる。
  • 結局は、Cからアクセスできる LLVM.LLVMGetDefaultTargetTriple() がある(5,000行のヘッダをスキャンした最初の数回、私はこの点を見逃していたようです)。
  • 「CPU」は、昔ながらのコンパイラで使う-marchと同じで、マイクロアーキテクチャを指定する。ここで armv7 を指定した場合、armv6がサポートできない最適化が得られるが、ソフトウェアもarmv6では動作しなくなる。
  • 「CPU」と「features」の文字列は、空文字列も指定できる。空文字列にした場合、求めたtripleに対して最も保守的なマイクロアーキテクチャが得られる( sys.h から得られる「デフォルト」よりも、いずれにせよ必要なものに近いでしょう。結局、「デフォルト」を真新しいPCで求めると、他の誰も実行できない結果が得られる可能性があります)。
  • 「target」は非常に奇妙で、LLVMの内部的な指定であり、どのLLVMバックエンドを動かしたいかを参照する。これを得るには、tripleからtargetを得る前述の関数を使う必要がある(targetは名前で調べることもできますが、名前はかなり不可解です。というのも、命名法に一貫性や外部との対応関係がないようで、その1つのLLVMアドオンを書いた人次第で決まっていたのです。例えば、x86とx86-64の両方に対応する1つの x86 targetがありますが、32ビットと64ビットのARM targetは別個のものとなっています)。

こうした様々な情報を得た結果、出力関数を作り上げることができました。デフォルトのtripleから全て出力する関数です( config.lua を通じてオーバーロードが許可されます)。

function Builder:emitToFile()
    local targetRefPtr = ffi.new("LLVMTargetRef [1]")
    compileTriple = config.compileTriple or LLVM.LLVMGetDefaultTargetTriple()
    compileCpu = config.compileCpu or ""
    compileFeatures = config.compileFeatures or ""

    if LLVM.LLVMGetTargetFromTriple (compileTriple, targetRefPtr, errPtr) == 1 then
        llvmErr("LLVMGetTargetFromTriple")
    end
    if LLVM.LLVMTargetMachineEmitToFile(
            LLVM.LLVMCreateTargetMachine(targetRefPtr[0], compileTriple, compileCpu, compileFeatures,
                LLVM.LLVMCodeGenLevelAggressive, LLVM.LLVMRelocDefault, LLVM.LLVMCodeModelSmall),
        self.llvm.module, cstring(self.moduleName .. ".o"), LLVM.LLVMObjectFile, errPtr) == 1 then
        llvmErr("LLVMTargetMachineEmitToFile")
    end
end

すると、 奇妙な 状況になりました。

問題の最初の兆候となったのは、 Unable to find target for this triple (no targets are registered) という不可解なメッセージが出たことです。このメッセージが意味するところに気付くまで、私は間違ったtripleを入力してしまったのだと考えて時間を無駄にしました。 実は、targetが1つもないということだったのです。 LLVMGetTargetFromTriple() が機能する前に、ロードする可能性のある全てのtargetを初期化するための特別な関数を呼び出さなければいけないことが分かりました。初期化の関数はtargetによって違うのです。例えば64ビットのARM用の LLVMInitializeAArch64Target() や、 LLVMInitializeX86Target() などです。大半のユーザはLLVMとプログラムを静的にリンクさせるので、このようになっているのでしょう。targetのバックエンドをいくつかの関数に分けると、サポートするつもりがない場合に、「MSP430」のどんなサポートコードもバイナリにパックする必要がなくなります。それは納得できるのですが、私は動的にリンクしているので、どのプラットフォームをサポートしていて、どのプラットフォームをサポートしていないのかが前もって分かりません。

私のように動的LLVMを使用していて、サポート対象がはっきり分からないというユーザは、通常 LLVMInitializeAllTargets() 関数を呼び出します。これはインライン関数として Target.h で次のように定義されます。

/** LLVMInitializeAllTargets - The main program should call this function if it
    wants to link in all available targets that LLVM is configured to
    support. */
static inline void LLVMInitializeAllTargets(void) {
#define LLVM_TARGET(TargetName) LLVMInitialize##TargetName##Target();
#include "llvm/Config/Targets.def"
#undef LLVM_TARGET  /* Explicit undef to make SWIG happier */
}

さあ、ここで詰まってしまいました。LuaJITはこれらを 呼び出す ことがまったくできません。LuaJITは関数の呼び出しを動的ライブラリからのロードで行うわけですが、インライン関数は動的ライブラリ の中 にはありません。直接ヘッダファイルに埋め込まれたCコードなのです。

私は実にひどい方法でこの問題を乗り越えました。 LLVMInitializeAllTargets() は、Cプリプロセッサを使って Targets.def から生成されたインライン関数です。Cヘッダに埋め込まれた文字列の中にあるプリプロセッサディレクティブをクリアするために、私はすでにLuaのファイル上でこのプリプロセッサを実行し、 Target.Lua を生成しています。なので、とにかく先に進めてみましょう。そうですね…、Cプリプロセッサを使ってLuaを生成してみます。

function LLVMInitializeAllTargets()
  #define LLVM_TARGET(TargetName) LLVM.LLVMInitialize##TargetName##Target()
  #include "llvm/Config/Targets.def"
  #undef LLVM_TARGET
end

このプリプロセッサを実行すると、それぞれの関数を呼び出すLuaの関数ができました。アクセスできないCの LLVMInitializeAllTargets() が呼び出し続けていたはずの関数です。このような関数をさらに3つ作成します(「target」自体を初期化するものに加えて、それぞれのtargetで「target infos」、「target MCs」、さらに「ASM printers」も初期化しなければいけません。そうしないと、エラーメッセージが出てすっかり惑わされる羽目になります)。すると、これはうまく動きました。

結合された大きな LLVMInitializeAll() 関数を最初に呼び出せば、 emitToFile() はエラーを表示することなく、 file が64ビットのMach-Oであることを示す200バイトの main.o ファイルができます。

さあ、これで実際に何か 実行 できそうですね。

(後編に続く)

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。