Dockerファイルがビルドできなかったのでコンパイラをいじめる

TL;DR

  • ある環境でビルドできたDockerfileが別の環境でビルドできなかったのは、メモリ制限のせいだった

はじめに

理研シミュレータというシミュレータがあります。

RIKEN-RCCS/riken_simulator

これは、「京」の次のスーパーコンピュータ「富岳」が採用しているアーキテクチャ「Fujitsu A64FX」のシミュレータです。Gem5というアーキテクチャシミュレータがあり、それにARM AArch64を実装したものです。

これを使うと、AArch64のプロセッサレベルでのシミュレートができるのですが、ビルドに結構手間がかかります。なので、その「手間」をまとめたDockerファイルを作りました。

kaityo256/aarch64env

Dockerファイルはこんな感じです。

FROM ubuntu:18.04
MAINTAINER kaityo256

ENV USER user
ENV HOME /home/${USER}
ENV SHELL /bin/bash

RUN useradd -m ${USER}
RUN gpasswd -a ${USER} sudo
RUN echo 'user:userpass' | chpasswd

RUN apt-get update && apt-get install -y \
    g++ \
    g++-8-aarch64-linux-gnu \
    git \
    m4 \
    python-dev \
    scons \
    sudo \
    vim \
    qemu-user-binfmt \
    zlib1g-dev

USER ${USER}

RUN cd ${HOME} \
 && mkdir build \
 && cd build \
 && git clone --depth 1 https://github.com/RIKEN-RCCS/riken_simulator.git

RUN cd ${HOME} \
 && cd build/riken_simulator \
 && sed -i "369,372s:^:#:" SConstruct \
 && scons build/ARM/gem5.opt -j 20

RUN cd ${HOME} \
 && git clone https://github.com/kaityo256/aarch64env.git

RUN cd ${HOME} \
 && echo alias gem5=\'~/build/riken_simulator/build/ARM/gem5.opt ~/build/riken_simulator/configs/example/se.py -c\' >> .bashrc \
 && echo alias ag++=\'aarch64-linux-gnu-g++-8 -static -march=armv8-a+sve\' >> .bashrc

たいしたことはしていません。ビルドで僕が詰まったところをちょこちょこ修正してからビルドしているだけです。Riken Simulatorはビルドにえらい時間がかかるのですが、手元に20コアのLinuxマシンがあったので、sconsに-j 20を指定して20並列でビルドしています。

さて、このDockerファイルがビルドできない、という連絡が来ました。Dockerって後ろがMacだろうがWindowsだろうかLinuxだろうが同じ環境を作ってくれるものなのに、環境依存性があるとは何事ぞ?と思って調査を始めました。こういう調査ログは、たまに誰かの役に立つこともあるので残しておきます。

調査ログ

並列ビルドとキャッシュ

まず疑うのはキャッシュです。Dockerはビルドする時にキャッシュするため、作業の手順によってはおかしなことがおきることがあります。まずは、ローカルで作業した人に--no-cacheの指定をお願いしましたが、やはりこけたという連絡が来ます。

次に疑ったのは自分のビルドです。キャッシュのせいでビルドできたけれど、実はクリーンビルドしたらこけるのではないかと思い、Linuxマシンで--no-cacheを指定してビルドしなおします。普通にビルドできます。

-j 20-j 4に減らしてもらってもこける、という報告がきます。また、並列ビルドをやめたら、こけなくはなったがビルドが途中で止まる、という連絡が来ました。

ローカルでのチェック

とりあえず、自分でもローカルマシンで試すことにしました。まずは-j 20のままビルドします。こけます。 j20.png

internal compiler errorさんお久しぶりです。整数を419378回インクリメントした時以来ですね。internal compiler error、略してICEですが、普通に生きていればあまり見かけないと思います。・・・というようなことをあるところで口走ったら、「え?ICEなんて日常的に見ますよね?」みたいな反応があったのでC++ガチ勢は怖いなと思いました。閑話休題。

