アペフチ

RubyでのApache Arrowの使い方(Parquetもあるよ)

Apache Arrow、名前は知ってたけど縁遠いなあと思っていた。最近CSV処理するスクリプト書いてて、そういやデータフォーマットでArrowってあったなっと思って、ちょっと触ってみたのでした。

結論、「正しく使えば」、速くなる。

Apache Arrow

まだよく分かってないんだけど、Apache Arrowは新しいデータフォーマット。

  • インメモリーでデータを扱う時に最適化されたフォーマット

  • 言語やプロセス間でのデータ交換を高速にするのを目標としている

  • 列指向のフォーマット

という特徴を持っているらしい。PythonのPandasの開発者も開発の中心的人物みたい。詳しくは公式サイトで: https://arrow.apache.org/

Arrowでのファイル読み込み

RubyでArrow形式のデータを扱うにはRed Arrowを使う。これはArrow形式のファイル[1]の他、CSVやParquet形式のファイルも読み込めます。

require "arrow"

table = Arrow::Table.load("sample-data.csv")

という感じ。

CSV、Arrow、Parquetのベンチマーク

「列指向」ってことだから(CSVは反対に行指向フォーマット)、一行一行を舐めるような操作は遅いのかな、とか思ったりしつつも、ほんとのところどうなんだろうとベンチマークを取ってみました。

環境

  • Ubuntu 18.04 on WSL

  • Ruby 2.6.5

  • CSV 3.1.2

  • Red Arrow 0.15.1

  • CPUコア 8個

WSLはIOが遅いことで有名なのでその辺は差し引いてください。

対象ファイル

% xsv count sample-data.csv
82141
% ruby -rarrow -rparquet -e 't = Arrow::Table.load("sample-data.csv"); t.save("sample-data.arrow"); t.save("sample-data.parquet")'
% ls -lh sample-data.*
Permissions Size User          Date Modified Name
.rw-r--r--   95M kitaitimakoto 20 Dec 18:28  sample-data.arrow
.rw-rw-rw-   61M kitaitimakoto 20 Dec 18:27  sample-data.csv
.rw-r--r--   33M kitaitimakoto 20 Dec 18:28  sample-data.parquet

というわけで、

  • CSV 61MiB、Arrow 85MiB、Parquet 33MiBのファイルサイズ

  • 8万行ちょっとのデータ量

のファイルを元にベンチマークを取ってみました。

余談だけど、CSVファイルを調べるならxsvはすごく便利なコマンドなので是非入れよう。

ベンチマーク

後でスクリプトも置くけど、「ある列(数値)の合計値を計算する」という処理を行っています。

  • 最初がRuby標準添付のCSVライブラリーで処理を行った結果、

  • 次がFastestCSVっていうCで実装したgemでの結果、

  • 三番目がRed ArrowのCSV読み込み機能でCSVファイルを読み込んだ時の結果、

  • 以降三つがRed Arrowでの各メソッドでの処理結果、

  • 以降三つがRed Parquestでの同様の結果、

となっている。

% ruby ./arrowbenchmark.rb
(snip)
user     system      total        real
CSV(Ruby 標準添付CSVライブラリー)            850541456
24.625000   0.625000  25.250000 ( 24.844013)
CSV(FastestCSV RubyGem)            850541456
1.343750   0.171875   1.515625 (  1.507318)
CSV(Red Arrow each_record_batch)   850541456
11.609375   4.328125  15.937500 ( 10.657816)
CSV(Red Arrow each_record)         850541456
68.671875   3.078125  71.750000 ( 67.946809)
CSV(Red Arrow 対象カラムだけ each) 850541456
4.281250   2.781250   7.062500 (  2.939687)
Arrow(each_record_batch)           850541456
10.390625   0.125000  10.515625 ( 10.410525)
Arrow(each_record)                 850541456
68.359375   0.078125  68.437500 ( 68.751072)
Arrow(対象カラムだけ each)         850541456
2.203125   0.000000   2.203125 (  2.236634)
Parquet(each_record_batch)         850541456
11.125000   1.968750  13.093750 ( 10.353967)
Parquet(each_record)               850541456
12.828125   1.093750  13.921875 ( 12.133342)
Parquet(対象カラムだけ each)       850541456
4.031250   0.781250   4.812500 (  2.798203)

