
GCC 16 で C++26 の std::simd を使い、オーディオ DSP の自動ベクトル化と比較してみた
はじめに
C++ で信号処理を高速化するとき、通常のループをコンパイラーへ任せるか、CPU 固有の intrinsics を使って SIMD 処理を書くか迷うことがあります。通常のループは読みやすい一方、意図した方向へベクトル化されるとは限りません。intrinsics なら処理を細かく制御できますが、命令セットごとの実装と保守が必要になります。
GCC 16 では、C++26 のデータ並列型である std::simd が libstdc++ へ試験的に追加されました。 標準 C++ の型と演算で SIMD 処理を書けるようになりました。先の選択肢のどちらでもない中間の方法です。早速試してみました。
結果、単純な gain は自動ベクトル化と std::simd の性能が近くなりました。一方、64 タップ FIR では、std::simd で並列化する方向を明示すると、スカラー比 3.50〜14.14 倍になりました。 (ただしこれは今回の検証環境での結果です。) 本記事では、通常の C++ と std::simd を使い分ける基準を考えていきたいと思います。
std::simd とは
std::simd は、同じ型の複数要素へ同じ演算を適用するための C++26 の API です。std::simd::vec<float> のように要素型を指定し、ロードした複数の値に対して、通常の算術演算子で処理を記述できます。
本記事では、要素数を省略した std::simd::vec<float> を使いました。今回のコンパイル条件では、float の要素数が SSE2 で 4、AVX2 で 8、AVX-512 で 16 になりました。
検証環境
- Amazon EC2
c6i.large - Intel Xeon Platinum 8375C
- GCC 16.2.0
- C++26、
-O3 - 48 kHz、64、128、256、512 frames
対象読者
- C++ のオーディオ DSP で SIMD 化を検討している方
- 自動ベクトル化と
std::simdの使い分けを知りたい方 - CPU 固有 intrinsics を書く前に、標準 C++ で可能な範囲を知りたい方
参考
検証方法
性質の異なる処理として、全サンプルに同じ係数を掛ける gain と、積和演算を行う 64 タップ FIR を比較しました。それぞれに、スカラー版・自動ベクトル化版・std::simd 版を用意しました。FIR には、std::simd 版と同じ順序で複数フレームを処理する通常 C++ 版も加えました。
各実装を SSE2・AVX2・AVX-512 向けにコンパイルし、出力の正しさとコンパイラーが生成した命令、および、処理時間を観察しました。処理時間だけで SIMD 化の成否を判断せず、コンパイラーの最適化レポートと逆アセンブルも見ます。
実装
最初に、連続したサンプルへ同じ係数を掛ける gain を実装しました。自動ベクトル化版は、通常のループを -O3 でコンパイルしています。
void gain_auto(const float* input, float* output, std::size_t frames,
float gain) {
for (std::size_t frame = 0; frame < frames; ++frame) {
output[frame] = input[frame] * gain;
}
}
std::simd 版は、複数のサンプルをロードし、ベクトル単位で乗算してから保存します。ベクトル幅で割り切れない末尾は、通常のループで処理しました。
using FloatVec = std::simd::vec<float>;
void gain_simd(const float* input, float* output, std::size_t frames,
float gain) {
constexpr std::size_t width = FloatVec::size();
std::size_t frame = 0;
const FloatVec gains(gain);
for (; frame + width <= frames; frame += width) {
const auto samples = std::simd::unchecked_load<FloatVec>(
std::span(input + frame, width));
std::simd::unchecked_store(samples * gains,
std::span(output + frame, width));
}
for (; frame < frames; ++frame) {
output[frame] = input[frame] * gain;
}
}
次に、計算量の多い処理として 64 タップ FIR を実装しました。通常のループは、1 フレームずつタップ方向へ積和します。
for (std::size_t frame = 0; frame < frames; ++frame) {
float accumulator = 0.0F;
for (std::size_t tap = 0; tap < taps; ++tap) {
accumulator += input[frame + taps - 1 - tap] * coefficients[tap];
}
output[frame] = accumulator;
}
std::simd 版は、複数フレームの独立した積和を 1 つのベクトルに保持します。タップ方向のループが終わるまでベクトルを累積し、最後に出力へ保存します。
for (; frame + width <= frames; frame += width) {
FloatVec accumulator(0.0F);
for (std::size_t tap = 0; tap < taps; ++tap) {
const auto samples = std::simd::unchecked_load<FloatVec>(
std::span(input + frame + taps - 1 - tap, width));
accumulator += samples * FloatVec(coefficients[tap]);
}
std::simd::unchecked_store(accumulator,
std::span(output + frame, width));
}
この 2 実装だけでは、API の違いに加えて、タップ方向とフレーム方向というループ構造の違いが含まれます。そこで、std::array<float, N> に複数フレームの累積値を持たせ、std::simd 版と同じ順序で処理する通常 C++ 版も加えました。N は SSE2 で 4、AVX2 で 8、AVX-512 で 16 としてコンパイルしています。
スカラー版は、自動ベクトル化を無効にしてコンパイルしました。
各条件は論理 CPU 0 に固定し、1 試行が 100 ms 以上になるよう内部反復数を調整してから、順序を入れ替えながら 30 回測定しました。結果の数値は、バッチ実行時間を内部反復数で割ったスループット換算値の中央値とします。
結果
全ブロックサイズで傾向が共通していたため、代表として 128 frames の結果を示します。倍率は、同じ命令セットのスカラー版に対する速度向上の倍率です。
gain の結果は次のとおりです。
| ISA | スカラー | 自動ベクトル化 | std::simd |
|---|---|---|---|
| SSE2 | 43.11 ns (1.00 倍) | 11.79 ns (3.66 倍) | 12.74 ns (3.38 倍) |
| AVX2 | 47.41 ns (1.00 倍) | 8.03 ns (5.90 倍) | 7.39 ns (6.42 倍) |
| AVX-512 | 47.79 ns (1.00 倍) | 9.43 ns (5.07 倍) | 9.52 ns (5.02 倍) |
FIR の結果は次のとおりです。「フレーム並列を表した通常 C++」は、固定レーン数の配列で複数フレームの累積値を表した対照実験です。
| ISA | スカラー | 通常ループの自動ベクトル化 | フレーム並列を表した通常 C++ | std::simd |
|---|---|---|---|---|
| SSE2 | 5.728 µs (1.00 倍) | 5.579 µs (1.03 倍) | 2.354 µs (2.43 倍) | 1.631 µs (3.51 倍) |
| AVX2 | 5.728 µs (1.00 倍) | 4.801 µs (1.19 倍) | 3.190 µs (1.80 倍) | 0.740 µs (7.74 倍) |
| AVX-512 | 5.729 µs (1.00 倍) | 4.876 µs (1.17 倍) | 4.023 µs (1.42 倍) | 0.407 µs (14.09 倍) |
64〜512 frames における std::simd 版 FIR の速度向上は、SSE2 で 3.50〜3.52 倍、AVX2 で 7.62〜7.82 倍、AVX-512 で 14.04〜14.14 倍でした。
コンパイラーの最適化レポートでは、gain の自動ベクトル化版が、それぞれの命令セットに応じた 16・32・64 bytes のベクトルへ変換されていました。フレーム並列を表した通常 C++ 版では、ソースコード上のレーンループではなく、タップループが同じ幅へベクトル化されていました。(逆アセンブルでも、SSE2 の mulps、AVX2 の YMM レジスター、AVX-512 の ZMM レジスターを使う vmulps を確認。)
一方、フレーム並列を表した通常 C++ 版は、AVX2 と AVX-512 で入力の並べ替えとスタックへの一時退避を多数含んでいました。 ソースコードの走査順をそろえても、コンパイラーが選んだベクトル化の方向と命令列は std::simd 版と同じになりませんでした。
誤差については、入力と係数を乗算前に double へ変換した参照 FIR と全出力を比較しました。絶対誤差の最大は 6.2434349412665568e-8 でした。
考察
gain のように単純な処理では、最初から明示的な SIMD コードへ置き換えず、通常のループが自動ベクトル化されたか確認する方法で十分だと思います。 一方、FIR のような積和では、どの方向に独立した計算が並んでいるかをコンパイラーへ伝えることが重要そうです。
なお、今回の速度差については、std::simd という API だけの効果と断定はできません。通常 C++ 版は ISA ごとの固定レーン数を使い、生成命令も std::simd 版と一致しなかったためです。また、CPU 固有 intrinsics との性能比較は行っていません。そのため、intrinsics を全面的に置き換えられるとは判断できません。
今回の範囲では、intrinsics を書かずに対象の SIMD 幅を利用し、FIR で明確な性能向上を得られました。まず通常の C++ と自動ベクトル化を確認し、意図した並列性を表せない処理で std::simd を検討するのがよさそうです。
まとめ
GCC 16.2 の C++26 std::simd を使い、オーディオ DSP の gain と 64 タップ FIR を比較しました。単純な gain では自動ベクトル化と std::simd の性能が近く、FIR ではフレーム方向の並列性を明示した std::simd 版がスカラー比 3.50〜14.14 倍になりました。
通常のループで十分な処理まで書き換える必要はなさそうです。一方、自動ベクトル化が意図した処理にならない箇所では、生成命令と対象 CPU の対応状況を確認しながら std::simd を試す価値があると思います。本記事が、C++ のオーディオ DSP で SIMD の書き方を選ぶ際の判断材料になれば幸いです。






