天泣記

2015-10-07 (Wed)

#1 frozen_string_literal: true の効果

えんどうさんの「Ruby 3.0 の特大の非互換について」が話題になっているので定量的な話と社会問題について述べてみよう。

文字列リテラルの評価において文字列オブジェクト生成を抑制することで本当に速くなるのか、というのは興味のあるところである。ぜんぜん速くならないなら、.freeze をつけるようなリクエストはぜんぶ拒否してしまえばいいわけで、社会問題などという話にはならない。

ささださんの計測によれば、文字列オブジェクト生成に対して、GC のコストは少ないそうである。とはいえ、文字列オブジェクトを生成しなければ、生成コストと GC コストを除去できるので、それが積み上がって実際のアプリケーションがどの程度速くなるのか、というのが問題である。(GC コストについては、除去されるというより、GCの間隔が開く、というべきかもしれない)

というわけで、ためしに transcode-tblgen.rb で試してみた。これは、Ruby のエンコーディング変換器のソースコード生成を行うプログラムで、Ruby をビルドするときに動くものである。サイズは 1000行くらいで、文字列リテラルはざっと数えて200個以上ある。frozen_string_literal: true をつけて動かすためには dup の呼び出しを文字列リテラル 7個に付加する必要があった。

以下のようにしてしばらく動かしてみた。

% ./miniruby -v
ruby 2.3.0dev (2015-10-06 trunk 52062) [x86_64-linux]
% while true; do
echo -n "frozen: "
rm -f /tmp/big5.c; time ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c enc/trans/big5.trans >& /dev/null
echo -n "org: "
rm -f /tmp/big5.c; time ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c enc/trans/big5.trans >& /dev/null
done
frozen: ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c  >&/dev/null  3.04s user 0.06s system 99% cpu 3.099 total
org: ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c  >&/dev/null  3.12s user 0.04s system 99% cpu 3.162 total
frozen: ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c  >&/dev/null  3.03s user 0.06s system 99% cpu 3.104 total
org: ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c  >&/dev/null  3.25s user 0.02s system 99% cpu 3.274 total
frozen: ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c  >&/dev/null  3.04s user 0.02s system 99% cpu 3.070 total
org: ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c  >&/dev/null  3.09s user 0.03s system 99% cpu 3.128 total
frozen: ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c  >&/dev/null  3.01s user 0.04s system 99% cpu 3.052 total
org: ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c  >&/dev/null  3.21s user 0.04s system 99% cpu 3.250 total
frozen: ./miniruby -Ilib tool/transcode-tblgen-frozen.rb -vo /tmp/big5.c  >&/dev/null  3.05s user 0.04s system 99% cpu 3.093 total
org: ./miniruby -Ilib tool/transcode-tblgen-org.rb -vo /tmp/big5.c  >&/dev/null  3.10s user 0.04s system 99% cpu 3.142 total
...

tool/transcode-tblgen-frozen.rb が frozen_string_literal: true をつけたもので、tool/transcode-tblgen-org.rb がもとのものである。

とりあえず total のところをみると、frozen のほうは 3.0秒台がいくつもでているのに、 org のほうは 3.1秒台が多いので、いくらか速くなっているようだ。

整形してCSVに変換して、プロットしてみると以下のようになる。

transcode-tblgen-frozen.png

左が以前のもの、右が frozen_string_literal: true としたものである。縦軸はユーザモードで消費した時間であり、frozen_string_literal: true としたほうがあからさまに処理時間が短く、つまり速くなっている。

数値でまとめると以下のようになる。

> summary(d$user.s.[d$frozen_string_literal == "false"])
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
  3.000   3.070   3.090   3.099   3.120   3.660
> summary(d$user.s.[d$frozen_string_literal == "true"])
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
  2.930   3.000   3.020   3.027   3.040   3.520

平均で処理時間が 3.099秒から 3.027秒に減っているので、減っているのは 2.3% ほどである。

変更点は以下のようになる。

% wc tool/transcode-tblgen-org
 1074  2874 28509 tool/transcode-tblgen-org.rb
% diff -u tool/transcode-tblgen-org.rb tool/transcode-tblgen-frozen.rb
--- tool/transcode-tblgen-org.rb        2015-10-06 22:50:25.080244672 +0900
+++ tool/transcode-tblgen-frozen.rb     2015-10-06 22:48:48.684242011 +0900
@@ -1,3 +1,6 @@
+#
+# -*- frozen_string_literal: true -*-
+
 require 'optparse'
 require 'erb'
 require 'fileutils'
@@ -53,7 +56,7 @@
     @type = type
     @name = name
     @len = 0;
-    @content = ''
+    @content = ''.dup
   end

   def length
@@ -517,7 +520,7 @@
     infos = infos.map {|info| generate_info(info) }
     maxlen = infos.map {|info| info.length }.max
     columns = maxlen <= 16 ? 4 : 2
