GCC 16 で C++26 の std::simd を使い、オーディオ DSP の自動ベクトル化と比較してみた

GCC 16 で C++26 の std::simd を使い、オーディオ DSP の自動ベクトル化と比較してみた

単純な gain では、std::simd と自動ベクトル化の性能は近くなりました。64 タップ FIR では、フレーム方向の並列性を明示した std::simd 版がスカラー比 3.50〜14.14 倍になりました。
2026.09.10

はじめに

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 の書き方を選ぶ際の判断材料になれば幸いです。

この記事をシェアする

関連記事