Ruby 2.6 新機能:本番環境での利用を目指したコードカバレッジ計測機能

技術部の遠藤(mame)です。1 ヶ月くらい風邪が直らず、苦しみながらこれを書いています。

昨日は Ruby 2.6 の NEWS を裏話付きで解説する記事を書きました(プロと読み解く Ruby 2.6 NEWS ファイル)。今日と明日は、その中でクックパッドのフルタイムRubyコミッタが主に担当したところを少し詳しく紹介します。

今日は、遠藤が作った "oneshot coverage" と言う 2.6 の新機能を紹介します。

背景:Ruby では不要コードの発見・削除が難しい

クックパッドのサービスの多くは、cookpad_all という 1 リポジトリからなる、巨大な Rails アプリケーションとして実現されていました。しかし、このやり方ではメンテナンスが限界になってきたので、「お台場プロジェクト」という大整理プロジェクトが行われてきました。この辺の詳細は次の 2 つの記事が詳しいです。

お台場プロジェクトの活動のひとつに、「不要になったコードを削除する」というものがあります。

クックパッドに入るまで考えたことがなかったのですが、この「不要コードを発見・削除する」が、なかなかむずかしいのでした。他人の書いたコードが不要になったかどうかの判定はむずかしいですし、不要と思って消したら予想外のところで使われていたということもあります。特に Ruby は、インタプリタ言語なのでコンパイルによるチェックがなく*1、その上リフレクションが多用される文化なので、検証がとてもむずかしいです。

そこでクックパッドでは現在、Ruby 本体にパッチを入れて、各コード断片が初めて実行されたときに記録をつけながら本番運用をしています。これにより、長期間運用していても全く使われていないコード断片を効率的に発見できます。また、実際に使われていないコードなので、わりと安心して削除できます。この詳細は次の記事をご覧ください。

これを同じようなことを、Ruby にパッチをあてずに実現するのが、oneshot coverage です。

コードカバレッジとは

コードカバレッジを知っている人はこの節をスキップしてください。

コードカバレッジとは、どのコード断片が実行されたかを記録したものです。

-: # test.rb
1: def foo(n)
2:   if n <= 10
2:     p "n < 10"
-:   else
0:     p "n >= 10" # テストされていない
-:   end
-: end
-:
1: foo(1)
1: foo(2)

左端の数字がコードカバレッジです。各行が何回実行されたかを表しています。空行など意味のない行は - になっています。0 になっているところは、1 度も実行されなかったことを意味しています。通常、コードカバレッジはテスト時に使われ、テスト不足のコード(端的に言うと実行回数が 0 の行)を探すために使われます。

Ruby では、coverage ライブラリを使うことでコードカバレッジを計測できます。

require "coverage"

# コードカバレッジ測定開始
Coverage.start(lines: true)

# 測定対象のプログラムを読み込む
load "test.rb"

# 結果の取得
p Coverage.result
#=> {"test.rb"=>{:lines=>[nil, 1, 2, 2, nil, 0, nil, nil, nil, 1, 1]}}

配列の数字が各行の実行回数に対応しています。

Ruby のコードカバレッジ測定について詳しく知りたい方は RubyKaigi 2017 の遠藤の発表資料をご覧ください。

このコードカバレッジ測定を本番環境で使えば、いつまでたっても実行されない行を発見することができます。しかし、通常のコードカバレッジ測定では、各行を実行するたびにカウントアップのフックを実行することになり、オーバーヘッドが問題になります。また、Covearge.result は、全ソースファイル行数の長さの配列を作るので、こちらもオーバーヘッドが気になるところです。

この問題を解決するのが oneshot coverage です。

oneshot coverage とは

oneshot coverage とは、各行の実行回数ではなく、各行が 1 回でも実行されたかどうかを計測するコードカバレッジです。コードカバレッジ測定ツールは伝統的に、行ごとの実行回数を数えるものが多いですが、実際の用途としては、未テストの行(実行回数が 0 の行)を探すというのがふつうです。なので、実行回数が取れなくなることにデメリットはほとんどないと思います。

