PubAnnotationという文章への注釈を管理するWebアプリケーションがあります。
このアプリケーションには注釈のついた文章データを一括アップロードする機能があります。
7万文章をアップロードすると7時間掛かります。この処理を高速化するために工夫しました。
PubAnnotationはRuby on Railsで実装されたWebアプリケーションです。
一括アップロード機能は、ActiveJobで実装され、Sidekiqで実行されているバッチ処理です。
結果
最初に結果を示します。
文章をアップロードしたときの平均アップロード数です。
文章によってデータ量がちがいます。2000~2500程度の文章をアップロードして平均を取りました。
縦軸が1秒当たりのアップロード文章数です。横軸はアップロードした文章数です。
次の三つの修正をしました
- キャッシュの導入 (x1.14)
- INSERTクエリの戻り値でIDを取得 (x1.48)
- activerecord-importからActiveRecord#insert_allへの置き換え (x1.16)
全部合わせて2.2倍の高速化を実現しました。
高速化のための修正
大量データのアップロード処理なので、最初、INSERTクエリに時間が掛かっていると予想しました。
キャッシュの導入
実際に時間が掛かっている処理を特定するためにStackprofを使います。
具体的には次のような計測用のスクリプトを作って計測します。
file = './PMC-7646410.json'
annotations_collection = JSON.parse File.read(file)
annotations_collection.each { _1.deep_symbolize_keys! }
project = Project.find_by(id: 1)
StackProf.run(out: 'tmp/stackprof.dump', raw: true) do
InstantiateAndSaveAnnotationsCollection.call project, annotations_collection
end
InstantiateAndSaveAnnotationsCollection.call
が計測対象の処理です。
最初から、このように計測しやすかったわけではありません。
計測したいところを、事前のリファクタリングで切り出しています。
stackprof gemを追加します。
InstantiateAndSaveAnnotationsCollection.call
はActiveRecordに依存しています。
rails runner
経由で実行します。
bundle add stackprof
bundle
bin/rails runner benchmark_scripts/profile_stack_instantiate_and_save_annotations_collection.rb
stackprofには取得したプロファイルをグラフ化してくれる機能があります。
stackprof --d3-flamegraph tmp/stackprof_store_annotations_collection.dump > framegraph.html
作成されたhtmlファイルをブラウザで表示すると、どの関数に時間が掛かっているか図示されます。
カラムをホーバーするとツールチップで該当の関数と定義されている場所が表示されます。
幅の広いカラムをホバーして、ボトルネックを探していきます。
上の図はアプリケーションでボトルネックになっていそうなところをホバーしたものです。
ツールチップに表示されているのはDenotation.find_by_doc_id_and_project_id_and_hid
です。
INSERTクエリより検索に時間が掛かっていそうです。
注釈には次の三種類があります
- Denotation
- Attribute
- Relation
AttributeとRelationはDenotaitonを参照する注釈です。つまりAttributeとRelationをDBに保存するまえに、参照先のDenotaitonのidを取得する必要があります。この検索に時間が掛かっています。
そこでAttributeとRelationを保存する度に、Denotaiton.find_by
するをやめ、事前にまとめて検索しメモリ上にキャッシュします。
この修正で1.14倍の高速化ができました。
INSERTクエリの戻り値でIDを取得
一つ前の修正で検索に時間が掛かっていることが本当であるとわかりました。
そこでもうちょっと大きな変更を加えて、さらなる高速化をはかります。
PostgreSQLではINSERTクエリの戻り値から、発行したIDを取得できます。
activerecord-importもこの機能をサポートしています。
Additionally, for users of Postgres, there will be two arrays ids and results that can be accessed.
これを使うと、INSERTクエリの戻り値で発行したIDがわかります。Denotaitonの検索そのものを無くせます。
前述のStackProfで取得した図の該当カラムを丸っと削ったことになります。
これは効果が大きく、1.48倍の高速化ができました。
activerecord-importからActiveRecord#insert_allへの置き換え
activerecord-importを使っていると、疑問におもうことがあります。
activerecord-importとActiveRecord#insert_all
のどちらが速いかです。
ActiveRecord#insert_all
はRails 6から導入された、Railsの標準機能です。
一方、activerecord-importは実績のあるgemです。
どちらも複数のインスタンスやハッシュから一文のINSERTクエリを作成します。SQLの実行を一回にすることで、複数レコードのINSERTを高速化します。
ほぼ同じ機能ですし、実装も大きくは違わないので、そんなに差はでない気もしますが、気にはなります。計測してみます。
今回はbenchmark
gemを使って実際の処理時間を計測します。
次のようなスクリプトを作成し、InstantiateAndSaveAnnotationsCollection.call
内部の実装を変えて計測します。
require 'benchmark'
count = 3
annotations_collection = JSON.parse File.read(ARGV[0])
annotations_collection.each { _1.deep_symbolize_keys! }
project = Project.find_by(id: 1)
Benchmark.bm do
_1.report("#{count}") do
count.times do
InstantiateAndSaveAnnotationsCollection.call project, annotations_collection
end
end
end
activerecord-import ハッシュ
►bin/rails runner ./bench_instantiate_and_save_annotations_collection.rb ./PMC-7646410.json
user system total real
3 0.816089 0.023025 0.839114 ( 1.223663)
activerecord-import columns and array
►bin/rails runner ./bench_instantiate_and_save_annotations_collection.rb ./PMC-7646410.json
user system total real
3 0.791784 0.031821 0.823605 ( 1.109341)
ActiveRecord#insert_all
►bin/rails runner ./bench_instantiate_and_save_annotations_collection.rb ./PMC-7646410.json
user system total real
3 0.719464 0.023214 0.742678 ( 1.030884)
activerecord-importにはレコードを配列で指定する方法もあります。そちらも測定しています。
意外なことにActiveRecord#insert_all
が明らかに速かったです。
これを採用して1.14倍高速化出来ました。
ただ、常にActiveRecord#insert_all
が速いわけではないようです。
activerecord-importを速くつかう - @ledsun blogではactiverecord-importの方が速かったです。
生成するSQLクエリに依存するのでしょうか?アプリケーション毎にどちらが速いか計測する必要がありそうです。