えんどうさんの「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.R:
library(ggplot2) d <- read.csv("2015-10/transcode-tblgen-frozen.csv") p <- qplot(frozen_string_literal, user.s., geom="jitter", data=d) + scale_y_continuous("user[s]") print(p)
左が以前のもの、右が 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 な挙動をデフォルトにしようという意図もなかった。これをデフォルトにするのは非常に大きな非互換性が発生するため困難なことはわかりきっている。でも、もし非互換性を乗り越えられるのであれば、デフォルトにするのは良いことだと思う。同時に、非互換性によりデフォルトにできない可能性も十分あると思う。
ささださんの記事には、Ruby の世代別 GC のオーバーヘッドについて以下のように書いてある。
これで気になるのは sweep である。(伝統的な Copying GC とは異なり) 全領域に比例した時間がかかるので、プロセスに存在するオブジェクトが多ければ多いほど動作は遅くなるはずである。それで、どのくらい遅くなるのか、可視化してみようと思い立った。
以下のようなプログラムを測定することにする。
an = [] N.times { an << Object.new } while true am = [] M.times { am << Object.new } end
N個の GC されないオブジェクトが生成された後、M個のオブジェクトが生成されては捨てられるのが繰り返される。つまり、生きているオブジェクトの数はのこぎり状に推移する。
このプログラムは無限ループだが、測りたいのは GC の sweep の時間なので、GC がある程度動いたら終了することにする。具体的には minor GC が 10回動くまで繰り返す。また、測定時間が増えすぎても困るので10秒を越えても打ちきる。このため、ループの実行回数は決まっていない。
そして sweep の速さ遅さをどう数値化するかが問題なのだが、ここでは Object.new ひとつあたりにかかる時間を測ってみよう。ループを nloop 回実行して M*nloop個のオブジェクトを生成したのに t 秒かかったとすると、ひとつあたり t/(M*nloop) 秒、というわけである。もちろん、この値が小さいほど速い。(興味があるのは定常状態なので、最初の N 個のオブジェクトの生成にかかる時間は含めない)
N, M は 0 から 10**7 まで変えてみた。64bit 環境で動かしたのでオブジェクトはひとつ 40bytes であり、最大で 40*2*10**7=800Mbytes くらい確保することになる。
GC_STAT_KEYS = GC.stat.keys def make_script(n, m) script = <<"End" n = #{n} m = #{m} End script << <<'End' an = [] n.times { an << Object.new } first_gc_stat = GC.stat major_gc_count1 = first_gc_stat[:major_gc_count] minor_gc_count1 = first_gc_stat[:minor_gc_count] t1 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) nloop = 0 while true am = [] m.times { am << Object.new } nloop += 1 t2 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) minor_gc_count2 = GC.stat[:minor_gc_count] minor_gc_count = minor_gc_count2 - minor_gc_count1 break if 10 <= minor_gc_count break if 10.0 < t2 - t1 end last_gc_stat = GC.stat major_gc_count2 = last_gc_stat[:major_gc_count] minor_gc_count2 = last_gc_stat[:minor_gc_count] timediff = (t2-t1) time_per_alloc = timediff/(nloop*m) major_gc_count = major_gc_count2 - major_gc_count1 minor_gc_count = minor_gc_count2 - minor_gc_count1 print "#{n},#{m},#{time_per_alloc},#{timediff},#{nloop},#{major_gc_count},#{minor_gc_count}" End GC_STAT_KEYS.each {|k| script << "print \",\#{first_gc_stat[:#{k}]}\"\n" } GC_STAT_KEYS.each {|k| script << "print \",\#{last_gc_stat[:#{k}]}\"\n" } script << <<'End' puts End #puts script; exit script end nmax = 10000000 mmax = 10000000 nstep = 100 mstep = 100 print "N,M,TimePerAlloc[s],TimeDiff[s],Nloop,MajorGC,MinorGC" GC_STAT_KEYS.each {|k| print ",first_#{k}" } GC_STAT_KEYS.each {|k| print ",last_#{k}" } puts 1.upto(nstep) {|i| n = nmax * i / nstep 1.upto(mstep) {|j| m = mmax * j / mstep IO.popen("./ruby", "w") {|f| f << make_script(n, m) } } }
測定結果は <URL:2015-10/gc-2d.csv> となった。
測定した Ruby のバージョンは以下のとおり。Kazuho さんの改善が入る直前くらいのバージョンである。
% ./ruby -v ruby 2.3.0dev (2015-10-07 trunk 52068) [x86_64-linux]
まず、M に対するループ回数をみてみよう。
gc-2d-loop.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p <- ggplot(d, aes(M, Nloop, color=MinorGC)) p <- p + geom_jitter() p <- p + scale_y_log10() print(p)
M が小さければ GC を起こすためにたくさんループしないといけないし、M が大きければ実行時間の制約からたくさんループすることはできない。グラフを見ると、たしかに右肩下がりでそうなっているようである。
minor GC の数を色で示してあるが、minor GC が 10回起きずに実行が打ちきられる (色が黒っぽい) のは、M が大きいときに起きているようだ。
M に対する実行時間をみてみよう。各 N に対して線を引いている。
gc-2d-timediff.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p <- ggplot(d, aes(M, TimeDiff.s., color=MinorGC, group=N)) p <- p + geom_line() p <- p + scale_y_continuous("TimeDiff[s]") print(p)
M が増えると minor GC が 10回起こるまでの時間が増えていくが、10秒を越えると打ちきられているのがみてとれる。(でも、なんで M が増えると時間が増えていくんだろう?)
さて、N, M に対してオブジェクトひとつの生成にかかる時間がどう変化するかみてみる。
gc-2d-raster.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p <- ggplot(d, aes(N, M, fill=TimePerAlloc.s.)) p <- p + geom_raster() p <- p + scale_fill_gradient("TimePerAlloc[s]") p <- p + coord_cartesian(c(0,1e7), c(0,1e7)) print(p)
おおざっぱには右や上に行くほど明るくなっているかんじなので、N や M が大きくなると、生成は遅くなっているぽい。
おもしろいのは縦横斜めに線が見えることだな。この線はなんだろう、と思って調べると、どうも major GC の回数が変化しているところらしい。以下は N, M に対して major GC の回数を示したものだが、形がよく似ている。(こんなことになるなら、major GC の回数が一定になるように測定した方がよかったかもしれない。いや、major GC が起きないと困るか。)
gc-2d-MajorGC.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p <- ggplot(d, aes(N, M, fill=MajorGC)) p <- p + geom_raster() p <- p + coord_cartesian(c(0,1e7), c(0,1e7)) print(p)
ついでに minor GC も示しておくと以下のようになる。
gc-2d-MinorGC.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p <- ggplot(d, aes(N, M, fill=MinorGC)) p <- p + geom_raster() p <- p + coord_cartesian(c(0,1e7), c(0,1e7)) print(p)
だいたいは限界の 10回実行されているが、オブジェクト数が多くなると 6回くらいまで落ちるようだ。とすると、10秒の制限をつけなかったとしても、測定時間は許容可能だったかもしれない。
N に対するオブジェクトひとつの生成時間をプロットしてみる。各 M に対して線を引いている。
gc-2d-N.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p = ggplot(d, aes(N, TimePerAlloc.s., group=M, color=M)) p <- p + geom_line() p <- p + scale_y_continuous("TimePerAlloc[s]") print(p)
これをみると、かなりおおざっぱには、N に対して線形に生成時間が増えているぽい。10e7 個のオブジェクトで 0.25e-7 秒くらい増えているので、オブジェクトが 100万個増えるたびにオブジェクトひとつの生成が 2.5 ns 遅くなる、というくらいかな。これは最初の「全領域に比例した時間がかかる」ということの可視化になっているのではなかろうか。
ついでに、M に対するオブジェクトひとつの生成時間をプロットしてみる。各 N に対して線を引いている。
gc-2d-M.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p = ggplot(d, aes(M, TimePerAlloc.s., group=N, color=N)) p <- p + geom_line() p <- p + scale_y_continuous("TimePerAlloc[s]") print(p)
これは... 全体としては右肩上がりではあるが、ブロックに分かれていて、個々のブロックでは右肩下がりになっているのか。
つまり、オブジェクトの生成数を減らすと遅くなる、ということがあっても不思議でない、ということだろうな。
こういうふうにいろんなグラフを描いてひとつひとつ確認していくのは面倒くさいよな、と感じてなんか測定した各項目の対それぞれのグラフをぜんぶ並べてくれるのがあったんじゃないかと思い出して調べると、それはペアプロットというもので、GGally というので描けるらしい。以下のようにして使えるようだ。
gc-2d-ggally.R:
library(GGally) d <- read.csv("2015-10/gc-2d.csv") p = ggpairs(d[c(1,2,4,5,6,7,3)]) print(p)
オブジェクトひとつの生成にかかる時間をみると縦横斜めの線が見えて、それが major GC の数と関係しているっぽいと述べたが、生成時間を (前と同じく) 色で示して、その上に等高線で major GC の数を描けばおなじ形であることが明確になるのではないかと思いついた。
で、描いたのが以下である。
gc-2d-raster-contour.R:
library(ggplot2) d <- read.csv("2015-10/gc-2d.csv") p = ggplot(d, aes(N, M, fill=TimePerAlloc.s., z=MajorGC)) + geom_raster() + geom_contour(color="#FFFFFF") print(p)
やはり、かなり似ていることがわかる。色の変化と等高線による分割は同じような形になっていると思う。
脳からみた心 (著: 山鳥重)を読んだ。
なかなかおもしろかった。読んだ後で検索したら、けっこう定番な本のようだ。もともと1985年にNHK出版から出たのは入手できない時期があったみたいだけど、2013年に角川ソフィア文庫で出し直したようだ。
プログラマから見ると、さまざまなバグの症状からブラックボックスなプログラムの構造を推測するというような話に思えた。ここでいう「ブラックボックスなプログラム」は脳で、「バグの症状」は様々な症例なわけだけど。
紹介されているいろんな例をみると、人間の認知というのは、勝手に動作するたくさんのプロセスが重層的に組み合わさっているという感じに思える。
「脳からみた心」を読みながら考えていたのだが、API のデザインでも「人間の認知が、勝手に動作するたくさんのプロセスが重層的に組み合わさっている」ということを利用している気がする。
たとえば、この前のRuby開発者会議で、map_keys, map_valuesの提案 について議論があった。hash.map_values は、hash の値それぞれを変換した新しいハッシュを返すメソッドである。いつものことで名前問題なのだが、hash.map_values は hash の値それぞれを変換した値の配列を返すようにみえて良くない、という (しばたさんの) 意見があった。私はそれを hash.map_values と hash.values.map が似すぎている (どちらも map と values という単語から構成されていて、単語の順序と間の記号しか違いがない) ことが問題なのではないかと述べた。
ハッシュにはすでに values メソッドと map メソッドが存在するので、hash.values.map という記述が可能で、これが hash の値それぞれを変換した値の配列を求める書き方である。hash.map_values は、配列ではなくハッシュを返すという違う動作なのだが、似すぎていて、プログラマは区別しづらいのではないか、というわけである。
ここで私が述べたことは hash.map_values という記述をみたときに、map という単語と values という単語からの連想が頭の中で勝手に行われてプログラマの認識に影響する、ということを前提としている。この前提は、言語処理系が行う解釈 (文法にしたがってメソッド呼び出しを認識してからメソッドの名前の解釈を行う) とは異なる。
また、最近の matz の tweet で、「実際、String#+,-についていろいろ考えてるんですよね。-(マイナス)は氷点下からfrozenを連想するとか。」 というのがあった。これも、+ や - という記号からの連想が勝手に起こることが前提となっているように思う。
そういう頭の中で勝手に起こる連想が、実際の動作と一致する (あるいは、少なくとも矛盾しない) というのは API デザインの大きな要素なのではないだろうか。
DSL (Domain Specific Language) もそのような考えで理解できる。DSL をつくるときは domain における記述になるべく合わせて記述できるように工夫する。そうしておけば、domain における記述からの意味の連想が頭の中で勝手に起こる。そして、その連想のとおりの動作を行うようにしておけば、勝手に起こる連想を利用できるので、人間は自然に解釈できるようになる。
人間がプログラムを読むときには、言語処理系の解釈 (もっと正確にはプログラミング言語の意味論) にしたがって解釈すべきだ、という考え方もあるかもしれない。そうしないと間違う可能性があるのは確かで、プログラムが規格の範囲内かどうかを検討するとか、意識的にそうやって解釈しなければならない場合があるのはまちがないない。それに、プログラミングの学習で、上で述べたような人間の話を正面から扱うことはあまりないので、言語処理系が行う解釈しか意識しないひとは多そうな気がする。
そのように考えていると、上記の map, values, +, - といった字面からの連想というのは些細な話だと思うかもしれない。でも、人間の認知のやりかたを考えると、些細な話ではないのだ。
[latest]