Rails6で追加されたinsert_allとimport(とその他)のパフォーマンス検証

[前回書いた記事](https://qiita.com/ham0215/items/dad6f5d3d63bd498d999)で、activerecord-importとRails6で追加されたinsert_allのパフォーマンスを比べたところ、importの方が速そうだったのでもう少しちゃんと検証してみました。

# 検証方法
## 速度検証
1,000件のユーザーを様々な方法でバルクインサートします。
1回のバルクインサートだと数msで終わってしまうので、上記を100回実行したときの合計時間で比較します。
計測は`benchmark`を使用します。

## メモリ使用量検証
こちらも同様に1,000件のユーザーを様々な方法でバルクインサートします。
計測は`memory_profiler`を使用します。

# 環境
Ruby: 2.6.5
Rails: 6.0.0
rspec-rails: 3.9
factory_bot_rails: 5.1.1

# 対象
## activerecord-import
Railsでバルクインサートできるようにするもっとも有名なgemではないでしょうか。
生成したモデルの配列を渡すだけで簡単にバルクインサートできます。

```ruby
users = (1..100).map { User.new(name: 'name') }
User.import users
```
github
https://github.com/zdennis/activerecord-import

## insert_all
Rails6.0.0で追加されたバルクインサート用のメソッド。
ハッシュを渡すことで簡単にバルクインサートできます。
importと異なり、created_atとupdated_atを明示的に指定する必要があるので注意が必要です。

```ruby
users = (1..100).map { { name: 'name', created_at: Time.current, updated_at: Time.current } }
User.insert_all users
```
doc
https://edgeapi.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-insert_all

## 素のクエリー
Railsではクエリーをそのまま実行できるのでクエリーを文字列で生成して実行します。

```ruby
sql = "INSERT INTO users (name, created_at, updated_at) VALUES ('name', NOW(), NOW()),('name', NOW(), NOW())...;"
ActiveRecord::Base.connection.execute sql
```

# 検証
## 実装
benchmarkで検証する`benchmark_bulk_insert`とMemoryProfilerで検証する`profiler_bulk_insert`を作りました。
純粋にバルクインサートの処理を検証したかったので、データ作成等はBenchmark外で生成しています。

```ruby:app/models/user.rb
class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      import_data = []
      1_000.times { import_data << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      insert_data = []
      1_000.times { insert_data << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      values = []
      1_000.times { values << "('name', '#{Time.current.to_s(:db)}', '#{Time.current.to_s(:db)}')" }
      sql = "INSERT INTO users (name, created_at, updated_at) VALUES #{values.join(',')}"

      require 'benchmark'
      Benchmark.bm 15 do |r|
        transaction do
          r.report 'sql' do
            100.times { bulk_insert_using_sql(sql) }
          end
          raise ActiveRecord::Rollback
        end

        transaction do
          r.report 'insert_all' do
            100.times { bulk_insert_using_insert_all(insert_data) }
          end
          raise ActiveRecord::Rollback
        end

        transaction do
          r.report 'import' do
            100.times { bulk_insert_using_import(import_data) }
          end
          raise ActiveRecord::Rollback
        end
      end
    end

    def profiler_bulk_insert
      # create data
      import_data = []
      1_000.times { import_data << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      insert_data = []
      1_000.times { insert_data << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      values = []
      1_000.times { values << "('name', '#{Time.current.to_s(:db)}', '#{Time.current.to_s(:db)}')" }
      sql = "INSERT INTO users (name, created_at, updated_at) VALUES #{values.join(',')}"

      p '################# sql ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_sql(sql)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end

      p '################# insert_all ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_insert_all(insert_data)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end

      p '################# import ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_import(import_data)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end
    end

    def bulk_insert_using_import(users)
      import users
    end

    def bulk_insert_using_insert_all(users)
      insert_all users
    end

    def bulk_insert_using_sql(sql)
      connection.execute sql
    end
  end
end
```

## 結果
rails consoleで実行しました。

```console
irb(main):080:0> User.benchmark_bulk_insert;nil
                      user     system      total        real
sql               0.000000   0.010000   0.010000 (  0.601744)
insert_all        8.900000   0.030000   8.930000 (  9.951685)
import           10.870000   0.210000  11.080000 ( 12.255004)

irb(main):080:0> User.profiler_bulk_insert
"################# sql ########################"
Total allocated: 4152 bytes (23 objects)
Total retained:  928 bytes (1 objects)

"################# insert_all ########################"
Total allocated: 4493518 bytes (67974 objects)
Total retained:  145088 bytes (2005 objects)

"################# import ########################"
Total allocated: 7187421 bytes (91961 objects)
Total retained:  1824536 bytes (13654 objects)
```

まず処理時間を見てimportが速いというのは勘違いだったとわかりました。。。ちゃんと調査しないとダメですね。
メモリー使用量を見ると、処理時間と比例して増えています。importでは処理の過程で様々なオブジェクトを生成しているので他の処理より遅いと思われます。
前回の記事でinsert_allの方が遅かったのはbuild_listで生成したオブジェクトを変換するところも計測に入っていたからですね。
そしてimportやinsert_allよりも素のsqlがパフォーマンス最強ということがわかりました。
オブジェクトを生成すればするほど遅くなるので文字列をDBに投げるだけだとかなりパフォーマンスに差がでますね。
素のsqlで実装すると可読性や生産性が落ちるので乱用はしたくないですが、ここぞというときに使うと良さそうです。