🔗

RustでLLVM-C APIへの高レベル抽象インターフェースを考える

6 min read

https://github.com/yubrot/llrl/tree/main/llvm0

LLVMはC APIを提供しており、C++以外のプログラミング言語からも比較的簡単に利用できる。

Rustではllvm-sysというC APIのバインディングが既にあるので、C APIを単に利用するのであればこのクレートを依存に加えればすぐに利用できる。しかし llvm-sys はC APIの純粋なバインディングなので、Rustのコードから使用するには貧相であり、なにより アンセーフ である。 *-syscrates.ioの命名規約の一種で、高レベルな抽象は別途提供すべきとされている。
LLVMでは既にinkwellllvm-irのような高レベルな抽象インターフェースを提供するクレートが存在するが、Rustの学習も兼ねて自分で書いてみた。


Context

LLVMで最初に作られて最後に破棄されるコンテキスト(Context)を抽象化するのは簡単で、以下のようにnewtypeを設けて Drop 実装で自動的に破棄されるようにできる。

// 生のLLVMContextRefを包む
pub struct Context(LLVMContextRef);

impl Context {
    pub fn new() -> Self {
        Context(unsafe { LLVMContextCreate() })
    }

    pub fn as_ptr(&self) -> LLVMContextRef {
        self.0
    }
}

impl Drop for Context {
    fn drop(&mut self) {
        // Contextがもはや使用されなくなったので、生のLLVMContextRefが指すLLVM側のリソースを解放する
        // LLVM C APIではリソースの解放を行うAPIの命名に `Dispose` が用いられている
        unsafe { LLVMContextDispose(self.as_ptr()) };
    }
}

Type

LLVMの型(Type)はどうだろうか? 例えばLLVMの double 型を表現する LLVMTypeRef は、関数 LLVMTypeRef LLVMDoubleTypeInContext(LLVMContextRef) によって得ることができる。これを素直に実装する:

pub struct Type(LLVMTypeRef);

impl Type {
    pub fn double(context: &Context) -> Self {
        Type(unsafe { LLVMDoubleTypeInContext(context.as_ptr()) })
    }
}

一見すると問題なさそうなコードだが、ここで参照している LLVMTypeRefLLVMのコンテキスト上で管理されているリソース なので、 Context がdropされるとLLVMのコンテキストは Dispose され、 LLVMTypeRef は死んだ参照になってしまう。Rustではこれはセーフなコードとは言えない。

let double_ty = {
    // コンテキストが生成され
    let context = Context::new();
    // そのコンテキスト上のdouble型を得たが
    let ty = Type::double(&context);
    // スコープを抜けることでLLVM側ではコンテキストごと破棄されるにも関わらずtyが生き残ってしまう!
    ty
};

ではどうするか。

  1. Rustのレベルで参照カウンタ Rc を使って ContextType の関係を管理する
  2. Rustのライフタイムを利用する
  3. unsafeマークする

今回の場合は、LLVMのレベルで型はコンテキストに属しており、 LLVMDoubleTypeInContext の返す型もコンテキスト上に都度作成しているのではなく Context が保持している既定のdouble型への参照を得ている形となっているので、(2)が適切そうだ。このような情報はしばしばC APIでは抜け落ちて暗黙のものとなっているので、C++ APIのリファレンスLLVM自体のリファレンスマニュアルから読み取ったり考える必要があった。

(2)のパターンで実装すると以下のようになる:

pub struct Type<'ctx> {
    inner: LLVMTypeRef,
    parent: PhantomData<&'ctx Context>,
}

impl<'ctx> Type<'ctx> {
    // Typeはこのコンテキストへの借用が有効な範囲('ctx)で利用できる
    pub fn double(context: &'ctx Context) -> Self {
        Type {
            inner: unsafe { LLVMDoubleTypeInContext(context.as_ptr()) },
            parent: PhantomData,
        }
    }
}

Type を得るには Context への借用を必要とする。得られた Type はその借用のライフタイム 'ctx を含んでおり、 この借用のライフタイムの終端より後に Type を使用するコードがプログラム中に含まれる場合はコンパイルエラーとして通知してくれる。