とりあえず4コアしかないマシンで20並列するのもアレなんで、-j 4でやり直してみます。

j4.png

やっぱりこけますね。

さて、ビルドに失敗する理由がICEである、ということから、メモリ不足を疑います。

まず、LinuxサーバでDockerfileをビルド中にdocker statsで利用メモリを確認します。

memory.png

おおぅ、2.9GB使ってますね。

次に、ローカルのDockerのメモリ制限を見てみましょう。

docker_memory.png

Memoryが2.00GB。これですね。

メモリが潤沢にあるLinuxマシンで、2GBのメモリ制限をかけてビルドしなおしてみましょう。

docker build -t kaityo256/aarch64env:memtest -m 2gb . --no-cache 

linux_failed.png

はい、こけましたね。メモリ不足が原因と確定です。ローカルマシンでビルドに失敗した人には、DockerのSettingsのResourcesでメモリ上限を増やして再度試すようお願いし、ちゃんとビルドできることが確認できてめでたしでした。

どこでこけたか?

さて、Dockerファイルがビルドできない問題はこれで解決としても、「なんでこんなにメモリを消費したのか」は気になります。20並列はともかく、4並列でもこけて、シリアルビルドだとこけないけどビルドが止まってしまう、ということは、一つのファイルをコンパイルするのに2GB以上を使うファイルがあるはずです。それを調べてみましょう。

まずは、ビルド直前のイメージを作ります。

FROM ubuntu:18.04
MAINTAINER kaityo256

ENV USER user
ENV HOME /home/${USER}
ENV SHELL /bin/bash

RUN useradd -m ${USER}
RUN gpasswd -a ${USER} sudo
RUN echo 'user:userpass' | chpasswd

RUN apt-get update && apt-get install -y \
    g++ \
    g++-8-aarch64-linux-gnu \
    git \
    m4 \
    python-dev \
    scons \
    sudo \
    vim \
    qemu-user-binfmt \
    zlib1g-dev

USER ${USER}

RUN cd ${HOME} \
 && mkdir build \
 && cd build \
 && git clone --depth 1 https://github.com/RIKEN-RCCS/riken_simulator.git

RUN cd ${HOME} \
 && cd build/riken_simulator \
 && sed -i "369,372s:^:#:" SConstruct

このイメージをビルドします。

docker build -t kaityo256/aarch64before .

そして、2GBの制限をかけた上でコンテナを起動し、アタッチします。

docker run -it -u user -m 2gb kaityo256/aarch64before

userは、作業用に作ったユーザアカウントです。さて、とりあえず並列ビルドしてこけることを確認します。

cd
cd build
cd riken_simulator
scons build/ARM/gem5.opt -j 20

もう一枚ターミナルを開いて、docker statsでリソースを監視します。メモリのリミットが2GiBになっています。

で、こけたところで、続けて2並列でビルドしましょう。

scons build/ARM/gem5.opt -j 2

あるところでメモリを使い切り、ビルドが進まなくなります。

memory_full.png

ここでビルドを止めます。どこで止まったか調べるため、scons --dry-runしましょう。

$ scons build/ARM/gem5.opt --dry-run
(snip)
 [     CXX] ARM/arch/arm/generated/inst-constrs-3.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_1.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_2.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_3.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_4.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_5.cc -> .o
 [     CXX] ARM/arch/arm/generated/generic_cpu_exec_6.cc -> .o
(snip)

ビルドできていないターゲットの先頭はARM/arch/arm/generated/inst-constrs-3.oです。

こいつを単独でビルドしてみましょう。

scons build/ARM/arch/arm/generated/inst-constrs-3.o

もう一枚のターミナルでdocker statsで監視すると、メモリを使い切っていることがわかります。

memory_full_single.png

メモリが十分にあればビルドできるはずなので、このファイルをビルドするのにどれくらいのメモリが必要なのか調べてみましょう。

