Rubyの標準添付ライブラリーのcsvをメンテナンスしている須藤です。

歴史

csvは名前の通りCSVを読み書きするための便利ライブラリーです。

もともとRuby本体とは別に開発されていたのですが、Ruby 1.8.0のときにRuby本体にバンドルするようになりました。dRubyやREXMLがRuby本体にバンドルされたのも同じタイミングです。Ruby 1.8.0のときにバンドルするライブラリーをすごく増やしたのです。（その頃の様子がわかるURLをここに置いておきたかったけど見つけられなかった。。。）

Rubyではcsvのように require するだけで使えるライブラリーを「標準添付ライブラリー」と呼んでいます。 String のように require しなくても使えるライブラリーは。。。なんだろう。組み込みクラスかしら。

その後、Ruby 1.9.0のタイミングで実装をFasterCSVに置き換えました。FasterCSVは名前の通りもともとのcsvよりも速いライブラリーです。もともとのcsvもFasterCSVもRubyだけで実装してあり、Cを使っていません。Rubyで実装したCSVライブラリーでは最速です。今のcsv（FasterCSVベースのcsv）よりも速いといっているCSVライブラリーはCを使っているはずです。

そんなcsvをさらに速くしたものがRuby 2.6.0に入っています。

FasterCSV実装がなぜ速いか

FasterCSVがなぜ速いかというと各行を line.split(",") でパースしているからです。 String#split はCで実装されているので速いのです。

ただ、世の中には line.split(",") でパースできないCSVがたくさんあります。たとえば、次のようなCSVです。

a,"b,c",d

このCSVではダブルクォートで囲んでいる中にコンマがあるので line.split(",") では次のようにパースしてしまいます。

[ "a" , " \" b" , "c \" " , "d" , ]

このようなケースにも対応するために、FasterCSVは line.split(",") した後の各要素のダブルクォートの数を数えます。ダブルクォートの数が偶数ならダブルクォートの対応が取れていて、奇数なら取れていないというわけです。ダブルクォートの対応が取れていない場合は後続する要素と連結します。

このようにして速さを維持したまま複雑なCSVもパースできるようになっています。ただ、複雑なCSVをパースするときは速度が落ちてしまいます。次の表はcsvが使っているベンチマークを使ったパース性能の計測結果です。複雑になるほど性能が落ちている（単位時間あたりでのパース回数が減っている）ことがわかります。

100msでのパース回数 ダブルクォートなし 373 ダブルクォートあり 207 ダブルクォート中にコンマあり 140 ダブルクォート中に改行あり 82

Ruby 2.6.0に入っているcsvでは次のようになります。「ダブルクォートあり」の場合は少し性能が落ちています（207から194に減っている）が、ダブルクォート内が複雑になっても「ダブルクォートあり」と性能が変わりません。（「コンマあり」と「改行あり」が193と192で194とほとんど変わらない。）「ダブルクォートなし」の場合は少し性能があがっています。（373から401に増えている）

100msでのパース回数 ダブルクォートなし 401 ダブルクォートあり 194 ダブルクォート中にコンマあり 193 ダブルクォート中に改行あり 192

Ruby 2.6.0のcsvがなぜ速いか

「最速」だったcsvがどうやってさらに速くなったかというと StringScanner を使うようになったからです。 StringScanner は標準添付ライブラリーの1つで、正規表現を使って高速に文字列をスキャンできます。

ただ、単に StringScanner を使っても「最速」だったcsvよりも速くはなりません。 line.split(",") は強敵です。@284kmが取り組んだ、まず line.split(",") を StringScanner に置き換えるpull requestでも全体的に遅くなっています。ただ、これでも高速にするための工夫をした後の結果です。Red Data Toolsの開発の集まりなどで@284kmと一緒に高速にするための書き方を模索していました。その結果、次の知見を得ました。

どの正規表現を使ってどの順番でスキャンするかが重要

StringScanner を使ったコードは次のようなコードになります。ポイントは「次はこういう値が来るはず、来なかったらエラー」というのをつなげていくところです。

row = [] scanner = StringScanner . new ( line ) column_value = scanner . scan ( /[^",\r

]+/ ) # カラムの値 raise "no column value" unless column_value row << column_value raise "no comma" unless scanner . scan ( /,/ ) # カラムの区切り文字（コンマ） column_value = scanner . scan ( /[^",\r

]+/ ) # 次のカラムの値 raise "no column value" unless column_value row << column_value raise "no comma" unless scanner . scan ( /,/ ) # カラムの区切り文字（コンマ） raise "extra data" unless scanner . eos? # すべてのデータを使ったか p row

