ぶていのログでぶログ

思い出したが吉日

mruby API最適化のポイント: rfコマンドにおける正規表現処理の改善

[!NOTE] この記事はrfコマンド Advent Calendar 2025の21日目の記事です。 昨日はrfコマンドの実例: コマンドを複数回実行するという記事でした。 rfコマンド Advent Calendarでは、私(buty4649)がOSSで開発しているrfコマンドの解説や推しポイント、便利な使い方を紹介していきます。


[!NOTE] この記事はmrubyファミリ (組み込み向け軽量Ruby) Advent Calendar 2025の21日目の記事です。 昨日はhasumikinさんのPicoRubyのTCPソケットどうする?でした。


[!NOTE] この記事は🎄GMOペパボ エンジニア Advent Calendar 2025の8日目の記事です。


私がOSSで作っているrfコマンドではアドカレを行っている。これに向けて記事を書いている際に、例として出すコマンドを何個か試していたのだが、その中にはバグやいけていない仕様やバグが見つかった。それらのほとんどは次期バージョンのv1.33.0で改修される予定である。 その改修の中にいれようと思っている変更があり、それは正規表現による文字列分割にかかる時間を85.9%短縮した改善である。これはmruby APIの利用を最適化したことで達成することができたので、この記事ではその最適化のポイントについて解説したい。

要約

  • プロファイリングによりmruby-onig-regexpにおけるstring_splitの処理時間が顕著に遅かった
  • 動的なシンボル取得からPreallocate Symbolsの利用やキャッシュすることで46%処理時間を短縮した
  • 次に、mrb_funcallの利用を極力なくしCの関数を直接呼び出すことで68.4%処理時間を短縮した
  • そして、mrb_gv_setやmrb_iv_setなどの呼び出しを減らしたことで最終的に修正前の状態から84%処理時間を短縮した
  • 最終的に、rfコマンドの正規表現による文字列分割は85.9%処理時間の短縮ができた

きっかけ

rfコマンドにおける正規表現による文字列分割の処理が遅いことに気がついたのは、冒頭にも書いた通りアドカレ向けに記事を書いているときだった。 rfコマンドにはawkやrubyコマンドにおける-Fと同等のオプションが搭載されている*1。 しかし、このオプションを付けたときと付けなかったときで、実行時間に大きな差があることに気がついた。 以下は実行例で、test.txtは20万行のファイルである。

# test.txtの生成
$ ruby -e 'puts((["a b c d e f"] * 20_0000).join("\n"))' > test.txt

# -Fオプションなし
$ time rf -q _ test.txt

________________________________________________________
Executed in    1.50 secs    fish           external
   usr time    1.36 secs    0.00 millis    1.36 secs
   sys time    0.01 secs    2.21 millis    0.01 secs

# -Fオプションあり
$ time rf -F" " -q _ test.txt

________________________________________________________
Executed in   14.20 secs    fish           external
   usr time   13.10 secs    1.15 millis   13.10 secs
   sys time    0.00 secs    0.49 millis    0.00 secs

-Fをつけるとなんと約10倍遅くなることがわかる。。 さすがにこれは遅すぎる!っと思い調査することにした。

rfコマンドの調査

実は遅くなる原因は検討がついていて、String#splitのオプションに正規表現を使っているか否かの違いだと考えた。 具体的には、-Fが指定されていればString#splitの区切り文字に正規表現パターンを登録し、指定がなければデフォルトのままで使っていた。コードとしては以下のような感じである。

# -Fオプションが指定されていれ区切り文字を指定
$; = Regexp.new(fs) if fs

# ファイルから1行読み取って分割する
while line = file.gets
  fields = line.split
end

このString#splitの実装を追っていくと、mruby-onig-regexpがこのメソッドをオーバライドしていることがわかった。

mruby-onig-regexpの調査とプロファイリング