-    code = ""
+    code = "".dup
     0.step(infos.length-1, columns) {|i|
       code << "    "
       is = infos[i,columns]
@@ -817,7 +820,7 @@
 end

 TRANSCODERS = []
-TRANSCODE_GENERATED_TRANSCODER_CODE = ''
+TRANSCODE_GENERATED_TRANSCODER_CODE = ''.dup

 def transcode_tbl_only(from, to, map, valid_encoding=UnspecifiedValidEncoding)
   if VERBOSE_MODE
@@ -881,7 +884,7 @@
 end

 def transcode_register_code
-  code = ''
+  code = ''.dup
   TRANSCODERS.each {|transcoder_name|
     code << "    rb_register_transcoder(&#{transcoder_name});\n"
   }
@@ -1006,7 +1009,7 @@
   this_script = File.read(__FILE__)
   this_script.force_encoding("ascii-8bit") if this_script.respond_to? :force_encoding

-  base_signature = "/* autogenerated. */\n"
+  base_signature = "/* autogenerated. */\n".dup
   base_signature << "/* #{make_signature(File.basename(__FILE__), this_script)} */\n"
   base_signature << "/* #{make_signature(File.basename(arg), src)} */\n"

@@ -1044,7 +1047,7 @@
   libs2 = $".dup

   libs = libs2 - libs1
-  lib_sigs = ''
+  lib_sigs = ''.dup
   libs.each {|lib|
     lib = File.basename(lib)
     path = File.join($srcdir, lib)
@@ -1053,7 +1056,7 @@
     end
   }

-  result = ''
+  result = ''.dup
   result << base_signature
   result << lib_sigs
   result << "\n"

このスクリプトについて、このようにコードを変更するのは難しくない。frozen_string_literal: true をつけて実行して、動かないところがあったら文字列リテラルに dup をつけて対処すればいいだけだ。それに対して、各文字列リテラルに .freeze をつけていくというのは、無差別につけるのでなければ、ベンチマークをとりながらホットスポットを探さなければならない。つまり、変更をおこなうのはずいぶんと楽になっている。

さて、社会問題が発生する状況として、このような高速化パッチを送る、受け取る、という状況を考えよう。

パッチを作って送る立場からいうと、上記のようにホットスポットを探す必要がないぶん簡単である。また、文字列生成の抑制の機会があるすべての文字列リテラルで抑制できるので、この手法で高速化できる限界まで高速化できる。.freeze の場合は、コードの美しさや保守性の点から、あまり効かないところにつけるわけにはいかないので、どこまでつけるか恣意的な判断が必要になる。

あなたがパッチを受け取る立場ならどうだろうか。frozen_string_literal: true でなく、.freeze が使われたとすると、ベンチマークではトータルで 2.3% 速くなるという話で、もともと存在する文字列リテラル200個程度のうちいくつかに .freeze がついているパッチが送られてくる。でも、パッチを受け取ったあなたはそれぞれの .freeze が実際にどのくらい高速化に寄与しているかはわからない。あなたなら取り込むだろうか、あるいは拒否するだろうか。

拒否するなら、どんな理由で拒否するだろうか。たとえば、.freeze が多くて美しくないとか保守性が悪いとかだろうか。その場合、美しさや保守性と高速化を自信を持って比較できるだろうか。どのくらい高速化するなら美しさや保守性に見合うんだろう? そういう悩みをかかえることにならないだろうか。もちろん、あなたが比較の判断に自信を持っていたとしても、パッチを送ってきた人が同じ判断をするとは限らない。というか、パッチを送ってきたということは、おそらく高速化の方が重要だと考えているのだろう。だから、明確な結論を出せない論争になるかもしれない。それでも拒否するだろうか。

また、frozen_string_literal: true が使われた場合を考えよう。トータルで 2.3% 速くなるという話で、変更点の各 .dup はその文字列リテラルが生成した文字列オブジェクトは変更される可能性があるから、という意味で理解できる。あなたなら取り込むだろうか、あるいは拒否するだろうか。

拒否するなら、どんな理由で拒否するだろうか。たとえば、ホットスポットを調べるべきだ、といって拒否するのは適切だろうか? 実際、私は文字列オブジェクト生成の抑制が個々の文字列リテラル毎にどのくらいの効果があったのかは調べていないし、わからない。この場合、それはいけないことだろうか?

私はべつに問題ないと思う。早すぎる最適化というのは、最適化することでコードの保守性が落ちるというのが問題で、frozen_string_literal: true については保守性は落ちていない。だから、これを早すぎる最適化といって批判するのはあたらないと思う。

つまり、frozen_string_literal: true と指定できる機能の導入により、

逆にいえば、今の状態は明確な結論を出せない論争を開発者に強要するという社会問題を発生させる仕様になっている。現在の frozen_string_literal: true はそれを解決しようという話だ。

もちろん、これは Rails に限った話ではない。Rails が .freeze を利用していること、松田さんが開発者会議に来てファイル単位の指定を繰り返しプッシュしたのは事実だけれど、Ruby 自体の話であり、Ruby で書かれたあなたのソフトウェアに .freeze をつけるリクエストが来る可能性はある。実際、Ruby の標準添付ライブラリにもいくつか来ている。そういうリクエストが来れば開発者はどう対応するか考えなければならない。

なお、frozen_string_literal: true というようなファイル単位での指定の時点では、社会問題を解決するという狙いがあったわけではない。そもそも、その時点では "...".freeze で文字列オブジェクト生成を抑制する機能は存在しなかった。提案時は "..."f というシンタックスが入ったころで、このシンタックスの問題を解決し、かつ、それが狙いとするオブジェクト生成の抑制を実現することが主な狙いであった。結局そのときは "..."f のかわりに "...".freeze が入ったわけだけど。

あと、提案時点では、frozen_string_literal: true な挙動をデフォルトにしようという意図もなかった。これをデフォルトにするのは非常に大きな非互換性が発生するため困難なことはわかりきっている。でも、もし非互換性を乗り越えられるのであれば、デフォルトにするのは良いことだと思う。同時に、非互換性によりデフォルトにできない可能性も十分あると思う。


[latest]


田中哲