一度Dockerコンテナから出ます。そして、ビルド直前のイメージからやりなおします。こういうことができるのがDockerの便利なところですね。今度はメモリ制限をかけません。

docker run -it -u user kaityo256/aarch64before

利用メモリを調べるためにtimeをインストールします。

sudo apt install -y time

time -vをかませて、問題のファイルをビルドしてみましょう。普通にtimeとするとシェルのtimeが使われてしまうため、フルパスで指定します。

$ cd
$ cd build/riken_simulator/
$ /usr/bin/time -v scons build/ARM/arch/arm/generated/inst-constrs-3.o
(snip)
scons: done building targets.
        Command being timed: "scons build/ARM/arch/arm/generated/inst-constrs-3.o"
        User time (seconds): 117.09
        System time (seconds): 10.16
        Percent of CPU this job got: 101%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 2:05.54
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 2686200
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 2598152
        Voluntary context switches: 10887
        Involuntary context switches: 963
        Swaps: 0
        File system inputs: 0
        File system outputs: 177568
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

注目すべきは「Maximum resident set size」です。2686200 (kbytes)、つまりたった一つのファイルのコンパイルに2.56GB使ってますね。このファイルが原因と判明しました。

なぜそんなにメモリを食うのか

さて、問題のファイルがbuild/ARM/arch/arm/generated/inst-constrs-3.ccであると判明しました。inst-constrs-1.ccinst-constrs-2.ccという似たファイルもありますが、同様にtime -vで調べても(まぁまぁ使いますが)死ぬほどメモリを使っている、という感じはしません。

では、このファイルをどうやってビルドしているのか確認しましょう。まず、このファイル関連をクリーンします。SConsは-cをつけると関連ファイルを消してくれます。

scons -c build/ARM/arch/arm/generated/inst-constrs-3.o

次に、dry runでビルドコマンドを確認しましょう。

$ scons --dry-run build/ARM/arch/arm/generated/inst-constrs-3.o
(snip)
scons: Building targets ...
 [ISA DESC] ARM/arch/arm/isa/main.isa -> generated/decoder-g.cc.inc, generated/decoder-ns.cc.inc, generated/decode-method.cc.inc, generated/decoder.hh, generated/decoder-g.hh.inc, generated/decoder-ns.hh.inc, generated/exec-g.cc.inc, generated/exec-ns.cc.inc, generated/max_inst_regs.hh, generated/decoder.cc, generated/inst-constrs-1.cc, generated/inst-constrs-2.cc, generated/inst-constrs-3.cc, generated/generic_cpu_exec_1.cc, generated/generic_cpu_exec_2.cc, generated/generic_cpu_exec_3.cc, generated/generic_cpu_exec_4.cc, generated/generic_cpu_exec_5.cc, generated/generic_cpu_exec_6.cc
 [     CXX] ARM/arch/arm/generated/inst-constrs-3.cc -> .o
scons: done building targets.

情報ゼロです。SConsは通常、ビルドコマンドを表示してくれますが、SConstructの設定で消されているようです。見てみましょう。

if GetOption('verbose'):
    def MakeAction(action, string, *args, **kwargs):
        return Action(action, *args, **kwargs)
else:
    MakeAction = Action
    main['CCCOMSTR']        = Transform("CC")
    main['CXXCOMSTR']       = Transform("CXX")
    main['ASCOMSTR']        = Transform("AS")
    main['ARCOMSTR']        = Transform("AR", 0)
    main['LINKCOMSTR']      = Transform("LINK", 0)
    main['SHLINKCOMSTR']    = Transform("SHLINK", 0)
    main['RANLIBCOMSTR']    = Transform("RANLIB", 0)
    main['M4COMSTR']        = Transform("M4")
    main['SHCCCOMSTR']      = Transform("SHCC")
    main['SHCXXCOMSTR']     = Transform("SHCXX")

ここですね。オプションに--verboseがついていない場合、g++によるビルドが [ CXX]とだけ表示されるようになっているようです。

