ようへいの日々精進XP

よかろうもん

tr と split を混ぜたようなコマンドラインツールを mruby で作って学んだことのいくつか

tl;dr

職場のエンジニアではない方から, とある文字列がカンマ区切りで約 30 万個列挙されている (1 行になっている) ファイルを 3 万個ずつに分割して欲しいという相談を頂きました. 以下のような内容のファイルです.

aaaaaaaa1,aaaaaaaa2,aaaaaaaa3,aaaaaaaa4,aaaaaaaa5.... (これが約 30 万個)

急ぎということだったので, その時には tr(1) コマンドと split(1) コマンドを駆使して分割することが出来ましたが, できれば, 担当者の方だけで完結して欲しいと思ったので...

Web アプリケーションを提供できれば嬉しかったんですが, Web サーバーを立てたり, 場合によっては分割するファイルがセンシティブな情報を含んでいたりすると, 分割する前のファイルの所在とかをはっきりさせる必要があるなとか色々と悩んだ挙げ句, コマンド一発で完結出来るようにコマンドラインツールを作ることにしました.

ツール自体は, タイトルの通り, tr(1) コマンドと split(1) コマンドが出来ることの一部分ずつをガッチャンコしたものなので, どんなことが出来るかは容易に想像出来るかと思いますので, ツールの紹介自体は簡単に済ませた上で, 実装にあたり調査したことなどをダラダラと書いていきたいと思います.

尚, 本記事で扱う Ruby のバージョンは特に記載が無い場合には, 以下の通りです.

# mruby
$ ./mruby/build/x86_64-apple-darwin14/bin/mruby --version
mruby 2.0.1 (2019-4-4)

# CRuby
$ ruby -v
ruby 2.5.3p105 (2018-10-18 revision 65156) [x86_64-darwin18]

また, OS は以下の通りです.

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.4
BuildVersion:   18E226

作ったもの

github.com

ひとまず, macOS 向けのバイナリしか生成しないようにしています. (理由は後述)

使い方は, 以下のようにシンプルに split で分割して. verify で検証する感じです.

# 分割
$ kaminari split --file=data.csv --num=30000

# 検証
$ kaminari verify --file=data.csv

冒頭で紹介した 30 万個列挙されているファイルを 3 万個ずつ分割した場合のパフォーマンスは以下のような感じです.

$ time kaminari split --file=data.csv --num=30000
data.csv を分割処理します.
処理が終了しました.
        0.83 real         0.80 user         0.02 sys

悪くないと思います.

出来なくて...諦めたこと

全て

自分のスキルの無さによって, いくつかの妥協点があります.

IO#lineno を使いたかったけど...

IO#readlines を使うことにした

CRuby では IO#gets と IO#lineno を使うことで, ファイルの行数を簡単に取得することが出来ます.

$ cat test.txt
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
$ irb
irb(main):005:0> File.open('test.txt') do |f|
irb(main):006:1*   while f.gets; end
irb(main):007:1>   p f.lineno
irb(main):008:1> end
10
=> 10

しかし, mruby では IO#lineno は未実装になっています.

$ cat test.txt
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
$ ./kaminari/mruby/build/x86_64-apple-darwin14/bin/mirb
mirb - Embeddable Interactive Ruby Shell

> File.open('test.txt') do |f|
*   while f.gets; end
*   p f.lineno
* end
(mirb):6: undefined method 'lineno' (NoMethodError)

CRuby の IO#lineno は IO#gets が呼ばれた回数をカウントしているとのことですが, 何らかの理由で mruby-io (mruby では IO クラスは mruby-io という mrbgems に切り出されています) には実装されていないんだと思います.

ということで, 今回は IO#readlines を利用しました. IO#readlines は, データを全て読み込んだ上で, その各行を要素としてもつ配列を返すので, 返ってきたオブジェクトのクラスは Array クラスとなる為, Array#size (Array#length) メソッドで取得出来る値が行数になります.

$ cat test.txt
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
aaaaaaaa
# CRuby で実行
$ irb
irb(main):004:0> File.open('test.txt') do |f|
irb(main):005:1*   p f.readlines.size
irb(main):006:1> end
10
=> 10
# mruby で実行
$ ./kaminari/mruby/build/x86_64-apple-darwin14/bin/mirb
> File.open('test.txt') do |f|
*   p f.readlines.size
* end
10
 => 10

CRuby で IO#lineno と IO#readlines を比較してみる (1)