oneshot coverage の計測モードでは、各行について最初の 1 回だけカウントアップのフックを実行します。1 度実行されたら、その行のフックのフラグを消し去るので、あとはカバレッジ計測のない状態と同じになります。*2

以下、具体的な使い方を説明していきます。

1. oneshot モードにする方法

oneshot coverage を有効にするには、カバレッジ測定開始メソッドを Coverage.start(oneshot_lines: true) というように呼び出します。

require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
load "test.rb"

#  1: # test.rb
#  2: def foo(n)
#  3:   if n <= 10
#  4:     p "n < 10"
#  5:   else
#  6:     p "n >= 10"
#  7:   end
#  8: end
#  9:
# 10: foo(1)
# 11: foo(2)

# 結果の取得
p Coverage.result
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

Coverage.result の返り値が「実行された行番号」の列に変わりました。

2. インクリメンタルに計測する方法

oneshot coverage は運用環境で使うことを想定しているので、たとえば 100リクエストごとや 10 分ごとなど、定期的に Coverage.result を呼んで測定結果を記録していきたくなると思われます。しかし Coverage.result を無引数で呼ぶと、カバレッジの測定を停止してしまいます*3

現在までに実行した行番号は知りたいが、その後もカバレッジ測定は継続して欲しい、というときのために、Coverage.resultstop というキーワード引数を追加しました。次のように使えます。

require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
load "test.rb"

# 結果の取得
p Coverage.result(stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

# 実行されていなかった test.rb の 6 行目を実行する
foo(100)

# 新たな結果の取得:6 行目が追加された
p Coverage.result(stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11, 6]}}

1 回目の Coverage.result の呼び出し結果と 2 回目の結果を比べると、6 行目が追加で実行されたことがわかります。

また、clear キーワードを使うと、1 度見た行をクリアできます。

require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
load "test.rb"

# 結果の取得
p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[2, 10, 3, 4, 11]}}

# 実行されていなかった test.rb の 6 行目を実行する
foo(100)

# 新たな結果の取得
p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[6]}}

# もう未到達の行は存在しない
foo(0)
foo(100)

# 新たな結果の取得(新たに実行された行はない)
p Coverage.result(clear: true, stop: false)
{"test.rb"=>{:oneshot_lines=>[]}}

このように、oneshot coverage では Coverage.result(clear: true, stop: false) を使うのが基本です。

oneshot coverage の結果は「実行された行番号の列」なので、Coverage.result を呼ぶたびに全ソースファイル行数分の配列ができるのを避けることができます。

追記(2019/01/09):clear キーワードを間違えて reset と書いてました。すみません。

3. 実行されなかった行を調べる

「実行された行番号」の列が取れるようになりましたが、実際に興味があるのは「実行されなかった行番号」です。ただし、空行とかコメント行とかのように、実行という概念がない行は無視する必要があります。

そのために、Coverage.line_stub という補助関数を用意しました。この関数を使うと、行カバレッジの配列のスタブを作れます。

require "coverage"

# 行カバレッジのスタブ配列を作る
ary = Coverage.line_stub("test.rb")
p ary #=> [nil, 0, 0, 0, nil, 0, nil, nil, nil, 0, 0]

nil になっているのは空行やコメント行など、0 になっているのは行カバレッジの測定対象(実行という概念がある行)です。

「実行された行番号」番目を 1 にすることで、見慣れた行カバレッジの形式に変換できます。

require "coverage"

# カバレッジ測定開始 (oneshot_lines モードにする)
Coverage.start(oneshot_lines: true)

# 測定対象のプログラムを読み込む
load "test.rb"

# 行カバレッジのスタブ配列を作る
ary = Coverage.line_stub("test.rb")

# 実行された行番号の要素を 1 にしていく
Coverage.result["test.rb"][:oneshot_lines].each do |i|
  ary[i - 1] = 1
end

# test.rb の行カバレッジ
p ary #=> [nil, 1, 1, 1, nil, 0, nil, nil, nil, 1, 1]

このように変換すれば、既存のカバレッジ可視化ツールに渡したり、自分で可視化したりがやりやすくなると思います。

評価実験