というわけで--verboseをつけてみましょう。

$ scons --verbose build/ARM/arch/arm/generated/inst-constrs-3.o
(snip)
g++ -o build/ARM/arch/arm/generated/inst-constrs-3.o -c -std=c++11 -pipe -fno-strict-aliasing -Wall -Wundef -Wextra -Wno-sign-compare -Wno-unused-parameter -Wno-error=suggest-override -g -O3 -DTRACING_ON=1 -Iext/pybind11/include -Ibuild/nomali/include -Ibuild/libfdt -Ibuild/libelf -Ibuild/iostream3 -Ibuild/fputils/include -Ibuild/drampower/src -Iinclude -Iext -I/usr/include/python2.7 -I/usr/include/x86_64-linux-gnu/python2.7 -Iext/googletest/include -Ibuild/ARM build/ARM/arch/arm/generated/inst-constrs-3.cc

コンパイルコマンドがわかりました。多数のインクルードファイルに依存しているようなので、それらを全部インクルードしたファイルを作りましょう。g++ -Eを使います。

g++ -E -std=c++11 -pipe -fno-strict-aliasing -Wall -Wundef -Wextra -Wno-sign-compare -Wno-unused-parameter -Wno-error=suggest-override -g -O3 -DTRACING_ON=1 -Iext/pybind11/include -Ibuild/nomali/include -Ibuild/libfdt -Ibuild/libelf -Ibuild/iostream3 -Ibuild/fputils/include -Ibuild/drampower/src -Iinclude -Iext -I/usr/include/python2.7 -I/usr/include/x86_64-linux-gnu/python2.7 -Iext/googletest/include -Ibuild/ARM build/ARM/arch/arm/generated/inst-constrs-3.cc > expanded.cc

これでexpanded.ccという、単独でコンパイルできるファイルができました。コンパイルして利用メモリを確認してみましょう。

$ /usr/bin/time -v g++ -O3 -S expanded.cc
        Command being timed: "g++ -O3 -S expanded.cc"
        User time (seconds): 73.39
        System time (seconds): 1.48
        Percent of CPU this job got: 99%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 1:15.06
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 1993036
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 1390330
        Voluntary context switches: 4
        Involuntary context switches: 94
        Swaps: 0
        File system inputs: 0
        File system outputs: 61456
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

2GBくらい使っています。元のビルドオプションには-gもついていたため、さらにメモリを食っていましたが、外しても結構あります。行数を見てみましょうか。

$ wc expanded.cc
 203875  533178 5907751 expanded.cc

20万行ですか。なかなかですね。

とりあえず、g++ -Eで生成したファイルの常として、空白行や#で始まる行が多いため、それらを削除しましょう。

sed -i '/^$/d' expanded.cc
sed -i '/^#/d' expanded.cc

