指令集优化

本文整理常见的 CPU 侧优化手段,重点放在 NEONOpenMP 两类方法。前者偏数据级并行,后者偏线程级并行,二者可以结合使用。

1. 基本概念

1.1 SIMD 与 SIMT

  • SIMD:Single Instruction Multiple Data,单指令多数据,核心是同一条指令同时处理多个数据元素。
  • SIMT:Single Instruction Multiple Threads,单指令多线程,更常见于 GPU 执行模型。

在 CPU 侧讨论指令集优化时,通常关注的是 SIMD。它利用更宽的寄存器和向量指令,在单线程内提升吞吐。

1.2 指令集优化的核心目标

  • 减少循环中的标量运算次数
  • 提高单次指令处理的数据量
  • 减少内存访问带来的开销
  • 让编译器更容易生成高效机器码

2. NEON 优化

NEON 是 ARM 平台常见的 SIMD 指令集。以 float32x4_t 为例,一个 128-bit 向量寄存器可以同时处理 4 个 float32

2.1 使用场景

适合以下类型的计算:

  • 点云或图像中的批量坐标变换
  • 向量加减乘除
  • 矩阵中的逐元素计算
  • 一些前处理、后处理阶段的固定公式运算

2.2 注意事项

  • 向量化并不等于一定更快,前提是数据排布和访存方式合理。
  • 如果使用过多寄存器,可能触发寄存器溢出,编译器会把部分中间变量写回内存,反而降低性能。
  • 对结构体数组做向量化时,要特别关注数据访问是否连续。

2.3 常见 NEON 类型与指令

  • float32x4_t:4 个 float32
  • vmulq_n_f32:向量乘标量
  • vmlaq_n_f32:向量乘标量再加到原向量
  • vaddq_f32:向量加法
  • vdupq_n_f32:标量广播为向量
  • vgetq_lane_f32:取出向量某一位的标量值

2.4 示例:点云外参变换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
for (int i = 0; i <= N - 4; i += 4) {
float32x4_t x = { lidar_cloud->points[i].x, lidar_cloud->points[i + 1].x,
lidar_cloud->points[i + 2].x, lidar_cloud->points[i + 3].x };
float32x4_t y = { lidar_cloud->points[i].y, lidar_cloud->points[i + 1].y,
lidar_cloud->points[i + 2].y, lidar_cloud->points[i + 3].y };
float32x4_t z = { lidar_cloud->points[i].z, lidar_cloud->points[i + 1].z,
lidar_cloud->points[i + 2].z, lidar_cloud->points[i + 3].z };

float32x4_t cx = vmlaq_n_f32(
vmlaq_n_f32(vmulq_n_f32(x, extrinsic_(0, 0)), y, extrinsic_(0, 1)),
z, extrinsic_(0, 2));
cx = vaddq_f32(cx, vdupq_n_f32(extrinsic_(0, 3)));

float32x4_t cy = vmlaq_n_f32(
vmlaq_n_f32(vmulq_n_f32(x, extrinsic_(1, 0)), y, extrinsic_(1, 1)),
z, extrinsic_(1, 2));
cy = vaddq_f32(cy, vdupq_n_f32(extrinsic_(1, 3)));

float32x4_t cz = vmlaq_n_f32(
vmlaq_n_f32(vmulq_n_f32(x, extrinsic_(2, 0)), y, extrinsic_(2, 1)),
z, extrinsic_(2, 2));
cz = vaddq_f32(cz, vdupq_n_f32(extrinsic_(2, 3)));

cam_cloud->points[i].x = vgetq_lane_f32(cx, 0);
cam_cloud->points[i + 1].x = vgetq_lane_f32(cx, 1);
cam_cloud->points[i + 2].x = vgetq_lane_f32(cx, 2);
cam_cloud->points[i + 3].x = vgetq_lane_f32(cx, 3);

cam_cloud->points[i].y = vgetq_lane_f32(cy, 0);
cam_cloud->points[i + 1].y = vgetq_lane_f32(cy, 1);
cam_cloud->points[i + 2].y = vgetq_lane_f32(cy, 2);
cam_cloud->points[i + 3].y = vgetq_lane_f32(cy, 3);

cam_cloud->points[i].z = vgetq_lane_f32(cz, 0);
cam_cloud->points[i + 1].z = vgetq_lane_f32(cz, 1);
cam_cloud->points[i + 2].z = vgetq_lane_f32(cz, 2);
cam_cloud->points[i + 3].z = vgetq_lane_f32(cz, 3);
}

2.5 适合总结的经验点

  • 尽量保证数据连续,减少 gather/scatter 风格访问
  • 优先处理 N 能整除向量宽度的主循环,尾部单独处理
  • 关注编译器优化级别和目标平台选项
  • 先确认瓶颈在计算,再做 NEON 化

3. OpenMP 优化

OpenMP 解决的是线程级并行问题,适合把一个大的循环任务分给多个 CPU 核心同时执行。

3.1 使用方式

通常不需要额外安装,只需要在编译时打开 -fopenmp

3.2 常见调度策略

static

特点:

  • 循环开始前就把迭代任务分好
  • 每个线程拿到一段连续区间
  • 分配开销低

适合:

  • 每次迭代耗时接近
  • 规则型数值计算
  • 负载比较均匀的循环

示例:

1
2
3
4
#pragma omp parallel for schedule(static, chunk)
for (int i = 0; i < N; ++i) {
// ...
}

dynamic

特点:

  • 线程处理完当前任务后,再领取下一块任务
  • 负载更均衡
  • 调度开销更高

适合:

  • 每次迭代耗时差异较大
  • 某些分支路径明显不均匀的场景

guided

特点:

  • 一开始给大块任务
  • 后面逐渐减小 chunk
  • 兼顾调度成本和负载均衡

适合:

  • 任务整体不均衡,但不希望 dynamic 调度成本过高

auto

特点:

  • 交给编译器或运行时决定如何调度

3.3 常用 API

  • omp_get_num_threads()
  • omp_get_thread_num()

3.4 同步与规约

critical 可以保证临界区互斥,但会有明显同步开销。对于求和、最大值这类操作,更推荐使用 reduction

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <cstdio>
#include "omp.h"

using std::cout;
using std::endl;

#define NUMS 100

int main() {
int sum = 0;

#pragma omp parallel for
for (int i = 0; i < NUMS; i++) {
#pragma omp critical
{
sum += i;
}
}

#pragma omp parallel for num_threads(4) reduction(+: sum)
for (int i = 1; i < NUMS; i++) {
sum += i;
}

cout << sum << endl;
}

4. NEON 与 OpenMP 的配合

可以把两者理解为两个不同层次:

  • NEON:单线程内把一条循环做宽
  • OpenMP:把多次循环分给多个线程做

常见组合方式:

  1. 外层使用 OpenMP 切分数据块
  2. 每个线程内部再用 NEON 做向量化

这种方式通常可以同时利用多核和 SIMD。

5. 实践建议

5.1 优化前先分析

先确认程序瓶颈到底在哪里:

  • 是纯计算瓶颈
  • 还是缓存/内存访问瓶颈
  • 还是线程同步导致的瓶颈

5.2 常见误区

  • 数据太小也强行并行,导致线程开销大于收益
  • 访存不连续,导致 SIMD 收益很弱
  • critical 包住大段代码,线程基本串行化
  • 没有区分“计算优化”和“调度优化”

5.3 建议的整理顺序

如果后续继续补充内容,可以按下面顺序扩展:

  1. 标量实现
  2. NEON 向量化
  3. OpenMP 多线程
  4. NEON + OpenMP 组合
  5. 性能测试与对比