mruby-onig-regexpはrfコマンドが正規表現エンジンとして利用している。早速mruby-onig-regexpの調査…っと行きたいところなのだが、変更するたびにrfコマンドをビルドし直すのは大変なので、mruby本体を直接使って調査することにした。 mruby本体にmruby-onig-regepのみ組み込んだ状態でビルドし、以下のようなスクリプトを実行して計測した。

# split-with-regexp.rb
file = File.open("test.txt")

while line = file.gets
  _ = line.split(" ")
end

test.txtは冒頭で使ったファイルと同じものを利用し計測を行った。この時の実行時間は、1.72sだった。

$ time ./build/host/bin/mruby split-with-regexp.rb

________________________________________________________
Executed in    1.72 secs    fish           external
   usr time    1.72 secs    2.64 millis    1.72 secs
   sys time    0.00 secs    0.00 millis    0.00 secs

次にstring_splitのどこが遅いのかを計測するためにperfコマンドを使いコールグラフを見ることにした(見やすくするために加工をしています)。

# Children      Self  Command  Shared Object  Symbol          
# ........  ........  .......  .............  ................
#
    93.36%     0.23%  mruby    mruby          [.] string_split
            |          
             --93.13%--string_split
                       |          
                       |--86.23%--onig_match_common
                       |          |          
                       |          |--31.27%--mrb_intern
                       |          |          |    
------------------------  中略 ------------------------
                       |          |--29.80%--mrb_funcall
                       |          |          |          
------------------------  中略 ------------------------
                       |          |           --3.44%--mrb_intern_cstr

この結果を見ると、string_splitから呼び出されるonig_match_commonの処理時間の割合が大きいことがわかる。そして、その中のいくつかの関数が原因になっていそうなことがわかった。

改善1: シンボルの動的取得をやめる

まずコールグラフの結果から、実行時間の割合の大きいmrb_intern(mrb_intern_lit)から改善していくことにする。

mrb_intern_litの置き換え

コールグラフにあるmrb_internはCの文字列からRubyのシンボルに変換する関数である。mruby-onig-regexpにおいては、この関数を直接波使っておらず、Cのリテラルを直接指定できるように拡張されたmrb_intern_litマクロを利用している。以降、便宜上mrb_intern_litと表記する。 mrb_intern_litの動作は比較的軽い処理のように思えるが、それなりの数があるRubyVM内のシンボルを探索する必要があるため実際の処理はそこそこ重い。 これを解決するための手段として、Preallocate Symbol(presym)がある。これは、コンパイル時にあらかじめシンボルの割当を行いマクロ展開することで、実行時に高速でシンボルの解決をするというもの。 使い方は簡単で#include <mruby/presym.h> をし、mrb_intern_litをMRB_SYMに置き換えるだけである。具体的には以下の通り。

/* 変更前 */
mrb_iv_set(mrb, c, mrb_intern_lit(mrb, "string"), mrb_str_dup(mrb, str));
mrb_iv_set(mrb, self, mrb_intern_lit(mrb, "@source"), str);

/* 変更後 */
mrb_iv_set(mrb, c, MRB_SYM(string), mrb_str_dup(mrb, str));
mrb_iv_set(mrb, self, MRB_IVSYM(source), str);

ここで注意が必要なのはダブルクォートは外す必要があり、また、@で始まるシンボルはMRB_IVSYMに、[]などの演算子を表す記号はMRB_OPSYMを使う必要がある。 詳しくは公式ドキュメントを参照してほしい。

記号・数字シンボルのキャッシュ

これでほとんどのシンボルをpresymにできるのだが、$1$2といった数字や$~といった記号を使ったシンボルをpresymにしようとするとシンタクスエラーなってしまう。 これについてmrubyのリポジトリにissueを立てた*2が、これらの特殊変数についてサポート予定はなく、キャッシュするのが手間が少なくてよいとのコメントをいただいたので、グローバル変数で保持することにした。

static mrb_sym sym_dollar_tilde;       // $~
static mrb_sym sym_dollar_ampersand;   // $&
static mrb_sym sym_dollar_backtick;    // $`
static mrb_sym sym_dollar_quote;       // $'
static mrb_sym sym_dollar_plus;        // $+
static mrb_sym sym_dollar_semicolon;   // $;
static mrb_sym sym_dollar_numbers[10]; // $0 to $9