これ見て分かるのは、

  • 「正しく使えば」 FastestCSV > Arrow > Parquet > CSV の順で速い

  • Arrowで行操作すると遅い

  • 特に each_record はピュアRubyのCSVより遅いぐらいなので必ず each_record_batch 使うこと

  • 可能なら対象カラムだけ取り出して( find_column )操作するべし

ということかな。

追記。「更に正しく使えば」Arrowが圧倒的に速かった。こちらの記事参照:RubyでのCSV処理はApache Arrowが速い

後掲のスクリプト見ると分かるように、取り出したデータに対して to_i を読んでいる。CSVを読み込んで何もせずArrowやParquet形式で保存しているので、対象カラムが文字列のままだからだ。

本当は、ここをuint32とかuint64とかにして保存してやるとさらに高速化できるんだろうなと思いつつ、Arrowのテーブルからそういう別のテーブルを作る方法がいまいち分からないというか、気軽にベンチマークとる上ではめんどそうなので今日はやめました。仕事で使っててどうにもパフォーマンスが欲しいとなったら挑戦するかも。あと何となく時間できた時とか。

ベンチマークスクリプトはこちら:

require "benchmark"
require "csv"
require "fastest-csv"
require "arrow"
require "parquet"

CSVFILE = "sample-data.csv"
ARROWFILE = "sample-data.arrow"
PARQUETFILE = "sample-data.parquet"

Benchmark.bmbm do |x|
  x.report "CSV(Ruby 標準添付CSVライブラリー)" do
    amount = 0
    CSV.foreach CSVFILE, headers: true do |row|
      amount += row[4].to_i
    end
    puts amount
  end

  x.report "CSV(FastestCSV RubyGem)" do
    amount = 0
    headers = true
    FastestCSV.foreach CSVFILE do |row|
      if headers
        headers = false
        next
      end
      amount += row[4].to_i
    end
    puts amount
  end

  x.report "CSV(Red Arrow each_record_batch)" do
    amount = 0
    Arrow::Table.load(CSVFILE).each_record_batch do |records|
      records.each do |record|
        amount += record[4].to_i
      end
    end
    puts amount
  end

  x.report "CSV(Red Arrow each_record)" do
    amount = 0
    Arrow::Table.load(CSVFILE).each_record do |record|
      amount += record[4].to_i
    end
    puts amount
  end

  x.report "CSV(Red Arrow 対象カラムだけ each)" do
    amount = 0
    Arrow::Table.load(CSVFILE).find_column(4).each do |record|
      amount += record.to_i
    end
    puts amount
  end

  x.report "Arrow(each_record_batch)" do
    amount = 0
    Arrow::Table.load(ARROWFILE).each_record_batch do |records|
      records.each do |record|

        amount += record[4].to_i
      end
    end
    puts amount
  end

  x.report "Arrow(each_record)" do
    amount = 0
    Arrow::Table.load(ARROWFILE).each_record do |record|
      amount += record[4].to_i
    end
    puts amount
  end

  x.report "Arrow(対象カラムだけ each)" do
    amount = 0
    Arrow::Table.load(ARROWFILE).find_column(4).each do |record|
      amount += record.to_i
    end
    puts amount
  end

  x.report "Parquet(each_record_batch)" do
    amount = 0
    Arrow::Table.load(PARQUETFILE).each_record_batch do |records|
      records.each do |record|
        amount += record[4].to_i
      end
    end
    puts amount
  end

  x.report "Parquet(each_record)" do
    amount = 0
    Arrow::Table.load(PARQUETFILE).each_record do |record|
      amount += record[4].to_i
    end
    puts amount
  end

  x.report "Parquet(対象カラムだけ each)" do
    amount = 0
    Arrow::Table.load(PARQUETFILE).find_column(4).each do |record|
      amount += record.to_i
    end
    puts amount
  end
end

1. Arrowはそのままの形式で(?)ファイルにも保存できるみたい

Rustのライフタイムが(やっぱり)分からない

ずっと何もしてなかったのだけどなんかフラストレーション溜まってきたのでRustを何でもいいからやろうかあ! って気分になって、ダイクストラ法を実装してみようかと思った。

