Speee DEVELOPER BLOG

Speee開発陣による技術情報発信ブログです。 メディア開発・運用、スマートフォンアプリ開発、Webマーケティング、アドテクなどで培った技術ノウハウを発信していきます!

MemoryView: Ruby 3.0 から導入される数値配列のライブラリ間共有のための仕組み

Ruby コミッターの村田です。Ruby 3.0 に組み込まれる実験的な新機能を作ったので解説します。

新機能は MemoryView と名付けられました。これは C などで書かれる拡張ライブラリ向けの機能です。メモリ上の、型が均一で同一サイズの要素から構成される配列 (e.g. 行列や画像など) を、複数の拡張ライブラリ間でコピーレスで共有するために必要な仕組みを提供します。

MemoryView が導入された背景

多次元数値配列が重要な役割を持つ時代になった

深層学習やデータサイエンスの流行にあわせて、メモリ上で大きなサイズの多次元数値配列データを処理する事例が増加しています。このような数値配列データに対する処理は、複数のライブラリの機能を組み合わせて実現されます。この分野でよく使われる Python では、データ構造を numpy と pandas が提供し、機械学習アルゴリズムを scikit-learn が提供し、統計解析の仕組みを statsmodels が提供し・・・と、挙げればきりがない程多様なライブラリ群で構成されるエコシステムが確立しています。これら複数のライブラリを適宜組み合わせて目的の処理を実現するわけですが、そうすると数値配列データがライブラリ間でやり取りされる状況が自然と発生します。

東京 RubyKaigi 11 での私の公演

2016年に開催された東京 RubyKaigi 11で、当時私が趣味で作っていた IMF という画像操作ライブラリの開発の過程で経験した苦労について話しました *1。その苦労の原因は、画像のような多次元数値配列データを複数のライブラリ間で共有する仕組みが無いことでした。この講演のデモでは、TensorFlow の簡易バインディングと IMF の間でデータを共有するための仕組みを実装して紹介しました。しかし、実際にやりたかったことは IMF、RMagick、numo-narray の3者間でのデータ共有でした。データ共有の仕組みを個別に対応する場合、2者間より3者間の方が大変です。そして、対応するライブラリ数を n とすると、個別対応の場合に発生する共有経路数は O(n^2) のオーダーで増加することになります。一方、データ共有のためのハブとなる仕組みがあれば、経路数は n で済みます。

Python の Buffer Protocol 相当の機能リクエストがいくつか提出されていた