なお、上の例の Context では自身がポインタ LLVMContextRef = *mut LLVMContext を内包する構造のため、借用 &Context は不必要な二重の参照になっている。 &ContextLLVMContextRef 相当の表現になるようにするためには Context は型チェックのためのダミー型とする必要があり (参照外し *ctx が不正となるため)、したがって Context 自身には意味のあるデータは持たず、 OwnedPtr<Context> のような Box<T> のポインタ型用の表現も必要になる。今回はトレイトを用いてダミー型 ContextLLVMContext を結びつけるようにしてみた: opaque.rs, context.rs

実際のところ参照のネストが問題かどうかは調べていない(最適化で消えるか等)が、LLVMのC APIにはRustでいう所有権がAPIの利用者側に存在しないような定義がいくつか存在するので、これを型レベルで表現しようとするならいずれにせよRust側にOwnedな型とBorrowedな型を設ける必要がある。

ちなみに、Empty enumである型やそれを含む型の値を std::mem::uninitialized() で得ようとするとランタイムに Attempted to instantiate uninhabited type ... とpanicする。 uninitialized の実装を見ると intrinsics::panic_if_uninhabited::<T>(); といった文があり、このintrinsicsでpanicしているらしい。

Value

LLVMの値 (Value) はどうなるか。

LLVMはライブラリの性質上、複雑なツリー構造 (LLVM-IR) を構築・操作するための各種APIからなり、これらAPIにはこのツリー構造に基づいた様々な暗黙の取り決めがある。例えば LLVMDeleteFunction は関数自身をモジュールから消去するので、この関数自身を指す LLVMValueRef や関数内の LLVMBasicBlockRef, 引数を表す LLVMValueRef 等は消去以降利用してはならなくなる。

Rustの所有権・借用・ライフタイムの検査はあくまで静的な、保守的なものなので、これらの取り決めが全て常に満たされるような静的な定義を与えるのは難しく、またそうしたAPIの使い勝手も良くないだろう。 ContextModule があり、 ModuleFunction があり、 FunctionBasicBlock があり...といった構造全てを静的にトラックしようとすると型がライフタイムまみれになる。かといって、中途半端にトラックした上で例えば basic_block.parent() のようなAPIを提供しても、もとの Function がどこまで有効なのかは型に残っておらず、知る術がない、といったことになる。

今回の Value 実装(value.rs) では以下のようにしてみた:

  • Value<'ctx, 'p> はコンテキスト 'ctx と親となる要素 'p のライフタイムを持つ
    • コンテキストグローバルな値(nullptr等)は Value<'ctx, 'ctx> として得られる
    • モジュールグローバルな値は &'m Module<'ctx> から Value<'ctx, 'm> として得られる
  • ValueCopy とし、 Functionappend_blockBasicBlock への Value の追加などは 'p を小さくしない
  • 代わりにFunctionBasicBlock の消去メソッド erase_from_parentunsafe で定義してしまう

このようにすると、ライブラリとしては以下のようなデザインになる。

  • Value は常に借用として提供される
    • 例えばモジュール以下の Value は、モジュールの借用のライフタイムの範囲において自由に生成して移動できる
  • Valuetyped-arenaのようにappend-only、つまり生成と追加のみ
    • 削除には unsafe が必要で、削除される定義への参照(借用)が残らないことをライブラリの利用者側が保証する必要がある

自分が利用してみた範囲では erase 系のAPIはほとんど呼ぶ機会が無かったので、このあたりが丁度よい落としどころとなった。


雑感

Rustらしい抽象インターフェースを提供するならば、C APIでは潰れて暗黙になっていることが多い所有の関係や借用の関係を洗い出すのが望ましいことがわかった。
もちろん unsafe を用いて直接C APIを叩いていくこともできるが、Rustらしい安全な抽象インターフェースが用意できれば内部のC APIもいくらか安心して利用することができる。

TypeValue のライフタイムの問題の他にも、LLVMのC APIには安全でない操作が多々存在する。例えば、C APIでは全て LLVMTypeRefLLVMValueRef に潰れているが、C++ API側の TypeValue は細かな継承によって振る舞いが加えられているので、 LLVMGetElementType(LLVMTypeRef Ty)double 型で呼び出すことはできない、など。今回の実装(types.rs)ではトレイトを駆使しているが、場合によっては型レベルでの表現はせず単に assert を挟むなど、いい塩梅を見出してやれるといいかなと思う。