これで15万行になりますが、まだ多いです。気合で中身を見てみると、ARMのISAを定義しているところが大部分で、最後の方に命令のデコーダ関連と思しきコードがあります。例えばこんなのです。

    static StaticInstPtr
    decodeNeonThreeRegistersSameLength(ExtMachInst machInst)
    {
...

なんか三個のレジスタで同じ長さの何かをどうにかするコードなんでしょうね。

で、ここからは気合です。#if 0#endifで囲ってはコンパイルして、どの部分がメモリを食うのかを調べます。すると、最後のデコーダまわりで、一番最後の名前空間

namespace ArmISAInst {
...
}

1万3千行のコンパイルにメモリを食っていることがわかりました。全体が15万5千行あるので、10%未満ですね。実はそのコードの前あたりでテンプレートが大量にあるので、テンプレート展開が原因だと疑っていたのですが、namespace ArmISAInstで囲まれた問題箇所にはテンプレートまわり怪しいところがありません。その代わり、やたらとswitch、caseがありました。特に、多段switchがあるのが気になります。

          case 0x2:
          case 0x3:
          {
            uint32_t imm12 = bits(machInst, 21, 10);
            uint8_t shift = bits(machInst, 23, 22);
            uint32_t imm;
            if (shift == 0x0)
                imm = imm12 << 0;
            else if (shift == 0x1)
                imm = imm12 << 12;
            else
                return new Unknown64(machInst);
            switch (opc) {
              case 0x0:
                return new AddXImm(machInst, rdsp, rnsp, imm);
              case 0x1:
                return new AddXImmCc(machInst, rdzr, rnsp, imm);
              case 0x2:
                return new SubXImm(machInst, rdsp, rnsp, imm);
              case 0x3:
                return new SubXImmCc(machInst, rdzr, rnsp, imm);
            }
          }

fall throughがあるのも気になりますね。巨大なswitch文、特に多段switchがあることがメモリを使う原因なのでしょうか?

多段switch

というわけで、多段switch文を吐くスクリプトを組んで、実際にコンパイルしてメモリを食うことを確認してみましょう。

こんなRubyスクリプトを書きます。

if ARGV.size != 2
  puts "usage: ruby switch.rb max num"
  exit
end

$max_level = ARGV[0].to_i
num = ARGV[1].to_i

def print_switch(num, level)
  indent = "  "*level + "  "
  puts "#{indent}switch(i#{level}){"
  num.times do |i|
    puts "#{indent}  case #{i}:"
    if level < $max_level
      print_switch(num, level+1)
    else
      puts "#{indent}    return #{i};"
    end
  end
  puts "#{indent}}"
end

arg = Array.new($max_level+1) { |i| "int i"+i.to_s }.join(",")
puts "int func(#{arg}){"
print_switch(num, 0)
puts "}"

これは、n段m行のswitchを持つコードを吐くスクリプトです。2段2行ならこんな感じです。

$ ruby switch.rb 1 2
int func(int i0,int i1){
  switch(i0){
    case 0:
    switch(i1){
      case 0:
        return 0;
      case 1:
        return 1;
    }
    case 1:
    switch(i1){
      case 0:
        return 0;
      case 1:
        return 1;
    }
  }
}

nを指定しているのにn+1段になっているのに後から気が付きましたが、気にしないことにします。まずは5段10行から試しましょうか。

$ ruby switch.rb 4 10 > test.cpp
$ /usr/bin/time -v g++ -O3 -S test.cpp
        Command being timed: "g++ -O3 -S test.cpp"
        User time (seconds): 48.12
        System time (seconds): 0.96
        Percent of CPU this job got: 99%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 0:49.26
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 642564
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 199589
        Voluntary context switches: 0
        Involuntary context switches: 0
        Swaps: 0
        File system inputs: 0
        File system outputs: 0
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

627MBですか。もう一声って感じですかね。5段12行でいきましょう。

$ ruby switch.rb 4 12 > test.cpp
$ /usr/bin/time -v g++ -O3 -S test.cpp
        Command being timed: "g++ -O3 -S test.cpp"
        User time (seconds): 301.32
        System time (seconds): 3.32
        Percent of CPU this job got: 99%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 5:05.30
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 1263288
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 569445
        Voluntary context switches: 0
        Involuntary context switches: 0
        Swaps: 0
        File system inputs: 0
        File system outputs: 0
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

コンパイルに5分かかって、メモリも1.2GiB使ってますね。ちなみにこのコードは56万行で、switchは5段のが一つ、元のコードは1万3千行で、switchも2段くらいのが多数という違いがあります。でもまぁ、テストコードは単に整数をreturnしてますが、元のコードはなんかオブジェクトを作ってreturnとかしてたので、その絡みで余計にメモリを食ったのかな、という気がします。本当にそうかは知りませんが。

まとめ

Dockerファイルがビルドできない、という連絡を受け、その原因がメモリ不足であること、その原因となるファイルの特定とかやっているうちに、いつのまにかコンパイラをいじめていました。なぜだ?