最近ちょくちょくfast-rubyを眺めています。
Rubyが速くなる書き方はいろいろありますが、常日頃kazzさんから「ビジネスロジックはしょっぱなから最適化するもんじゃないよ⚠️」「最初は素直に、可読性とプロジェクトの方針重視で書こう」「最適化は速度が問題になってからにすべき」とよく言われます。
まだ調べている途中ですが、fast-rubyで速いとされている書き方の多くは、rubocop様のお告げと必ずしも一致しなかったり、速い方と遅い方のどちらも何も言われなかったりします。
速い書き方に飛びついて業務コードの可読性を落とすのは避けたい。
そこで、fast-rubyの中から、rubocop様のデフォルト設定のお眼鏡にかない、かつ十分高速な、安心して使える書き方をピックアップしてみました。やってみると意外に少ないですね。
環境
- Ruby: 2.6.5
- rubocop: 0.75.0
- benchmark-ips: 2.7.2
以下を実行する前にrubocopとrubocop-performanceとbenchmark-ipsをインストールしておきます。
gem install rubocop rubocop-performance benchmark-ips
参考: RuboCop 本体から Performance Cops が外される - koicの日記
fast-rubyとの違いについて
fast-rubyに載っているベンチマークコードのままだとrubocopにビシビシ怒られるので修正してあります。
また、元のベンチマークコードではどちらもARRAY = [*1..100]
というソート済み配列だったので、ARRAY = Array.new(1000) { rand(1000) }.freeze
に置き換えました。
最初
1000.times.map { rand(1000) }.freeze
と書き変えたらperformance-copに怒られました😅
1.⚓ array要素のランダム取り出しはArray#sample
で書こう
コード
# frozen_string_literal: true
require 'benchmark/ips'
ARRAY = Array.new(100) { rand(100) }.freeze
def slow
ARRAY.shuffle.first
end
def fast
ARRAY.sample
end
Benchmark.ips do |x|
x.report('Array#shuffle.first') { slow }
x.report('Array#sample') { fast }
x.compare!
end
結果
rubocop --require rubocop-performance shuffle_first_vs_sample.rb
Inspecting 1 file
C
Offenses:
shuffle_first_vs_sample.rb:8:9: C: Style/Sample: Use sample instead of shuffle.first.
ARRAY.shuffle.first
^^^^^^^^^^^^^
1 file inspected, 1 offense detected
$ ruby shuffle_first_vs_sample.rb
Warming up --------------------------------------
Array#shuffle.first 64.139k i/100ms
Array#sample 492.291k i/100ms
Calculating -------------------------------------
Array#shuffle.first 715.276k (± 3.6%) i/s - 3.592M in 5.028096s
Array#sample 14.881M (± 2.8%) i/s - 74.828M in 5.032589s
Comparison:
Array#sample: 14881174.9 i/s
Array#shuffle.first: 715276.5 i/s - 20.80x slower
Array#sample
が20倍以上高速です。なおソート済みARRAY = [*1..100]
でも差は変わりませんでした。
さらにarrayのサイズを100から1000に増やすと180倍近く差が付きました。
$ ruby shuffle_first_vs_sample.rb
Warming up --------------------------------------
Array#shuffle.first 8.318k i/100ms
Array#sample 517.737k i/100ms
Calculating -------------------------------------
Array#shuffle.first 84.052k (± 4.3%) i/s - 424.218k in 5.057472s
Array#sample 14.948M (± 4.8%) i/s - 74.554M in 5.000535s
Comparison:
Array#sample: 14948009.3 i/s
Array#shuffle.first: 84052.2 i/s - 177.84x slower
⚓2. 未ソートなenumerable要素の最小・最大はEnumerable#min_by
と#max_by
で取り出そう
最大要素ならmax_by
ですね。minmax_by
というのもあります。
- instance method
Enumerable#min_by
(Ruby 2.6.0) - instance method
Enumerable#max_by
(Ruby 2.6.0) - instance method
Enumerable#minmax_by
(Ruby 2.6.0)
コード
こちらも余分なエラーが出ないようにしてあります。
# frozen_string_literal: true
require 'benchmark/ips'
ARRAY = Array.new(100) { rand(100) }.freeze
def fast
ARRAY.min_by(&:succ)
end
def slow
ARRAY.sort_by(&:succ).first
end
Benchmark.ips do |x|
x.report('Enumerable#min_by') { fast }
x.report('Enumerable#sort_by...first') { slow }
x.compare!
end
結果
以下はmin_by
ですが、max_by
とsort_by
+last
も同様の結果でした。
$ rubocop --require rubocop-performance sort_by_first_vs_min_by.rb
Inspecting 1 file
C
Offenses:
sort_by_first_vs_min_by.rb:12:9: C: Style/UnneededSort: Use min_by instead of sort_by...first.
ARRAY.sort_by(&:succ).first
^^^^^^^^^^^^^^^^^^^^^
1 file inspected, 1 offense detected
$ ruby sort_by_first_vs_min_by.rb
Warming up --------------------------------------
Enumerable#min_by 21.777k i/100ms
Enumerable#sort_by...first
7.075k i/100ms
Calculating -------------------------------------
Enumerable#min_by 220.555k (± 4.1%) i/s - 1.111M in 5.045125s
Enumerable#sort_by...first
72.116k (± 3.4%) i/s - 360.825k in 5.009160s
Comparison:
Enumerable#min_by: 220555.2 i/s
Enumerable#sort_by...first: 72115.7 i/s - 3.06x slower
Enumerable#min_by
が3倍以上高速です。なお、修正前のソート済みARRAY = [*1..100]
でも2倍の差が付きました。
さらにサイズを100から1000に増やすと4倍以上差が付きました。Enumerable#min_by
のような極端な差ではなく、対数感覚で差が開いていますね。
$ ruby sort_by_first_vs_min_by.rb
Warming up --------------------------------------
Enumerable#min_by 2.422k i/100ms
Enumerable#sort_by...first
554.000 i/100ms
Calculating -------------------------------------
Enumerable#min_by 24.343k (± 2.7%) i/s - 123.522k in 5.078018s
Enumerable#sort_by...first
5.541k (± 2.4%) i/s - 27.700k in 5.002130s
Comparison:
Enumerable#min_by: 24343.4 i/s
Enumerable#sort_by...first: 5540.9 i/s - 4.39x slower
考えてみればどちらも遅い方はソートしている分、遅いのは当然ですね😅。
以上は素のRubyの場合です。RailsのActive RecordオブジェクトのようにDB永続化が絡む場合は#order
などでソート済みにします。