FreeBSD portsでCPUTYPE=nativeを試す(GMP,libargon2)
Tuning FreeBSD ports with CPUTYPE=native: GMP vs libargon2
はじめに
FreeBSDのpkgで入るバイナリパッケージは、基本的に汎用amd64ターゲット(だいたいSSE2〜x86-64-v2程度)向けに-O2でビルドされています。手元のサーバはAVX2やAES-NIなどをちゃんと積んでいるCPUなのに、その恩恵を受けずにいるのはもったいないなと思い、portsで再構築してCPUTYPE=nativeを試してみることにしました。
全部をports化するのはビルド時間的に割に合わないので、計算集約系で恩恵が出やすそうなパッケージを2つだけ選んで検証しました。以下私の環境の依存関係です。
math/gmp— 多倍長演算ライブラリ。gcc14 / mpfr / mpc が依存security/libargon2— パスワードハッシュ関数Argon2の実装。php83が依存
環境
- FreeBSD amd64 14.4
- CPU: Intel(R) Xeon(R) CPU E5-2650L v4 @ 1.70GHz(Broadwell世代、28スレッド)
- 対応する拡張命令: AVX2, FMA, BMI1/BMI2, ADX, AES-NI, PCLMULQDQ, RDSEED, F16C(SHA-NIは非搭載)
hw.model: Intel(R) Xeon(R) CPU E5-2650L v4 @ 1.70GHz
hw.ncpu: 28Structured Extended Features=0x21cbfbb<FSGSBASE,TSCADJ,BMI1,HLE,AVX2,SMEP,BMI2,ERMS,INVPCID,RTM,PQM,NFPUSG,PQE,RDSEED,ADX,SMAP,PROCTRACE>/etc/make.confの設定
本来はnativeだけで良いと思いますが、念の為指定しています。
CPUTYPE=native
CFLAGS+= -O2 -pipe
MAKE_JOBS_NUMBER=4ビルド
cd /usr/ports/math/gmp
make install clean
cd /usr/ports/security/libargon2
make install cleannative最適化が実際に効いているか確認
コンパイラがどの拡張命令を前提にコード生成する設定になっているかをまず確認します。
cc -march=native -dM -E - </dev/null | grep -E "AVX2|FMA|BMI2|ADX|__AES__"
#define __ADX__ 1
#define __AES__ 1
#define __AVX2__ 1
#define __BMI2__ 1
#define __FMA__ 1ports側のCFLAGSにも反映されていることを確認。
make -V CFLAGS
-O2 -pipe -O2 -pipe -march=native -fstack-protector-strong -fno-strict-aliasing※ -O2 -pipeが2回重複しているのは、ports標準のフラグと/etc/make.conf側の設定が両方載っているだけで実害はありません
ベンチマーク方法
GMP
3,000,000の階乗を計算するだけの小さなCプログラムを書いて、直接libgmpを呼び出しました。
#include <gmp.h>
#include <stdio.h>
#include <time.h>
int main(void) {
mpz_t f;
mpz_init(f);
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
mpz_fac_ui(f, 3000000);
clock_gettime(CLOCK_MONOTONIC, &t1);
double elapsed = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
printf("%.3f sec (%zu row)\n", elapsed, mpz_sizeinbase(f, 10));
mpz_clear(f);
return 0;
}
cc -O2 -I/usr/local/include -L/usr/local/lib bench_gmp.c -lgmp -o bench_gmplibargon2
portが入れてくれるCLIツールargon2をそのまま使いました。
echo -n "test-password" | argon2 saltsaltsalt12 -t 50 -m 16 -p 4 -idargon2コマンドは実行するだけでハッシュ値・エンコード済み文字列に加えて所要秒数まで出力してくれるため、別途timeなどで計測用にラップする必要はありませんでした。
どちらも、CPUTYPE=nativeでビルドした状態と、make CPUTYPE= reinstall cleanで一時的に汎用ビルドへ差し替えた状態を切り替えて計測しています。共有ライブラリのsonameは変わらないため、ベンチ用バイナリ自体を再コンパイルする必要はありません。
GMPのベンチマーク結果
| ビルド | 実行時間 |
|---|---|
native(-march=native) | 0.946秒 |
汎用(CPUTYPE=) | 0.950秒 |
ほぼ同じ。むしろ汎用の方がわずかに遅いくらいで、実質誤差の範囲でした。
なぜ差が出なかったのか
GMPのconfigureは、ports側から渡されるCFLAGSとは別に、自分自身でホストCPUを検出して使用するアセンブリ実装を選ぶ仕組みを持っています。GMPのconfigure.acを見ると、CPU世代ごとに使うmpnアセンブリのパスがあらかじめ定義されていて、Broadwell系のCPUであればx86_64/coreibwlという専用実装が自動的に選ばれるようになっています。この選択は-march=nativeの有無に関係なく行われるため、乗算処理の心臓部(mpn_mulなど)は最初から最適化済みだった、ということになります。
grep -rn "coreibwl" mpn/x86_64/fat/fat.c
mpn/x86_64/fat/fat.c:381: CPUVEC_SETUP_coreibwl;
mpn/x86_64/fat/fat.c:396: CPUVEC_SETUP_coreibwl;x86_64/fatはGMPの「fatビルド」と呼ばれる仕組みで、特定CPUを指定せずに汎用ターゲット向けにビルドした場合、実行バイナリが起動時にCPUID命令でホストCPUを判定し、coreibwlを含む複数の最適化済み実装から適切なものを動的に選択します。FreeBSDのpkgが配布するGMPバイナリはまさにこの汎用ターゲット向けビルドにあたるため、CPUTYPE=nativeを指定しなくても、実行時にBroadwell向けのcoreibwl実装へ自動的に切り替わっていたと考えられます。
つまり「GMPはADX/BMI2の恩恵が大きいはず」という予想自体は間違っていませんでしたが、その恩恵はpkgバイナリの時点で既に得られていたというのが実態でした。
対応
再構築を維持する意味が薄いと判断し、pkg管理のバイナリパッケージに戻しました。
sudo pkg unlock -y gmp
sudo pkg install -f gmppkgとportsのバージョン番号(6.3.0)が変わらないため、-fを付けて強制的にインストールしましょう
libargon2のベンチマーク結果
| 回数 | ビルド | 実行時間 |
|---|---|---|
| 1 | native | 5.906秒 |
| 2 | native | 5.578秒 |
| 3 | native | 5.672秒 |
| 平均 | native | 5.719秒 |
| 1 | 汎用 | 7.102秒 |
| 2 | 汎用 | 7.211秒 |
| 平均 | 汎用 | 7.157秒 |
native版は平均5.719秒、汎用版は平均7.157秒で、約20%の速度差が出ました。GMPと違って再現性のある明確な差になっています。
なぜ差が出たのか
libargon2はGMPのような独自のCPU検出機構を持たず、AVX2向けの実装をコンパイラの-marchフラグにそのまま委ねる設計になっています。そのため、ports側のCPUTYPE=nativeが素直にコード生成へ反映され、ベンチマークにもはっきり表れました。
対応
明確に速くなったのでnativeビルドを維持し、上書きされないようロックしました。
cd /usr/ports/security/libargon2
sudo make reinstall clean
sudo pkg lock -y libargon2考察
多倍長・暗号系ライブラリだからSIMD拡張の恩恵が大きいはずという前提で選んだ2つのportsでしたが、結果は対照的でした。理由として、CPU拡張命令の活用をライブラリ自身の設計が握っているか、コンパイラのフラグに委ねているかの違いにあります。
GMPのようにCPU検出→最適実装選択を自前で持つライブラリは、ports側のCPUTYPE操作の効果が乗りにくい反面、libargon2のように「コンパイラの自動ベクトル化・intrinsics切り替え」に頼るライブラリは、CPUTYPE操作がそのまま効くていう感じです。
native化すれば速くなるはずと決めつけず、対象ライブラリがどういう仕組みで最適化を行っているかを個別に確認する必要がありますね。
まとめ
native化すれば速くなるだろうくらいの雑な期待から始めた検証でしたが、GMPで見事に肩透かしを食らったのは収穫でした。ベンチマークを取らずに多分効いてるはずで全部native化していたら、GMPの再構築コストだけ無駄に払い続けるところでした。CPUTYPE周りを触るなら、対象ライブラリごとに最適化の仕組みを軽く調べてから手を付けるのが良さそうです。