Open15

GhostCell 論文を読む

yukiyuki

雑にアイディアを説明すると、「任意のID (Rust のライフタイムを利用する)を発行して、その中だったら安全に borrow / borrow_mut できる仕組みを作っちゃおう」という話。これを利用すると、ゼロコストで interior mutability を実現できる。コンパイルタイムで borrow できるかどうかの検証が走るので、RefCell とかより安心して使える上に、パフォーマンスもいい。

雑に論文のポイントを整理すると、

  • GhostCell<'id, T> と GhostToken<'id> という2つの型を用意する。
    • GhostCell は T への shared reference を表現する。
    • GhostToken は T というデータに対する権限(permission)を表現する。
  • 2つの異なる型に対して、ライフタイム 'id をもたせる。これは後述するが brand と呼ぶ。
    • これは実際に使用されるライフタイムではない。
    • いわゆる「幽霊ライフタイム(Phantom Lifetimes)」というものに該当する。
yukiyuki

構成

  • 1章はモチベーションに関する話。
  • 2章はBeingessner[2015]で提唱されている branded types を簡単に説明。
  • 3章は GhostCell API を説明。doubly-linked list を再実装したサンプルを掲載。
  • 4章は RustBelt を振り返ったあと、Coq で健全性を説明。
  • 5章はパフォーマンスを検証。
  • 6章は related work

GhostCell そのものに興味がある人は3章を読むといい。branded types の前提知識が必要となるので、前提知識に不安のある人は2章も読む必要がありそう。

結果どうなったか、GhostCell はなぜ速いのかみたいな話は5章を読むとよさそう。

4章は私は形式検証に関する知識がないので、説明はできなそう。他の詳しい日本語話者の人が解説してくれるのを期待しています。

yukiyuki

一旦3章からまずは読む。GhostCell は下記の2つの要素から構成される。

  • GhostCell<'id, T>: T 型のデータそのものを表している。T は brand type の 'id によって記しづけされている。Cell 型が共有される場合には、Cell のもつ brand type 'id に対応するライフタイムをもつ GhostToken<'id> を使用してしか共有することができない。
  • GhostToken<'id>: 同じ brand type 'id をもつ GhostCell のデータにアクセスする際に使用されるもので、データへのアクセス権限を調整する役割をもつ。

GhostCell は、GhostToken のもつ brand の範囲内であれば interior mutability を安全に確保できることを保証できる手法のように思われる。そして、その安全性の担保は Rust の Borrow Checker によってコンパイルタイムで行える。

yukiyuki

brand と言っているが、実質なにかのタグと言ったほうがわかりやすいかもしれない。

私は TypeScript を書いている最中に Brand Type という概念を知りました。TypeScript の場合は構造的部分型が効いているので、フィールドとかが一致してしまえば同一の型とみなされてしまう問題があるが、それを別々の型として手軽に扱えるようにするための手法のひとつ。

https://betterprogramming.pub/nominal-typescript-eee36e9432d2

yukiyuki

Rust における branded types の実装を実現する際に大きな役割を果たすのが、Phantom Lifetimes を担う InvariantLifetimes という構造体。

#[derive(Clone, Copy, Default)]
struct InvariantLifetime<'id>(PhantomData<*mut &'id ()>);

impl<'id> InvariantLifetime<'id> {
    #[inline]
    const fn new() -> InvariantLifetime<'id> {
        InvariantLifetime(PhantomData)
    }
}

中身はただの値のない PhantomData を保持させているが、ここに 'id というライフタイムが付与されている。実質的に 'id は brand type というものを示すことになる。

GhostToken は、この InvariantLifetime を中にもつ構造体。実装は new 関数のみをもっている。

pub struct GhostToken<'id> {
    _marker: InvariantLifetime<'id>,
}

impl<'id> GhostToken<'id> {
    #[inline]
    pub fn new<F, R>(f: F) -> R
    where
        F: for<'new_id> FnOnce(GhostToken<'new_id>) -> R,
    {
        let token = GhostToken {
            _marker: InvariantLifetime::new(),
        };
        f(token)
    }
}
yukiyuki

GhostCell はこの GhostToken を用いて、&T の取得と &mut T の取得を行えるように作られている。ポイントは、borrowborrow_mut&'a self しか要求していないところだと思う。これによって、いわゆる interior mutability を実現できている。

