はじめに
おはこんばんちは。 hacomono 開発部の iwazer です。
最近は Cyberpunk 2077 を再開して、やってなかったサブクエストやNCPDミッションを延々と潰している毎日です。そろそろやることもなくなってきたので早く最終 DLC 来てくれないかな。 あと Starfield の発売日が決まりましたね!ゼルダとだいぶ離れてて安心です。
余談はこのくらいにして、私は hacomono の基盤開発チームで主にプラットフォームの設計や改善を行っております。 最近は、プロダクトも成長してきてボトルネックを発見して改善するお仕事をよくやってます。
やけに遅い処理
データが多くて遅い(重い)ページがあったのですが、劇的に改善するにはフロントも含めて大改造が必要で、そのための時間を稼ぐため詰められるところがないかプロファイラをかけるなどして探していたところ、やけに遅いパートを見つけてしまいました。
そこだけ抜き出すとこういうものです。
records.map do |record| { hash_id: Hashids.new('salt', 10).encode(record.id), name: record.name } end
Hashids というのはIDを文字列形式で扱いたい時に使えるライブラリ(gem)で、元の整数のIDに戻すこともできます。
この gem のソースコードを見てみると、アルゴリズムをそのまま Ruby で記述したもののようで、いささか Ruby に向いてない処理だなぁという印象を受けました。
このページは結構な数のIDを変換していたので目立ってしまったわけです。少数のIDを変換する用途なら問題になることはないでしょう。
まず、 encode()
するためのインスタンスは同じパラメータで生成されていれば使い回せたので毎回作らなくてよくて、これでだいぶ速くなりましたが、それでもまだ処理時間のトップに君臨していました😅
C拡張で高速化してみる
Ruby には C 言語で書かれたネイティブコードを Ruby のコードと統合して、高速な処理や低レベルのアクセスを実現することができる仕組みがあります。
参照:Rubyリファレンス(Rubyの拡張ライブラリの作り方)
Hashids のコードをみてみるとのコンストラクタ、encode 、および(今回の例では呼んでませんが) decode から共通に呼ばれている consistent_shuffle()
という protected メソッドがあり、これだけなら簡単そうなので試しにC言語に置き換えてみることにしました。
HashidsExt::Hashids
が Hashids
を継承していて consistent_shuffle()
だけ C による実装を呼ぶようにし、ビルドできるように必要なファイルを追加したのがこの fork したリポジトリです。
https://github.com/iwazzer/hashids.rb
実装はRuby版をそのまま置き換えただけです。実験なので参考程度に😅
1000回のループで整数を encode してみました。 毎回インスタンスを再生成した場合、約2倍高速。
0.1717s [Hashids: always new instance, repeats:1000] 0.0809s [HashidsExt::Hashids: always new instance, repeats:1000]
インスタンスを再利用した場合は Ruby 実装でもわりと速くなりますが、書き換え版はそれよりもさらに約4倍速かったです。
0.0730s [Hashids: same instance, repeats:1000] 0.0169s [HashidsExt::Hashids: same instance, repeats:1000]
1メソッドだけでもこの効果ですので、全部を C で書き換えたらとっても速そう(゚∀゚)と思いましたが、リスクも考えると趣味の域を出ない感じがします。
C版の実装があったw
ここまで書いておいて、何を今更という気もしますが hashids というのは同じ結果を得られるライブラリが様々な言語で用意されているプロダクトでした💦 ホントに↑の実験をやったあとに気づきました|彡サッ
ここにはもちろん C 言語による実装もあり、これを呼んでみたくなりました。
ソースコードを見たところ、テーブルを使って分岐してたり最適化されてそうな匂いがします。 (まだそれほど読み込んでません🙇♂
別のリポジトリとしてイチから gem を作成し、Hashids
とインターフェースが同じになる HashidsExt::Hashids
から C 言語実装を呼び出したのがこのリポジトリです。
https://github.com/iwazzer/hashids_ext.rb
まだ動作確認の段階なので、端折って hashids.{c,h}
のコピーをそのまま ext/hashids_ext
に置いてますが、本家のリポジトリを参照してビルドするようにしたいです。
先ほどと、同じベンチマークを実行してみると…
0.0058s [HashidsExt::Hashids: always new instance, repeats:1000] 0.0014s [HashidsExt::Hashids: same instance, repeats:1000]
Ruby版の実装に比べて毎回インスタンス生成、使い回しのそれぞれで約30倍、50倍と大変速くなりました。
73msも使っていた処理が、1.4msしか使わなくなりますね。何度も実行される処理ですので、これはなかなか効果があると言えるでしょう。
まとめ
有効なことはわかりましたが、プロダクトに入れるためにはしっかりテストする必要があります。hashids.rb との互換性をもっと確認して、特にメモリ周りにバグがないかGCを起こしつつぶん回すなどあまりやらない確認も必要そうです。
Ruby は長く使っていますが、C拡張は随分前に一度だけ使ったことがある程度なので、今回は動かすところまで持っていくのもそれなりに大変でした😅 ただ、普段やらない試みだったので非常に楽しかったです。
まだ我々は追いついてませんが、Ruby 3.1 からはRust拡張が使えるようなので、このような対応がもっと安全にできる可能性が増えるのは嬉しいですね。