しかし, ファイルの行数が 1 億や 10 億行となった場合 (実務上, きっと, そんなことにはならないとは思いますが...), どちらが効率が良いのでしょうか. 効率という言葉が若干, 曖昧ではありますが, CRuby の Benchmark モジュールを利用して処理時間を比較してみました. 約 1 億行のデータを利用して, 以下のようなコードでベンチマークしてみます.

Benchmark.bm 10 do |x|
  File.open('data3.csv') do |f|
    x.report('lineno') do
      while f.gets; end
      f.lineno
    end
  end
  File.open('data3.csv') do |f|
    x.report('readlines') do
      f.readlines.size
    end
  end
end

あくまでも CRuby での比較となるので, 結果がそのまま mruby の結果と同じなるとは限らないので, あくまでも参考程度になるとは思いますが, 以下の通り, IO#readlines の方が処理時間が長くなっています.

$ wc -l data3.csv
 107488620 data3.csv
$ ruby bench.rb
                 user     system      total        real
lineno      10.898706   0.195019  11.093725 ( 11.096890)
readlines   18.441521   1.723986  20.165507 ( 21.407681)

CRuby で IO#lineno と IO#readlines を比較してみる (2)

ついでに, 標準添付のプロファイラを使って, IO#lineno と IO#readlines が実行される時に内部で呼ばれているメソッド達も確認してみたいと思います. 引き続き, 検証に使うデータは約 1 億行のデータを利用します.

以下は, #IO.lineno を利用したコードをプロファイルした結果です. IO#gets がファイルの行数分 (107488621) 呼ばれていることが解ります. そして, 興味深いのが, nil クラスが 1 回しか呼ばれていないにも関わらず, 処理全体の 65% を占める時間 (801 秒) 居座っている点です.

$ ruby -r profile lineno.rb
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 65.57   801.93    801.93        1 801934.81 1223022.77  nil#
 34.43  1223.02    421.09 107488621     0.00     0.00  IO#gets
  0.00  1223.02      0.00        1     0.42     0.42  TracePoint#enable
  0.00  1223.02      0.00        1     0.09 1223022.91  IO.open
  0.00  1223.02      0.00        1     0.02     0.02  IO#close
  0.00  1223.02      0.00        1     0.02     0.02  IO#lineno
  0.00  1223.02      0.00        1     0.01     0.01  IO#closed?
  0.00  1223.02      0.00        1     0.01     0.01  TracePoint#disable
  0.00  1223.02      0.00        1     0.01     0.01  File#initialize
  0.00  1223.02      0.00        2     0.00     0.00  IO#set_encoding
  0.00  1223.02      0.00        1     0.00 1223024.85  #toplevel

続いて, IO#readlines を利用したコードの結果です. IO#readlines が, たった 1 回だけ呼ばれています. ガッと IO#readlines でファイル全体を読み込んだ後で Array オブジェクトが生成され, Array#length (Array#size は Alias になっているんだと思います) で Array オブジェクトの長さを取得していることが解ります.

$ ruby -r profile readlines.rb
  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 94.31    19.98     19.98        1 19979.32 19979.32  IO#readlines
  5.68    21.18      1.20        1  1204.28 21183.60  nil#
  0.00    21.18      0.00        1     0.45     0.45  TracePoint#enable
  0.00    21.18      0.00        1     0.03 21183.65  IO.open
  0.00    21.18      0.00        1     0.01     0.01  File#initialize
  0.00    21.18      0.00        2     0.00     0.00  IO#set_encoding
  0.00    21.18      0.00        1     0.01     0.01  IO#close
  0.00    21.18      0.00        1     0.00     0.00  Array#length
  0.00    21.18      0.00        1     0.00     0.00  IO#closed?
  0.00    21.18      0.00        1     0.00     0.00  TracePoint#disable
  0.00    21.19      0.00        1     0.00 21185.72  #toplevel

IO#readlines を利用した方が呼ばれるメソッドの回数等はシンプルだと思います.

CRuby で IO#lineno と IO#readlines を比較してみる (3)

メソッドの呼び出し回数という観点からすると, IO#readlines の方が呼び出し回数も少ないので, 効率的であると言えそうですが, 念の為, それぞれのメモリ使用量についても確認してみたいと思います. メモリの使用量には, 以下の Gem を利用したいと思います. また, 引き続き, 検証に使うデータは約 1 億行のデータを利用します.

github.com

導入自体はとても簡単で, gem install するだけです. 今回は bundle install で導入しています. 検証自体は, 以下のようにコード内に一手間加える必要があります.

# IO#lineno 版
require 'memprof2'
Memprof2.start
File.open('data3.csv') do |f|
  while f.gets; end
  f.lineno
end
Memprof2.report
Memprof2.stop

