Dockerを使ってポータブルなArm64エミュレート環境を構築

![](https://raw.githubusercontent.com/multiarch/dockerfile/master/logo.jpg)

初投稿です. 本記事のDockerfileは以下に置いています.

https://github.com/muscat201807/emulate-arm64v8

**忙しい方へ**
以下のコマンドでSVE/ACLE対応GCCのある環境をx86_64上で構築できます(ビルドには3~6Hくらいかかります).

```sh
git clone https://github.com/muscat201807/emulate-arm64v8.git
cd gcc10.2-ubuntu20.04
NJOBS=4 ./build.sh # NJOBは搭載コア数の半分程度がオススメ
```

## 1. はじめに

最近NvidiaによるARM買収やM1チップなど何かとArmアーキテクチャが話題に上がっており, つい先日も10年ぶりにアーキテクチャが刷新され注目されています([armv9](https://www.arm.com/company/news/2021/03/arms-answer-to-the-future-of-ai-armv9-architecture)).
Armアーキテクチャには2種類のSIMDがあり, Advanced SIMD(いわゆるNEON)とSVE(Scalable Vector Extension)があります(ちなみに, SVEはarmv8以降でサポートされています).

SVEは富岳で使われているA64FXやArm社のNeoverse V1などでサポートされており, その名の通りSIMD命令のビット幅が可変となっています.
ビット幅が可変なのでたとえは, 256bitのCPUでビルドしたバイナリをそのまま526bitのCPUへもっていって動作させるなんてことも可能です. うまく活用すればSSE,AVX,AVX512のように128,256,512それぞれの実装を用意する, なんてことも必要なくなりそうです.

しかしながら, クラスのメンバに出来ない・sizeofでサイズが取得出来ないなどの制約もあり設計には注意が必要になります(参考 : [Arm SIMD intrinsic C++](https://qiita.com/NatsukiLab/items/ad6e9967f7eccadd9c99)).

また, サポートしているCPUはまだまだ少数なので気軽に試すのも難しいです.
そこで, 今回はx86_64上でArmのSVEをエミュレートする環境を(できるだけ簡単に)構築して, Arm64向けクロスコンパイルを可能にしたりSVEで遊んだりできるDocker環境を作ってみます.

構築にはDocker+QEMUを利用し, **コンテナ内に入ったらarm64環境**というのを目指します.


## 2. 必要なもの

Docker(とできるだけ高スペックなx86_64マシン).

以上!!

ちなみに, 動作確認した環境は

- Intel(R) Core(TM) i7-4770 CPU @ 3.40GHz
- ホストOS : Ubuntu:18.04LTS
- Docker : 20.10.5 
    - あとで述べるbuildxを利用する場合は`Experimental features`を有効化する必要があります

## 3. 生成される環境(docker image)

以下の2つのパターンの環境を構築します.
お好みの方をどうぞ.

- Ubuntu:20.04 + gcc10.2
- CentOS:8 + clang + llvm11.1

※1 SVE組み込み命令にはgcc>=10またはllvm>=11が必要です.
※2 SVEサポート自体はgcc8からだが, ALCE(Arm C Language Extensions)のサポートはgcc10以降.


## 4. ホスト側の設定

`x86_64`上でArmアーキテクチャ用のdockerイメージを動かす方法結構知られていてググると割と出てきます.
方法は主に2つあって,

- (1) `qemu-aarch64-static`の入ったイメージを使う方法
- (2) `docker buildx`のマルチプラットフォームを使う方法

どちらもbinfmt_miscとQEMUを使っていて原理は同じです.
(2)は[公式document](https://matsuand.github.io/docs.docker.jp.onthefly/buildx/working-with-buildx/)に書かれた方法なだけあって使いやすい印象です. 
ただし, buildxが使えるdocker環境が必要で, 古いdockerを使っている場合などは(1)で構築する必要があります.

以下のコマンドを実行することでbinfmt_misc, qemu-aarch64-staticの設定をしてくれます.

----

**(1)の場合**

```sh
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
```

----

**(2)の場合**
Docker Desktopを利用している場合はすでにマルチプラットフォーム機能が使えるはずです.
それ以外の場合は以下を実行します.

```sh
docker run --rm --privileged tonistiigi/binfmt:latest --install linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386,linux/arm/v7,linux/arm/v6

# 確認
docker buildx ls

> NAME/NODE DRIVER/ENDPOINT STATUS  PLATFORMS
> default * docker
>   default default         running linux/amd64, linux/386, linux/arm64, linux/ppc64le, linux/s390x, linux/arm/v7, linux/arm/v6
```

----

無事成功すれば以下のファイルが生成されます.

```sh
ls /proc/sys/fs/binfmt_misc | grep qemu-aarch64
```

```sh:output
qemu-aarch64
```

```sh
cat /proc/sys/fs/binfmt_misc/qemu-aarch64
```

```sh:output
enabled
interpreter /usr/bin/qemu-aarch64-static
flags: F
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff
```

`binfmt_misc`については詳しくは述べないですが, これを使用し`qemu-aarch64-static`を経由することでArmのバイナリを動かすことが可能となります.
再起動すると上記の設定は消えてしまうので, もう一度実行する必要があります.

実はこれだけでArmのエミュレート環境は出来上がりです. 実際に以下のコマンドを実行するとArm64のコンテナが起動できることが確認できます.

```sh
docker run --rm -it arm64v8/ubuntu:20.04 arch
> aarch64
```

ただし, **機械語を翻訳してエミュレートしているので, 物理CPUはHOSTのCPUが見えています**.

```sh
docker run --rm -it arm64v8/ubuntu:20.04 lscpu | grep -e "Architecture" -e "Model name"
```

```sh:output
Architecture:                    aarch64
Model name:                      Intel(R) Core(TM) i7-4770 CPU @ 3.40GHz
```

Architectureはaarch64(Arm64)なのにCPUはIntelというなんとも不思議な感じですね.


## 5. SVE利用環境構築


### 5.1. ubuntu20:04 + gcc10.2の環境構築

以下のDockerfileを用意します.

```Dockerfile
# Stage 1. Prepare builer
FROM arm64v8/ubuntu:20.04 as builder

WORKDIR /root

# install dependence of gcc
RUN apt update && apt install -y \
	build-essential \
	tar \
	wget \
	git

# Install GCC
RUN wget -q http://ftp.tsukuba.wide.ad.jp/software/gcc/releases/gcc-10.2.0/gcc-10.2.0.tar.xz \
	&& tar xf gcc-10.2.0.tar.xz \
	&& cd gcc-10.2.0 \
	&& ./contrib/download_prerequisites \
	&& mkdir -p build \
	&& cd build \
	&& ../configure --prefix=/usr/local --disable-bootstrap --disable-multilib \
	&& make -j4 \
	&& make install

# Stage 2. Produce a minimal release image with build results.
FROM arm64v8/ubuntu:20.04

# Copy build results of stage 1 to /usr/local.
COPY --from=builder /usr/local /usr/local

# Install essential libs
RUN apt update && apt install -y \
	binutils \
	binutils-aarch64-linux-gnu \
	binutils-common \
	libatomic1 \
	libbinutils \
	libc6-dev \
	libcc1-0 \
	&& apt-get clean \
	&& rm -rf /var/lib/apt/lists/*
```

**ビルドの並列数(`-j`)についてはリソースに合わせて適宜設定してください**.
並列数は搭載スレッドの半分くらいがおすすめです. 多すぎると動作が安定しないみたいで, 私のデスクトップ(4コア8スレッド)では, 全スレッド(`-j8`)を使うと途中でフリーズしてしまいました.)

ポイントとしては,

- `--disable-bootstrap` : 複数回のビルドを無効
- `--disable-multilib` : エミュレート環境がそもそもarm64なので64bit以外不要
- マルチステージビルドでサイズの圧縮

以下のコマンドでビルドします.
めっちゃ時間かかります. 私の環境では`-j4`で約6時間かかりました.

----

**(1)の場合**

```sh
docker build -t arm64v8/gcc10.2/ubuntu:20.04 .
```

----

**(2)の場合**

```sh
docker buildx build --platform linux/arm64 -t arm64v8/gcc10.2/ubuntu:20.04 .
# Dockerfileのベースイメージはarm64v8/ubuntu:20.04 -> ubuntu:20.04としてもOK.
# そうしておけば, --platformの値を変えるだけで異なるアーキテクチャのイメージがビルド可能.
```

----

しばらく放置すると`arm64v8/gcc10.2/ubuntu:20.0`が完成します\\(^o^)/

### 5.2 CentOS:8 + llvm11.1の環境構築

CentOS:8 + llvm11.1の環境を構築します.
別にSVEを使える環境さえあれば良いという方はgcc10.2があれば十分なので読み飛ばしてください.

同じUbuntuでは面白くない(?)ので, 今度はRedHat系のCentOSでやってみます(まぁ, 2021年末でサポート終了となってしまいましたが...).

以下のDockerfileを用意します.

```Dockerfile
# Stage 1. Check out LLVM source code and run the build.
FROM arm64v8/centos:8 as builder

WORKDIR /root

# Install build dependencies of llvm.
RUN dnf update -y && dnf install -y \
	bzip2 \
	which \
	wget \
	git \
	zip \
	unzip \
	zlib \
	zlib-devel \
	python3 \
	openssl \
	openssl-devel \
	&& dnf groupinstall -y "Development Tools"

# Install cmake
RUN wget -q https://github.com/Kitware/CMake/releases/download/v3.20.0/cmake-3.20.0.tar.gz \
	&& tar zxf cmake-3.20.0.tar.gz \
	&& cd cmake-3.20.0 \
	&& ./configure --prefix=/usr/local \
	&& make -j4 \
	&& make install

# Install Ninja
RUN wget -q https://github.com/ninja-build/ninja/archive/refs/tags/v1.10.2.tar.gz \
	&& tar zxf v1.10.2.tar.gz \
	&& cd ninja-1.10.2/ \
	&& cmake -Bbuild-cmake -H. \
	&& cmake --build build-cmake --parallel 4 \
	&& cp build-cmake/ninja /usr/local/bin/

# Install LLVM
RUN wget -q https://github.com/llvm/llvm-project/archive/refs/tags/llvmorg-11.1.0.tar.gz \
	&& tar zxf llvmorg-11.1.0.tar.gz \
	&& cd llvm-project-llvmorg-11.1.0/ \
	&& mkdir build \
	&& cd build \
	&& cmake -GNinja \
	-DCMAKE_BUILD_TYPE=Release \
	-DCMAKE_INSTALL_PREFIX=/usr/local \
	-DLLVM_ENABLE_PROJECTS='clang;clang-tools-extra;libcxx;libcxxabi;libunwind;lldb;compiler-rt;lld;polly;openmp;parallel-libs;mlir;flang;debuginfo-tests' \
	../llvm \
	&& cmake --build . --parallel 4 \
	&& cmake --install .

# Stage 2. Produce a minimal release image with build results.
FROM arm64v8/centos:8

# Copy build results of stage 1 to /usr/local.
COPY --from=builder /usr/local /usr/local

# Install essential libs
RUN dnf update -y && dnf install -y \
	glibc-devel \
	glibc-headers \
	gcc \
	gcc-c++ \
	&& dnf clean all
```

**先程と同じく, 並列数(`-j`, `--parallel`)は適宜設定してください.**

ポイントは,

- llvmのビルドのために`cmake>=3.13.4`を用意([参考](https://llvm.org/docs/GettingStarted.html#software))
- 好みの問題で`Ninja`使用. 別にcmakeだけでもできるっぽい.
- マルチステージビルドでサイズの圧縮
- ↑をしたら`library not found for -lgcc`と言われたので, `gcc, gcc-c++`をインストール

ビルドします. かな〜り時間がかかります.
同環境でまる一日かかりました.

----

**(1)の場合**

```sh
docker build -t arm64v8/llvm11.1/centos:8 .
```

----

**(2)の場合**

```sh
docker buildx build --platform linux/arm64 -t arm64v8/llvm11.1/centos:8 .
# Dockerfileのベースイメージはarm64v8/centos:8 -> centos:8としてもOK.
# そうしておけば, --platformの値を変えるだけで異なるアーキテクチャのイメージがビルド可能.
```

----

しばらく放置すると`arm64v8/llvm11.1/centos:8`が完成します\\(^o^)/

## 6. 色々試してみる

構築はここまで完了です.
以下ではUbuntu20.04+GCC10.2のイメージを使って, サンプルプログラムを動かしたりします.

### 6.1 Hello, World

```sh
# @ Host
docker run -it --rm arm64v8/gcc10.2/ubuntu:20.04 bash
```

コンパイルしてみます.

```sh
# @ Contena
cat <<__EOF__ > test.c
#include <stdio.h>
int main(void) {
    printf("Hello, World.\n");
    return 0;
}
__EOF__

# Compile
gcc -march=armv8.2-a test.c
```

```sh
readelf -h ./a.out
```

```sh:output
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           AArch64
  Version:                           0x1
  Entry point address:               0x660
  Start of program headers:          64 (bytes into file)
  Start of section headers:          7440 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         28
  Section header string table index: 27
exit
```

ちゃんどArm64(Aarch64)用のバイナリとなっています.
もちろん, 実行もできます.

```sh:output
Hello, World.
```


### 6.2 SVE

`example.c`を作成しSVE命令が使用されているか見てみます.

```sh
# @ Host
docker run -it --rm arm64v8/gcc10.2/ubuntu:20.04 bash
```

```sh
# @ Contena
cat <<__EOF__ > example.c
#define SIZE 1024
int a[SIZE], b[SIZE], c[SIZE];
void sub(int *restrict a, int *restrict b, int *restrict c) {
    for (int i = 0; i < SIZE; i++) a[i] = b[i] - c[i];
}

int main() {
    sub(a, b, c);
}
__EOF__
```

SVEをON/OFFでコンパイルしてみてアセンブラの差分を見てみます.

```sh
# @ Contena
gcc -O3 -S -march=armv8-a+sve -o example_sve.s example.c
gcc -O3 -S -march=armv8-a+nosve -o example_nosve.s example.c
diff -u example_nosve.s example_sve.s
```

```diff
--- example_nosve.s
+++ example_sve.s
@@ -1,4 +1,4 @@
-       .arch armv8-a
+       .arch armv8-a+sve
        .file   "example.c"
        .text
        .align  2
@@ -9,15 +9,17 @@
 .LFB0:
        .cfi_startproc
        mov     x3, 0
+       mov     w4, 1024
+       whilelo p0.s, wzr, w4
        .p2align 3,,7
 .L2:
-       ldr     q0, [x1, x3]
-       ldr     q1, [x2, x3]
-       sub     v0.4s, v0.4s, v1.4s
-       str     q0, [x0, x3]
-       add     x3, x3, 16
-       cmp     x3, 4096
-       bne     .L2
+       ld1w    z0.s, p0/z, [x1, x3, lsl 2]
+       ld1w    z1.s, p0/z, [x2, x3, lsl 2]
+       sub     z0.s, z0.s, z1.s
+       st1w    z0.s, p0, [x0, x3, lsl 2]
+       incw    x3
+       whilelo p0.s, w3, w4
+       b.any   .L2
        ret
        .cfi_endproc
 .LFE0:
@@ -30,22 +32,24 @@
 main:
 .LFB1:
        .cfi_startproc
-       adrp    x3, a
-       adrp    x2, b
-       adrp    x1, c
-       add     x3, x3, :lo12:a
-       add     x2, x2, :lo12:b
-       add     x1, x1, :lo12:c
+       adrp    x4, b
+       adrp    x3, c
+       adrp    x1, a
+       add     x4, x4, :lo12:b
+       add     x3, x3, :lo12:c
+       add     x1, x1, :lo12:a
        mov     x0, 0
+       mov     w2, 1024
+       whilelo p0.s, wzr, w2
        .p2align 3,,7
 .L6:
-       ldr     q0, [x2, x0]
-       ldr     q1, [x1, x0]
-       sub     v0.4s, v0.4s, v1.4s
-       str     q0, [x3, x0]
-       add     x0, x0, 16
-       cmp     x0, 4096
-       bne     .L6
+       ld1w    z0.s, p0/z, [x4, x0, lsl 2]
+       ld1w    z1.s, p0/z, [x3, x0, lsl 2]
+       sub     z0.s, z0.s, z1.s
+       st1w    z0.s, p0, [x1, x0, lsl 2]
+       incw    x0
+       whilelo p0.s, w0, w2
+       b.any   .L6
        mov     w0, 0
        ret
        .cfi_endproc
@@ -55,7 +59,7 @@
        .global b
        .global a
        .bss
-       .align  4
+       .align  3
        .type   c, %object
        .size   c, 4096
```

命令やレジスタが変わっているのが分かります. q0,q1などのレジスタを使っているのがAdvanced SIMD(NEON)でz,pなどのレジスタを使っているのがSVEです.
コンパイラによりますが, ここではrestrictをつけることでコンパイラにSIMD可が可能なことを伝えています.

もちろん, 実行もでます.

```sh
# @  Contena
gcc -O3 -march=armv8-a+sve -o example.out example.c
./example.out && echo "Success"
> Success
```

### 6.3 ACLE

最後にACLE(Arm C Language Extensions)です. ACLEを使うことでAdvanced SIMDやSVEの命令を直接C/C++から呼ぶことが出来ます.
AVXで言うところの`_mm256_{op}_{xxx}`や`_mm512_{op}_{xxx}`のようなものです. ~~コンパイラの最適化では満足できない方々が~~よりシビアな性能が求められる場面で利用します.

まずはエミュレートしているbit長を見てみます.

```sh
cat <<__EOF__ | gcc -march=armv8-a+sve -xc - && ./a.out
#include <stdio.h>
#include <arm_sve.h>
int main() {printf("%ld\n", svcntd());}
__EOF__
```

```sh:output
8
```

`svcntd`は1度の命令で演算可能な`double`型の要素数を返します.
8要素ということは8*64=512bitの環境をエミュレートしていることになります.

次に, 以下のようなサンプルをビルドします.

```c:test_acle.c
#include <stdio.h>
#include <arm_sve.h>

void daxpy_1_1(int64_t n, double da, double *dx, double *dy)
{
  int64_t i = 0;
  svbool_t pg = svwhilelt_b64(i, n);                            // [1]
  do {
    svfloat64_t dx_vec = svld1(pg, &dx[i]);                     // [2]
    svfloat64_t dy_vec = svld1(pg, &dy[i]);                     // [2]
    svst1(pg, &dy[i], svmla_x(pg, dy_vec, dx_vec, da));         // [3]
    i += svcntd();                                              // [4]
    pg = svwhilelt_b64(i, n);                                   // [1]
  } while (svptest_any(svptrue_b64(), pg));                     // [5]
}

// 以下のコードと同じ
// void daxpy_1_1(int64_t n, double da, double *dx, double *dy)
// {
//   for (int64_t i = 0; i < n; ++i) {
//     dy[i] = dx[i] * da + dy[i];
//   }
// }

int main() {
  double da=1.0, dx[4]={1,2,3,4}, dy[4]={5,6,7,8};
  daxpy_1_1(4, da, dx, dy);
  for (int64_t i = 0; i < 4; i++) printf("dy[%ld]=%.1lf\n", i, dy[i]);
  return 0;
}
```

コードは[ArmのDocument](https://developer.arm.com/documentation/100891/0612/coding-considerations/using-sve-intrinsics-directly-in-your-c-code)のサンプルを参考にしました. コンパイルしてみます.

```sh
gcc -march=armv8-a+sve test_acle.c && ./a.out
```

```sh:output
dy[0]=6.0
dy[1]=8.0
dy[2]=10.0
dy[3]=12.0
```

ちゃんと計算できてますね.

ちなみに, サンプルプログラムでは4要素なので8要素ごとの計算だとオーバーランしてしまうのでは？という疑問が湧きますが, 実はSVEでは使用しない要素をマスクして計算してくれます(ここが**スケーラブル**の肝).

具体的には, コードの[1]のところでプレディケータ(マスクのようなもの)を作り, `pg`が計算しなくても良い要素分をマクスしてくれるため不正アクセスせずに済みます.

便利ですね.
参照元に詳しい説明があるので, 興味があれば見てみてください.

## 7. 制約

本環境ですが以下のような制約があります(もっとあるかも).

- エミュレートしているため実行は非常に遅い
- CPUはホストのCPUが見えているため, cpuinfoなどを参照する処理はうまく動作しない

## 8. おわりに

Docker+QEMUを使うことで異なるアーキテクチャのエミュレートを作成しました.
今回はそれをSVEに利用しましたが, 別にSVEに限らず他のアーキテクチャをシミュレートすることも可能です.

実機がなくても検証が可能なので, マルチアーキテクチャ向けのソフトウェア開発のCI/CDに組み込んでみるのも面白いかもしれないですね.

## 参考・URL

- [記事で使用したDockerfile](https://github.com/muscat201807/emulate-arm64v8)
- [Arm SIMD intrinsic C++](https://qiita.com/NatsukiLab/items/ad6e9967f7eccadd9c99)
- [Arm SVE命令セットって美味しいの?](https://qiita.com/aKatsuhiroMihara/items/f4799ae4e6f12743b88e)
- [Learn the architecture: SVE and Neon coding compared](https://developer.arm.com/documentation/102131/0101/Fundamentals)
- [multiarch/qemu-user-static](https://github.com/multiarch/qemu-user-static)
- [Using SVE intrinsics directly in your C code](https://developer.arm.com/documentation/100891/0612/coding-considerations/using-sve-intrinsics-directly-in-your-c-code)