Ruby 2.5 は引数に &block を書いても速い!!! #megurorb

Meguro.rb#10 で「引数に &block を書いても速い!!!」という素晴らしい改善について話してきた。

b.r-l.o の issue で言うとこちら。 Feature #14045: Lazy Proc allocation for block parameters

前提知識

block の呼び出し方 3 パターン

block をメソッドで使う場合、大きく分けてこの 3 パターンがあると思う。

# block を引数で受け取って、call で呼ぶ
def block_call_with_block_arg(&block)
  block.call
end

# block を引数で受け取らずに yield で呼ぶ
def yield_without_block_arg
  yield
end

# 引数で受け取った block を捨てて yield で呼ぶ
def yield_with_block_arg(&block)
  yield
end

引数で受け取った block を捨てる?

一番下の「引数で受け取った block を捨てて yield で呼ぶ」というパターンがなぜ存在するのかと言うと、 メソッドシグネチャで、このメソッドがブロックを受け取れることを明示できる という点です。中を読まなくても分かるのはメリットがデカい。

ところが遅い

require "benchmark/ips"
Benchmark.ips do |x|
  # メソッド定義は上に書いたので省略

  x.report("with arg and call") {
    block_call_with_block_arg { 1 + 1 }
  }
  x.report("without arg") {
    yield_without_block_arg { 1 + 1 }
  }
  x.report("with arg") {
    yield_with_block_arg { 1 + 1 }
  }
  x.compare!
end
$ ruby benchmark.rb
Warming up --------------------------------------
   with arg and call    85.049k i/100ms
         without arg   203.843k i/100ms
            with arg    95.914k i/100ms
Calculating -------------------------------------
   with arg and call      1.287M (±11.1%) i/s -      6.379M in   5.025664s
         without arg      6.029M (±16.2%) i/s -     29.150M in   5.001207s
            with arg      1.444M (±12.1%) i/s -      7.098M in   5.002793s

Comparison:
         without arg:  6029135.4 i/s
            with arg:  1444165.4 i/s - 4.17x  slower
   with arg and call:  1286794.1 i/s - 4.69x  slower

仮引数 &block があるだけで 4 倍遅い。 call すると更に遅い。

このことから、「block を引数で受け取らずに yield で呼ぶ」のパターンを使うのが 理想的な Ruby のコードとされてしまっているのでした。

(ちなみに call を呼ぶパターンは、Performance/RedundantBlockCall cop が警告します)

ユーザの声

&block 書かないと速いんだけど、書かなかったら block 渡せるかどうかを yield の有無で判断せざるを得ないので読解が難しくなるという悩ましさとは付き合っていくしか無いのかなぁ。

— Takafumi ONAKA (@onk) March 27, 2017

僕です。「&block を書いたら遅い」という現象を知ってからずっと言ってる気がします。

Rubyで明示的なブロック引数(&block)のほうが暗黙的なブロック引数(yield)より遅いのバグだと思うので誰かなんとかしてほしい。

— FUJI Goro (@__gfx__) October 5, 2017

「バグ」という表現を用いるの、強いですね。

&bを作ってyieldするコードは(わざわざ未使用なローカル変数が出現しており、bindingとかで使われるのではないかといった疑いを持って読む必要が発生し)意図が逆にわかりにくいのでレビューで見たら反対する

— k0kubun (@k0kubun) October 23, 2017

もちろん、逆の意見もあります。

コミッタの声

&blockを渡してhttps://t.co/A6pIfK5f8qするよりyieldする方がずっと速いのを何とかする方法に半日くらい悩んだ結果、めちゃくちゃ厳しいのでお前らyieldを書けという結論に至りました

— k0kubun (@k0kubun) October 21, 2017

やっぱり難しいから遅いまま放置されてるんですよねぇ……。

一番難しいのはどこかでevalされてスコープの外に変数が持ち出される可能性があることですね。evalは任意のタイミングで発生し得るしevalの文字列の中身はわからない。変数の形になってなければ問題にはならないけれど。

— Urabe, Shyouhei (@shyouhei) October 5, 2017

呼ぶときは yield でも、$g = block とかあるとアウト

— \_ko1 (@_ko1) October 21, 2017

これは本当に難しそうです。

変数にするとそのメソッドの外にそのProcオブジェクトを持っていけるので適当にコンテキストをラップしており、&block があるメソッドを呼び出す旅にProcオブジェクトのアロケートが走っており、それが大変遅いようです