# IO#readlines
require 'memprof2'
Memprof2.start
File.open('data3.csv') do |f|
  f.readlines.size
end
Memprof2.report
Memprof2.stop

それぞれをスクリプトにして実行してみた結果が以下の通りです. 尚, メモリ使用量の出力単位は bytes となります.

# IO#lineno 版
$ bundle exec ruby lineno.rb
229160 lineno.rb:4:String
80 lineno.rb:3:String

# IO#readlines
$ bundle exec ruby readlines.rb
4299544800 readlines.rb:4:String
1006510416 readlines.rb:4:Array
120 readlines.rb:3:String

一目瞭然でした. IO#lineno は String クラスが約 220 キロバイト利用するだけでしたが, IO#readlines は String クラスが約 4 ギガバイトもメモリを使用していました!これは, ファイルの全データを一気に読み込む為にこれだけのメモリを使ってしまうのではと考えています. さらに, Array クラスも負けじど約 1 ギガバイトもメモリを使用していました. ただ, ファイルの行数を数えるだけの処理にこのメモリの使用量は贅沢すぎると思います.

メモリ使用量の観点からすると, 圧倒的に IO#lineno の方が効率的であると言えると思います. ということは, メモリ搭載量等のリソースが潤沢ではない環境で動かすことが想定されている mruby でも IO#readlines を使うよりも IO#lineno を使った方が良いのかなと考えています (実装されていないので, どうしようも無いのですが...)

StirngIO#lineno は実装されている

この記事を書いた後, mgem-list を眺めていたら, StringIO クラスでは lineno メソッドが実装されていることを確認しました. . github.com

おお.

Dir.glob を使いたかったけど...

苦肉の策

mruby でも, https://github.com/gromnitsky/mruby-dir-glob を使うことで Dir.glob は利用可能なはずです. しかし, 今回, mruby-dir-glob をビルドする際に依存する https://github.com/ksss/mruby-file-stat のビルドで以下のようなエラーが出てしまい, これを解決することが出来ませんでした...orz.

...
/home/mruby/code/mruby/build/mrbgems/mruby-file-stat/src/file-stat.c:11:10: fatal error: 'config.h' file not found
#include "config.h"
         ^
6 warnings and 1 error generated.
rake aborted!

mruby-file-stat のビルド過程を確認してみましたが, config.h ファイル自体はちゃんと生成されているのにも関わらず, not found... 原因を究明することが出来ませんでした. 残念.

結果として mruby-dir-glob の利用を諦めて, ls コマンドをバックスラッシュで囲んで mruby 内部から ls を実行するという苦肉の策を取っています.

    def merge_files
      # 苦肉の策...
      files = `ls #{@path}-*`
      contents = []
      files.split("\n").each do |file|
        File.open(file, "r") do |f|
          contents << f.read.tr("\n", "")
        end
      end
      splited = contents.join(",")
      splited
    end

これをやることで macOSLinux でしか動かないツールが出来てしまい, mruby の良さを 100% 活かすことが出来なくなってしまったことが非常に残念です.

素の mruby であれば

mruby-dir-glob のビルドは通ることを確認しました.

MRuby::Build.new do |conf|
  if ENV['VisualStudioVersion'] || ENV['VSINSTALLDIR']
    toolchain :visualcpp
  else
    toolchain :gcc
  end
... 略 ...
  conf.gem :mgem => 'mruby-dir-glob'

以下, ビルドサマリです.

...
Build summary:

================================================
      Config Name: host
 Output Directory: build/host
         Binaries: mrbc
    Included Gems:
             mruby-dir
             mruby-io - IO and File class
             mruby-errno
             mruby-time - standard Time class
             mruby-file-stat
             mruby-process
             mruby-pack - Array#pack and String#unpack method
             mruby-dir-glob - 0.0.1 - File.fnmatch() & Dir.glob()
             mruby-metaprog - Meta-programming features for mruby
             mruby-sprintf - standard Kernel#sprintf method
...

mirb で動かしてみます.

$ bin/mirb
mirb - Embeddable Interactive Ruby Shell

> Dir.glob("*")
(mirb):1: uninitialized constant Regexp (NameError)

うおお, おしい.

以上

mruby でツールを作った際にハマったり気付いたりしたことをざっくりと纏めてみました. mruby の IO#lineno が未実装なのがとても気になるので, 自分で実装出来ないか引き続き調査中です. また, IO#readlines との比較にあたり, CRuby でのベンチマークやプロファイル, メモリ使用量の調査方法等を学ぶことが出来たので, とても良い機会でした. 先人の皆さんに感謝です.