ruby/rexml のメンテナーになりました

というわけで、ruby/rexml のメンテナーになりましたので、この1年間の REXML (と StringScanner) への取り組みをまとめてみます。

1. rexml 高速化

Ruby 3.3.0 添付の rexml 3.2.6 gem から、Ruby 3.4.0 添付の rexml 3.4.0 gem の間で最大4割の高速化を実現しました。🎉

Add 3.4.0 entry · ruby/rexml@19d8ebf · GitHub

ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [x86_64-linux]
Calculating -------------------------------------
                     rexml 3.2.6      master  3.2.6(YJIT)  master(YJIT) 
                 dom       8.073       9.400       13.512        16.667 i/s -     100.000 times in 12.386407s 10.638806s 7.400877s 5.999736s
                 sax      13.416      15.955       19.910        25.617 i/s -     100.000 times in 7.453542s 6.267482s 5.022630s 3.903620s
                pull      15.971      19.289       23.253        30.003 i/s -     100.000 times in 6.261289s 5.184190s 4.300510s 3.332993s
              stream      14.660      18.499       20.502        28.797 i/s -     100.000 times in 6.821191s 5.405752s 4.877656s 3.472597s

rexml 3.2.6 → 3.4.0 の比較

YJIT=OFF YJIT=ON
dom +16.4% +23.3%
sax +18.9% +28.6%
pull +20.7% +29.0%
stream +26.1% +40.4%
  • YJIT=OFF 状態で 16.4%〜26.1% の高速化
  • YJIT=ON 状態で 23.3%〜40.4% の高速化

という感じで、最大4割の高速化を実現しましたので、やった事を説明しようと思います。

パース処理の内部実装を Regexp クラスから StringScanner に変更

RubyRegexp クラスを用いた正規表現でのパース処理を StringScanner を用いたパース処理に置き換えました。 この辺りは RubyKaigi 2024 LT で発表しましたので詳細は下記を参照ください。

naitoh.hatenablog.com

なお、上記blog でStringScanner#checkRegexp#match とのマイクロベンチマーク単純なケースでは StringScanner は Regexp より 1.67 倍速い と言いました。

この点、LT 発表では詳細には触れなかったのですが、Regexp クラスでは Onigmo という正規表現エンジンが使用されており、StringScanner の正規表現もOnigmoを使用しています。 つまり同じ正規表現エンジンを使って処理しているのですが、Regexp クラスの場合、マッチ時に MatchData Object を生成して返すのでその分遅くなっているようです。 (StringScanner はマッチした文字列だけを返すので速い。)

似たような StringScanner#match?Regexp#match? の下記マイクロベンチマークでは、両方ともマッチデータは応答しないため、ほぼ同じ性能です。

prelude: |-
  require "strscan"
  str = "test string"
  s = StringScanner.new(str)
  re = /\A\w+/
benchmark:
  s.match?(/\w+/): |
    s.match?(/\w+/)
  re.match(str): |
    re.match?(str)
$ benchmark-driver benchmark/test.yaml 
Warming up --------------------------------------
     s.match?(/\w+/)    12.391M i/s -     12.828M times in 1.035236s (80.70ns/i)
       re.match(str)    12.888M i/s -     12.964M times in 1.005849s (77.59ns/i)
Calculating -------------------------------------
     s.match?(/\w+/)    14.362M i/s -     37.174M times in 2.588407s (69.63ns/i)
       re.match(str)    14.672M i/s -     38.665M times in 2.635401s (68.16ns/i)

Comparison:
       re.match(str):  14671575.6 i/s 
     s.match?(/\w+/):  14361690.4 i/s - 1.02x  slower

StringScanner#match? はパターンマッチの値 self[nth] は変更され、Regexp#match?$~ などパターンマッチに関する組み込み変数の値は変更されないため、Regexp#match? が若干速いようです。

同じ正規表現エンジンを用いているのでどこまで高速化できるのか? が疑問点だったのですが、下記の観点で最適化した結果、 最終的な REXML の高速化に繋がったと考えています。

正規表現をなるべく使わないようにする (文字列のみのマッチで代替する)

正規表現を使うと処理に時間がかかるため、XMLパース処理を見直し、このタイミングではこの文字列かこの文字列が来る、違う文字列が来たらエラー といった感じで文字列比較でパース可能な処理は正規表現を使わないように変更しました。

StringScanner は文字列の先頭から順番にパースしていくので StringScanner らしい処理に書き換えることで高速化できました。

不要な文字列 Object の生成を抑える形で最適化する

StringScanner#scan はマッチした文字列を返すのですが、返された文字列を使用しないケースでは文字列 Object 生成処理が無駄なため、マッチした文字列長を返す StringScanner#skip を使うように変更しました。

繰り返し使う正規表現オブジェクトやエンコード処理をキャッシュして生成コストを抑える

頻繁に呼ばれる double quotationsingle quotation正規表現オブジェクト生成やエンコード処理に時間がかかっていたためキャッシュしました。

2. 不正XMLチェックの強化

下記のようなXMLは不正で、処理を続行するとパース処理で不要な考慮が必要になるためエラーするように変更しています。

  • 複数のルートタグ
<root1></root1><root2></root2>
  • 開始ルートタグ前の文字列
foo<root></root>
  • 終了ルートタグ後の文字列
<root></root>bar
  • ルートタグ無し文字列
404 error

アプリケーション側も不正なXMLはエラーになってくれると嬉しいと思います。

3. 各パーサー間でのパース処理結果の統一

REXML はパース処理方法の違いから DOM/SAX2/Pull/Stream の各パーサーがあり、それぞれ使い勝手が異なり用途に合わせて使って頂く事を想定しているのですが、パース処理結果に差異があったので揃えました。

4. REXML の SAX2 & Pull パーサ使用時のDoS脆弱性を報告 & 修正

CVE-2024-41946: REXML内のDoS脆弱性 で報告したのですが、内容的には DOM の REXML におけるエンティティ展開に伴うサービス不能攻撃について (CVE-2013-1821)CVE-2014-8080: REXML におけるXML展開に伴うサービス不能攻撃について と同じになります。

上記 3 の各パーサー間でのパース処理結果の統一の一環で、各パーサー間の実装差を減らしたいなと処理を見てると、DOM だけ対処されて、なんで SAX2 & Pull パーサの場合、この処理無いんだろう?と気づいてDOMの再現用テストコードを流用して SAX2 & Pull パーサで試したら問題があったので横展開する形で治しました。 テストコード大事。

セキュリティ報告ってやった事が無かったので新鮮で勉強になりました。(久々に patch コマンド使った。)

5. StringScanner の改善

REXMLを高速化するために StringScanner を使い出したのですが、StringScanner もいい感じにする必要があったので改善しています。

上記の 2〜4 は RubyKaigi 2024 follow up で話させて頂きましたので、下記の資料が参考になると思います。

speakerdeck.com

6. まとめ

という感じで REXML をいい感じに改善していたら ruby/rexml のメンテナーに推薦頂き、REXMLが大分わかってきたのでメンテナーになりました。

REXML をもっと良い感じにしていくぞ💪