CPU律速なRuby/Pythonコードはデフォルト設定のdocker上で遅くなる

English version

要約

dockerはデフォルトでセキュリティ機構(Spectre脆弱性の対策)を有効にします。この影響で、RubyPythonのようなインタプリタは速度が劣化します。特にCPU律速なプログラムで顕著に遅くなります(実行時間が倍くらいになることがあります)。

現象

Rubyで1億回ループするコードを、直接ホスト上で実行する場合と、docker上で実行する場合で実行時間を比較してみます。

直接ホスト上で実行した場合:

$ ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
1.321703922 sec

docker上で実行した場合:

$ docker run -it --rm ruby:2.7 ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
2.452876383 sec

dockerを使うと実行時間がおよそ倍になっています(1.3秒→2.5秒)。

docker runに--security-opt seccomp=unconfinedというオプションを与えて実行すると、ホストと同等程度の速度になります。

$ docker run --security-opt seccomp=unconfined -it --rm ruby:2.7 ruby -ve 't = Time.now; i=0;while i<100_000_000;i+=1;end; puts "#{ Time.now - t } sec"'
ruby 2.7.1p83 (2020-03-31 revision a0c7c23c9c) [x86_64-linux]
1.333669449 sec

Ruby特有ではなく、Pythonでも同じ現象が起きます(i=0 / while i<100000000: i+=1 という2行のコードで7.0秒→11秒)。

なお、カーネルの設定次第では、この現象が再現しないこともあります(後述)。

原因

LinuxカーネルにはSpectre脆弱性の対策がいくつか実装されています。

この対策の中に間接分岐予測を抑制するもの(STIBPと呼ばれる)があり、CPU律速なコードが50%ほど速度低下するということで、Linuxカーネルのデフォルトではオフになっています(参考)。 一方、dockerはデフォルトで、Spectre脆弱性の対策を有効にしてコンテナを実行します。*1

これはほぼすべてのプログラムの速度を低下させます*2が、RubyPythonのようなインタプリタは間接分岐(switch/caseやdirect threadingなど)を多用しているので、特に強く影響を受けます。dockerの外では対策がオフなので高速に実行され、dockerの中ではオンなので速度劣化が起きます。

証拠として、上記のベンチマークプログラムのperf statを見てみると、branch-missesが圧倒的に増えていることが観察できました(522,663回→199,260,442回)。 https://www.phoronix.com/scan.php?page=article&item=linux-420-stibp&num=2 --security-opt seccomp=unconfined あり(Spectre対策なし)

 Performance counter stats for process id '153095':
          1,235.67 msec task-clock                #    0.618 CPUs utilized          
                 8      context-switches          #    0.006 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                 2      page-faults               #    0.002 K/sec                  
     4,284,307,990      cycles                    #    3.467 GHz                    
    13,903,977,890      instructions              #    3.25  insn per cycle         
     1,700,742,230      branches                  # 1376.378 M/sec                  
           522,663      branch-misses             #    0.03% of all branches        
       2.000223507 seconds time elapsed

--security-opt seccomp=unconfined なし(Spectre対策あり)

 Performance counter stats for process id '152556':
          3,300.42 msec task-clock                #    0.550 CPUs utilized          
                16      context-switches          #    0.005 K/sec                  
                 2      cpu-migrations            #    0.001 K/sec                  
                 2      page-faults               #    0.001 K/sec                  
    11,912,594,779      cycles                    #    3.609 GHz                    
    13,906,818,105      instructions              #    1.17  insn per cycle         
     1,701,237,677      branches                  #  515.460 M/sec                  
       199,260,442      branch-misses             #   11.71% of all branches        
       6.000985834 seconds time elapsed

なおこの問題は、STIBPがconditional(デフォルトはオフだがseccompで有効にできる)のときだけ起きます。STIBPがdisabled(常にオフ)またはforced(常にオン)の環境では、dockerだけ遅いという現象は起きません。disabledの場合ホストもdockerも速い(そして脆弱)、forcedの場合はホストもdockerも遅い、ということになります。*3