その後、2017年と2018年にそれぞれ1件ずつ Python の Buffer Protocol 相当の機能が欲しいという機能リクエストが提出されました。

  • [Feature #14722] python's buffer protocol clone
  • [Feature #13767] add something like python's buffer protocol to share memory between different narray like classes

Python の Buffer Protocol は、複数のライブラリ間で数値配列データをコピーレスで共有するための仕組みです。データ解析でよく使われる Python にはそのような仕組みがすでに備わっていて、異なるライブラリ間で無駄なくデータをやり取りできます。

Ruby 3.0 に導入された MemoryView は、2015年から2016年に私が経験したこととこれらの機能リクエストを踏まえて、Python の Buffer Protocol を参考に私が Ruby 向けに設計と実装を行ったものです。

MemoryView を使って実現できること

コピーレスで多次元数値配列を共有できる

MemoryView は、数値配列データの先頭アドレスと、数値配列の次元数や要素型などの性質を記述するメタデータを持っています。このアドレスとメタデータの組み合わせをライブラリ間でやり取りすることで、多次元数値配列をコピーレスで共有できます。

構造体の配列も扱える

MemoryView は、Array#packString#unpack で使うフォーマットとほぼ同じフォーマット指定に対応しています。このフォーマット指定によって、1要素内に複数の成分を持つ構造体の配列も扱えます。

Fiddle を使えば Ruby レイヤからもアクセスできる

Ruby には Fiddle という libffi のバインディングが標準添付されています。この Fiddle に Fiddle::MemoryView という rb_memory_view_t 構造体のラッパークラスを追加しました。これを使えば、MemoryView をエクスポートできるオブジェクトの内部データに Ruby レイヤからもアクセスできます。これは、MemoryView をエクスポートするオブジェクトのデバッグやテストで使うことになると思います。

MemoryView がやらないこと

個別のライブラリの対応

MemoryView はデータを共有するための仕組みを提供するだけなので、ライブラリ間で実際にデータを共有するためには個々のライブラリが MemoryView に対応していなければなりません。

複雑なデータ構造への対応

MemoryView は、型が均一で固定サイズ要素の配列のエクスポートに対応しています。そうではないメモリ上のデータ、たとえばデータフレームや木構造のデータは扱えません。

MemoryView の使い方

オブジェクトが持つメモリ上のデータを MemoryView としてエクスポートする方法を見ていきましょう。

MemoryView に対応しているクラスとして登録する

MemoryView をエクスポートする仕組みは、クラス単位で定義します。 rb_memory_view_register 関数にクラスと MemoryView エントリ (rb_memory_view_entry_t 構造体) を渡して、MemoryView のエクスポートに対応するクラスであることを登録します。

typedef bool (* rb_memory_view_get_func_t)(VALUE obj, rb_memory_view_t *view, int flags);
typedef bool (* rb_memory_view_release_func_t)(VALUE obj, rb_memory_view_t *view);
typedef bool (* rb_memory_view_available_p_func_t)(VALUE obj);

typedef struct {
    rb_memory_view_get_func_t get_func;
    rb_memory_view_release_func_t release_func;
    rb_memory_view_available_p_func_t available_p_func;
} rb_memory_view_entry_t;

MemoryView エントリは get_func 関数、release_func 関数、available_p_func 関数の3つの関数ポインタから構成されます。それぞれ、MemoryView をエクスポートする際に呼ばれる関数、MemoryView を解放する際に呼ばれる関数、オブジェクトが MemoryView のエクスポートに対応しているかどうか調べる際に呼び出される関数を指定します。それぞれの関数の役割は以下の項で説明します。

オブジェクトの MemoryView を取得する

オブジェクトが MemoryView に対応していることを確認できたら、MemoryView を取得してオブジェクト内のデータに直接アクセスしましょう。オブジェクトの MemoryView は rb_memory_view_get 関数で取得します。この関数は、第1引数で受け取ったオブジェクトのクラスが MemoryView 対応クラスとして登録されていたら、受け取った3つの引数をそのまま渡す形式で MemoryView エントリの get_func 関数を呼び出します。MemoryView のエクスポート処理の詳細は次の項で説明します。

MemoryView の取得に成功すると、第2引数で渡すポインタが指す構造体に MemoryView の情報がセットされ、戻り値として true が返されます。失敗すると false が返されます。

MemoryView をエクスポートしているオブジェクトが GC に回収されてしまうと困るので、rb_memory_view_get 関数はオブジェクトが GC に回収されないようにガードする処理も行います。

MemoryView をエクスポートする

MemoryView のエクスポート処理は MemoryView エントリの get_func 関数が行います。この関数は第1引数に MemoryView を取得したいオブジェクト、第2引数に rb_memory_view_t 構造体のポインタ、第3引数にフラグをとります。

第3引数のフラグは、取得したい MemoryView の性質を指定するために使います。フラグは enum ruby_memory_view_flags として定義されている列挙定数をビット OR で組み合わせて指定できます。

enum ruby_memory_view_flags {
    RUBY_MEMORY_VIEW_SIMPLE            = 0,
    RUBY_MEMORY_VIEW_WRITABLE          = (1<<0),
    RUBY_MEMORY_VIEW_FORMAT            = (1<<1),
    RUBY_MEMORY_VIEW_MULTI_DIMENSIONAL = (1<<2),
    RUBY_MEMORY_VIEW_STRIDES           = (1<<3) | RUBY_MEMORY_VIEW_MULTI_DIMENSIONAL,
    RUBY_MEMORY_VIEW_ROW_MAJOR         = (1<<4) | RUBY_MEMORY_VIEW_STRIDES,
    RUBY_MEMORY_VIEW_COLUMN_MAJOR      = (1<<5) | RUBY_MEMORY_VIEW_STRIDES,
    RUBY_MEMORY_VIEW_ANY_CONTIGUOUS    = RUBY_MEMORY_VIEW_ROW_MAJOR | RUBY_MEMORY_VIEW_COLUMN_MAJOR,
    RUBY_MEMORY_VIEW_INDIRECT          = (1<<6) | RUBY_MEMORY_VIEW_STRIDES,
};

たとえば RUBY_MEMORY_VIEW_COLUMN_MAJOR を指定した場合、もしオブジェクトの内部データが行指向の順序で値を持っていたら get_func 関数は MemoryView をエクスポートできずに失敗することになります。

get_func 関数は、第2引数で指定されたポインタが指す構造体を適切に初期化する責務を持ちます。この構造体は次のように定義されています。

typedef struct {
    /* The original object that has the memory exported via this memory view.
     * The consumer of this memory view has the responsibility to call rb_gc_mark
     * for preventing this obj collected by GC.  */
    VALUE obj;

    /* The pointer to the exported memory. */
    void *data;

    /* The number of bytes in data. */
    ssize_t byte_size;

    /* true for readonly memory, false for writable memory. */
    bool readonly;

    /* A string to describe the format of an element, or NULL for unsigned bytes.
     * The format string is a sequence of the following pack-template specifiers:
     *
     *   c, C, s, s!, S, S!, n, v, i, i!, I, I!, l, l!, L, L!,
     *   N, V, f, e, g, q, q!, Q, Q!, d, E, G, j, J, x
     *
     * For example, "dd" for an element that consists of two double values,
     * and "CCC" for an element that consists of three bytes, such as
     * an RGB color triplet.
     *
     * Also, the value endianness can be explicitly specified by '<' or '>'
     * following a value type specifier.
     *
     * The items are packed contiguously.  When you emulate the alignment of
     * structure members, put '|' at the beginning of the format string,
     * like "|iqc".  On x86_64 Linux ABI, the size of the item by this format
     * is 24 bytes instead of 13 bytes.
     */
    const char *format;

    /* The number of bytes in each element.
     * item_size should equal to rb_memory_view_item_size_from_format(format). */
    ssize_t item_size;

    struct {
        /* The array of rb_memory_view_item_component_t that describes the
         * item structure.  rb_memory_view_prepare_item_desc and
         * rb_memory_view_get_item allocate this memory if needed,
         * and rb_memory_view_release frees it. */
        const rb_memory_view_item_component_t *components;

        /* The number of components in an item. */
        size_t length;
    } item_desc;

    /* The number of dimension. */
    ssize_t ndim;

    /* ndim size array indicating the number of elements in each dimension.
     * This can be NULL when ndim == 1. */
    const ssize_t *shape;

    /* ndim size array indicating the number of bytes to skip to go to the
     * next element in each dimension. */
    const ssize_t *strides;

    /* The offset in each dimension when this memory view exposes a nested array.
     * Or, NULL when this memory view exposes a flat array. */
    const ssize_t *sub_offsets;

    /* the private data for managing this exported memory */
    void *const private;
} rb_memory_view_t;

obj にはエクスポートする MemoryView を持つオブジェクトを入れておきます。数値配列のビューのように、他のオブジェクトの内部データを間接参照しているオブジェクトが MemoryView をエクスポートする場合は、ビューオブジェクトの方を obj に入れておくべきです。

datalen は、それぞれ、エクスポート対象のメモリ領域の先頭アドレスと長さ (バイト数) です。このメモリ領域に書き込まれると困る場合は readonlytrue を入れておきましょう。たとえば、freeze されているオブジェクトの内部データを MemoryView としてエクスポートする場合は readonlytrue にすべきです。

format は、1要素を構成する成分の型を文字列で記述します。このとき、pack フォーマット書式とほぼ同じ方式で複合型の各成分の型を記述できます。1要素のサイズは item_size で指定します。

item_desc は要素の成分の情報を詳細に記述する配列です。各成分の情報は rb_memory_view_item_component_t 構造体で記述されます。

typedef struct {
    char format;
    unsigned native_size_p: 1;
    unsigned little_endian_p: 1;
    size_t offset;
    size_t size;
    size_t repeat;
} rb_memory_view_item_component_t;

item_desc.components は、その構造体の配列で、item_desc.length にその配列の長さが入ります。 item_desc は必要になったときに自動で初期化されるのですが、MemoryView をエクスポートするときに初期化する場合は format に適切なフォーマット文字列を指定してから rb_memory_view_prepare_item_desc 関数を呼び出します。予め初期化しない場合は、item_desc.componentsNULL を、item_desc.length に 0 を入れます。

エクスポートするメモリ領域が多次元配列である場合は、ndimshapestrides を適切に初期化する必要があります。

ndim には配列の次元数を入れます。多次元配列じゃない場合は 1 を入れましょう。

shape には、各次元の要素数を入れます。strides には、各次元について、次の要素までのバイト数を入れます。shape の要素はすべて0以上になっている必要がありますが、strides の要素は負の数も許されます。

sub_offsets は、エクスポート対象のメモリ領域がポインタのポインタ形式で作られた入れ子の配列である場合に使います。詳しい使い方は、後日 Ruby 3.0 に同梱される予定の MemoryView のドキュメント*2を参照してください。

shapestridessub_offsetsを使わない場合は NULL を入れておきましょう。使う場合は、個々の MemoryView について新たにメモリを割り当てて使うほうがよいでしょう。

最後に残った private は、MemoryView をエクスポートするライブラリが自由にポインタをセットできます。MemoryView をエクスポートするために必要な追加情報を保持するために利用できます。

エクスポートされた MemoryView を使う

自由に使ってください。ただし、エクスポートされる MemoryView を使う側では、rb_memory_view_t 構造体の各フィールドを書き換えてはいけません。readonlyfalse の場合に限り、data が指すメモリ領域の中を書き換えられます。

前項でも説明したとおり strides の要素は負の数になる場合があります。MemoryView を使う側は strides の要素が負の場合もあることを想定しておく必要があります。

多次元配列の要素の位置や要素そのものを取得するためのユーティリティとして rb_memory_view_get_item_pointer 関数と rb_memory_view_get_item 関数を提供しています。

取得した MemoryView を解放する

使い終わった MemoryView は rb_memory_view_release 関数に渡して解放する必要があります。この関数は rb_memory_view_t 構造体のポインタを引数にとります。この構造体の obj メンバに入ってるオブジェクトのクラスから MemoryView エントリを取得し、release_func 関数を呼び出します。

MemoryView の解放処理を実行する

release_func 関数を提供するライブラリは、rb_memory_view_t 構造体を適切に後始末する責務を持ちます。具体的には、shapestridessub_offsetsprivate のために割り当てたメモリ領域の解放が必要になるでしょう ((const 修飾子がついているので解放時に適切にキャストしないと警告が出てしまいます。ご注意ください。))。item_descrb_memory_view_release 関数が解放するので触らないでおきましょう。

オブジェクトが MemoryView のエクスポートに対応しているかどうか確認する

オブジェクトの MemoryView を取得する必要はないが、MemoryView のエクスポートに対応しているかどうかを確認したい場合もあるでしょう。そのような場合は rb_memory_view_available_p 関数を使います。

rb_memory_view_available_p 関数にオブジェクトを渡すと、そのオブジェクトが MemoryView のエクスポートに対応しているかどうかが分かります。対応している場合は 1 が返され、そうでない場合は 0 が返されます。

if (!rb_memory_view_available_p(obj)) {
    // オブジェクト obj が MemoryView に対応している場合の処理
}
else {
    // オブジェクト obj が MemoryView に対応していない場合の処理
}

オブジェクトが MemoryView のエクスポートに対応しているかどうか報告する

rb_memory_view_available_p 関数は、引数で与えられたオブジェクトのクラスから rb_memory_view_entry_t 構造体のポインタを取り出し、引数で与えられたオブジェクトを available_p_func 関数に渡します。 available_p_func 関数は、MemoryView をエクスポートするライブラリ側が適切に実装する必要があります。与えられたオブジェクトが MemoryView のエクスポートに対応している場合に 1 を返し、そうでない場合に 0 を返すようにします。

MemoryView の使用例と今後の展開

いまのところ MemoryView の使用例として参考にできるコードは、テストのために実装された拡張ライブラリ、Fiddle::PointerFiddle::MemoryView の3つだけが存在しています。それぞれ次の場所で参照できます。

現在、numo-narray を MemoryView に対応させる作業を進めています。その後は Red Arrow、RMagick など対応ライブラリを増やしていく予定です。

まとめ

MemoryView という、メモリ上の数値配列を複数の拡張ライブラリ間で共有するための仕組みを設計・実装し、Ruby 3.0 に導入しました。

これはまだ実験的な機能という位置づけなので、どんどん使って機能の不足や仕様バグを見つけて修正していきたいです。そして、この仕組みが Ruby の将来の発展に寄与するといいなぁと思っています。

*1:その後個人的な需要がなくなったため開発は止まっています・・・

*2:これから頑張って書きます・・・