— k0kubun (@k0kubun) October 21, 2017

分かりやすい解説。

解決策を思いつきました!!! yield を遅くすればいいんだ

— \_ko1 (@_ko1) October 6, 2017

難しさのあまり錯乱しています。

&block と書くと遅いことによる弊害

JRubyだと&blockのほうが速いらしいんでRubyの&block速くなったらこのコード消したい https://t.co/idRB3TTDRD

— Ryuta Kamizono (@kamipo) October 5, 2017

https://github.com/rails/rails/blob/v5.1.4/activerecord/lib/active_record/attribute_methods/read.rb#L63-L73

JRuby かどうかでメソッド定義ごと分けるの、面白コードだ……。

速くなった!

Ruby 2.5 の目玉機能を作っておいた https://t.co/DmNGJozbap

— \_ko1 (@_ko1) October 23, 2017

https://t.co/8MyhIkEmfp これが本命。

— \_ko1 (@_ko1) October 23, 2017

ささださんすごい!!!!

https://gist.github.com/ko1/9d58b59a9f89e3089d236473b56b9bae

gist のファイル名が良いですね。

https://bugs.ruby-lang.org/issues/14045

Proposal: Lazy Proc allocation for

To avoid this overhead, I propose lazy Proc creation for block parameters.

Ideas:

We don't optimize b.call type block invocations. If we call block with b.call, then create Proc object.We need to hack more because Proc#call is different from yield statement (especially they can change $SAFE).

とある通り、触るまで Proc オブジェクトのアロケーションを遅延させるというアイディアです。

次のメソッドに pass するだけでも速いの、なるほどなぁ。

上に書いたベンチを流すと

$ ruby benchmark.rb
Warming up --------------------------------------
   with arg and call    83.870k i/100ms
         without arg   210.480k i/100ms
            with arg   165.800k i/100ms
Calculating -------------------------------------
   with arg and call      1.030M (±19.4%) i/s -      4.948M in   5.033583s
         without arg      5.276M (±18.2%) i/s -     25.047M in   5.012327s
            with arg      4.714M (±15.3%) i/s -     22.880M in   5.007125s

Comparison:
         without arg:  5275892.6 i/s
            with arg:  4713655.5 i/s - same-ish: difference falls within error
   with arg and call:  1030325.9 i/s - 5.12x  slower

と、実際速い。

今後の更なる発展?

def foo(&block)
  yield
end

def foo(&b)
  yield
end

と、使わないのに block とか b とかの仮引数名があるのが気持ち悪いし、名前に悩む人が出そうだし。

僕はブロックを引数で取るときは、さらにメソッドを呼び出して引数に渡すのを暗示している節があるので…。

必ずブロック引数を明示しないといけないとなると、ブロック引数の命名や、呼び出し方(block.call or yield)とか考えなきゃいけないのも🤔

— いっくん (@alpaca_tc) December 10, 2017

なので、捨てる前提でシグネチャとしての明示だけならば

def foo(&)
  yield
end

と仮引数を省略してブロックを受け取ることだけ書けるようにしようという提案を見た気がする。(うろ覚え)

んだけど b.r-l.o 検索力が低くて見つけられない……=■●_

3 行まとめ

LT 王を取った

結果発表!最も票を集めたのは @onk さんでした!おめでとうございます!#megurorb pic.twitter.com/J92A777kiq

— Meguro.rb (@megurorb) December 19, 2017

なんと賞品で ルビィのぼうけん コンピューターの国のルビィ サイン本をいただきました。

優勝者の @onk さんには、 @yotii23 さんのサイン入りで「ルビィのぼうけん コンピューターの国のルビィ」をお贈りします!おめでとうございます!#megurorb pic.twitter.com/obfB74YPdG

— Meguro.rb (@megurorb) December 19, 2017

あ、あしたルビィのぼうけんのKindle出るらしいですよ。お子さんには物理本の方がいいと思うけど、中身読みたいけど絵本はかさばるなぁという大人にどうぞ。

— やきとりい (@yotii23) July 28, 2016

(これは前作 ルビィのぼうけん こんにちは!プログラミング のときの tweet)

僕は絵本はかさばるなぁという大人だったのですが、やっぱり絵本という媒体は手に取ってみると物理本が良いですねぇ。

まずは35歳児が童心に返ってページをめくります。ありがとうございます!