ちなみに、STIBP以外にも、投機的ストアバイアスのサイドチャネル攻撃の対策(spec_store_bypass_disable)もかなり速度劣化を起こすようです。笹田耕一さんの調査によると spectre_v2_user=off spec_store_bypass_disable=off というカーネルオプションを用いることで、--security-opt seccomp=unconfinedなしのDocker上でも速度劣化がなくなったということでした。

対策

残念ながら、あまりオススメできる対策はありません。--security-opt seccomp=unconfined をつければ速くなりますが、Spectre攻撃に対して脆弱になるのでやめたほうがいいでしょう(ちなみに--privilegedでも速くなります)。

ただし、これはCPU律速なコードでしか問題にならないと思います。Ruby on Railsを使ったWebアプリは多くの場合IOやGC律速になっているので、Spectre対策を止めても観測できるほどの速度向上はしないでしょう(たぶん)。なので当面気にしないことをおすすめします。

長期的には、CPU側でSpectre対策がなされればこの問題は解決すると思います(ただし10年スパンで考える必要はある)。もしくは、笹田耕一さんがRubyKaigi 2019で発表していた実行方式(context threading)は間接分岐を多用しないので、これが取り込まれれば解決するかもしれません(数年スパンで考える必要はある)。

謝辞

  • 相談ツイートに反応してくださったみなさん(特に@ryotaraiさんのリツイート
  • ruby-jp Slackの #container チャンネルのみなさん(特に件のオプションが効く理由を調査してくれた笹田耕一さん)
  • Rubyコミッタのみなさん

余談

Ruby 3の速度目標ベンチマークになってるoptcarrotがこの影響をとても受けて、ホストだと33 fpsなのがdockerだと14 fpsになるので困ってました。原因と(一応の)対策がわかったので、これでベンチマークが取れる。

蛇足(2020/05/23 12:00)

  • 律速とは、速度を決定する要因、つまりボトルネックのことです :-)

  • dockerが遅いと言えば、ふつうはdockerが仮想化しているファイルシステムやネットワーク関係を疑うところですが、メイン部で一切syscallをしないCPU律速なコードでも遅くなることがある、というのが意外なところでした。

  • Ruby/Pythonコードは」というタイトルにしてしまいましたが、本文に書いた通りだいたいどんなプログラムでも遅くなります(脚注に書いたように、実際Javamemcachedなども遅くなるらしい)。最初に気づいたのがRubyで、Rubyにはちょっと詳しいので限定して書いてしまった。他のプログラムよりRuby/Pythonのようなインタプリタは重症度がひどい可能性はあると思ってますが、特に比較はしていないし、ワークロードに強く依存するので比較は難しそうです。

  • この問題は「誰かが悪い」ということではないです :-) 用途を考えると、dockerがSpectre対策をデフォルトで有効にするのは理解できます(意識してやってるのか、たまたまそうなってるのかは不明ですが)。

*1:笹田注:より正確には、Linuxの起動時の設定が、「通常実行では脆弱性対策を有効にしない(脆弱なまま)だが、seccompなどを利用すると有効にする」となってるときにこの現象が起きるようです。手元の Ubuntu 18.04 がそういう設定でした。最近の Docker は、デフォルトでseccompを有効にするため、何もしないとこれらの対策が入り、速度劣化が観察できる、というようです。Linux Kernel の起動オプションで色々選べるようです。具体的な設定は Spectre Side Channels — The Linux Kernel documentation をご覧下さい。Docker起動時にseccompの設定で制御できるのかはわかりませんでした(出来ても良さそうですが)。

*2:この記事によると、STIBPはJava、Node.js、memcachedPHPなどのベンチマークで速度劣化が確認されたようです。

*3:どういう設定になっているかは /sys/devices/system/cpu/vulnerabilities/spectre_v2 というファイルを見ればわかります。