取り合えず、Rust 2018のモジュールの仕組みを思い出しながら(Rustのモジュールの使い方 2018 Edition版 | κeenのHappy Hacκing Blog)モジュールとしてダイクストラ法をやる場所を作って、それから部品としてのグラフ、ノード、エッジを作ろうと思った。

のだけど、それがもううまくいかない。現行のソースコードは src/dijkstra.rs なんだけど、 cargo clippy で怒られてしまう。

struct Edge<'a> {
    dest: &'a Node<'a>,
    cost: usize,
}

struct Node<'a> {
    edges: Vec<&'a Edge<'a>>,
    solved: bool,
    label: &'a str,
}

impl Node<'_> {
    fn new<'a>(label: &'a str) -> Node<'a> {
        Node {
            edges: Vec::new(),
            solved: false,
            label,
        }
    }

    fn add_edge_to<'a>(&self, dest: &'a Node, cost: usize) {
        let edge = &Edge {dest, cost};
        self.edges.push(&edge);
    }
}

struct Graph<'a> {
    nodes: Vec<&'a Node<'a>>,
}

pub fn run() -> Result<(), ()> {
    Ok(())
}
% cargo clippy
    Checking rust-algorithm v0.1.0 (/home/kitaitimakoto/src/gitlab.com/KitaitiMakoto/rust-algorithm)
error[E0495]: cannot infer an appropriate lifetime for lifetime parameter `'a` due to conflicting requirements
  --> src/dijkstra.rs:22:21
   |
22 |         let edge = &Edge {dest, cost};
   |                     ^^^^
   |
note: first, the lifetime cannot outlive the lifetime 'a as defined on the method body at 21:20...
  --> src/dijkstra.rs:21:20
   |