mrb_mruby_onig_regexp_gem_init(mrb_state* mrb) {
  struct RClass *clazz;

  // Initialize global symbols with special characters
  sym_dollar_tilde = mrb_intern_lit(mrb, "$~");
  sym_dollar_ampersand = mrb_intern_lit(mrb, "$&");
  sym_dollar_backtick = mrb_intern_lit(mrb, "$`");
  sym_dollar_quote = mrb_intern_lit(mrb, "$'");
  sym_dollar_plus = mrb_intern_lit(mrb, "$+");
  sym_dollar_semicolon = mrb_intern_lit(mrb, "$;");

  // Initialize $0 to $9 symbols
  for (int idx = 0; idx < 10; ++idx) {
    char const n[] = { '$', '0' + idx };
    sym_dollar_numbers[idx] = mrb_intern(mrb, n, 2);
  }
-- 中略 --

mrb_funcallとmrb_class_getの置き換え

これでmrb_inter_litの置き換えが終わったが、実はmrb_funcallとmrb_class_getが実は内部的に使用している。これらは、Cの文字列リテラル指定からシンボル指定で行えるmrb_funcall_idとmrb_clss_get_idに置き換えると オーバーヘッドが少なくなる。

/* 変更前 */
mrb_funcall(mrb, self, "string_split", argc, pattern, mrb_fixnum_value(limit));
mrb_class_get(mrb, "OnigMatchData"), onig_region_new(), &mrb_onig_region_type));

/* 変更後 */
mrb_funcall_id(mrb, self, MRB_SYM(string_split), argc, pattern, mrb_fixnum_value(limit));
mrb_class_get_id(mrb, MRB_SYM(OnigMatchData)), onig_region_new(), &mrb_onig_region_type));

ここまでの改善結果

ここまでの改善をコミットし再度計測したところ0.756秒となり46%処理を短縮することができた。

$ time ./build/host/bin/mruby split-with-regexp.rb

________________________________________________________
Executed in  756.18 millis    fish           external
   usr time  720.91 millis    1.08 millis  719.83 millis
   sys time    4.34 millis    0.31 millis    4.02 millis

なお、ここまでの改善で一番寄与した対応は特殊変数のキャッシュである。これはmrb-onig-regexpの処理の特性上、これらの特殊変数の更新が多くなるためである。

改善2: mrb_fucall_idの呼び出しをなくす

ここまででだいぶ早くなったが、再度プロファイリングすると、mrb_funcall_idの呼び出しが支配的であった。

# Children      Self  Command  Shared Object  Symbol          
# ........  ........  .......  .............  ................
#
    88.15%     0.44%  mruby    mruby          [.] string_split
            |          
             --87.71%--string_split
                       |          
                       |--75.82%--onig_match_common
                       |          |          
                       |          |--40.47%--mrb_funcall_id
                       |          |          |          

そこでこの処理を見てみる。 どうやら正規表現のマッチ処理が終わった後、特殊変数の更新にMatchDataオブジェクトのインスタンスのメソッドを呼び出しているようだった。 この処理はとても合理的なのだが、一度RubyVMを経由しているためとてもオーバーヘッドが大きい状態であった。 そこでこの部分を、Cの関数を直接呼び出すことでオーバーヘッドをなくすことにした。 幸いにも、呼び出し先のCの関数を使い回すことができたので、mrb_funcall_idを置き換えるだけで良かった。

/* 変更前 */
mrb_gv_set(mrb, sym_dollar_ampersand,
           MISMATCH_NIL_OR(mrb_funcall_id(mrb, match_value, MRB_OPSYM(aref), 1, mrb_fixnum_value(0))));

/* 変更後 */
mrb_gv_set(mrb, sym_dollar_ampersand,
           MISMATCH_NIL_OR(mrb_ary_entry(match_data_to_a(mrb, match_value), 0)));

