1. SIMD 与自动向量化
一句话总结: SIMD 让一条指令并行处理多个数据,自动向量化的任务是在不改变程序语义的前提下,把标量循环改写成向量运算。
从 MMX、SSE 到 AVX-512、NEON、SVE,现代 CPU 都提供单指令多数据(SIMD)能力:一条指令可以同时完成 4 个 float 的加法、8 个 int32 的比较,或者 16 个 int16 的乘累加。以 AVX2 为例,一个 256 位 YMM 寄存器能装 8 个 int32,理论上做整数加法的吞吐可以接近标量的 8 倍(实际受端口与频率影响,通常 3~6 倍)。
// 标量版本: 每次处理一个元素
void add_scalar(float *restrict a, float *restrict b, float *restrict c, int n) {
for (int i = 0; i < n; i++)
c[i] = a[i] + b[i];
}
// 编译器在 -O2 -march=native 下会自动生成类似下面的向量版本
#include <immintrin.h>
void add_avx(float *restrict a, float *restrict b, float *restrict c, int n) {
int i = 0;
for (; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
_mm256_storeu_ps(c + i, _mm256_add_ps(va, vb));
}
for (; i < n; i++) c[i] = a[i] + b[i]; // 处理尾巴
}
# 观察自动向量化的结果
cc -O3 -march=native -fopt-info-vec-optimized vec.c -c
cc -O3 -march=native -fopt-info-vec-missed vec.c -c # 为什么没向量化
clang -O3 -march=native -Rpass=loop-vectorize vec.c -c
objdump -d vec.o | grep -E 'vaddps|vmovups' | head
| 指令集 | 寄存器宽度 | float 数 | int32 数 | 代表平台 |
|---|---|---|---|---|
| SSE2 | 128 位 | 4 | 4 | 所有 x86-64 基线 |
| AVX2 | 256 位 | 8 | 8 | Haswell 之后 |
| AVX-512 | 512 位 | 16 | 16 | 服务器 Skylake-X 之后 |
| NEON | 128 位 | 4 | 4 | ARMv7/ARMv8 |
| SVE | 可变(128~2048) | 可变 | 可变 | ARMv8.2+ 服务器 |
自动向量化(auto-vectorization)的目标就是让程序员写普通标量循环,编译器自动改写为向量版本。它有两副面孔:循环向量化(loop vectorization)把循环的多次迭代打包成一次向量运算,SLP 向量化(superword level parallelism)把循环体内的多条同构标量语句合并成向量语句。两者的合法性判定都建立在同一个基础之上——依赖分析。
2. 依赖分析与合法性判定
一句话总结: 向量化只有在循环的各次迭代互不干扰时才合法,依赖分析通过判断数组访问是否存在真实、反、输出依赖来给出结论。
循环能否向量化,核心问题是:把相邻几次迭代并行执行,会不会改变结果? 设循环里存在对同一内存位置的两次访问,若至少一次是写,且两次访问的顺序会影响最终结果,就构成数据依赖。经典分类有三种:
| 依赖类型 | 记法 | 含义 | 是否阻碍向量化 |
|---|---|---|---|
| 真实依赖 | RAW | 先写后读 | 阻碍(除非距离足够) |
| 反依赖 | WAR | 先读后写 | 阻碍 |
| 输出依赖 | WAW | 先写后写 | 阻碍 |
| 归约 | Reduction | 读-改-写同一标量 | 可变换后向量化 |
// 存在真实依赖: 无法直接向量化
for (int i = 1; i < n; i++)
a[i] = a[i - 1] + 1; // 每次迭代依赖前一次的结果
// 依赖距离为 k 时, 可以按 k 个一组做向量化
for (int i = 4; i < n; i++)
a[i] = a[i - 4] * 2; // 距离 4, 分成 4 条独立链
// 反依赖: 写入的位置是下一次迭代要读的
for (int i = 0; i < n; i++)
a[i] = b[i] + 1, b[i + 1] = a[i]; // 顺序敏感
判定依赖需要做别名分析(alias analysis):编译器必须知道两个指针是否可能指向同一块内存。int *a 与 int *b 在没有额外信息时是「可能别名」的,此时 a[i] = b[i] + 1 无法确认安全。C 的 restrict 关键字与 C++ 的 __restrict 就是给编译器的承诺:「这两个指针指向的对象在生命周期内不重叠」,加上它往往能立刻解锁向量化。
// 别名是向量化最常见的阻碍
void scale(int *a, int *b, int n) {
for (int i = 0; i < n; i++) a[i] = b[i] * 2; // a 与 b 可能别名, 不向量化
}
void scale_fast(int *restrict a, int *restrict b, int n) {
for (int i = 0; i < n; i++) a[i] = b[i] * 2; // 承诺不重叠, 向量化
}
# 极简依赖判定: 仿射下标下的距离计算
def distance(dim_a, dim_b):
"""下标为 i*c + k 形式时, 距离 = (k_b - k_a) / (c_a - c_b)."""
ca, ka = dim_a
cb, kb = dim_b
if ca == cb:
return 0 if ka == kb else None # 同址或平行不相交
num, den = kb - ka, ca - cb
return num // den if num % den == 0 else None
print(distance((1, 0), (1, -1))) # a[i] 与 a[i-1]: 距离 1, 有依赖
print(distance((1, 0), (1, -4))) # a[i] 与 a[i-4]: 距离 4
print(distance((2, 0), (2, 1))) # a[2i] 与 a[2i+1]: 距离非整数, 无依赖
对于下标是循环变量线性函数(仿射下标)的情形,可以用依赖距离与依赖向量精确描述依赖关系;更一般的情形则需要 Banerjee 判据或 Omega 测试等近似方法。当分析无法证明「无依赖」时,编译器只能保守地放弃向量化——这就是为什么向量化报告(-fopt-info-vec-missed)里最常见的理由是 possible dependence。
3. 循环向量化
一句话总结: 循环向量化把循环体复制成向量宽度份、合并访问,再配合运行时别名检查与标量尾巴处理,是自动向量化的主战场。
循环向量化的基本流程是:选定向量因子(vectorization factor,VF,即一次处理几个元素)→ 把循环体按 VF 份展开 → 把同构的标量指令合并成向量指令 → 处理尾巴(不足 VF 的剩余迭代)→ 生成运行时检查(若无法静态证明无别名,运行时比较指针范围,重叠则回退到标量版本)。
// 原始循环
for (int i = 0; i < n; i++) c[i] = a[i] * b[i] + 1.0f;
// 向量化后的等价形态 (VF = 8)
for (int i = 0; i + 8 <= n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
__m256 vc = _mm256_fmadd_ps(va, vb, _mm256_set1_ps(1.0f)); // FMA
_mm256_storeu_ps(c + i, vc);
}
for (int i = n - n % 8; i < n; i++) c[i] = a[i] * b[i] + 1.0f; // 尾巴
def vectorize_plan(n, vf, has_tail_check=True):
"""生成向量化的迭代划分方案."""
plan = {"vector_iters": n // vf, "vf": vf, "tail": n % vf}
if has_tail_check:
plan["peel"] = 0
plan["runtime_alias_check"] = True # 无法静态证明时插入运行时检查
return plan
print(vectorize_plan(1000, 8))
# {'vector_iters': 125, 'vf': 8, 'tail': 0, 'peel': 0, 'runtime_alias_check': True}
向量因子的选择是个权衡:VF 越大,理论吞吐越高,但尾巴占比也越大、寄存器压力越高(AVX-512 下 32 个 ZMM 寄存器很容易用尽),且低端 CPU 上可能因降频而变慢。编译器一般会结合目标机器模型(指令延迟、端口数、寄存器数量)估算多个候选 VF 的收益,取最优者。GCC 与 LLVM 都支持交错向量化(interleaved / SLP within loop):把多个独立运算链交错排列,提升指令级并行度。
| 场景 | 处理方式 | 说明 |
|---|---|---|
| 迭代数未知 | 运行时检查 + 标量回退 | 最通用 |
| 迭代数已知且整除 | 直接生成纯向量循环 | 最优 |
| 存在对齐要求 | 剥离循环(peeling)使首地址对齐 | align 属性可省 |
| 依赖距离固定 | 按距离分组向量化 | 如 a[i] = a[i-4] |
| 内层循环 | 外层向量化或循环交换 | 需考虑访存局部性 |
4. SLP 向量化
一句话总结: SLP 向量化不改造循环结构,而是把基本块内多条「形状相同」的标量语句合并成向量语句,特别适合展开后的循环与直线代码。
循环向量化处理的是「不同迭代之间的并行」,SLP 处理的是「同一次迭代内部的并行」。典型场景是手工或自动展开(unroll)后的循环体:展开 4 次后,循环体里有 4 条结构完全相同的 c[i] = a[i] * b[i],SLP 把它们打包成一条向量乘。SLP 也用于处理无循环的直线代码,比如结构体字段的批量计算。
// 展开后的循环体: 4 条同构语句
c[0] = a[0] * b[0];
c[1] = a[1] * b[1];
c[2] = a[2] * b[2];
c[3] = a[3] * b[3];
// SLP 合并为: c[0..3] = a[0..3] * b[0..3] (一条 128 位乘法)
def slp_pack(stmts, width):
"""把形状相同的标量语句按 width 打包."""
groups, out = {}, []
for s in stmts:
key = (s[0], s[2]) # 操作类型 + 操作数形状
groups.setdefault(key, []).append(s)
for key, items in groups.items():
while len(items) >= width:
batch, items = items[:width], items[width:]
out.append((f"vec{width}", key, [b[1] for b in batch]))
out.extend(items) # 不足一批的保持标量
return out
stmts = [("mul", f"c{i}", ("a", "b", i)) for i in range(6)]
for op in slp_pack(stmts, 4):
print(op)
SLP 的关键难点在于打包的贪心性:哪些语句该合并、合并后是否真的更快,取决于指令代价模型。合并两条标量乘法为一条向量乘法显然划算,但如果为了合并而需要大量 shuffle/insert 指令把数据拼进向量寄存器,收益可能被抵消甚至为负。因此 SLP 实现普遍带有代价模型,逐组比较「向量版代价 vs 标量版代价」,只在有正收益时才应用。
| 维度 | 循环向量化 | SLP 向量化 |
|---|---|---|
| 处理对象 | 跨迭代的并行 | 基本块内的并行 |
| 触发条件 | 循环体同构、无依赖 | 语句形状相同 |
| 典型来源 | 紧凑循环 | 展开后的循环体、直线代码 |
| 主要难点 | 依赖判定、别名 | 数据打包成本、代价模型 |
| 收益上限 | 高(访存规整) | 中(受打包指令拖累) |
两者常协同工作:先做循环展开(unroll),再对展开后的循环体做 SLP,等于变相实现了更激进的循环向量化。LLVM 的 SLP 向量化器支持「look-ahead」分析,能跨越基本块边界寻找可打包的语句,显著扩大了适用范围。
5. 掩码与归约
一句话总结: 掩码把条件执行转成向量选择,归约把标量累加拆成向量部分和再横向合并,这两类变换让「看似不可向量化」的循环也能向量化。
归约(reduction)是最常见的向量化障碍。sum += a[i] 表面上是跨迭代的真实依赖,但加法满足结合律,可以拆成多个部分和分别累加,最后横向相加。前提是运算满足结合律且浮点误差可接受——这也是为什么浮点归约需要 -ffast-math 或 #pragma omp simd reduction 之类的显式许可。
// 标量归约
float sum = 0;
for (int i = 0; i < n; i++) sum += a[i];
// 向量化: 8 条独立部分和 + 末尾横向相加
__m256 vsum = _mm256_setzero_ps();
for (int i = 0; i + 8 <= n; i += 8)
vsum = _mm256_add_ps(vsum, _mm256_loadu_ps(a + i));
float lanes[8];
_mm256_storeu_ps(lanes, vsum);
for (int k = 1; k < 8; k++) lanes[0] += lanes[k];
for (int i = n - n % 8; i < n; i++) lanes[0] += a[i];
def vector_reduce(data, vf):
"""模拟向量归约: vf 条部分和, 最后横向合并."""
partial = [0.0] * vf
for i, x in enumerate(data):
partial[i % vf] += x
return sum(partial), partial
print(vector_reduce([1.0] * 100, 8))
掩码(mask / blend)解决的是「循环体内有条件」的问题。if (a[i] > 0) b[i] = a[i]; 若直接向量化,条件只对部分 lane 成立。做法是:无条件算出所有 lane 的结果,再用比较产生的掩码把不满足条件的 lane 换回原值(或使用 AVX-512 的原生掩码寄存器,由硬件屏蔽该 lane 的写入与异常)。
// 条件赋值向量化: 用掩码选择
for (int i = 0; i < n; i += 8) {
__m256 va = _mm256_loadu_ps(a + i);
__m256 vb = _mm256_loadu_ps(b + i);
__m256 mask = _mm256_cmp_ps(va, _mm256_setzero_ps(), _CMP_GT_OQ);
_mm256_storeu_ps(b + i, _mm256_blendv_ps(vb, va, mask)); // 满足则取 a
}
| 模式 | 变换手段 | 前提 |
|---|---|---|
| 归约 | 部分和 + 横向合并 | 运算满足结合律 |
| 条件赋值 | 全量计算 + 掩码选择 | 两个分支都无副作用/异常 |
| 条件累加 | 掩码 + 加零 | 同上 |
| 提前退出 | 整块检查 + 块内回退 | 循环有 break |
| 间接寻址 | gather/scatter | 硬件支持,通常很慢 |
需要特别提醒的是浮点归约的语义变化:标量顺序累加与向量部分和相加的结果在浮点下并不严格相等,误差可能变大也可能变小。科学计算代码若对可复现性有要求,必须显式控制(如 Kahan 求和或固定归约树),不能指望编译器「随便换个顺序还能给出逐位相同的结果」。
6. Intrinsics 与内建函数
一句话总结: 当自动向量化失败或不够精确时,intrinsics 让程序员直接书写向量指令,代价是失去可移植性。
内建函数(intrinsics)是编译器暴露的、与机器指令一一对应的 C/C++ 函数,如 _mm256_add_ps、_mm512_maskz_loadu_epi32。它们让程序员能精确控制向量宽度、对齐要求、掩码与舍入模式,是高性能库(BLAS、FFT、图像处理、编解码器)的标准写法。
// 手写 intrinsics 版: 点积
#include <immintrin.h>
float dot_avx(const float *a, const float *b, int n) {
__m256 acc = _mm256_setzero_ps();
for (int i = 0; i + 8 <= n; i += 8)
acc = _mm256_fmadd_ps(_mm256_loadu_ps(a + i),
_mm256_loadu_ps(b + i), acc);
float tmp[8];
_mm256_storeu_ps(tmp, acc);
float s = tmp[0] + tmp[1] + tmp[2] + tmp[3];
s += tmp[4] + tmp[5] + tmp[6] + tmp[7];
for (int i = n - n % 8; i < n; i++) s += a[i] * b[i];
return s;
}
// 可移植的向量类型: GCC/Clang 的 vector extension
typedef float v8sf __attribute__((vector_size(32)));
v8sf add8(v8sf a, v8sf b) { return a + b; } // 直接写运算符
float lane0(v8sf v) { return v[0]; }
| 方案 | 可移植性 | 可控性 | 适用场景 |
|---|---|---|---|
| 自动向量化 | 最好 | 低 | 规整循环,首选 |
| vector extension | 较好(GCC/Clang) | 中 | 需要显式向量但不想绑死指令集 |
| intrinsics | 差(绑指令集) | 最高 | 库实现、极致优化 |
| 汇编 | 最差 | 最高 | 极少数关键内核 |
除了直接写 intrinsics,还有一种折中:编译器自带的向量化 pragma。#pragma omp simd 告诉编译器「这个循环可以安全向量化,别做别名检查」,#pragma clang loop vectorize_width(16) 指定向量宽度,#pragma GCC ivdep 声明忽略假定的依赖。这些 pragma 既能提升向量化成功率,又保留了跨平台能力,是生产代码里性价比最高的选择。使用时的风险是:pragma 是承诺,编译器不再验证,若程序员判断错误,就会静默地生成错误代码。
7. 收益、阻碍与实测
一句话总结: 向量化的理论加速比常被访存带宽、对齐、混洗开销与频率下降吃掉,实测收益需要以基准数据为准。
向量化的收益不是自动的。理论加速比由 VF 决定,但真实瓶颈往往在别处:内存带宽——当数据量超出缓存、循环只做一次加法时,CPU 大部分时间在等内存,向量化只能减少指令数,无法减少访存字节数,加速比会迅速饱和在 1.5~2 倍;对齐——非对齐加载在旧硬件上有显著惩罚,新硬件虽已大幅改善,但对齐访问仍有优势;混洗开销——数据布局不匹配(如 AoS 结构体数组)时,把字段收集到向量寄存器需要大量 shuffle,抵消收益;频率下降——重度 AVX-512 使用会让部分 CPU 降频,长循环收益可能为负。
def speedup_model(vf, mem_bytes_per_iter, compute_cycles,
bandwidth_bytes_per_cycle, overhead_ratio=0.15):
"""粗略估算向量化收益: 取访存与计算瓶颈的较大者."""
compute = compute_cycles / vf * (1 + overhead_ratio)
memory = mem_bytes_per_iter / bandwidth_bytes_per_cycle
scalar = compute_cycles + mem_bytes_per_iter / bandwidth_bytes_per_cycle
return scalar / max(compute, memory)
# 访存密集: VF 提升收益有限
print(round(speedup_model(8, 24, 3, 32), 2))
# 计算密集: 接近 VF
print(round(speedup_model(8, 24, 40, 32), 2))
# 实测: 关掉与打开向量化的对比
cc -O3 -march=native -fno-tree-vectorize bench.c -o bench_scalar
cc -O3 -march=native bench.c -o bench_vector
perf stat -e cycles,instructions,fp_arith_inst_retired.256b ./bench_vector
| 阻碍因素 | 表现 | 应对 |
|---|---|---|
| 可能别名 | 报告 possible dependence | 加 restrict / __builtin_assume_aligned |
| 非仿射下标 | 无法判定依赖 | 改写为仿射形式或手工向量化 |
| 控制流复杂 | 无法打包 | 用掩码重写、分离循环 |
| 数据布局差 | shuffle 开销大 | 改为 SoA 布局 |
| 迭代数太小 | 启动开销占比高 | 合并循环、提高粒度 |
| 浮点语义 | 不允许重排 | -ffast-math 或显式归约 |
工程实践中,最高效的路径是「先让编译器自己来」:用 -O3 -march=native 编译,读 -fopt-info-vec-missed 或 -Rpass-missed=loop-vectorize 的输出,逐条修掉「可能别名」「非仿射下标」「不支持的模式」这些可修复的阻碍;只有当自动向量化确实做不到、而这段代码又确实在热点上时,才投入精力手写 intrinsics。盲目手写 intrinsics 的常见后果是:代码难维护、绑死指令集、在下一代 CPU 上反而比编译器自动生成的更慢。
8. 总结
| 环节 | 要点 |
|---|---|
| SIMD 基础 | 一条指令处理 4~16 个数据,宽度随指令集递增 |
| 依赖分析 | RAW/WAR/WAW 阻碍向量化,归约可变换后处理 |
| 别名分析 | 无法证明无别名时放弃或插入运行时检查 |
| 循环向量化 | 选 VF、展开合并、处理尾巴、运行时回退 |
| SLP 向量化 | 打包基本块内同构语句,受打包成本制约 |
| 掩码 | 全量计算 + 掩码选择,替代分支 |
| 归约 | 部分和 + 横向合并,需结合律许可 |
| Intrinsics | 精确可控但绑死平台,是最后的选项 |
| 实测收益 | 受带宽、对齐、混洗、降频制约,需以数据为准 |
向量化是「用并行换吞吐」的典型:它不减少工作量,只是把同样的工作压缩进更少的指令。因此它的收益上限由算法与数据布局决定,而不是由指令集决定——把 AoS 改成 SoA、消除别名、把不可仿射的下标改写成仿射形式,这些准备工作带来的收益往往超过把 SSE 换成 AVX-512。理解了依赖分析与代价模型,也就理解了为什么编译器时而激进时而保守:它必须在「不敢向量化导致慢」与「错误向量化导致错」之间选一个安全的位置。下一篇转向运行时的另一半——垃圾回收算法,看看托管语言如何在自动内存管理下兼顾吞吐与暂停。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。