Rails 7: ActiveRecord::Base.loggerがclass_attributeで7倍高速化(翻訳)
私たちの見解では、このプルリクはRails 7におけるきわめてシンプルかつ大きなパフォーマンス改善です。最近のRubyで、クラス変数の読み取りにインラインキャッシュが導入されました(#17763)1。これにより、クラス変数の値解決で複雑な継承ツリーをたどるかわりにキャッシュから値を読み取れるようになりました。Rubyでクラス変数が読み込まれると、継承ツリーにある各クラスをチェックして、そのクラス変数がツリー内の他のクラスに設定されていないことを確認する必要があります。
もうお気づきかと思いますが、これはO(n)問題になります。ツリー内のノード数が増えるにつれて、読み取りのパフォーマンスは線形に低下します。
それでは、1個のモジュールを継承するクラス、30個のモジュールを継承するクラス、最後に100個のモジュールを継承するクラスを使ったデモを見てみましょう。
require "benchmark/ips"
MODULES = ["B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "BB", "CC", "DD", "EE", "FF", "GG", "HH", "II", "JJ", "KK", "LL", "MM", "NN", "OO", "PP", "QQ", "RR", "SS", "TT", "UU", "VV", "WW", "XX", "YY", "ZZ", "AAA", "BBB", "CCC", "DDD", "EEE", "FFF", "GGG", "HHH", "III", "JJJ", "KKK", "LLL", "MMM", "NNN", "OOO", "PPP", "QQQ", "RRR", "SSS", "TTT", "UUU", "VVV", "WWW", "XXX", "YYY", "ZZZ", "AAAA", "BBBB", "CCCC", "DDDD", "EEEE", "FFFF", "GGGG", "HHHH", "IIII", "JJJJ", "KKKK", "LLLL", "MMMM", "NNNN", "OOOO", "PPPP", "QQQQ", "RRRR", "SSSS", "TTTT", "UUUU", "VVVV", "WWWW"]
class A
@@foo = 1
def self.foo
@@foo
end
eval <<-EOM
module #{MODULES.first}
end
include #{MODULES.first}
EOM
end
class Athirty
@@foo = 1
def self.foo
@@foo
end
MODULES.take(30).each do |module_name|
eval <<-EOM
module #{module_name}
end
include #{module_name}
EOM
end
end
class Ahundred
@@foo = 1
def self.foo
@@foo
end
MODULES.each do |module_name|
eval <<-EOM
module #{module_name}
end
include #{module_name}
EOM
end
end
Benchmark.ips do |x|
x.report "1 module" do
A.foo
end
x.report "30 modules" do
Athirty.foo
end
x.report "100 modules" do
Ahundred.foo
end
x.compare!
end
キャッシュなしのRubyでは以下の結果になります。
Warming up --------------------------------------
1 module 1.231M i/100ms
30 modules 432.020k i/100ms
100 modules 145.399k i/100ms
Calculating -------------------------------------
1 module 12.210M (± 2.1%) i/s - 61.553M in 5.043400s
30 modules 4.354M (± 2.7%) i/s - 22.033M in 5.063839s
100 modules 1.434M (± 2.9%) i/s - 7.270M in 5.072531s
Comparison:
1 module: 12209958.3 i/s
30 modules: 4354217.8 i/s - 2.80x (± 0.00) slower
100 modules: 1434447.3 i/s - 8.51x (± 0.00) slower
それではキャッシュありのRubyの結果を見てみましょう。
Warming up --------------------------------------
1 module 1.641M i/100ms
30 modules 1.655M i/100ms
100 modules 1.620M i/100ms
Calculating -------------------------------------
1 module 16.279M (± 3.8%) i/s - 82.038M in 5.046923s
30 modules 15.891M (± 3.9%) i/s - 79.459M in 5.007958s
100 modules 16.087M (± 3.6%) i/s - 81.005M in 5.041931s
Comparison:
1 module: 16279458.0 i/s
100 modules: 16087484.6 i/s - same-ish: difference falls within error
30 modules: 15891406.2 i/s - same-ish: difference falls within error
Rubyのmasterブランチでは、モジュール100個をinclude
するとモジュール1個のinclude
の8.5倍遅くなります。しかしキャッシュを使えば、モジュール1個のinclude
とモジュール100個のinclude
のパフォーマンスは変わらなくなります。
それでは、Railsコアチームがこのパフォーマンス向上をどのようにRailsに取り入れたかを見てみましょう。
変更前
ActiveRecord::Base.logger
は継承ツリーに63個2のモジュールを持つcvar
(クラス変数)です。以下を実行すればこのことを確かめられます。
ActiveRecord::Base.ancestors.size
# => 62
ActiveRecord
コアのコードを見てみると、ロガーは以下のように定義されています。
mattr_accessor :logger, instance_writer: false
ここがクラス変数としてではなくmattr_accessor
として定義されているので、このままでは最新のRubyで導入されたパフォーマンス改善が効いてくれません。
変更後
Railsへのプルリク#42237によって、logger
の定義方法が以下のように変更されました。
class_attribute :logger, instance_writer: false
それではパフォーマンスを比較して改善を確かめてみましょう。
Calculating -------------------------------------
logger 1.700M (± 0.9%) i/s - 8.667M in 5.097595s
clogger 11.556M (± 0.9%) i/s - 58.806M in 5.089282s
Comparison:
clogger: 11555754.2 i/s
logger: 1700280.4 i/s - 6.80x (± 0.00) slower
7倍近い高速化は大きな改善です!現実のRailsアプリケーションが強化された見事な例です。
注意
この変更にはいくつかの注意点があります。
ActiveRecord::Base.logger
がclass_attribute
になったため、@@logger
で直接アクセスできなくなります。また、サブクラスに logger =
を設定しても親クラスのロガーを変更できません。
logger
はほとんどの場合単なるメソッドとして使われているので、きわめてささいな不都合に過ぎませんが、注意するに越したことはありません!
この改修が皆さんのRailsアプリのコードベースで効くかどうかを今のうちに調べておきましょう。
概要
原著者の許諾を得て翻訳・公開いたします。