この改善により、再度計測したところ0.543秒となり、改善1より28.2%処理時間が短縮され、元のコードから68.4%処理時間が短縮された。

$ time ./build/host/bin/mruby split-with-regexp.rb

________________________________________________________
Executed in  543.80 millis    fish           external
   usr time  543.96 millis    1.39 millis  542.58 millis
   sys time    0.00 millis    0.00 millis    0.00 millis

改善3: 関数の呼び出しを減らす

さすがに改善ポイントはないだろうと思うが、念のため再度プロファイリングしてみる。

# Children      Self  Command  Shared Object  Symbol
# ........  ........  .......  .............  ................
#
    81.84%     0.65%  mruby    mruby          [.] string_split
            |
            |--81.19%--string_split
            |          |
            |          |--66.39%--onig_match_common
            |          |          |
            |          |          |--16.64%--match_data_to_a
------------------------  中略 ------------------------
            |          |          |--11.85%--mrb_gv_remove
------------------------  中略 ------------------------
            |          |          |--7.51%--mrb_class_get_id
------------------------  中略 ------------------------
            |          |          |--6.90%--mrb_gv_set

どれが改善できそうかコードを眺めていたところ上記それぞれの改善ができそうなことがわかった。

match_data_to_a におけるキャッシュの利用

この関数は、onigmoのマッチ情報をArray化するために使用される。変更を行う前から @cache にアクセスして存在しなければ、Array化の処理を行うようになっていたのだが、肝心の @cacheへの格納の処理がなかった。 そのため、この @cache へ格納するようにし、処理時間を減らした。

mrb_gv_remove / mrb_gv_setの呼び出し回数の削減

これらの関数は改善2でも少し触れたが、マッチ後の特殊変数の更新を行なっていて、仕様上必ず必要なものであった。また、実装を読んでみたが他の関数に置き換えたり、キャッシュすることで高速化することができないことがわかった。 どうしたものかっと考えた結果、そもそも呼び出さなければ良いという発想に至った。 どういうことかというと、この特殊変数の更新処理はマッチ処理を行うたびに実行される。つまり、String#splitにおいて区切り文字に設定されたパターンにマッチする回数と同じだけ呼び出されるということである。そこで、すべてのマッチ処理が終わった後に一度だけ更新することでmrb_gv_removeとmrb_gv_setの呼び出し回数を減らすことができた。

mrb_class_get_idの呼び出し回数の削減

OnigRegexp、OnigMatchDataのクラス情報を取得するためにmrb_class_get_idが呼び出されている。これらも、変更されることはないので、シンボル同様にグーバル変数にキャッシュすることで呼び出し回数を削減した。

改善を行った結果

ここまで紹介した改善以外にも細かい改善をしたのだが、それらを含めて改めて再度計測したところところ0.276秒となり、改善前より84%処理時間が短縮された!

$ time ./build/host/bin/mruby split-with-regexp.rb

________________________________________________________
Executed in  275.90 millis    fish           external
   usr time  271.87 millis  990.00 micros  270.88 millis
   sys time    4.28 millis  301.00 micros    3.98 millis

またrfコマンドに搭載し、冒頭の処理をさせたところ2秒となり、当初から85.9%処理時間を短縮させることができた!やったね

# 速度改善版のrfコマンド
$ time ./build/bin/rf -F" " -q _ test.txt

________________________________________________________
Executed in    2.00 secs    fish           external
   usr time    1.98 secs    1.68 millis    1.98 secs
   sys time    0.01 secs    0.76 millis    0.01 secs

まとめ

mruby-onig-regexpの改善を例にmruby APIの最適化により高速化する手法を解説した。 今回解説した手法、特に改善1は少ない手間でできるわりに効果が大きいので試してみる価値があると思う。

なお、ここで紹介した改善を行ったmruby-onig-regexpはすでにPRを作っているので、これがマージされれば誰でも試せるようになると思う。

github.com

*1:-Fで指定した文字で入力値を分割するオプション

*2:https://github.com/mruby/mruby/issues/6687