次の 3 つの条件で、プログラムの実行にかかる時間を測定してみました。

  • (1) コードカバレッジ測定なし
  • (2) 従来の行コードカバレッジ測定(Coverage.start(lines: true)
  • (3) oneshot coverage モード(Coverage.start(oneshot_lines: true)

マイクロベンチマーク

次のプログラムは、コードカバレッジの計測オーバーヘッドが最大になりそうな人工的な例です。

# bench.rb
def bench
  x = 1
  x = 2
  x = 3
  x = 4
  x = 5
  x = 6
  x = 7
  x = 8
  x = 9
  x = 10
end

10000000.times { bench }

このプログラムの実行時間を測定した結果はこちら。それぞれ 3 回測定した平均です。

条件 時間 (秒)
(1) カバレッジ測定なし 0.972
(2) 従来の行カバレッジ 4.53
(3) oneshot coverage 0.967

(2) がだんとつで遅く、(1) と (3) がほぼ同じです。oneshot coverage が実質ゼロオーバーヘッドであることがわかります。

optcarrot の例

もう少し現実的な例として、Ruby 3 のデファクトベンチマークである optcarrot も測定しました。

結果はこちら。やはり 3 回ずつ測定した平均です。単位は frame per second で、数字が大きいほうが速いです。

条件 fps
(1) カバレッジ測定なし 39.8
(2) 従来の行カバレッジ 10.6
(3) oneshot coverage 39.4

やはり、(2) が 4 倍くらい遅く、(1) と (3) はほぼ同じです。

先ほどのマイクロベンチマークでも optcarrot も CPU 律速のベンチマークなので、Rails のような IO 律速なアプリでは、オーバーヘッドはさらに小さくなっていくはずです。

余談:MJIT との相性

少しだけ補足です。Ruby 2.6 の一番の目玉機能である、JIT コンパイラ、MJIT との相性について。

coverage(というか TracePoint API)が有効だと MJIT は動かないようになっています。oneshot coverage はバイトコード(フックフラグ)を実行時に書き換えるので、JIT コンパイルしてもフラグが変わったらコンパイルのやりなおしになるためです。optcarrot を --jit オプション付きで測定し直した結果がこちら。

条件 --jit なし fps --jit あり fps
(1) カバレッジ測定なし + --jit 39.8 56.1
(2) 従来の行カバレッジ + --jit 10.6 10.3
(3) oneshot coverage + --jit 39.4 38.8

--jit をつけると、(1) カバレッジ測定なしなら 39.8 → 56.1 fps に大幅スピードアップしていますが、(3) oneshot coverage は 39.4 → 38.8 fps と、MJIT が無効になっていることが確認できます。

しかし、現時点では MJIT はまだ Rails アプリを高速化するには至っていない(k0kubun さんによる進捗報告記事)ので、気にしなくても大丈夫です(?)。MJIT のクオリティが上がっていったら、なんか改善できる(してくれる)のではないかと思います。

まとめ

Ruby 2.6 で入った、(最初の1回のフックの後は)ゼロオーバーヘッドでカバレッジを測定できる oneshot coverage という機能を紹介しました。これを本番環境で使うことで、使われていなさそうな行の情報が得られます。不要コードの発見・削除のお役に立てば幸いです。

なお cookpad_all はまだ Ruby 2.6 で運用されていないので(当たり前)、まだ oneshot coverage は適用できていませんが、使っていく方向で計画が進んでます。これについてはまたの機会に報告できればと思います。

*1:インタプリタ言語だからといって同様の検証ができないとも限りませんが、とりあえず Ruby にはそういう検証ツールがいまのところありません。

*2:もう少し言うと、Rails のような IO ボトルネックなプログラムでは、カバレッジ計測のオーバーヘッドは元々ほとんど問題にならないと思います。少なくとも、cookpad.com のテストをカバレッジ計測あり・なしで走らせても、実行時間に違いはみられませんでした。ただ、本番環境で従来のカバレッジ計測を有効にするのは、オーバーヘッドが問題になるリスクがあるかもしれません。

*3:これは Coverage.result の仕様を最初に決めたときの判断ミスで、とても後悔しています。