CSVのように複雑なものだと、「次はこういう値が来るはず、来なかったら別のこの値なはず」というようにフォールバックしていきます。たとえば、「ダブルクォートで囲まれていない値があるはず、なかったらダブルクォートで囲まれた値のはず」といった具合です。

line.split(",") を超える性能を出すためにはフォールバックをいかに減らすかが大事になります。フォールバックのオーバーヘッドがあると line.split(",") に負けてしまうのです。

フォールバックを減らすには「次はこういう値が来るはず」ができるだけ当たるような順番にします。カラムの値の次はコンマがきやすいので、次の2つでは後者の方がフォールバックの回数が減ります。

カラムの値もコンマも並列に扱う（フォールバックが多い）：

row = [] column_value = nil until scanner . eos? if scanner . scan ( /[^",\r

]+/ ) # カラムの値 column_value = scanner . matched elsif scanner . scan ( /,/ ) # コンマ row << column_value column_value = nil else raise "invalid" end end row << column_value if column_value p row

カラムの値の後はコンマがくるはず（フォールバックが少ない）：

row = [] until scanner . eos? if ( column_value = scanner . scan ( /[^",\r

]+/ )) # カラムの値 if scanner . scan ( /,/ ) or scanner . eos? row << column_value else raise "no comma" end else raise "invalid" end end p row

line.split(",") に勝つには正規表現のマッチ回数をいかに減らすかを頑張る必要があります。これが基本的なコンセプトです。それではさらに具体的な方法を説明していきます。

行ごとの処理をやめる

line.split(",") ベースのアプローチでは次のようにダブルクォート中が複雑になる処理で性能劣化が大きかったです。

100msでのパース回数 ダブルクォートあり 207 ダブルクォート中にコンマあり 140 ダブルクォート中に改行あり 82

これを解決するために行に分割してから処理することをやめました。

行に分割せずに、ダブルクォート中がどうなっていても（たとえば改行文字を含んでいても）統一的に処理することで性能劣化を防ぎました。

100msでのパース回数 ダブルクォートあり 194 ダブルクォート中にコンマあり 193 ダブルクォート中に改行あり 192

これが一番大変でした。というのは、パースするロジックをすべて StringScanner らしく書き換える必要があるからです。

書き換えた後は次のようなコードになりました。すっきりですね。

row = [] while true value = scanner . scan ( /[^",\r

]+/ ) if scanner . scan ( /,/ ) row << value elsif scanner . scan ( /\r

/ ) row << value p row row = [] elsif scanner . eos? row << value p row return else raise "invalid" end end

これでダブルクォートを使っていても性能劣化しなくまりました。（ダブルクォート中に改行がある方が速くなっているのはなぜだ。。。）

100msでのパース回数 ダブルクォートあり 165 ダブルクォート中にコンマあり 160 ダブルクォート中に改行あり 187

これを実現することによりコードをメンテナンスしやすくなり、最適化や機能追加をしやすくなりました。 line.split(",") ベースのコードも200行未満の実装なのでそんなに長すぎるわけではないのですが、状態が多くて適切な場所に適切な処理を入れるのが難しかったのです。

以前と同じくらいの性能にできれば StringScanner ベースのパーサーで開発を進められます。

loop を while true にする

性能改善の大きなポイントは正規表現のマッチ回数を減らすことですが、それ以外の部分でも少しずつ性能改善できます。その1つでやりやすいものが loop do ... end ではなく while true ... end でループするようにすることです。

Rubyを使っている場合は do ... end を使いたいので私は普段は次のようにループを書きます。

loop do # ... end

しかし、今回のように性能改善したいケースでは次のように while true ... end を使った方が高速です。これは loop だと do ... end の中でスコープが変わるのでその準備をしないといけないのに対し、 while はスコープが変わらないのでその準備が必要ないからです。

while true # ... end

csvのケースでは while true の方が15%ほど高速です。

100msでのパース回数：

ダブルクォートなし ダブルクォートあり loop 377 166 while 401 192

string[start...end] を string[start, end - start] にする

String には文字列データの一部を共有する機能があるため、既存の String の一部で必要な String を作れる場合は共有機能を使うことで高速になります。

文字列データを共有するには String#[] を使います。 String#[] は便利なメソッドでいろんな引数を受けつけます。たとえば、次の2つは同じ結果を返します。

string [ 1 ... 6 ] string [ 1 , 5 ]

ただし、 string[1, 5] の方が速いです。これは、引数が2つの場合は特別扱いされているためです。

csvの場合、 string[1, 5] のスタイルを使った場合の性能改善の度合いは軽微です。

100msでのパース回数 string[1...6] 405 string[1, 5] 409

