初投稿です. 本記事のDockerfileは以下に置いています.
忙しい方へ
以下のコマンドでSVE/ACLE対応GCCのある環境をx86_64上で構築できます(ビルドには3~6Hくらいかかります).
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).
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++).
また, サポートしている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
を有効化する必要があります
- あとで述べるbuildxを利用する場合は
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に書かれた方法なだけあって使いやすい印象です.
ただし, buildxが使えるdocker環境が必要で, 古いdockerを使っている場合などは(1)で構築する必要があります.
以下のコマンドを実行することでbinfmt_misc, qemu-aarch64-staticの設定をしてくれます.
(1)の場合
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
(2)の場合
Docker Desktopを利用している場合はすでにマルチプラットフォーム機能が使えるはずです.
それ以外の場合は以下を実行します.
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
無事成功すれば以下のファイルが生成されます.
ls /proc/sys/fs/binfmt_misc | grep qemu-aarch64
qemu-aarch64
cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/bin/qemu-aarch64-static
flags: F
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff
binfmt_misc
については詳しくは述べないですが, これを使用しqemu-aarch64-static
を経由することでArmのバイナリを動かすことが可能となります.
再起動すると上記の設定は消えてしまうので, もう一度実行する必要があります.
実はこれだけでArmのエミュレート環境は出来上がりです. 実際に以下のコマンドを実行するとArm64のコンテナが起動できることが確認できます.
docker run --rm -it arm64v8/ubuntu:20.04 arch
> aarch64
ただし, 機械語を翻訳してエミュレートしているので, 物理CPUはHOSTのCPUが見えています.
docker run --rm -it arm64v8/ubuntu:20.04 lscpu | grep -e "Architecture" -e "Model name"
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を用意します.
# 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)の場合
docker build -t arm64v8/gcc10.2/ubuntu:20.04 .
(2)の場合
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を用意します.
# 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
を用意(参考) - 好みの問題で
Ninja
使用. 別にcmakeだけでもできるっぽい. - マルチステージビルドでサイズの圧縮
- ↑をしたら
library not found for -lgcc
と言われたので,gcc, gcc-c++
をインストール
ビルドします. かな〜り時間がかかります.
同環境でまる一日かかりました.
(1)の場合
docker build -t arm64v8/llvm11.1/centos:8 .
(2)の場合
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
# @ Host
docker run -it --rm arm64v8/gcc10.2/ubuntu:20.04 bash
コンパイルしてみます.
# @ 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
readelf -h ./a.out
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)用のバイナリとなっています.
もちろん, 実行もできます.
Hello, World.
6.2 SVE
example.c
を作成しSVE命令が使用されているか見てみます.
# @ Host
docker run -it --rm arm64v8/gcc10.2/ubuntu:20.04 bash
# @ 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でコンパイルしてみてアセンブラの差分を見てみます.
# @ 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
--- 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可が可能なことを伝えています.
もちろん, 実行もでます.
# @ 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長を見てみます.
cat <<__EOF__ | gcc -march=armv8-a+sve -xc - && ./a.out
#include <stdio.h>
#include <arm_sve.h>
int main() {printf("%ld\n", svcntd());}
__EOF__
8
svcntd
は1度の命令で演算可能なdouble
型の要素数を返します.
8要素ということは8*64=512bitの環境をエミュレートしていることになります.
次に, 以下のようなサンプルをビルドします.
#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のサンプルを参考にしました. コンパイルしてみます.
gcc -march=armv8-a+sve test_acle.c && ./a.out
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に組み込んでみるのも面白いかもしれないですね.