为何ARM NEON SIMD求和在M2 Mac上比串行求和慢?
M2 Mac上手动NEON SIMD求和性能低于串行代码的原因分析
在M2 Mac上测试内存带宽时,发现手动实现的ARM NEON SIMD求和代码处理速度远慢于朴素串行求和,内存带宽也更低。测试已开启-O3优化,推测串行代码被编译器自动向量化,希望手动SIMD代码能达到同等性能。
手动NEON SIMD求和代码
int arm_neon_simd_sum(const std::vector<int8_t>& arr) { // 检查数组是否为空 if (arr.empty()) return 0; int32x4_t v_sum = vdupq_n_s32(0); // 初始化NEON向量存储部分和 size_t i = 0; // 按16元素块处理数组 for (; i + 15 < arr.size(); i += 16) { int8x16_t v_data = vld1q_s8(&arr[i]); // 将16个元素加载到NEON寄存器 // 将int8x16_t转换为int16x8_t int16x8_t v_data_low = vmovl_s8(vget_low_s8(v_data)); // 低8个元素扩展为int16 int16x8_t v_data_high = vmovl_s8(vget_high_s8(v_data)); // 高8个元素扩展为int16 // 转换为int32x4_t并累加 v_sum = vaddq_s32(v_sum, vpaddlq_s16(v_data_low)); v_sum = vaddq_s32(v_sum, vpaddlq_s16(v_data_high)); } // 水平相加向量得到所有元素的总和 int32_t sum_array[4]; vst1q_s32(sum_array, v_sum); int sum = sum_array[0] + sum_array[1] + sum_array[2] + sum_array[3]; // 处理剩余元素 for (; i < arr.size(); ++i) { sum += arr[i]; } return sum; }
SIMD测试结果
2024-07-12 21:31:51.101 -------- running arm_neon_simd_sum -------- .................................................................................................... 2024-07-12 21:32:03.808 Result: 536870900 2024-07-12 21:32:03.808 Total execution time: 12.705522168999996 seconds 2024-07-12 21:32:03.808 Total bytes processed: 107374182400 2024-07-12 21:32:03.808 Throughput: 7.870593484460515 GB/s
朴素串行求和代码
int sum_sequential(const std::vector<int8_t> &arr) { int sum = 0; for (int64_t i = 0; i < kArraySize; i++) { sum += arr[i]; } return sum; }
串行测试结果
2024-07-13 10:28:54.706 -------- running sequential sum -------- .................................................................................................... 2024-07-12 21:31:02.750 Result: 536870900 2024-07-12 21:31:02.750 Total execution time: 3.303718871999998 seconds 2024-07-12 21:31:02.750 Total bytes processed: 107374182400 2024-07-12 21:31:02.750 Throughput: 30.268919322261286 GB/s 2024-07-12 21:31:02.750 -------- running random access sum -------
核心原因分析
1. 编译器自动向量化的优势
开启-O3后,Apple Silicon默认的Clang编译器会对串行代码做深度优化:
- 多累加器消除依赖:自动生成多个NEON向量累加器,避免单累加器的指令依赖瓶颈,充分利用CPU超标量执行能力。
- 高效指令序列:直接使用最优的转换/累加指令,比如将int8元素成对相加并扩展,减少冗余操作;同时自动做循环展开,一次加载更多数据(如32字节),提升内存带宽利用率。
- 优化水平求和:用
vaddvq_s32这类直接计算向量总和的指令,避免手动代码中「向量存内存再相加」的额外开销。
2. 手动SIMD代码的性能瓶颈
- 单累加器依赖瓶颈:仅用一个
int32x4_t累加器,每次循环需等待前一次加法完成,无法充分利用CPU并行执行单元,限制吞吐量。 - 冗余转换步骤:先通过
vmovl_s8扩展int8为int16,再用vpaddlq_s16相加转int32,比直接用vpaddlq_s8(int8成对相加并扩展为int16)的指令数更多,增加计算开销。 - 低效水平求和:手动将向量写入栈内存再相加,比NEON原生水平求和指令多一次内存读写,增加延迟。
- 无循环展开:每次仅处理16个int8元素,内存访问粒度太小,无法充分利用M2的内存带宽。
优化建议
- 使用多累加器:定义4个
int32x4_t累加器,循环中分别累加不同数据块,最后合并总和,消除指令依赖。 - 优化转换逻辑:用
vpaddlq_s8直接将int8x16转换为int16x8(成对相加并扩展),再用vpaddlq_s16得到int32x4,减少转换步骤的指令数。 - 替换水平求和方式:用
vaddvq_s32(v_sum)直接计算向量总和,避免内存读写操作。 - 手动循环展开:一次处理32个int8元素,提升内存访问吞吐量。
- 确保内存对齐:显式指定
std::vector的对齐方式,保证加载指令的执行效率。
内容的提问来源于stack exchange,提问作者Bob Fang
相关产品推荐
相关产品推荐

