『プログラミングElixir』出版記念: Elixir、Ruby、Squeak Smalltalkでspawn/chain.exの速度対決

なぜか Ruby インタプリタ開発者が翻訳をしたことで話題の『プログラミング Elixir』 p.167 にある「14.2 プロセスのオーバヘッド」のサンプルコード


これと似たようなことを Ruby の軽量スレッド(Fiber)と Squeak Smalltalk のプロセスでチャレンジしてみようという試みです。もちろん、Elixir や Erlang のプロセスとはいろいろ違うので、かなり大雑把に似たような処理…ということでご勘弁ください。^^;


ちなみに手元の Elixir では spawn/chain.ex の結果はこのようになりました。

$ elixir -v
Erlang/OTP 19 [erts-8.0] [64-bit] [smp:4:4] [async-threads:10]

Elixir 1.3.1


$ elixir -r spawn-chain.ex -e "Chain.run(10000)"
{62000, "Result is 10000"}


$ elixir -r spawn-chain.ex -e "Chain.run(40000)"
{156000, "Result is 40000"}


$ elixir -r spawn-chain.ex -e "Chain.run(100000)"
{438000, "Result is 100000"}


$ elixir -r spawn-chain.ex -e "Chain.run(400000)"
12:31:03.936 [error] Too many processes
** (SystemLimitError) a system limit has been reached


$ elixir --erl "+P 1000000" -r spawn-chain.ex -e "Chain.run(400000)"
{1609000, "Result is 400000"}


$ elixir --erl "+P 1000000" -r spawn-chain.ex -e "Chain.run(1000000)"
{4344000, "Result is 1000000"}

4万で 156ミリ秒 、40万で 1.61秒、100万で 4.34秒とはさすがです。


Squeak Smalltalk

残念ながら Squeak/Pharo には Io のようなアクター(あるいはメッセージの非同期通信)機能は組み込みではない上、プロセス(通常の言語でいうところのスレッド)についても resume の際に後述の Ruby の Fiber のように引数を与えることができないため、 Elixir の spawn/chain.ex の動きをストレートには再現できません。

そこで、SharedQueue(next を受け取ると、他のプロセスから nextPut: 等でエレメントがプッシュされるまでアクティブプロセスを停止)をメッセージキューに見立てたアクターっぽい機構で似たような動きを再現してみました。なお、Squeak/Pharo ではブロック(無名関数。[] で処理を括ったもの)に fork というメッセージを送ると、アクティブプロセスと同じ優先度で新しいプロセスが立ち上がるしくみになっています(今回は使いませんが、優先度を変更するには forkAt: を用います。ちなみに優先度が違うプロセス同士の並行処理はノンプリエンプティブっぽく振る舞います)。

| N time ans |
N := 40000.
Smalltalk garbageCollect.
time := [
   | me last |
   me := SharedQueue new.
   last := (1 to: N) inject: me into: [:sendTo :dummy |
      | mbox |
      mbox := SharedQueue new.
      [sendTo nextPut: mbox next + 1] fork.
      mbox
   ].
   last nextPut: 0.
   ans := me next
] timeToRun milliSeconds.
^{time. 'Result is ', ans printString}
=> {382 . 'Result is 40000'}
=> {3526 . 'Result is 400000'}

結果は、4万で 382ミリ秒、40万で 3.53秒(Squeak5.0 で計測。Squeak では Workspace、Pharo なら Playground へのコピペ → print it で動作します)。もちろん、Squeak/Pharo Smalltalk のプロセスは Elixir のそれと比べて限定的なので、あくまで参考値ではありますが、よく健闘してます。しかし試してみたところ 70万プロセスになると VM が落ちます。無念。


Ruby

Squeak/Pharo のプロセスとは違い Ruby の軽量スレッドである Fiber は、引数を受け取ることができます(今回は使いませんでしたが返値も返せます)。そこで Ruby 向けには Squeak/Pharo とは別のアプローチで spawn/chain.ex と似たような動きになる処理を書いてみました。なお、計時結果の数値の単位は秒です。

$ cat spawn-chain.rb
require 'benchmark'
require 'fiber'

n = ARGV[0].to_i
n = 10 if n == 0
ans = 0
time = Benchmark.realtime{
  me = Fiber.new{ |n| ans = n }
  last = (1..n).reduce(me){ |send_to,_|
    Fiber.new{ |n| send_to.transfer(n + 1) }
  }
  last.resume(0)
}
p [time, "Result is #{ans}"]


$ ruby -v
ruby 2.4.0dev (2016-08-22 trunk 55983) [x86_64-cygwin]


$ ruby spawn-chain.rb 40000
[22.913834010018036, "Result is 40000"]

結果は 4万で 23秒とふるわず。ちなみに 5万以上ではエラーで計測不能でした。Ruby3 での高速化に期待したいところです。