ActiveJobで実装したデータ取り込みジョブを2.2倍高速化した

PubAnnotationという文章への注釈を管理するWebアプリケーションがあります。
このアプリケーションには注釈のついた文章データを一括アップロードする機能があります。
7万文章をアップロードすると7時間掛かります。この処理を高速化するために工夫しました。

PubAnnotationはRuby on Railsで実装されたWebアプリケーションです。
一括アップロード機能は、ActiveJobで実装され、Sidekiqで実行されているバッチ処理です。


## 結果

最初に結果を示します。
文章をアップロードしたときの平均アップロード数です。
文章によってデータ量がちがいます。2000～2500程度の文章をアップロードして平均を取りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15964/8b407a4e-2e6d-9f86-62d6-8b0966b88d14.png)


縦軸が1秒当たりのアップロード文章数です。横軸はアップロードした文章数です。
次の三つの修正をしました

1. キャッシュの導入 (x1.14)
1. INSERTクエリの戻り値でIDを取得 (x1.48)
1. activerecord-importからActiveRecord#insert_allへの置き換え (x1.16)

全部合わせて2.2倍の高速化を実現しました。

## 高速化のための修正

大量データのアップロード処理なので、最初、INSERTクエリに時間が掛かっていると予想しました。

### キャッシュの導入

実際に時間が掛かっている処理を特定するために[Stackprof](https://github.com/tmm1/stackprof)を使います。
具体的には次のような計測用のスクリプトを作って計測します。

```ruby
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`経由で実行します。

```sh
bundle add stackprof
bundle
bin/rails runner benchmark_scripts/profile_stack_instantiate_and_save_annotations_collection.rb
```


stackprofには取得したプロファイルをグラフ化してくれる機能があります。

```sh
stackprof --d3-flamegraph tmp/stackprof_store_annotations_collection.dump > framegraph.html
```

作成されたhtmlファイルをブラウザで表示すると、どの関数に時間が掛かっているか図示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15964/b3a0ceaf-884d-78e2-f550-bbd72e56e130.png)

カラムをホーバーするとツールチップで該当の関数と定義されている場所が表示されます。
幅の広いカラムをホバーして、ボトルネックを探していきます。
上の図はアプリケーションでボトルネックになっていそうなところをホバーしたものです。
ツールチップに表示されているのは`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`するをやめ、事前にまとめて検索しメモリ上にキャッシュします。

https://github.com/pubannotation/pubannotation/commit/64123777069211171d24ef0479e1e93468666e37

この修正で1.14倍の高速化ができました。

### INSERTクエリの戻り値でIDを取得

一つ前の修正で検索に時間が掛かっていることが本当であるとわかりました。
そこでもうちょっと大きな変更を加えて、さらなる高速化をはかります。
PostgreSQLではINSERTクエリの戻り値から、発行したIDを取得できます。
activerecord-importもこの機能をサポートしています。

https://github.com/zdennis/activerecord-import#return-info
> Additionally, for users of Postgres, there will be two arrays ids and results that can be accessed.

これを使うと、INSERTクエリの戻り値で発行したIDがわかります。Denotaitonの検索そのものを無くせます。

https://github.com/pubannotation/pubannotation/commit/d9ca8e33e6684aa0871b808ce7af84833983dbcc

前述の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`内部の実装を変えて計測します。

```ruby
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 ハッシュ
```sh
►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
```sh
►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`

```sh
►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倍高速化出来ました。

https://github.com/pubannotation/pubannotation/commit/7480f2e47843b2211e09da9c00aa2a06bd91814f

ただ、常に`ActiveRecord#insert_all`が速いわけではないようです。
[activerecord-importを速くつかう - @ledsun blog](https://ledsun.hatenablog.com/entry/2022/07/25/235626)ではactiverecord-importの方が速かったです。
生成するSQLクエリに依存するのでしょうか？アプリケーション毎にどちらが速いか計測する必要がありそうです。