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
作ったもの
ひとまず, 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 億行のデータを利用します.
導入自体はとても簡単で, 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
これをやることで macOS と Linux でしか動かないツールが出来てしまい, 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 でのベンチマークやプロファイル, メモリ使用量の調査方法等を学ぶことが出来たので, とても良い機会でした. 先人の皆さんに感謝です.