(参考)コード一式は GitHub に置きました(GitHub へのリンク)
AArch64 その 2 です。Cortex-A53 で memset をやってみました。環境は RK3328 Cotex-A53 1.4GHz です。メモリはおそらく LPDDR3-1600 です。
Cortex-A72 と似ている点としては、
違う点としては、
こんなところでしょうか。A72 の glibc memset 関数はグラフが上がったり下がったりグチャグチャしていましたが、A53 だと割と素直になっています。
gcc -O3 -fno-builtin の測定結果(Cortex-A53 編)
gcc -O2 -ftree-vectorize -fno-builtin の測定結果(Cortex-A53 編)
gcc -O2 -fno-builtin の測定結果(Cortex-A53 編)
(※)A72 では単純な memset 関数は musl memset 関数にほぼ勝てない(16〜22バイトのみ勝つ)が、A53 では割と良い勝負(16〜22、32〜38、48〜52バイトで勝つ)をしている。
Cortex-A72 での memset は O2 に -ftree-vectorize と -fpeel-loops を足すと、O3 の性能とほぼイコールになることがわかりました。
gcc -O2 -ftree-vectorize -fpeel-loops -fno-builtin の測定結果(Cortex-A72)
元の処理が非常に単純なループ処理のためか、ループ系の最適化がメチャクチャ効くっぽいです。
GCC の GIMPLE を出力させ(-fdump-tree-all)眺めてみると、
こんな感じに見えます。正直言って、ループアンローリングなんて大したことないと思っていましたが、これほど効くとは思いませんでした。
メモ: 技術系の話は Facebook から転記しておくことにした。大幅に追記。
NEON intrinsic を使って自分で memset を実装してみました。ざっくりした設計方針としては、
相手は汎用実装ですし、Cortex-A72 に特化した実装なら楽勝だろう、などと考えて始めましたが、甘かった。glibc のフルアセンブラ版はかなり手ごわいです。
グラフの赤い線が、自作した memset の性能です。
最適化レベル O3 の simple memset にはほぼ全域で勝てますが、サイズが小さいときの musl は強い(サイズが小さい場合から判定しているから?)です。glibc のフルアセンブラもかなり強いです。測定によって勝ったり負けたりな程度です。
設計が甘すぎたことがわかったので、下記のように見直しました。
序盤で musl memset に負けていたのは、バイト数の条件判定の順序が良くなかった(大きいサイズから判定していた)ためなので、1番目で対策しています。2番目と 3番目の方針は良いとも悪いとも一概に言えませんが、RK3399 だとこれが一番性能が出ました。
自作 memset 改善後の測定結果(Cortex-A72)
設計意図通りに musl の序盤(特に高速な 1〜8バイト付近)と、glibc フルアセンブラの序盤(1〜32バイト)には勝てたものの、glibc フルアセンブラ版は中盤以降が強く、33バイト以降は全く勝てません。
私の作った memset は 32バイトまでは専用処理で、33バイトからループで処理するようになるので、33バイトから性能がかなり落ちます。
おそらく glibc フルアセンブラ版も同様に 16バイトから性能が落ちるので、ループ処理していると思うんですが、それ以降の巻き返しが凄くて、33バイト以降はまったく勝てないですね……。どうやってんだろうね、これ?
コンパイラが変な and とか sub を出力しているのを見つけたので、アセンブラでも実装してみましたが、性能はほぼ変わりませんでした。設計の根底が違うんでしょうね。
RK3328(Cortex-A53)で測ってみると、musl には勝てますが、glibc フルアセンブラ版には勝ち目無しで、ほぼ全域に渡ってボコボコにされます。
自作 memset 改善後の測定結果(Cortex-A53)
基本設計が「余計な write をしてでも、とにかく速く終われ」なので、write を正直に実行してしまうようなヘボいプロセッサになればなるほど勝ち目が薄いです。
最近、見かける SIMD 命令セット(AVX も NEON も)には、レジスタ下位 [7:0] の 1バイトを、レジスタ上位 ... [31:24] [23:16] [15:8] の各バイトに配る命令が用意されています。
この命令はどういう需要があるんだろうか……?memset の実装では超役に立ちましたが、他の使い道が良くわかりません。
Facebook で上記の話をしていたところ、
と教えてもらいました。なるほど、スカラベクトル積のスカラ側を配るときに便利ですね。
ちなみに SIMD のない処理系はどうしているのか見てみると、
int a = (何かの数字);
としたときに、
a &= 0xff;
a *= 0x01010101;
のように and, mov, mul を使っていました。もちろん、
a &= 0xff;
a |= a << 8;
a |= a << 16;
のように and, shift, or, shift, or でもできますが、今日日のプロセッサだと整数乗算の方が速そうですね。
先日 memset を書いていたとき(2020年 1月 12日の日記参照)に気づいたのですが、glibc のフルアセンブラ版 memset の性能が 2通り(遅い、速い)あることに気づきました。だいたい 1割くらい性能が変わります。
遅いときと比較すると、自作の memset の方が速いですが、速いときと比較するとボロ負けします。割と性能が迫っているためか、影響が大きいです。
何が違うんでしょうね?コードは当然同じですから、違いは memset 関数のロードされるアドレスくらいです。まさかなと思って、スタティックリンクしたら安定して速くなりました。
ダイナミックリンクだと、アプリ側は 0xaaaac4fba560 で、glibc だけ 0xffffbf2dce00 のような遠いアドレスに飛ばされます。ベンチマーク中は、アプリのコード ←→ glibc のコードを頻繁に行き来することになるので、TLB ミスヒットの影響が出ているんですかね……??
真因はわかりませんが、アドレスが関係している可能性は高いです。今後、似たようなことをやるときは、スタティックリンクで測った方が良さそうです。
先日(2020年 1月 12日の日記参照)の続きです。
あまりにも glibc フルアセンブラ版 memset の実装が速くて勝てないので、観念して実装を見たのですが、序盤(1バイト〜32バイト)が弱い理由と、以降(33バイト〜)で勝てない理由がわかりました。
他の実装と違って glibc はサイズの大きい方から条件を見ています。どうしても条件分岐命令を通る回数が増えるため、序盤に弱いです。
中盤は 96 バイトまでは NEON store x 4 と分岐で捌いていて、ループを使いません。分岐も cmp して branch ではなく、ビットセットされていたら分岐する命令(tbz, tbnz)を使っています(※)。
つまり私が書いた memset はループで処理している時点で、ほぼ勝ち目がなかったということです。
グラフでは 63バイトまでしか測っていなかったから気づかなかったのですが、ループの 2週目に入る 65バイトから、さらにボロ負けです。いやはや、これは勝てないですね……。
(※)cmp, branch の 2命令を tbz 1命令にする辺り、AArch64 アセンブラならではの実装に見えますが、実は C でも if (a & 0x10) とか書くとコンパイラが tbz 命令を使います。コンパイラ侮りがたし。
くそ長いですが、C 言語の未定義動作怖いね、printf でタイミング以外も動き変えられるよ、という話です。
環境ですが x86_64 向け Debian GNU/Linux 9.2 で実行しています。また GCC のバージョンは gcc (Debian 9.2.1-22) 9.2.1 20200104 です。
未定義動作のため、コンパイラの種類や、GCC のバージョンにより結果が変わると思われます。お家のマシンで試すならご留意ください。
この日記の最後に貼ったプログラム(このプログラムをコンパイルすると、激しい警告が出ます)を gcc -Wall -O2 a.c && ./a.out のように実行すると、
0: 0 0 0 1: 0 1 0 2: 0 2 0 3: 0 3 0 4: 0 4 0 ... 47: 0 47 0 48: 0 48 0 49: 0 49 0 1770: 1770
こうなります。0〜59 の和は 1770 です。あってます。良かったですね。
なに?そういう問題じゃない?「なぜ array 終端を超えて guard2 にバッファオーバーランしない?」と考えた方、するどいです。しかし世の中そう単純ではありません。
10行目の printf のコメントを外してローカル変数のアドレスを表示させると、
0x7ffd9b348a10 0x7ffd9b348ae0 0x7ffd9b348bb0 0: 0 0 52 1: 0 1 53 2: 0 2 54 3: 0 3 55 4: 0 4 56 ... 45: 0 45 0 46: 0 46 0 47: 0 47 0 48: 0 48 0 49: 0 49 0 1770: 1770
こうなります。突然オーバーランするようになりました。printf が何かしたんでしょうか、不思議ですね?
どうして for ループを無意味に 2分割したのか?くっつけてみたらわかります。Segmentation Fault します。
0: 0 0 0 1: 0 1 0 2: 0 2 0 3: 0 3 0 4: 0 4 0 ... 45: 0 45 0 46: 0 46 0 47: 0 47 0 48: 0 48 0 49: 0 49 0 Segmentation fault
もう意味不明ですよね。何が起こっているんでしょう?
この 60回の for ループは「配列の終端を超えたアクセス」が C 言語仕様上の未定義動作なので、何が起きても正しい、つまりどの結果も正しいです。
これだけだと、何言ってんのか意味不明だと思うので「printf 有効/無効」「for ループ 1つ/2つ」に着目して説明します。
3番目の実験の裏打ちとして、試しにループ回数を 80回くらいにすると for ループが 1つだろうが 2つだろうが、リターンアドレスがぶっ壊れて Segmentation Fault します。10行目の printf を有効にすると guard1, guard2 がスタックに配置されて、受け止めてくれるので、80回でも耐えます。
バッファオーバーランを期待していた向きには残念(?)かもしれませんが、guard1, guard2 はメモリ上に置いても置かなくても、C 言語仕様に矛盾しないなら、どっちでも良いです。もっというと C 言語仕様に矛盾しないなら、コンパイラの最適化は何をやっても OK です。
この「C 言語仕様に矛盾しないなら」はおそらくコンパイラ開発者には常識なのでしょうけども、C 言語の仕様は人間に優しくないのと、大多数の C 言語プログラマは言語仕様(特に未定義動作)を理解しておらず、何となく使っています。
難解な仕様、曖昧な理解、過激な最適化の相乗効果により、今日も世界のどこかで
「最適化で動きが変になっちゃったよ……。どうして…どうして……?」
とコンパイラとすれ違ったプログラマが泣いているでしょう。。。
大したものではありませんが、ソースコードを載せておきます。
#include <stdio.h>
#include <string.h>
int undefined()
{
int guard1[50];
int array[50];
int guard2[50];
int sum = 0, i;
memset(guard1, 0, sizeof(guard1));
memset(guard2, 0, sizeof(guard2));
//printf("%p %p %p\n", &guard1[0], &array[0], &guard2[0]);
for (i = 0; i < 60; i++) {
array[i] = i;
}
for (i = 0; i < 60; i++) {
sum += array[i];
}
for (i = 0; i < 50; i++) {
printf("%2d: %d %d %d\n", i, guard1[i], array[i], guard2[i]);
}
return sum;
}
int main(int argc, char *argv[])
{
int sum1 = 0, sum2 = 0, i;
sum1 = undefined();
for (i = 0; i < 60; i++) {
sum2 += i;
}
printf("%d: %d\n", sum1, sum2);
return 0;
}
新し目の AArch64 のクロスコンパイル用ツールチェーンを作ろうとして、かなりハマったのでメモしておきます。
基本的には前回(2019年 4月 29日の日記参照)ご紹介した手順でビルドします。GCC と glibc のコードを変えなくて良い組み合わせは下記の通りです。特に新しいバージョンを使う理由がなければ、この組み合わせが無難です。
私は新しい GCC が使いたかったので、HEAD にしました(バージョン的には 10.0 相当)。どうやら GCC のエラーチェックが厳しくなるらしく、glibc のビルドが通らなくなります。たくさんエラーが出ますが、一例を挙げると、下記のようなエラーです。
./../include/libc-symbols.h:534:26: error: '__EI___errno_location' specifies less restrictive attributes than its target '__errno_location': 'const', 'nothrow' [-Werror=missing-attributes] 534 | extern __typeof (name) __EI_##name \ | ^~~~~
エラーは glibc を新しくすると解決されるかと思いきや、よりおかしなことになります。例えば GCC 8.3 のまま glibc 2.30(おそらく 2.29 でも同じ症状が出る)にすると、下記のような変なエラーが出ます。
crosstool-builder-new-aarch64/buildroot/lib/gcc/aarch64-unknown-linux-gnu/8.3.0/../../../../aarch64-unknown-linux-gnu/bin/ld: crosstool-builder-new-aarch64/build/glibc/support/links-dso-program.o: Relocations in generic ELF (EM: 62) crosstool-builder-new-aarch64/buildroot/lib/gcc/aarch64-unknown-linux-gnu/8.3.0/../../../../aarch64-unknown-linux-gnu/bin/ld: crosstool-builder-new-aarch64/build/glibc/support/links-dso-program.o: Relocations in generic ELF (EM: 62) ...
エラーの原因となっているオブジェクト links-dso-program.o を調べると、AArch64 向けにビルドしているにも関わらず、なぜか x86_64 用のオブジェクトが生成されています。
build/glibc/support$ file *.o echo-container.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), with debug_info, not stripped links-dso-program.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), with debug_info, not stripped ★★★★これ★★★★ shell-container.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), with debug_info, not stripped stamp.o: empty test-container.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), with debug_info, not stripped true-container.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), with debug_info, not stripped
いったい何ですかね、これ。バグなのか、仕様なのかわかりません……。
GCC と glibc をお互い最新にした組み合わせ、すなわち下記の組み合わせにしたとき、
先ほど説明した、両方のエラーに遭遇してビルドできませんので、glibc にパッチを当ててビルドエラーを回避します。
まずはコンパイルエラーを無視するパッチです。本来はエラーを無視するのではなく、エラーが指摘している事項を直すべきですけど、今回の主眼ではないのと、いずれ glibc 本家が直るだろうことを期待しておきます。
diff --git a/Makeconfig b/Makeconfig
index fd36c58c04..106688e210 100644
--- a/Makeconfig
+++ b/Makeconfig
@@ -916,7 +916,8 @@ ifeq "$(strip $(+cflags))" ""
endif # $(+cflags) == ""
+cflags += $(cflags-cpu) $(+gccwarn) $(+merge-constants) $(+math-flags) \
- $(+stack-protector)
+ $(+stack-protector) \
+ -Wno-zero-length-bounds -Wno-array-bounds -Wno-maybe-uninitialized
+gcc-nowarn := -w
# Each sysdeps directory can contain header files that both will be
次の links-dso-program はコミットログを見る限り、テストのサポート用ライブラリなので、とりあえず無くても動くはずです。ビルド自体をやめるパッチをあてます。
diff --git a/support/Makefile b/support/Makefile
index ab66913a02..19c3de2043 100644
--- a/support/Makefile
+++ b/support/Makefile
@@ -184,12 +184,12 @@ CFLAGS-support_paths.c = \
-DSBINDIR_PATH=\"$(sbindir)\" \
-DROOTSBINDIR_PATH=\"$(rootsbindir)\"
-ifeq (,$(CXX))
-LINKS_DSO_PROGRAM = links-dso-program-c
-else
-LINKS_DSO_PROGRAM = links-dso-program
-LDLIBS-links-dso-program = -lstdc++ -lgcc -lgcc_s $(libunwind)
-endif
+#ifeq (,$(CXX))
+#LINKS_DSO_PROGRAM = links-dso-program-c
+#else
+#LINKS_DSO_PROGRAM = links-dso-program
+#LDLIBS-links-dso-program = -lstdc++ -lgcc -lgcc_s $(libunwind)
+#endif
ifeq (yes,$(have-selinux))
LDLIBS-$(LINKS_DSO_PROGRAM) += -lselinux
クロスコンパイル環境は、各モジュールのバージョンアップですぐ壊れてしまって辛いです。ARM がこれだけ覇権を握っているにも関わらず、gcc も glibc もあまりチェックしてないんですかね……??
わざわざ Makefile を書き換えなくても make LINKS_DSO_PROGRAM= のように、make 実行時に LINKS_DSO_PROGRAM 変数の値を強制的に空文字列に上書きすれば回避可能でした。理由は昔の日記で書いた通り(2019年 9月 17日の日記参照)、コマンドラインからの変数指定は Makefile 内の代入より強いからです。
Makefile 書き換えよりは多少スマートですけども、クロスコンパイルの時だけこんな指定が必要なのは妙ですね。まだ何か見落としているんでしょうかね?
以前 Zephyr OS を実行しました(2019年 1月 12日の日記参照)が、今回は RISC-V 32bit 版で試してみようと思います。
この例では build をビルドディレクトリとします。どこに置いても動くはずですが、私はとりあえず zephyr の下に置いています。
前回同様 Zephyr SDK もしくは Crosstool-NG が使えます。参考までに Crosstool-NG のコンフィグを載せておきます。
$ ./ct-ng menuconfig Target options ---> Target Architecture (riscv) ---> [*] Build a multilib toolchain (READ HELP!!!) Bitness: (64-bit) ---> (rv32ima) Architecture level (ilp32) Generate code for the specific ABI Toolchain options ---> (zephyr) Tuple's vendor string Debug facilities ---> [*] gdb ---> [*] Build a static cross gdb
一見すると 32bit CPU のコードをビルドする予定なのに、Bitness: 64bit にしており、不思議なコンフィグに見えるかもしれませんが、Zephyr OS のビルドはクセがあって、この設定が必要です。
Architecture level, Generate code for the specific ABI は GCC が生成するバイナリの命令セットを指定しており、それぞれ -march=rv32ima, -mabi=ilp32 に対応します。特に何も指定しないと rv32gc, ilp32f になるようです。
zephyr/cmake/toolchain/xtools/target.cmake set(CROSS_COMPILE_TARGET_riscv riscv64-zephyr-elf) set(CROSS_COMPILE_TARGET ${CROSS_COMPILE_TARGET_${ARCH}})
Zephyr の CMakefile を見るとわかるんですが、riscv64 も riscv32 も区別せず同じコンパイラでビルドし、しかもコンパイラ名は常に riscv64-zephyr-elf-gcc だと思っています。したがって 64bit 版をビルドする必要があり、multilib を有効にしています。
(余談)riscv 向けの -mabi, -march オプションの値を決めているのは zephyr/cmake/compiler/gcc/target.cmake で、rv32ima 固定になっています。
- list(APPEND TOOLCHAIN_C_FLAGS -mabi=ilp32 -march=rv32ima)
+ list(APPEND TOOLCHAIN_C_FLAGS -mabi=ilp32f -march=rv32gc)
上記のように変えると、Crosstool-NG で Architecture level, Generate code for the specific ABI の設定をしなくても良くなりますが、動くかどうかは試していません。
実ボードを持っていないので QEMU で試します。SiFive HiFive1 をエミュレートしているそうです。
$ cat ~/.zephyrrc export ZEPHYR_TOOLCHAIN_VARIANT=xtools export XTOOLS_TOOLCHAIN_PATH=/home/katsuhiro/x-tools $ cd zephyr $ source zephyr-env.sh $ mkdir build $ cd build $ cmake -G Ninja -DBOARD=qemu_riscv32 ../samples/hello_world/ -- Zephyr version: 2.1.99 -- Found PythonInterp: /usr/bin/python3 (found suitable version "3.7.6", minimum required is "3.6") -- Selected BOARD qemu_riscv32 -- Loading /home/katsuhiro/share/projects/oss/zephyr/boards/riscv/qemu_riscv32/qemu_riscv32.dts as base Devicetree configuration written to /home/katsuhiro/share/projects/oss/zephyr/build/zephyr/include/generated/devicetree.conf Parsing /home/katsuhiro/share/projects/oss/zephyr/Kconfig Loaded configuration '/home/katsuhiro/share/projects/oss/zephyr/boards/riscv/qemu_riscv32/qemu_riscv32_defconfig' Merged configuration '/home/katsuhiro/share/projects/oss/zephyr/samples/hello_world/prj.conf' Configuration saved to '/home/katsuhiro/share/projects/oss/zephyr/build/zephyr/.config' -- The C compiler identification is GNU 8.3.0 -- The CXX compiler identification is GNU 8.3.0 -- The ASM compiler identification is GNU -- Found assembler: /home/katsuhiro/x-tools/riscv64-zephyr-elf/bin/riscv64-zephyr-elf-gcc -- Cache files will be written to: /home/katsuhiro/.cache/zephyr -- Configuring done -- Generating done -- Build files have been written to: /home/katsuhiro/share/projects/oss/zephyr/build
実行するときは ninja run で良いんですが、前回同様に ninja が何を起動しているか調べてみます。
$ /usr/bin/qemu-system-riscv32 -nographic -machine sifive_e -net none -chardev stdio,id=con,mux=on -serial chardev:con -mon chardev=con,mode=readline -kernel /home/katsuhiro/share/projects/oss/zephyr/build/zephyr/zephyr.elf -s -S (gdb から continue をすると、下記が出力される) *** Booting Zephyr OS build zephyr-v2.1.0-1471-g7e7a4426d835 *** Hello World! qemu_riscv32
オプション -s は GDB の接続を localhost:1234 で受け付けます、という意味です。オプション -S はエミュレータを Halted 状態で起動します。もし GDB をつなぐ必要がなければ -s や -S を削って起動してください。
$ riscv64-zephyr-elf-gdb GNU gdb (crosstool-NG 1.24.0.60-a152d61) 8.3.1 Copyright (C) 2019 Free Software Foundation, Inc. ... (gdb) set arch riscv:rv32 The target architecture is assumed to be riscv:rv32 (gdb) target remote localhost:1234 Remote debugging using localhost:1234 warning: No executable has been specified and target does not support determining executable automatically. Try using the "file" command. 0x00001000 in ?? () (gdb) continue Continuing.
実行できました。おなじみの Hello World! です。
管理者: Katsuhiro Suzuki(katsuhiro( a t )katsuster.net)
This is Simple Diary 1.0
Copyright(C) Katsuhiro Suzuki 2006-2021.
Powered by PHP 5.2.17.
using GD bundled (2.0.34 compatible)(png support.)