ククログ

株式会社クリアコード > ククログ > Red FlatBuffers:IO::Bufferを使ったpure Ruby FlatBuffers処理系

Red FlatBuffers:IO::Bufferを使ったpure Ruby FlatBuffers処理系

これはRuby/Rails Advent Calendar 2025の9日目の記事です。

Red Data Toolsをやっている須藤です。pure RubyでApache Arrowの実装を作ることにしたのですが、Apache Arrowの実装に必要なFlatBuffersがRubyをサポートしていなかったのでそこから作っています。

FlatBuffersはパースなしでデータにアクセスできるシリアライゼーションフォーマットです。たとえば、"[10, "hello", true]"というようにJSONにシリアライズした場合は、文字列の"10"をパースして数値の10にしないとデータを使うことはできませんが、FlatBuffersではそんなことをしなくてもデータを使うことができるということです。

FlatBuffersを使う場合は、まずスキーマを定義して、そのスキーマから各種プログラミング言語のソースコードを生成します。その生成されたソースコードを使うと、対象のスキーマ向けにシリアライズされたFlatBuffersデータにアクセスできます。

ソースコードを生成するプログラムはC++で実装されているので、Rubyのソースコードを生成するモジュールをC++で実装したのですが、レビューもマージもされなそうな気がするので、pure RubyでFlatBuffersの処理系(FlatBuffersのスキーマからそれを処理するRubyのソースコードを出力するプログラム)を作ることにしました。

それがRed FlatBuffersです。Red FlatBuffersはとみたさんが紹介していたIO::Bufferを使っているので、どう使っているのかを紹介します。

ちなみに、Red FlatBuffersを作り始める直前くらいにFlatBuffers「も」処理できるUnibufが公開されていましたが、StringIO#readしてからString#unpack1するとか無駄なコピーをしていそうだったのでRed FlatBuffersを作ることにしました。

Red FlatBuffersとIO::Buffer

FlatBuffersのウリはパースなしでデータを使えることです。パースなしで使えるということは、暗黙的にゼロコピーという性質もついてきます。Rubyで「パースなしでゼロコピー」を完全に実現するのは無理なのですが、できるだけパースなしでゼロコピーにすることはできます。

たとえば、次のようにuint8_t1, 2, 3が入っているデータがあるとします。

data = "\x01\x02\x03"

これをパースもコピーせずにRubyで使えるようにしたいわけです。

残念ながらパースせずには実現できませんが、ゼロコピーは実現できます。たとえば、String#unpack1を使います。String#unpack1を使うとuint8_tのデータをパース(?デシリアライズの方がいいかも?)して数値オブジェクトにできます。

p data.unpack1("c") # => 1

対象のデータが常に先頭にあるわけではないので、そういうときはoffset:を使って対象のデータの位置を指定します。

p data.unpack1("c", offset: 1) # => 2
p data.unpack1("c", offset: 2) # => 3

offset:を使わずに次のように部分文字列を作って対象のデータを先頭にすることもできます。元の文字列の最後まで使った部分文字列なので文字列データはコピーされませんが、無駄なStringオブジェクトができてしまうのがイヤです。そのため、↑のoffset:を使ったほうがよいです。

p data.unpack1("c") # => 1
data = data[1, data.bytesize - 1] # 1..-1でもいいけど、Rangeオブジェクトを作りたくない
p data.unpack1("c") # => 2
data = data[1, data.bytesize - 1]
p data.unpack1("c") # => 3

IO::Bufferを使ってもString#unpack1(offset:)と同じように「パースはするがゼロコピーで値を参照」はできます。

IO::Buffer.for(data) do |buffer|
  p buffer.get_value(:U8, 0) # => 1
  p buffer.get_value(:U8, 1) # => 2
  p buffer.get_value(:U8, 2) # => 3
end

数値はこんな感じでゼロコピーできるのですが、残念ながら文字列はそうはいきません。たとえば、次のようにuint8_t1のあとにhelloという文字列があり、そのあとにさらにuint8_t2があるデータがあるとします。

data = "\x01hello\x02"

これは文字列を使ってもIO::Bufferを使ってもコピーが発生します。一方、パースは発生しません。

helloは短すぎるのでこの例には不適切なのですが、長い文字列にすると読みにくくなるのでhelloを使っています。短い文字列はStringオブジェクト内に埋め込まれるので、必ずコピーが発生します。しかし、長い文字列の場合でも文字列の最後まで使った部分文字列以外の場合はコピーが発生します。あと、どちらの場合でも新しいStringオブジェクトを作ってしまうので、コピーじゃないですがメモリー確保が発生します。)

data[1, data.bytesize - 2] # => "hello"
IO::Buffer.for(data) do |buffer|
  p buffer.get_string(1, buffer.size - 2) # => "hello"
end

文字列データもできるだけメモリー確保なしで実現したいのですが、どうするといいのかしら。StringではなくIO::Buffer#sliceで返すと文字列データはコピーされないですが、文字列として使うときに結局Stringにしないといけないので、微妙ですよねぇ。

Red FlatBuffersは最初はString#unpack1ベースでやっていましたが、途中からIO::Bufferを使うようにしました。IO::Bufferを使うとexperimentalだよという警告がでるのがアレなのですが、mmap()を使いたかったのでIO::Bufferベースにしました。Apache Arrowデータは大きくなる可能性が十分にあるので、mmap()してアクセスできるようにしたかったのです。

IO::Bufferを使ってmmap()するには次のようにします。

require "flatbuffers"
require "flatbuffers/generator"

File.open("/var/tmp/schema.bfbs", "rb") do |input|
  buffer = IO::Buffer.map(input, nil, 0, IO:::Buffer::READONLY)
  generator = FletBuffers::Generator.new(buffer)
  generator.generate
end

まとめ

Red FlatBuffersがIO::Bufferをどんな感じで使っているかを紹介しました。より実装の詳細を知りたい人はソースコードを参照してください。Apache Arrowのpure Ruby実装であるRed Arrow Formatでも使っているのでそちらのソースコードも参照してみてください。

Apache Arrowの方だとビットマップも効率的に扱いたいのですが、Rubyの標準機能だけだと効率的に扱う方法がないんですよねぇ。今は愚直に操作していますが、将来的にはどうにかしたい。

一緒に開発したい人はRed Data Toolsのチャットにどうぞ。

それはそうと、私となにか一緒にお仕事をしたい人はご連絡ください。