Ruby: rubocopもすすめる速い書き方: Array#sampleとEnumerable#min_by

最近ちょくちょく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#sample20倍以上高速です。なおソート済み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というのもあります。

コード

こちらも余分なエラーが出ないようにしてあります。

# 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_bysort_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_by3倍以上高速です。なお、修正前のソート済み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などでソート済みにします。

関連記事

RubyのArray(配列)の使い方

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好きで、Goで書かれたRubyライクなGoby言語のメンテナーでもある。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

開発
Ruby 2.7.0-preview2がリリース

2019年10月23日

夏のTechRachoフェア2019

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