ようへいの日々精進XP

よかろうもん

Ruby の YAML ライブラリで YAML をパースする vs オレオレ YAML パーサーで YAML をパースする 〜 この戦いは止むる (YAML) ことは出来ない 〜

これは

qiita.com

初老丸 Advent Calendar 2018 第 4 日目の記事になる予定です.

tl;dr

前々回, 以下のような記事を書きました.

inokara.hateblo.jp

hub という Github クライアントライブラリの長い歴史の中で, Ruby 標準の YAML ライブラリの変わりにオレオレ YAML パーサーが実装されていて, そのパーサーで解析出来ない YAML ファイルを書いてハマってしまったという話です.

github.com

コミットメッセージから察するに, コマンドラインツールにおいて, 設定ファイルの YAML ファイルを読み込む時間は 1 ミリ秒でも速くしたかった思いが伺えます.

では, どのくらい処理時間に差があるのか

調べてみました

というのが本記事です. 本記事では以下のような環境で検証を行います.

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.13.6
BuildVersion:   17G65

$ ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-darwin17]

まず, 以下のようなスクリプトをこさえて雑に YAML ファイルを生成してみます.

require 'erb'

contents =<<"EOS"
github.com:
<%- (0..100000).each do |id| -%>
  - user: foobar<%= format('%04d', id) %>
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<%= format('%04d', id) %>
<% end %>
EOS
erb = ERB.new(contents, nil, '-')
puts erb.result(binding).chomp

これを実行して YAML ファイルを生成します.

$ ruby generate-yaml.rb > sample.yml

以下のような無いようの YAML ファイルが生成されました.

github.com:
  - user: foobar0000
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx0000
  - user: foobar0001
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx0001
...
  - user: foobar99999
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx99999
  - user: foobar100000
    oauth_token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx100000

hub で利用する YAML ファイルのフォーマットそのままに 100000 ユーザー分のユーザー名 (user) とトークン (oauth_token) が生成されているはずです. (実際に 100000 ユーザー分を記載することは無いと思いますが...) これを sample.yml という名前で保存しておきます.

ruby-prof

調査には標準の profiler ライブラリではなく, ruby-prof という gem を利用しました.

github.com

profiler と ruby-prof の違いについては, ドキュメントや別の記事にお譲りしますが, ざっと使ってみた感じだと, プロファイルの実行速度や結果出力について profiler よりも優れているように思えます. ざっくりで恐縮ですが, ruby-prof の使い方は以下のように RubyProf.profile ブロック内に解析したいコードを差し込んで結果を RubyProf の各種プリンタクラスで表示します.

require 'yaml'
require 'ruby-prof'

result = RubyProf.profile do
  YAML.load_file('sample.yml')
end

printer = RubyProf::FlatPrinter.new(result)
printer.print(STDOUT, { :sort_method => :total_time })

上記の例だと, YAML.load_file('sample.yml') の解析結果が変数 result に格納され, RubyProf::FlatPrinter クラス (出力例はこちら) を使って結果を出力しています. 出力先として STDOUT に, total_time (対象のメソッドとそのメソッドから呼び出されたメソッドを含めた総処理時間) 順にソートされて出力します. 以下, 出力例です.

Measure Mode: wall_time
Thread ID: 70181951114860
Fiber ID: 70181959375120
Total: 0.119640
Sort by: total_time

 %self      total      self      wait     child     calls  name
  0.03      0.120     0.000     0.000     0.120        1   [global]#[no method]
  0.00      0.120     0.000     0.000     0.120        1   <Module::Psych>#load_file
  0.01      0.120     0.000     0.000     0.120        1   <Class::IO>#open
  0.01      0.119     0.000     0.000     0.119        1   <Module::Psych>#load
  0.00      0.066     0.000     0.000     0.066        1   <Module::Psych>#parse
...

尚, 出力は上記のテキスト出力だけではなく, HTML や graphviz で画像に書き出せる DOT 言語でも書き出すことが可能です. 詳細は README をご一読ください.

標準の YAML ライブラリを ruby-prof で解析してみる

コード

以下のようなコードで検証してみます.

require 'yaml'
require 'ruby-prof'

RubyProf::measure_mode = RubyProf::PROCESS_TIME
result = RubyProf.profile do
  YAML.load_file('sample.yml')
end

printer = RubyProf::FlatPrinter.new(result)
printer.print(STDOUT, { :sort_method => :self_time })

以下のように実行します.

$ bundle exec ruby parser1.rb > result1.txt

実行結果

以下のような結果となりました.

$ head -15 result1.txt
Measure Mode: process_time_time
Thread ID: 70238351920700
Fiber ID: 70238356042040
Total: 57.293642
Sort by: self_time

 %self      total      self      wait     child     calls  name
  7.07      5.715     4.050     0.000     1.665   400005   Psych::ScalarScanner#tokenize
  6.79      3.892     3.892     0.000     0.000   400005   Psych::Nodes::Scalar#initialize
  6.75      3.867     3.867     0.000     0.000   600014   Psych::TreeBuilder#event_location
  6.45     14.876     3.694     0.000    11.181   400005   Psych::TreeBuilder#scalar
  5.66     11.522     3.241     0.000     8.281   400005   Psych::Visitors::ToRuby#deserialize
  4.81     27.601     2.757     0.000    24.844        1   Psych::Parser#parse
  4.48      6.035     2.566     0.000     3.470   400005   Psych::TreeBuilder#set_location
  4.06      2.326     2.326     0.000     0.000   500009   Psych::TreeBuilder#set_start_location
