「向量化与 SIMD」

现代 CPU 的向量单元能一次处理 4 到 16 个数据,编译器能否利用它们取决于依赖分析。本文讲解向量化的合法性判定、循环向量化与 SLP 向量化、掩码与归约处理、intrinsics 内建函数,以及真实场景中阻碍向量化的因素与实测收益。

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 数代表平台
SSE2128 位44所有 x86-64 基线
AVX2256 位88Haswell 之后
AVX-512512 位1616服务器 Skylake-X 之后
NEON128 位44ARMv7/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。理解了依赖分析与代价模型,也就理解了为什么编译器时而激进时而保守:它必须在「不敢向量化导致慢」与「错误向量化导致错」之间选一个安全的位置。下一篇转向运行时的另一半——垃圾回收算法,看看托管语言如何在自动内存管理下兼顾吞吐与暂停。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类