必要になるまで処理を遅らせる

みなさんは CSV#line というメソッドがあるのを知っていますか？私は知りませんでした。このメソッドは最後に処理した行そのもの（パース前の行）を返します。普通はパース結果だけを使うので、この処理のために通常のパース処理が遅くなるのは微妙です。そのため、このための情報を必要になったときにはじめて取得するように変更しました。

100msでのパース回数 #line 用のデータを逐次処理 409 #line 用のデータを遅延処理 416

微妙に速くなっています。

通常は必要ない処理を必要になるまで処理しないことによる高速化はCSVの書き込み処理で顕著です。

CSV はCSVを読み書きできるのでインスタンスを作るときに読み書き両方用の初期化をしていました。そのため、CSVを読むだけ、書くだけのときに余計な処理をしていました。

この読む用の初期化・書く用の初期化を必要になるまで遅延するようにしました。これにより、読むだけのときは書く用の初期化を一切しなくなり、高速になります。

以下は書き込み処理のベンチマーク結果です。

100msでの処理回数：

CSV.generate_line CSV#<< 読む用の初期化を毎回実行 350 2177 読む用の初期化を遅延実行 850 3506

2倍ほど速くなっています。 CSV.generate_line は CSV オブジェクトを作らずに1行生成する便利機能ですが、たくさんの行を生成する時は CSV オブジェクトを作った方が高速です。これは、 CSV.generate_line の場合は1行生成する度に書く用の初期化を毎回しなければいけないためです。

つまり、次のように書くのは遅いということです。

rows . each | row | puts CSV . generate_line ( row ) end

それよりは次のように書いた方が速いです。

output = "" csv = CSV . new ( output ) rows . each | row | csv << row end puts output

String#each_char ではなく String#index を使う

読む用の初期化時に改行文字を自動検出しているのですが、そこも高速化できた処理でした。

従来は String#each_char で一文字ずつ確認していたのですが、そこを String#index を使って書き換えました。次のような感じです。

cr_index = sample . index ( " \r " ) lf_index = sample . index ( "

" ) if cr_index and lf_index if cr_index + 1 == lf_index " \r

" elsif cr_index < lf_index " \r " else "

" end elsif cr_index " \r " elsif lf_index "

" else :auto end

String#index を使うとCレベルで文字チェックをできるので速くなりました。（後でどのくらい速くなったか追記できるといいな。）

なんか野暮ったいコードなのでもう少しシュッとできるといいですね。

効果がなかった高速化

これで速くなるんじゃないかと試したものの逆に遅くなったアイディアもありました。

特化メソッドを持つモジュールを extend

csvにはまじめにパースするモードとゆるくパースするモードがあります。従来はメソッド内で if で分岐していました。インスタンス作成時にモードにあわせたモジュールを extend してパース時はメソッド内の if を減らすと速くなるのではないか、という案です。こんな感じです。

module StrictMode def parse # ... end end module LiberalMode def parse # ... end end class CSV :: Parser def initialize ( options ) if @options [ :liberal_mode ] extend LiberalMode else extend StrictMode end end end CSV :: Parser . new ( :liberal_mode ). parse # LiberalMode#parse

実際にやってみてところむしろ遅くなりました。メソッド内で if で分岐する方が速かったです。

さらに速いCSVパーサー

csvはRubyレベルで実装してあるCSVパーサーでは最速です。さらに速くするにはCで実装する必要があります。たとえば、Cを使っているfastest-csvはcsvよりも数倍高速です。

100msでのパース回数 csv 16 fastest-csv 76

なお、私のオススメはApache Arrowです。Apache ArrowはCSV用のライブラリーではありませんが、CSVパーサーもついています。Apache ArrowのCSVパーサーを使うとfastest-csvよりもさらに数倍高速です。

100msでのパース回数 csv 16 fastest-csv 76 Apache Arrow 223

使い方も簡単です。次のコードでCSVをロードできます。

require "arrow" Arrow :: Table . load ( "/tmp/a.csv" )

参考：Apache Arrowの最新情報（2018年9月版）

今後

コードを整理でき、最適化・機能拡張の準備ができました。たとえば、次のような改良をしていきたいです。興味がある人は一緒に開発しましょう。

バックスラッシュでダブルクォートをエスケープ #61

クォート文字を指定しなかったら line.split(",") を使う高速化 #56

を使う高速化 #56 ヘッダーがあるときの高速化 #59

まとめ

Ruby 2.6.0にあわせてcsvのコードを整理して高速化しました。より開発しやすいコードベースになったので一緒に開発していきましょう。

リリース直前にいろいろ変更をぶちこんでごめんなさい。