ruby/rexml のメンテナーになりました。
— NAITOH Jun (@naitoh) 2024年12月16日
rexml をもっと良い感じにしていくぞ💪https://t.co/Ve7bRd564m
というわけで、ruby/rexml のメンテナーになりましたので、この1年間の REXML (と StringScanner) への取り組みをまとめてみます。
- 1. rexml 高速化
- 2. 不正XMLチェックの強化
- 3. 各パーサー間でのパース処理結果の統一
- 4. REXML の SAX2 & Pull パーサ使用時のDoS脆弱性を報告 & 修正
- 5. StringScanner の改善
- 6. まとめ
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 に変更
Ruby の Regexp クラスを用いた正規表現でのパース処理を StringScanner を用いたパース処理に置き換えました。 この辺りは RubyKaigi 2024 LT で発表しましたので詳細は下記を参照ください。
なお、上記blog でStringScanner#check と Regexp#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
を使うように変更しました。
繰り返し使う正規表現オブジェクトやエンコード処理をキャッシュして生成コストを抑える
- Optimize Source#read_until method by naitoh · Pull Request #135 · ruby/rexml · GitHub
- Optimize `IOSource#read_until` method by naitoh · Pull Request #210 · ruby/rexml · GitHub
頻繁に呼ばれる double quotation
や single quotation
の正規表現オブジェクト生成やエンコード処理に時間がかかっていたためキャッシュしました。
2. 不正XMLチェックの強化
下記のようなXMLは不正で、処理を続行するとパース処理で不要な考慮が必要になるためエラーするように変更しています。
- 複数のルートタグ
<root1></root1><root2></root2>
- 開始ルートタグ前の文字列
foo<root></root>
- 終了ルートタグ後の文字列
<root></root>bar
- ルートタグ無し文字列
404 error
アプリケーション側も不正なXMLはエラーになってくれると嬉しいと思います。
3. 各パーサー間でのパース処理結果の統一
REXML はパース処理方法の違いから DOM/SAX2/Pull/Stream の各パーサーがあり、それぞれ使い勝手が異なり用途に合わせて使って頂く事を想定しているのですが、パース処理結果に差異があったので揃えました。
- SAX2 パーサーで 定義済み実態参照が展開されない
- 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 もいい感じにする必要があったので改善しています。
StringScanner#captures
の挙動がMatchData#captures
の振る舞いと異なっていたので修正- JRuby ではあまり使われていなかったのか
StringScanner#<<
でjava.lang.OutOfMemoryError
が発生する問題を見つけたので報告して修正してもらいました。 StringScanner#scan_until(pattern)
の文字列比較対応 (今までは正規表現のみ対応)
上記の 2〜4 は RubyKaigi 2024 follow up で話させて頂きましたので、下記の資料が参考になると思います。
6. まとめ
という感じで REXML をいい感じに改善していたら ruby/rexml のメンテナーに推薦頂き、REXMLが大分わかってきたのでメンテナーになりました。
REXML をもっと良い感じにしていくぞ💪