#[derive(Default)]
#[repr(transparent)]
pub struct GhostCell<'id, T: ?Sized> {
    _marker: InvariantLifetime<'id>,
    value: UnsafeCell<T>,
}

impl<'id, T> GhostCell<'id, T> {
    #[inline]
    pub const fn new(value: T) -> Self {
        GhostCell {
            _marker: InvariantLifetime::new(),
            value: UnsafeCell::new(value),
        }
    }

    #[inline]
    pub fn into_inner(self) -> T {
        self.value.into_inner()
    }

    #[inline]
    pub fn borrow<'a>(&'a self, _token: &'a GhostToken<'id>) -> &'a T {
        unsafe {
            &*self.value.get()
        }
    }

    #[inline]
    pub fn borrow_mut<'a>(&'a self, _token: &'a mut GhostToken<'id>) -> &'a mut T {
        unsafe {
            &mut *self.value.get()
        }
    }
}

  • ちょっとわからない: GhostToken があると、なぜそうしたデータへのアクセス権限の調査に使えるのか?という点。
    • 回答: ここに token をいれておくことで、「特定の brand 内に存在する GhostToken を使用できているか?」を常にチェックさせられるからだと思われる。
    • なくてもコンパイル自体は通る。が、安全な貸し出しになるかどうかは保証できなくなってしまう。

'id は、プログラムのレベルでは本当にただのマーカーというか、それ以上の役割はなさそう。形式検証のときとかに効いてくる感じなのかな。

yukiyuki

さて、interior mutability というと思いつくものが CellRefCell だと思う。そっちとの旨味の差は一体なんなのか?というのが疑問として残る。私はちょっと疑問に思った。

いろいろ実装を眺めてみたところ、GhostCell のおいしいところは、きちんとコンパイル時に borrow の検証をできることから、Borrow Checker を通じて interior mutability を実現できるところにあると思う。RefCell は実行時に borrow できるかどうかを確かめるので、場合によっては実行時にパニックする。また、RefCell を使用した箇所はゼロコストにならないので(要出典とチェック)、パフォーマンス上のオーバーヘッドが多少発生する。

RefCell のオーバーヘッドを少したしかめておく。

RefCell はご覧のように、BorrowFlag というものを中に(モジュールに)持っている。value 部分については GhostCell と同じように

#[stable(feature = "rust1", since = "1.0.0")]
pub struct RefCell<T: ?Sized> {
    borrow: Cell<BorrowFlag>,
    value: UnsafeCell<T>,
}

BorrowFlagisize のタイプエイリアス。モジュール内に UNUSED という定数を保持していて、そのカウンタを見ながら borrow できるかどうかを後続の処理でチェックしている。

type BorrowFlag = isize;
const UNUSED: BorrowFlag = 0;

#[inline(always)]
fn is_writing(x: BorrowFlag) -> bool {
    x < UNUSED
}

#[inline(always)]
fn is_reading(x: BorrowFlag) -> bool {
    x > UNUSED
}

borrow する際には、一度 BorrowRef という構造体を使って borrow できるかどうかのチェックをかけている。

// impl RefCell 内
    pub fn borrow(&self) -> Ref<'_, T> {
        self.try_borrow().expect("already mutably borrowed")
    }

    pub fn try_borrow(&self) -> Result<Ref<'_, T>, BorrowError> {
        match BorrowRef::new(&self.borrow) {
            // SAFETY: `BorrowRef` ensures that there is only immutable access
            // to the value while borrowed.
            Some(b) => Ok(Ref { value: unsafe { &*self.value.get() }, borrow: b }),
            None => Err(BorrowError { _private: () }),
        }
    }

    pub fn borrow_mut(&self) -> RefMut<'_, T> {
        self.try_borrow_mut().expect("already borrowed")
    }

    pub fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError> {
        match BorrowRefMut::new(&self.borrow) {
            // SAFETY: `BorrowRef` guarantees unique access.
            Some(b) => Ok(RefMut { value: unsafe { &mut *self.value.get() }, borrow: b }),
            None => Err(BorrowMutError { _private: () }),
        }
    }

BorrowRef 内では Cell を使用しているが、中で走る set では std::mem::replace が呼び出される。中では std::mem::swap が呼び出されるが、ここでまずコピーが走る(ので、オーバーヘッドが少しある)。

