先日、IntelのIntrinsic関数をARMへポートする話を読んでいて、実際に小さなCプロジェクトを作って試してみたくなりました。
SSEやAVXを使っているコードをARMへ持っていく場合、単純に再コンパイルすれば済むわけではありません。
たとえばIntel SSEでは、
__m128 r = _mm_add_ps(a, b);
のように書きますが、ARM NEONでは概ね、
float32x4_t r = vaddq_f32(a, b);
のようになります。
sse2neonのようにIntel Intrinsic互換レイヤーを使う方法もありますが、今回は小さなサンプルを用意して、自作している移植ツール Miruri にx86固有のまま渡してみました。
今回試したターゲットは、
- Linux ARM64
- macOS ARM64(Apple Silicon)
の2つです。
結果として、どちらもARM64向けの実行ファイルまで生成できました。
ただし、今回あらためて成果物を見直すと、Linux ARM64とmacOS ARM64では生成された修正が完全に同一ではありませんでした。
ここがむしろ面白くて、単純な、
_mm_add_ps
↓
vaddq_f32
のような固定置換ではなく、ターゲットごとに必要な範囲を判断してコードを変更していました。
実験用のCプロジェクト
今回の元プロジェクトはかなり小さいです。
intel-intrinsic-demo/
├── CMakeLists.txt
├── Makefile
├── README.md
├── include/
│ └── simd_math.h
└── src/
├── main.c
└── simd_math.c
わざとARMではそのままコンパイルできない状態にしています。
src/simd_math.cでは、
#include <xmmintrin.h>
を直接includeしています。
実装している処理は、
- 4要素floatの加算
- 4要素floatの積和
- 4要素floatの合計
だけです。
使っているIntel Intrinsicは次のものです。
_mm_loadu_ps
_mm_add_ps
_mm_mul_ps
_mm_storeu_ps
_mm_movehl_ps
_mm_shuffle_ps
_mm_add_ss
_mm_cvtss_f32
SSE1程度の小さな例ですが、単純なベクトル加算だけでなく、horizontal reductionも含めています。
元のSSEコード
まず4要素の加算です。
void simd_add4(const float a[4], const float b[4], float out[4])
{
const __m128 va = _mm_loadu_ps(a);
const __m128 vb = _mm_loadu_ps(b);
const __m128 vr = _mm_add_ps(va, vb);
_mm_storeu_ps(out, vr);
}
メモリから128bitを読み、
_mm_add_ps()
で4つのfloatを並列加算し、
_mm_storeu_ps()
で結果を書き戻しています。
積和も入れています。
void simd_mul_add4(
const float a[4],
const float b[4],
const float c[4],
float out[4])
{
const __m128 va = _mm_loadu_ps(a);
const __m128 vb = _mm_loadu_ps(b);
const __m128 vc = _mm_loadu_ps(c);
const __m128 product = _mm_mul_ps(va, vb);
const __m128 result = _mm_add_ps(product, vc);
_mm_storeu_ps(out, result);
}
計算としては、
(a * b) + c
です。
ここでは意図的にmultiplyとaddを別々のSSE演算として書いています。
horizontal sumも少し面倒な形にした
単純な加算だけでは面白くないので、4要素の合計もSSE1だけで実装しています。
float simd_sum4(const float a[4])
{
const __m128 v = _mm_loadu_ps(a);
const __m128 hi = _mm_movehl_ps(v, v);
const __m128 pair_sum = _mm_add_ps(v, hi);
const __m128 shuffled =
_mm_shuffle_ps(
pair_sum,
pair_sum,
_MM_SHUFFLE(1, 1, 1, 1));
const __m128 total =
_mm_add_ss(pair_sum, shuffled);
return _mm_cvtss_f32(total);
}
実質的な加算順序は、
(a[0] + a[2]) + (a[1] + a[3])
です。
この形にしたのは、ARMへポートしたときに「最終的な数学的意味だけを見るのか」「演算順序まである程度維持するのか」を確認したかったためです。
浮動小数点では、
(a + b) + c
と、
a + (b + c)
が必ずしも同じbit patternになるとは限りません。
SIMDコードを別ISAへ移植するときには、このような部分も気になります。
ビルドシステム側もわざとx86専用にした
今回の元プロジェクトは、ソースコードだけではなくビルド設定にもx86依存を入れています。
CMakeでは、
if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU")
target_compile_options(simd_math PRIVATE -msse)
endif()
となっています。
Makefileにも、
SSE_CFLAGS ?= -msse
があり、
$(CC) $(CPPFLAGS) $(CFLAGS) $(SSE_CFLAGS) -c $< -o $@
としてsimd_math.cへ常に-msseを渡します。
つまりARM Clangへそのまま持っていくと、ソースを読む前にビルドフラグでも失敗します。
実際、Linux ARM64では最初のビルドが、
clang: error: unsupported option '-msse' for target 'aarch64-unknown-linux-gnu'
で停止しました。
macOS ARM64でも、
clang: error: unsupported option '-msse' for target 'arm64-apple-darwin'
となりました。
今回は「Intrinsicを書き換えれば終わり」ではなく、周辺のbuild configurationも一緒に移植対象になるようにしてあります。
Miruriへそのまま渡す
元コードはARM対応せず、そのままMiruriへ渡しました。
Linux ARM64なら、
miruri port \
--target linux-arm64 \
--codex-mode port \
./intel-intrinsic-demo
Apple Silicon macOSなら、
miruri port \
--target macos-arm64 \
--codex-mode port \
./intel-intrinsic-demo
です。
今回の成果物はMiruri 0.1.0-alpha.9.5 で生成しています。
Linux ARM64ではMiruriがmanaged sysrootを構成し、Clangで、
aarch64-unknown-linux-gnu
向けにクロスコンパイルしています。
解析時点で2種類の依存が検出された
今回のソースをMiruriで解析すると、x86依存は1種類だけではありませんでした。
検出されたhard requirementは、
build.hardcoded-x86-flags
cpu.x86.intrinsics
です。
cpu.x86.intrinsicsの根拠は、
#include <xmmintrin.h>
です。
一方、build.hardcoded-x86-flagsは、
CMakeLists.txt
Makefile
の両方に存在する-msseから検出されています。
この結果を見ると、CPU Intrinsicの移植でも、ソースだけではなくbuild systemまで一緒に解析する必要があることが分かります。
実際にどうポートされたか
ここからが今回一番見たかった部分です。
面白かったのは、Linux ARM64とmacOS ARM64で、Miruri/Codexが生成した修正が少し違ったことです。
どちらも最終的にはNEONを使っていますが、変更範囲とfallback方針が同じではありません。
Linux ARM64版
Linux ARM64では、変更されたファイルは次の3つでした。
CMakeLists.txt
include/simd_math.h
src/simd_math.c
Makefileは変更されていません。
今回のLinux artifactはCMake経路で生成されているため、リンクに必要な範囲へ絞った修正になっています。
simd_math.cの先頭は、概ね次の形へ変更されました。
#if defined(__aarch64__) || defined(_M_ARM64)
#include <arm_neon.h>
#define SIMD_MATH_NEON 1
#elif defined(__SSE__) || defined(_M_X64) || \
(defined(_M_IX86_FP) && _M_IX86_FP >= 1)
#include <xmmintrin.h>
#define SIMD_MATH_SSE 1
#else
#error "simd_math requires AArch64 NEON or x86 SSE support"
#endif
つまりLinux版は、
AArch64
→ NEON
x86 SSE
→ 元のSSE実装
それ以外
→ compile-time error
という構成です。
元のx86コードを削除してARM専用コードにしたわけではなく、既存のSSE backendを残したままNEON backendが追加されています。
_mm_add_psはNEONへ
元のSSEコードは、
const __m128 va = _mm_loadu_ps(a);
const __m128 vb = _mm_loadu_ps(b);
const __m128 vr = _mm_add_ps(va, vb);
_mm_storeu_ps(out, vr);
です。
AArch64側には、
const float32x4_t va = vld1q_f32(a);
const float32x4_t vb = vld1q_f32(b);
vst1q_f32(out, vaddq_f32(va, vb));
が追加されました。
対応関係はかなり素直です。
| Intel SSE | ARM NEON |
|---|---|
_mm_loadu_ps |
vld1q_f32 |
_mm_add_ps |
vaddq_f32 |
_mm_storeu_ps |
vst1q_f32 |
どちらも128bit幅に4つの32bit floatを持つため、この例では自然に対応できます。
積和をFMAへまとめなかった
元のSSEコードは、
const __m128 product = _mm_mul_ps(va, vb);
const __m128 result = _mm_add_ps(product, vc);
です。
Linux ARM64側も、
const float32x4_t product = vmulq_f32(va, vb);
vst1q_f32(out, vaddq_f32(product, vc));
となりました。
つまりソースレベルでは、
multiply
↓
add
をそのまま維持しています。
ここで、
vfmaq_f32(...)
のようなFMAへまとめることもできますが、今回はそうしていません。
FMAへ変えると丸め回数が変わるため、数値結果が微妙に変化する可能性があります。
Linux版のhorizontal reduction
Linux ARM64版では、
const float32x4_t v = vld1q_f32(a);
const float32x2_t pair_sum =
vadd_f32(
vget_low_f32(v),
vget_high_f32(v));
return vget_lane_f32(
vpadd_f32(pair_sum, pair_sum),
0);
になっています。
最初に、
[a0, a1] + [a2, a3]
を作るので、
[a0 + a2, a1 + a3]
となります。
その後vpadd_f32()で2要素を加算するため、元SSEの、
(a0 + a2) + (a1 + a3)
に対応するpairwise reductionになっています。
単純に、
vaddvq_f32(v)
へ置き換えていないところが興味深いです。
macOS ARM64版ではさらに修正範囲が広かった
macOS ARM64では変更されたファイルが4つになっています。
CMakeLists.txt
Makefile
include/simd_math.h
src/simd_math.c
Linux版と違い、Makefileまで修正されています。
またsimd_math.cも、
#if defined(__SSE__)
#include <xmmintrin.h>
#elif defined(__ARM_NEON) || defined(__ARM_NEON__)
#include <arm_neon.h>
#endif
として、各関数内部を、
SSE
NEON
portable scalar
の3経路にしています。
加算のfallbackは、
for (int i = 0; i < 4; ++i) {
out[i] = a[i] + b[i];
}
です。
積和も、
for (int i = 0; i < 4; ++i) {
const float product = a[i] * b[i];
out[i] = product + c[i];
}
となっています。
つまりmacOS版では、ARMへ対応するだけでなく、x86/ARM以外でも動かせるportable C fallbackまで追加されました。
同じ元ソースを同じARM64へ持っていっても、LinuxとmacOSで全く同じパッチを生成していない点は、今回かなり面白かったところです。
macOS版は-ffp-contract=offまで追加した
macOS側の修正でもう一つ興味深かったのが、CMakeに、
if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU")
target_compile_options(simd_math PRIVATE -ffp-contract=off)
endif()
が追加されたことです。
Makefile側にも、
SIMD_CFLAGS += -ffp-contract=off
が追加されています。
元コードではmultiplyとaddを別々に書いているため、移植後もその構造を維持しています。
さらにmacOS版ではcompilerによるfloating-point contractionも明示的に無効化して、FMAへまとめられないようにしています。
Linux版ではソース上のmultiply/add分離は維持していますが、この-ffp-contract=offまでは追加されていません。
この差も、実際の生成結果を見直して気付いた部分でした。
macOS版のhorizontal reductionも演算順序を維持
macOS ARM64版では、
const float32x4_t v = vld1q_f32(a);
const float32x2_t pair_sum =
vadd_f32(
vget_low_f32(v),
vget_high_f32(v));
return vget_lane_f32(pair_sum, 0)
+ vget_lane_f32(pair_sum, 1);
となっています。
Linux版はvpadd_f32()を使用していましたが、macOS版はlaneを取り出してscalar addしています。
それでも演算の構造は、
(a0 + a2) + (a1 + a3)
です。
さらにscalar fallbackも、
return (a[0] + a[2]) + (a[1] + a[3]);
となっていました。
「ARMならこのIntrinsicへ置換する」という一対一対応だけではなく、元コードのreduction treeを見ていることが分かります。
-msseの扱いも変わった
CMake側では、Linux/macOSともに-msseを無条件で渡さなくなりました。
生成された条件は、
if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU" AND
CMAKE_SYSTEM_PROCESSOR MATCHES "^(i[3-6]86|x86)$")
target_compile_options(simd_math PRIVATE -msse)
endif()
という形です。
ここでx86_64が入っていないのは、x86-64ではSSEがarchitecture baselineに含まれているためです。
つまり、
32bit x86
→ 必要なら -msse
x86_64
→ baseline SSEを使用
ARM64
→ -msseを渡さない
という整理になっています。
macOS版ではMakefileもcompilerのtarget machineを見て、
TARGET_MACHINE := $(shell $(CC) -dumpmachine 2>/dev/null)
ifneq (,$(filter i386-% i486-% i586-% i686-%,$(TARGET_MACHINE)))
SIMD_CFLAGS ?= -msse
endif()
のように変更されています。
一方、Linux版の今回のrepair patchではMakefileは変更されていません。
このため「プロジェクト内に存在するすべてのbuild systemを完全にportable化する」という観点では、Linux版にはまだ改善余地があります。
ただ、Miruriが実際に使用したCMake経路ではARM64向けリンクまで完了しています。
元のtranslation unitもそのまま使われている
今回のMiruriにはfeature-fidelity gateがあり、移植後のbuildが元ソースを本当に利用しているかも確認しています。
Linux ARM64、macOS ARM64ともに、
Miruri feature-fidelity gate: PASS;
target build reuses 2/2 original translation unit(s).
となりました。
今回の元translation unitは、
src/main.c
src/simd_math.c
の2本です。
つまり別のサンプルを新しく作って「ARM版ができました」としているのではなく、元の2ファイルを実際のtarget buildへ接続した状態でリンクしています。
実際に生成されたartifact
Linux ARM64では、
dist/linux-arm64/artifacts/
├── intel_intrinsic_demo
└── libsimd_math.a
が生成されています。
実行ファイルは、
ELF
arm64
として記録されており、static library側もARM64 memberとして検証されています。
Linuxではmanaged sysrootとClang/LLVMを使ってクロスコンパイルされています。
macOS ARM64でも、
dist/macos-arm64/artifacts/
├── intel_intrinsic_demo
└── libsimd_math.a
が生成されています。
実行ファイルは、
Mach-O
arm64
です。
Apple Silicon向けのnative executableとしてリンクできています。
self-testも用意した
Intrinsicを変換して「コンパイルできた」で終わると少し不安なので、元プロジェクトには簡単なself-testを入れてあります。
入力は、
const float a[4] = {
1.0f, 2.0f, 3.5f, -4.0f
};
const float b[4] = {
10.0f, -2.0f, 0.5f, 8.0f
};
const float c[4] = {
0.25f, 1.0f, -1.0f, 2.0f
};
期待値は、
a + b
= [11.000, 0.000, 4.000, 4.000]
a * b + c
= [10.250, -3.000, 0.750, -30.000]
sum(a)
= 2.500
です。
x86 nativeで実行すると、
a + b = [11.000, 0.000, 4.000, 4.000]
a * b + c = [10.250, -3.000, 0.750, -30.000]
sum(a) = 2.500
SELFTEST: PASS
になります。
今回のMiruriによるtarget buildでは、生成したクロスターゲットartifactをホスト上で勝手に実行しない方針なので、ARM64側はリンクとarchitecture検証までです。
実際のbehavioral validationを行う場合は、各ターゲット環境で同じself-testを実行する形になります。
sse2neon方式との違い
今回の実験では、
_mm_add_ps(...)
というIntel Intrinsic API自体をARM上で互換実装する方法にはしていません。
アプリ側では、
void simd_add4(...)
というAPIを維持し、その内部にarchitectureごとのbackendを持たせています。
macOS版なら概ね、
#if defined(__SSE__)
/* SSE */
#elif defined(__ARM_NEON) || defined(__ARM_NEON__)
/* NEON */
#else
/* portable C */
#endif
です。
小規模なコードなら、この構造はかなり読みやすいと思います。
一方、大規模な既存コードに、
_mm_add_ps
_mm_mul_ps
_mm_shuffle_ps
_mm_unpacklo_ps
のようなIntrinsicが何千、何万箇所も直接書かれている場合は、sse2neonのようなcompatibility layerを入れた方が変更量を抑えられる場合もあります。
つまり、
既存Intel Intrinsic APIを維持する
方法と、
処理単位でnative backendを分離する
方法の2方向があります。
今回のサンプルは後者です。
実際にやってみて分かったこと
今回のような小さいコードでは、Intel SSEからARM NEONへの基本的な対応そのものはかなり素直でした。
ただ、本当に面白かったのはIntrinsic名の変換より、その周辺です。
-
-msseのようなcompiler option - CMakeとMakefileのarchitecture依存
- 浮動小数点の演算順序
- FMAへ変換してよいか
- compilerによるFMA contractionを許すか
- horizontal reductionのreduction tree
- x86 implementationを残すか
- portable fallbackを入れるか
- cross compile時のsysroot
- 元translation unitを本当に再利用しているか
- 最終artifactが本当にARM64向けか
単純な、
_mm_add_ps
↓
vaddq_f32
だけなら、それほど難しくありません。
実際のソフトウェア移植で難しくなるのは、そのIntrinsicがどういう前提と意味論の中で使われているかを維持する部分だと思います。
そして今回もう一つ面白かったのが、同じ元コードをARM64へ持っていっても、LinuxとmacOSで生成されたrepair patchが完全には同じではなかったことです。
Linux版は比較的最小限のSSE/NEON対応、macOS版はscalar fallbackやMakefile、-ffp-contract=offまで含めた変更になりました。
固定の変換表を適用しているだけではないことが、成果物を見ると分かります。
まとめ
今回はIntel SSE Intrinsicを意図的に使った小さなCプロジェクトを作り、MiruriでLinux ARM64とmacOS ARM64へポートしてみました。
基本的な対応としては、
_mm_loadu_ps → vld1q_f32
_mm_add_ps → vaddq_f32
_mm_mul_ps → vmulq_f32
_mm_storeu_ps → vst1q_f32
のような変換が行われています。
一方で、実際の修正はIntrinsicの名前置換だけではありませんでした。
Linux ARM64では、
x86 SSE
ARM NEON
を使い分ける構造になり、CMakeの-msseも32bit x86に限定されました。
macOS ARM64ではさらに、
x86 SSE
ARM NEON
portable scalar
の3経路になり、Makefileのarchitecture判定や-ffp-contract=offも追加されています。
horizontal reductionについても、元SSEの、
(a0 + a2) + (a1 + a3)
という演算構造を保つようなNEON実装になっていました。
最終的に、
Linux ARM64 → ELF arm64 executable
macOS ARM64 → Mach-O arm64 executable
まで生成できています。
また両方とも、Miruriのfeature-fidelity gateでは元のtranslation unitを2/2再利用していることが確認されています。
Intel IntrinsicのARM移植というと、最初は「Intelの関数とARMの関数の対応表を作ればよい」という印象がありますが、実際に試すと、compiler flag、build system、浮動小数点意味論、fallback方針、targetごとの差まで含めて考える必要があります。
小さいサンプルでも意外と見るところが多く、かなり面白い実験になりました。
次は、
SSE2
SSE4
AVX
AVX2
AVX-512
あたりを含む、もう少し複雑なコードでも試してみたいと思います。