画像処理や行列演算などの分野では、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)
ld1とst1を用いて、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/2ld1(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実装を検討する前に、まずは標準ライブラリの実装をベンチマークし、ハードウェアの理論上の限界値と比較することをお勧めします。