というか、そもそもこのようなチェック機構が1ステップ増えることになるので、GhostCell と比べるとオーバーヘッドがあるだろうし、何より実行時にしかこのチェック機構は走らないので、コンパイルタイムに安全に borrow できるのかどうかを判断できないという問題がある。

// borrow ref
struct BorrowRef<'b> {
    borrow: &'b Cell<BorrowFlag>,
}

impl<'b> BorrowRef<'b> {
    #[inline]
    fn new(borrow: &'b Cell<BorrowFlag>) -> Option<BorrowRef<'b>> {
        let b = borrow.get().wrapping_add(1);
        if !is_reading(b) {
            // Incrementing borrow can result in a non-reading value (<= 0) in these cases:
            // 1. It was < 0, i.e. there are writing borrows, so we can't allow a read borrow
            //    due to Rust's reference aliasing rules
            // 2. It was isize::MAX (the max amount of reading borrows) and it overflowed
            //    into isize::MIN (the max amount of writing borrows) so we can't allow
            //    an additional read borrow because isize can't represent so many read borrows
            //    (this can only happen if you mem::forget more than a small constant amount of
            //    `Ref`s, which is not good practice)
            None
        } else {
            // Incrementing borrow can result in a reading value (> 0) in these cases:
            // 1. It was = 0, i.e. it wasn't borrowed, and we are taking the first read borrow
            // 2. It was > 0 and < isize::MAX, i.e. there were read borrows, and isize
            //    is large enough to represent having one more read borrow
            borrow.set(b);
            Some(BorrowRef { borrow })
        }
    }
}
yukiyuki

さらにいうと、この GhostCell 自体に SendSync トレイトを実装できるので、スレッド間の所有権の送信やスレッド間のデータの共有ができるというところにメリットがありそう。RefCell はたしかスレッドセーフじゃないはず。

yukiyuki

GhostCell の肝心の使い方だが、下記のコードがわかりやすいと思った。

https://gitlab.mpi-sws.org/FP/ghostcell/-/blob/master/ghostcell/examples/dlist_arc.rs#L27

GhostToken::new で token を宣言しておく。GhostToken::new はシグネチャは下記のようになっている。

impl<'id> GhostToken<'id> {
    #[inline]
    pub fn new<F, R>(f: F) -> R
    where
        F: for<'new_id> FnOnce(GhostToken<'new_id>) -> R,
    {
        let token = GhostToken {
            _marker: InvariantLifetime::new(),
        };
        f(token)
    }
}

ここがトリッキーだけど、払い出されたその token は実質 brand の保持のみに使用される。

borrowborrow_mut などの操作は token の brand が正しいかをチェックしながら行われる。正しいうちは、特にオーバーヘッドなくそうした操作が行える。正しくない場合は、そもそもコンパイルエラーで落とされる。

yukiyuki

最近この GhostCell をつかって実験をする機会があったのだが、その際におもしろい亜種をみつけた。その名も FrankenCell 。
https://github.com/spencerwhite/frankencell

GhostCell ではライフタイム識別子をブランドとして利用していたが、const generics でもいけるでしょというのがこの手法のアイディアにあたる。そして試してみた結果、どうやらワークしているようだ。

yukiyuki

実際に使ってみて思ったが、GhostToken を毎回何かの操作をしようとするたびに引数として求められると、いくつか制約が生まれてしまって実用的じゃない感じがする。

たとえば Debug トレイトが↑の実装には提供されていなかったので作ろうと思ったが、その際 borrow して結果をデバッグさせようにも token が引数として求められてしまう。が、Debug トレイトの関数の都合上、token を渡すことができない。

回避策としては get_mut なる関数が生えており、それは token を引数として求めないのでこれを利用することだと思った。が、今度は debug 関数は &self なので、可変操作ができない。

というわけで、まず GhostCell::get を下記で作り、

https://github.com/yuk1ty/ghost-cell/commit/b09fd1ae41a065484a77941990ad68552fa74c38

Debug トレイトをようやく実装できた。

https://github.com/yuk1ty/ghost-cell/commit/b1067d795506dff77610b7ea75d1dd0b204733f2

↑はどこかのタイミングで本体のリポジトリにプルリクエストを投げておこうと思う。

たとえば次のように #[derive(Debug)] を追加することで、双方向リンクトリストの中身を書き出させることができるようになった。

https://github.com/yuk1ty/sandbox/commit/779fdf33151587f3c7052e61d5e0c7d1fc8b0de4