ARM Neon命令セットによるmemcpy最適化の深掘り:なぜ高速化しないのか

画像処理や行列演算などの分野では、ARMのNeon命令セット(SIMD)を用いた高速化が一般的です。しかし、単純なメモリコピー(memcpy)において、128ビット幅のベクトルレジスタを利用すれば、標準のmemcpyよりも高速化できるのではないかという疑問が生じます。結論から述べると、多くの場合、Neonを用いた自作の実装は標準ライブラリのmemcpyに及ばないか、同等程度のパフォーマンスに留まります。本記事では、この現象をCPUマイクロアーキテクチャの観点から詳細に分析します。

ベンチマーク環境の構築

まず、Neon命令を用いたメモリコピーの性能を検証するため、以下の条件下でテストを実施しました。

  • CPU: ARM Cortex-A55(小核)、Cortex-A76ベース(中核・大核)
  • OS: Linux 5.x
  • データサイズ: 1MB
  • 計測方法: 64バイトアライメント済みのアドレスを使用。1000回のループ実行の平均値を測定。

実装コードのバリエーション

標準のmemcpyをベースラインとし、Neon命令(ld1, st1, ld4, st4)を用いた複数の実装を比較します。

1. 64バイト展開(Neon V1)

ld1st1を用いて、1回のループで64バイトを処理するコードです。

void neon_copy_64b(void* dest, const void* src, size_t size) {
    const uint8_t* p_src = (const uint8_t*)src;
    uint8_t* p_dest = (uint8_t*)dest;
    const uint8_t* end_ptr = p_src + size;

    while (p_src != end_ptr) {
        asm volatile (
            "ld1 {v0.4s, v1.4s, v2.4s, v3.4s}, [%[src]], #64 \n"
            "st1 {v0.4s, v1.4s, v2.4s, v3.4s}, [%[dest]], #64 \n"
            : [src] "+r"(p_src), [dest] "+r"(p_dest)
            :
            : "memory", "v0", "v1", "v2", "v3"
        );
    }
}

2. 128バイト展開(Neon V2)

ループアンローリングをさらに進め、1回で128バイトを処理します。

void neon_copy_128b(void* dest, const void* src, size_t size) {
    const uint8_t* src_ptr = (const uint8_t*)src;
    uint8_t* dest_ptr = (uint8_t*)dest;
    const uint8_t* limit = src_ptr + size;

    while (src_ptr != limit) {
        asm volatile (
            "ld1 {v0.4s, v1.4s, v2.4s, v3.4s}, [%[s]], #64 \n"
            "ld1 {v4.4s, v5.4s, v6.4s, v7.4s}, [%[s]], #64 \n"
            "st1 {v0.4s, v1.4s, v2.4s, v3.4s}, [%[d]], #64 \n"
            "st1 {v4.4s, v5.4s, v6.4s, v7.4s}, [%[d]], #64 \n"
            : [s] "+r"(src_ptr), [d] "+r"(dest_ptr)
            :
            : "memory", "v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7"
        );
    }
}

測定結果の分析

単位:マイクロ秒(μs)

手法 大核 中核 小核
標準 memcpy 75 91 150
Neon 64B 展開 79 90 183
Neon 128B 展開 81 88 196

結果から明らかなように、Neonを用いた実装は標準memcpy(多くの場合、アセンブリレベルで最適化されたldp/stp命令を使用)と同等か、それより遅い傾向にあります。なぜベクトル命令を使っているのに速くならないのでしょうか。

マイクロアーキテクチャの制約

1. ロード/ストア・ユニットの実行能力

モダンなCPUでは、命令がデコードされた後、実際にデータをメモリから読み書きする「ロード/ストア・ユニット」へと送られます。例えば、Cortex-A55のようなプロセッサでは、1サイクルあたりに処理できるビット幅に物理的な制限があります。

  • A55の制約: 1サイクルあたりのロード能力は64ビット、ストア能力は128ビット程度に制限されています。
  • Neon命令の挙動: ld1で128ビット(16バイト)を読み込む場合、内部的に2サイクルに分割されるか、スループットが低下します。

一方で、標準の汎用レジスタを用いるldp(Load Pair)命令は、2つの64ビットレジスタを一度にロードしますが、これはロード・ユニットの帯域を最大限に活用するように最適化されています。

2. レイテンシとスループットの差

Cortex-A55の公式資料によれば、Neon命令(SIMD)のパイプラインは整数演算(ALU)のパイプラインよりも長く設計されています。これにより、命令の発行から完了までのレイテンシがNeonの方が大きくなる傾向があります。

具体例として、A55における命令実行の比較:

  • ldp (汎用レジスタ): レイテンシ 3-4サイクル、スループット 1/2
  • ld1 (Neonレジスタ 128ビット): レイテンシ 4-10サイクル、スループット 1/2〜1/8

つまり、Neon命令を使用すると、1回で扱えるデータ量は多く見えても、実行ユニットの占有時間が長く、単位時間あたりのデータ転送量(スループット)で汎用命令に勝てないのです。

3. キャッシュとメモリ帯域のボトルネック

メモリコピーのボトルネックは、多くの場合CPU命令そのものではなく、キャッシュ(L1/L2/L3)の読み書き速度やメインメモリの帯域幅にあります。CPUがどれだけ高度なベクトル命令を投げても、メモリサブシステムが供給できるデータ量を超えて高速化することはありません。現代のCPUには強力なハードウェア・プリフェッチャが搭載されており、連続したメモリアクセスであれば、標準のmemcpyでもキャッシュラインを最大限に活用できます。

高性能なメモリコピーを実現するための原則

実用的な高性能コピー関数を実装、あるいは選択する場合、以下の要素が重要となります。

アライメントの最適化

非アライメントアクセスは、ARMを含む多くのアーキテクチャでペナルティ(追加のサイクルや複数のバスアクセス)を発生させます。標準のライブラリは、コピーの開始時にアドレスをアライメントさせるための「前処理」を行い、メインループで最大の転送効率を維持します。

ループアンローリングとレジスタのインターリーブ

単一のレジスタに対する読み書きを繰り返すのではなく、複数のレジスタにまたがってロード命令とストア命令を交互に配置(インターリーブ)することで、メモリのロード・レイテンシを隠蔽し、命令レベルの並列性(ILP)を高めることができます。

// インターリーブの例(概念)
"ldp x0, x1, [src], #16 \n"
"ldp x2, x3, [src], #16 \n" // 1つ目の書き込みを待たずに次のロードを発行
"stp x0, x1, [dest], #16 \n"
"stp x2, x3, [dest], #16 \n"

キャッシュ制御命令の活用

一部のプラットフォームでは、データをキャッシュに載せずに直接メモリに書き込む「Non-temporal Store」や、ソフトウェア・プリフェッチ命令が有効な場合があります。ただし、これらはハードウェアの挙動に強く依存するため、プロファイリングに基づいた慎重な適用が必要です。

結論

ARMアーキテクチャ、特に電力効率を重視したコアにおいて、Neon命令は数値計算には極めて強力ですが、単なるデータ移動(memcpy)においては必ずしも特効薬ではありません。ハードウェアのロード/ストア・ポートの幅、命令ごとのスループット、そしてメモリ帯域の限界を理解することが、真の最適化への第一歩となります。独自のSIMD実装を検討する前に、まずは標準ライブラリの実装をベンチマークし、ハードウェアの理論上の限界値と比較することをお勧めします。

タグ: ARM Neon SIMD Performance optimization

7月22日 19:06 投稿