21 |     fn add_edge_to<'a>(&self, dest: &'a Node, cost: usize) {
   |                    ^^
note: ...so that reference does not outlive borrowed content
  --> src/dijkstra.rs:22:27
   |
22 |         let edge = &Edge {dest, cost};
   |                           ^^^^
note: but, the lifetime must be valid for the lifetime '_ as defined on the impl at 12:11...
  --> src/dijkstra.rs:12:11
   |
12 | impl Node<'_> {
   |           ^^
note: ...so that reference does not outlive borrowed content
  --> src/dijkstra.rs:23:25
   |
23 |         self.edges.push(&edge);
   |                         ^^^^^

error: aborting due to previous error

error: could not compile `rust-algorithm`.

To learn more, run the command again with --verbose.

こういう所でハマるのは、まさにそのために勉強しているわけなので歓迎なんだけど、それにしても分からん。なんでなんだろう……。

何か教えてくださるという親切な方がいらしたらこちらまでお願いします…… https://gitlab.com/KitaitiMakoto/rust-algorithm/issues
あと、実はこっそりSlackのrust-jpチームにもいます。

Ruby 2.6のRefinementsが使いやすい

EPUB Parser 0.4.1をリリースした( https://kitaitimakoto.gitlab.io/epub-parser/file.CHANGELOG.html#_0_4_1 )。XMLライブラリーを切り替え可能にしているのだけど、そこでOgaも使えるようにしたというリリース。

Ruby 2.7の足音も近付いている昨今ですが、このOga対応中に2.6のRefinementsの使いやすさをすごく実感したので今日はそのことを話したい。

Refinementの用途 - ライブラリー間の差を吸収するアダプター

EPUB ParserではXMLライブラリーをREXMLOgaNokogiriから選べるようになっている。こういうのの実現は、多く、 XMLDocument::REXML のようなアダプターを作ってその中にライブラリーの詳細を隠蔽する。EPUB Parserの方からは共通の XMLDocument のAPIを使って、内部でアダプターがREXMLならREXMLのメソッド呼び出しに変換する、という方法だ。

が、EPUB Parserではそういうラッパーは用意しなかった。直接 REXML::Document とか Oga::XML::Document とかのメソッドを呼び出している。とは言っても、勿論、一々

doc =
  case @adapter
  when :Oga
    Oga.parse_xml(xml)
  when :Nokogiri
    Nokogiri.XML(xml)
  else
    REXML::Document.new(xml)
  end
xpath = "/container/rootfiles/rootfile"
rootfiles =
  case @adapter
  when :Oga
    doc.xpath(xpath)
  when :Nokogiri
    doc.xpath(xpath)
  else
    doc.each(xpath)
  end

などと条件分岐するわけではない。その代わりに用いたのがRefinement、というわけだ。

例えばEPUB Parser内でXPath式に基づいて要素を取得する場合には each_element_by_xpath というメソッドを呼び出すことにしているが、ご存じの通りどのXMLライブラリーにもこういう名前のメソッドは備わっていない。だから各ライブラリーのクラスにこのメソッドを新しく定義するのだけど、それをオープンクラスでやったり prependinclude でやると、EPUB Parserで読み込んだ状態では各XMLライブラリーに未知のメソッドが生えることになってしまう。EPUB Parserを使ってHTMLコンテンツを読み込んで、それを自分の好きなXMLライブラリーでパースすることも割とあると思うのだけど、その時に意図しないメソッドが生えているのは嫌だ。そこで活躍するのがRefinement。 prepend みたいにメソッドを定義したモジュールを読み込ませるんだけど、その読み込みは明示的に using した範囲に限られる。EPUB Parserの内部処理で using EPUB::Parser::XMLDocuemnt::Refinements とかして、そのモジュール内で each_element_by_xpath とか定義してても、ユーザーが自分のXMLライブラリーを使う時にはそんなメソッドは無くなっている。

REXMLとNokogiriの両方を使えるようにする際にこういう仕組みを導入した。今回、0.4.1のリリースでOga対応する時にもこれに則って必要な書くライブラリーを定義していった。特に困ることなく普通にRefinementを使って実装した後GitLabにプッシュするとCIが動く。そしてテストの失敗を報告してくる。手元の開発はRuby 2.6だけでやっていて、2.6用のCIでは当然成功しているんだけど、2.3 - 2.5では失敗していて、これを修正する間に「ああ、Ruby 2.6のRefinementは過去のバージョンと比べて自然に使えるようになってるんだなあ、便利だなあ」と実感した次第だ。

モジュールをrefineできるようになっている。

2.4、2.5と違い、2.3のCIではテストを開始する以前にエラーが発生している。Ruby 2.3のRefinementは、どうやらクラスしか refine できず、 refine Oga::XML::Traversal でエラーになっていた。

今ではモジュールも refine できて便利、と言うか、開発中はできることを疑わなかった。

シンボルをブロック化する時にRefinementが適用される

見出しだけだと何を言っているか分からないと思う。僕も分からない。こういうことだ。

[::Oga::XML::Document, ::Oga::XML::Node].each do |klass|
  refine klass do
    [
      [:document, ::Oga::XML::Document],
      [:element, ::Oga::XML::Element],
      [:text, ::Oga::XML::Text]
    ].each do |(type, klass)|
      define_method "#{type}?" do
        kind_of? klass
      end
    end
  end
end

こうすると、 using すると Oga.parse_xml(xml).element? とかができるようになる。のだけど、Ruby 2.6未満では次のメソッド呼び出しが失敗してしまっていた。

def root
  root_node.children.find(&:element?)
end

children がイテレートするXMLノードに element? なんてメソッドは無い」と言われてしまう。定義しているのになんで? 次のようにするとちゃんと element? メソッドでフィルタリングできる。

def root
  root_node.children.find {|child| child.element?}
end

こういうことができるのか調べようという発想すらなく、自然と

root_node.children.find(&:element?)

って書いていたので、この時にRefinementが適用されるのはRubyistにとって自然なことなんだと思う[1]。2.6はすごく自然だ。

respond_to?でもRefinementが考慮される

上の例で引き続き、

node.respond_to? :root

respond_to? を呼び出すと、2.5までは false が返って来てしまう。2.6はちゃん[2]true が返って来る。素晴らしい。


Refinementはリリースノートをざっと見るだけだとどういう意味を持つのか分からない改善とかあるけど、Ruby 2.6ではRefinementがより自然に使えるようになっていたんだなあ。2.7ではもっとよくなっているのか知ら。楽しみですね。


1. 主語がでかい。僕にとっては、です。
2. 僕にとって、ちゃんと。