... 以下略

処理時間は Total: を確認すると良さそうです. 約 57 秒掛かっていることが分かります. また, Psych::ScalarScanner#tokenize というメソッドが 400005 回コールされて, 4 秒程処理に要していることが分かります. 1 回のコールあたり 9 マイクロ秒の時間で処理されていることになります. この数字だけ見ると遅いという印象は特に感じません.

ただ, YAML が解析されるまでに非常に多くのメソッドが呼ばれていることが結果から読み取れます. これを ruby-prof を利用することで可視化することが可能です. 検証コードを以下のように修正して Graphviz で読み取れる DOT 言語で結果をプリントするようにします.

require 'yaml'
require 'ruby-prof'

RubyProf::measure_mode = RubyProf::PROCESS_TIME
result = RubyProf.profile do
  YAML.load_file('sample.yml')
end

printer = RubyProf::DotPrinter.new(result)
printer.print(STDOUT, { :sort_method => :self_time })

以下のように実行して, Graphviz で画像に変換してみます. (事前に Graphviz をインストールしておきます.)

$ bundle exec ruby parser1.rb > result1.dot
$ dot -Tpng result1.dot -o result1.png
$ open result1.png

以下のように多くのメソッドが呼ばれていることが解ります. すごい.

f:id:inokara:20181128152125p:plain

オレオレ YAML パーサーを ruby-prof で解析してみる

コード

以下のようなコードで検証してみます.

require 'yaml'
require 'ruby-prof'

def yaml_load(string)
  hash = {}
  host = nil
  string.split("\n").each do |line|
    case line
    when /^---\s*$/, /^\s*(?:#|$)/
      # ignore
    when /^(.+):\s*$/
      host = hash[$1] = []
    when /(^[- ]) (.+?): (.+)/
      key, value = $2, $3
      host << {} if $1 == '-' or $2 =~ /^\s*-\s*/
      key.gsub!(/^\s*-\s*|^\s*/, '')
      # require 'byebug'; byebug
      host.last[key] = value.gsub(/^'|'$/, '')
    else
      raise "unsupported YAML line: #{line}"
    end
  end
  hash
end

RubyProf::measure_mode = RubyProf::PROCESS_TIME
result = RubyProf.profile do
  File.open("sample.yml", "r") do |f|
    yaml_load(f.read)
  end
end

printer = RubyProf::FlatPrinter.new(result)
printer.print(STDOUT, {})

以下のように実行します.

$ bundle exec ruby parser2.rb > result2.txt

実行結果

以下のような結果となりました.

$ cat result2.txt
Measure Mode: process_time_time
Thread ID: 70351828807260
Fiber ID: 70351829107000
Total: 7.317221
Sort by: self_time

 %self      total      self      wait     child     calls  name
 49.76      7.242     3.641     0.000     3.601        1   Array#each
 27.34      2.001     2.001     0.000     0.000   800011   Regexp#===
 12.90      0.944     0.944     0.000     0.000   200002   String#gsub!
  5.46      0.400     0.400     0.000     0.000   200002   String#gsub
  3.50      0.256     0.256     0.000     0.000   200002   Array#last
  0.91      0.067     0.067     0.000     0.000        1   String#split
  0.12      0.009     0.009     0.000     0.000        1   IO#read
  0.00      0.000     0.000     0.000     0.000        1   File#initialize
  0.00      7.317     0.000     0.000     7.317        1   [global]#[no method]
  0.00      7.317     0.000     0.000     7.317        1   <Class::IO>#open
  0.00      7.308     0.000     0.000     7.308        1   Object#yaml_load
  0.00      0.000     0.000     0.000     0.000        1   IO#close
  0.00      0.000     0.000     0.000     0.000        1   IO#closed?

* indicates recursively called methods

トータルの処理時間は 7.3 秒. Regex クラス=== メソッドが 800011 回コールされていることが分かります. === メソッドは左辺と右辺の String を正規表現マッチするメソッドなので, オレオレ YAML パーサーでは愚直に文字列比較を行って YAML を解析していると思われます. 処理時間は YAML ライブラリの 8 倍程度速く処理出来ていますので, hub の作者が嘆いていた理由は分かりました.

一応, YAML ライブラリと同様に, 呼ばれているメソッドを可視化したところ, 以下の通りとなりました.

f:id:inokara:20181128152402p:plain

YAML ライブラリと比較すると実にシンプルであることが分かりました.

以上

YAML ライブラリと hub で実装されていたオレオレ YAML パーサーの処理時間の比較を通して, ruby-prof を用いて, どのような処理が行われているかを把握し, 可視化することが出来ました.

速度を比較した結果は YAML ライブラリの方がオレオレ YAML パーサーよりも処理時間が長くなりましたが, これを一概に遅いと言えないと思いますし, 多くのメソッドが呼ばれるにはそれなりの意味があると考えています.

今回はこれ以上は踏み込んで調査は行いませんが, 機会があれば YAML ライブラリのソースコードをちゃんと読んで理解を深めたいと思います.

有難うございました.