2017-11-10

jbuilderで無理矢理N+1 partial renderingを回避する方法

実務では絶対に使わないけどrails/jbuilderのpartialレンダリングを無理矢理早くする方法。

partialでviewを使いまわしたい場合、特に1件のときはオブジェクト、複数件は配列を返したい場合、通常は以下のように書きます。

json.hoge do
  json.partial! 'api/hoge/record', collection: @records, as: :record
end

が、これはN+1 partial renderingが発生します。100件くらいの数値配列を出すだけでも遅いです。ログは以下のようにRenderedがたくさん表示されます。

  ...
  Rendered api/hoge/_record.json.jbuilder (0.2ms)
  Rendered api/hoge/_record.json.jbuilder (0.1ms)
  Rendered api/hoge/_record.json.jbuilder (0.1ms)
  Rendered api/hoge/index.json.jbuilder (143.0ms)
Completed 200 OK in 196ms (Views: 189.0ms | ActiveRecord: 0.0ms)

これはpartial!を使うと、collectionのパーシャルレンダリングとして扱われず、renderを複数回呼び出した結果をjbuilder内で連結しており、処理の重いfind_template_pathsがN+1回分呼びだされるためです。

jbuilderファイル内でrenderを直接呼び出すとcollectionのパーシャルレンダリングとして扱われるのでfind_templateも1回だけです。が、オブジェクトの文字列が単純に連結されてしまうので、カンマで無理矢理連結させて配列にした上でJSON.loadすればjbuilderのattibutesに配列としてセットされることになります。

records = render partial: 'api/hoge/record', collection: @records
json.hoge JSON.load("[#{records.gsub('}{', '},{')}]")

gsubでかなり雑に置換しているのが微妙ですが、これだけでもある程度の高速化が見込めます。

ベンチマーク

以下のスクリプトでベンチマークを取りました。
require 'benchmark/ips'
module Api
  class HogeController < ApplicationController
    def index
      slow = render_to_string 'slow'
      fast = render_to_string 'fast'
      raise "slow != fast" unless slow == fast

      result = Benchmark.ips do |x|
        x.report('slow') { render_to_string 'slow' }
        x.report('fast') { render_to_string 'fast' }
        x.compare!
      end

      render plain: result.data.to_json
    end
  end
end

app/views/api/hoge/slow.json.jbuilder

json.hoge do
  json.partial! 'api/hoge/record', collection: (1..100), as: :record
end

app/views/api/hoge/fast.json.jbuilder

records = render partial: 'api/hoge/record', collection: (1..100)
json.hoge JSON.load("[#{records.gsub('}{', '},{')}]")

これをproductionで起動してエンドポイントにアクセスしてベンチマークを取ったところ、2倍くらい性能差が出ました。

Warming up --------------------------------------
                slow     7.000  i/100ms
                fast    19.000  i/100ms
Calculating -------------------------------------
                slow     71.877  (±16.7%) i/s -    350.000  in   5.044258s
                fast    155.584  (±26.4%) i/s -    703.000  in   5.032553s

Comparison:
                fast:      155.6 i/s
                slow:       71.9 i/s - 2.16x  slower

stackprofをとったら以下のようになりました。これがpartial!を使ったバージョン

==================================
  Mode: wall(1000)
  Samples: 364 (23.85% miss rate)
  GC: 15 (4.12%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
       241  (66.2%)         133  (36.5%)     ActionView::PathResolver#find_template_paths
        19   (5.2%)          19   (5.2%)     Concurrent::Collection::NonConcurrentMapBackend#[]
       210  (57.7%)          13   (3.6%)     ActionView::PathResolver#query
        29   (8.0%)          13   (3.6%)     Logger::LogDevice#write
        12   (3.3%)          12   (3.3%)     Puma::Single#run

こちらがrenderとgsubでごにょごにょしたバージョン

==================================
  Mode: wall(1000)
  Samples: 67 (30.21% miss rate)
  GC: 0 (0.00%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
        22  (32.8%)          19  (28.4%)     ActionView::PathResolver#find_template_paths
         4   (6.0%)           4   (6.0%)     Puma::Single#run
         6   (9.0%)           3   (4.5%)     Rack::MiniProfiler::FileStore::FileCache#[]=
         6   (9.0%)           3   (4.5%)     Logger::LogDevice#write
         2   (3.0%)           2   (3.0%)     ActionView::LookupContext#registered_details

やはり、find_template_pathsがボトルネックになっています。

このエントリーをはてなブックマークに追加