跳至主要內容

自动矢量化(Auto-vectorization)

西风逍遥游大约 13 分钟

自动矢量化(Auto-vectorization)

自动矢量化是一种将多次标量运算组合成矢量运算的优化技术。例如,原本每次计算一个数组元素,转换后可以用一条加法指令同时计算多个元素。编译器需要先证明转换不会改变程序语义,再判断转换是否值得进行。

本节先从 SIMD 指令出发,再介绍依赖分析、循环矢量化、SLP 矢量化和代价模型,最后通过编译器的优化报告观察实际结果。阅读前可以先了解循环展开、SSA和别名分析。

什么是矢量化操作

SIMD(Single Instruction, Multiple Data)表示单指令多数据。一条指令对多个通道(lane)中的数据执行相同操作。以 x86 的 SSE 为例,一个 128 位寄存器能容纳 4 个 32 位单精度浮点数,_mm_add_ps 可以同时完成 4 次浮点加法:

标量:c[i] = a[i] + b[i]

矢量: [a[i], a[i+1], a[i+2], a[i+3]]
     + [b[i], b[i+1], b[i+2], b[i+3]]
     = [c[i], c[i+1], c[i+2], c[i+3]]

SSE、AVX、ARM NEON 等指令集提供不同的矢量宽度和操作;ARM SVE、RISC-V V 还支持运行时决定参与运算的元素数量。因此,矢量化不等于固定每次处理 4 个元素。

考虑下面的 C 代码。这里约定 n >= 0,三个数组都至少包含 n 个可访问元素,且输出区域与两个输入区域均不重叠:

void add(const float *a, const float *b, float *c, int n) {
    for (int i = 0; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

在支持 SSE 的目标上,手工使用内建函数(intrinsics)可以写成:

#include <xmmintrin.h>

void add_sse(const float *a, const float *b, float *c, int n) {
    int i = 0;
    int vector_end = n - n % 4;
    for (; i < vector_end; i += 4) {
        __m128 va = _mm_loadu_ps(a + i);
        __m128 vb = _mm_loadu_ps(b + i);
        __m128 vc = _mm_add_ps(va, vb);
        _mm_storeu_ps(c + i, vc);
    }
    for (; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

主循环每次处理 4 个元素,尾循环处理不足 4 个的部分。例如 n = 6 时,主循环处理下标 0~3,尾循环处理下标 4、5。直接用 i < n 控制每次访问 4 个元素的循环,会在长度不是 4 的倍数时越界。

这里使用 _mm_loadu_ps 和 _mm_storeu_ps,不要求地址按 16 字节对齐;_mm_load_ps 和 _mm_store_ps 则有对齐要求。不论是否对齐,一次加载所涉及的所有元素都必须可访问。

这属于手工矢量化。自动矢量化的目标是让编译器从标量版本生成类似代码,程序员不必把目标指令集写进算法。使用 4 个通道也不代表性能一定提高 4 倍:访存、循环控制和额外检查都会影响结果。

两条主要路线:循环矢量化与 SLP

循环矢量化

循环矢量化(Loop Vectorization)把不同迭代中的相同操作组合起来。上面的数组加法就是典型例子。假设矢量化因子 VF(Vectorization Factor)为 4,则原本 4 次迭代中的加载、加法和存储分别合并成矢量操作。

循环展开主要减少分支和计数开销,并暴露更多调度机会;矢量化则让一条指令处理多个数据。展开后的代码仍可能全是标量指令,矢量循环也可以继续展开,两者可以配合使用。

SLP 矢量化

SLP(Superword-Level Parallelism)从直线代码中寻找可以打包的同类标量操作,不要求源代码中一定有循环。例如,下面各次乘法的输入和输出都是独立的局部值:

struct Four {
    float x, y, z, w;
};

struct Four scale(struct Four v, float k) {
    struct Four r;
    r.x = v.x * k;
    r.y = v.y * k;
    r.z = v.z * k;
    r.w = v.w * k;
    return r;
}

编译器可以尝试把四个乘法打包为一次矢量乘法,把 k 广播到各通道。最终能否获益还取决于目标平台的传参方式、数据布局和打包成本。循环展开后出现的直线代码也可以成为 SLP 的输入。

合法性:哪些操作可以同时执行

矢量化会改变指令的执行顺序。例如标量循环通常先完成一次迭代的加载和存储,再执行下一次迭代;矢量版本可能先加载多个迭代的输入,再统一写回。因此,编译器必须分析这种顺序变化是否影响结果。

循环内依赖与跨迭代依赖

同一次迭代内,乘法结果被加法使用,并不妨碍矢量化:

for (int i = 0; i < n; ++i) {
    float t = a[i] * 2.0f;
    c[i] = t + 1.0f;
}

在输入、输出不重叠的前提下,编译器只需先执行矢量乘法,再执行矢量加法。这里保留了每个通道内部的依赖关系。

真正需要特别小心的是循环携带依赖,即一次迭代依赖另一次迭代的结果:

for (int i = 1; i < n; ++i) {
    a[i] = a[i - 1] + 1.0f;
}

第 i 次迭代需要读取第 i-1 次迭代刚刚写入的值。若直接同时加载 4 个旧值并计算,就会改变结果。因此它不能按普通逐元素映射的方式矢量化;处理这类递推需要额外的算法变换和语义证明。

从内存访问顺序看,常见依赖包括:

依赖含义重排时需要保护的内容
RAW(写后读,真依赖)后面的操作读取前面写入的值生产者必须先于消费者
WAR(读后写,反依赖)后面的写入可能覆盖前面还要读取的值旧值必须先被读取
WAW(写后写,输出依赖)两次写入同一位置最后保留的写入必须正确

并非所有循环携带依赖都绝对禁止矢量化。例如 a[i] = a[i - 8] + 1 的依赖距离为 8,在边界和迭代顺序满足要求时,VF 为 4 的分组可能保留依赖顺序。编译器需要综合分析依赖方向、距离和具体生成方式,而不是简单检查“有没有依赖”。

别名分析与运行时检查

数组下标不同,不代表内存地址一定不同。回到 add,如果调用者传入 c = a + 1,且底层缓冲区足够大,那么一次迭代写入的 c[i] 就是下一次迭代读取的 a[i+1]。矢量版本先加载多个元素的做法可能产生不同结果。

所以,示例中的“不重叠”约定只是说明了手工转换的适用条件;普通指针参数本身没有把这个条件告诉编译器。编译器可以采取以下策略:

  1. 通过静态别名分析和访问下标分析,证明相关读写不会发生有害冲突。
  2. 在循环前插入运行时内存范围检查,安全时走矢量路径,否则走原标量路径。这叫循环版本化(Loop Versioning)。
  3. 无法证明安全、或检查成本太高时,保留标量循环。

只读的 a 与 b 相互重叠通常不影响数组加法;需要重点分析的是涉及写入的冲突。而 const float * 只限制通过该指针写入,并不保证其他指针不会修改同一内存。

如果调用方确实能保证相关访问满足限制,C99 的 restrict 可以提供更强的信息:

void add_restrict(const float *restrict a,
                  const float *restrict b,
                  float *restrict c, int n) {
    for (int i = 0; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

对这个函数,调用者应保证被写入的 c[0..n) 不与读取的输入区域重叠。restrict 是程序员对访问方式的承诺,不会插入检查;违反约定会产生未定义行为。它是 C 的关键字,标准 C++ 没有同名关键字,部分编译器提供 __restrict 等扩展。

归约与浮点语义

求和中存在循环携带依赖,但编译器可以识别出特殊的**归约(Reduction)**模式:

float sum(const float *a, int n) {
    float s = 0.0f;
    for (int i = 0; i < n; ++i) {
        s += a[i];
    }
    return s;
}

一种转换方式是用多个通道分别累加,最后再水平归约为一个标量。例如先分别计算下标模 4 相同的元素之和,最后合并四个结果。

但浮点加法不满足数学上的结合律。受舍入影响,(x + y) + z 与 x + (y + z) 可能不同,因此这种分组改变了求和顺序。在严格浮点语义下,编译器不能随意采用它;某些目标也可能提供保持顺序的归约方案。

-ffast-math 等选项允许放宽部分浮点语义约束,可能让此类归约更容易矢量化,但也会影响 NaN、无穷大、有符号零等行为,不应只为了出现 SIMD 指令就盲目开启。归约是否可转换,要同时看运算规则和语言语义:无符号整数加法的模运算与浮点求和就不同。

从标量循环生成矢量循环

实现一个基础的循环矢量化优化,可以按以下流程组织:

  1. 规范化循环:识别循环头、退出条件、归纳变量和步长;必要时整理成便于分析的形式。
  2. 分析访问与依赖:描述 a[i] 等地址随迭代的变化,结合别名分析检查跨迭代读写关系,识别归约等特殊模式。
  3. 检查目标支持:确认元素类型和操作能否用目标指令表达,包括转换、比较、加载和存储。
  4. 选择变换方案:通过代价模型选择 VF、是否展开、是否加入运行时检查,以及如何处理尾部。
  5. 生成并修复 IR:构建矢量主循环和必要的标量路径,更新归纳变量、SSA 中的 φ 节点及退出处使用的值。
  6. 清理与降低:进行常量传播、死代码消除等清理,再由后端将矢量 IR 降低为机器指令。

以数组加法为例,忽略运行时别名检查时,固定 VF 的变换可以概括为:

vector_end = n - n % VF
i = 0
while i < vector_end:
    va = vector_load(a + i)
    vb = vector_load(b + i)
    vector_store(c + i, vector_add(va, vb))
    i = i + VF
while i < n:
    c[i] = a[i] + b[i]
    i = i + 1

这是一种“矢量主循环 + 标量尾循环”的方案。有的目标允许用掩码处理最后一组元素,也可以先剥离若干次迭代来满足对齐要求。中端描述的是矢量运算及其语义,具体指令选择仍由目标后端完成。

分支与掩码

循环中有条件判断,也不一定无法矢量化:

for (int i = 0; i < n; ++i) {
    if (a[i] > 0.0f) {
        b[i] = a[i];
    }
}

在读写不存在有害依赖时,编译器可以将比较结果变成逐通道掩码,只对条件为真的通道执行存储。没有合适的掩码指令时,也可能通过其他转换实现,或直接放弃。

掩码不能随意变成“先执行所有操作,再选择结果”。例如某个分支才允许访问有效地址,无条件加载就可能访问非法内存。编译器必须确认被屏蔽通道的访存和其他副作用符合原程序语义;尾部处理也有同样要求。

代价模型:合法不代表划算

通过合法性检查之后,编译器还要估计标量与矢量版本的开销:

因素对矢量化的影响
迭代次数很短的循环可能无法抵消检查、准备和尾部处理的成本
内存访问方式连续访问通常容易合并;跨步和间接访问可能需要 gather/scatter 或重排
数据布局结构体数组只访问部分字段时,可能需要额外解包;按字段分开的数组更容易连续加载
指令支持缺少某种矢量运算时,可能需要多条指令模拟,甚至逐通道处理
寄存器压力过宽的矢量或过多展开可能导致溢出到栈,增加访存
内存带宽简单数组运算可能受带宽限制,减少算术指令也不一定同比加速

因此,“没有生成矢量代码”可能是合法性问题,也可能只是代价模型认为收益不足。改善数据布局、减少不透明的函数调用、让循环边界更清晰,往往比直接添加强制矢量化提示更有帮助。编译器指令或 OpenMP SIMD 等提示还可能引入额外的程序员承诺,使用前必须理解其约束。

实验:观察编译器的自动矢量化

将前面的 add_restrict 保存为 vectorize.c,使用支持目标平台的 GCC 编译:

gcc -std=c11 -O3 -fopt-info-vec-optimized -fopt-info-vec-missed \
    -S vectorize.c -o vectorize.s

也可以使用 Clang:

clang -std=c11 -O3 -Rpass=loop-vectorize -Rpass-missed=loop-vectorize \
    -Rpass-analysis=loop-vectorize -S vectorize.c -o vectorize.s

-O3 开启一组优化,诊断选项用于报告成功或失败的原因,-S 输出汇编。结果受编译器版本和目标架构影响,不保证每个平台都给出相同报告。

在本机支持 SIMD 的情况下,还可以添加 -march=native,让编译器针对当前 CPU 选择指令;由此生成的代码不保证能在其他 CPU 上运行。

建议按以下顺序修改并观察:

  1. 保留 restrict,查看报告是否指出循环被矢量化,并在汇编中寻找对应目标的矢量加载、加法和存储。
  2. 去掉 restrict,观察是否出现“因可能别名而对循环进行版本化”的报告或相应的运行时分支。编译器也可能采取其他策略。
  3. 换成 a[i] = a[i-1] + 1.0f 的递推循环,观察依赖如何影响转换。
  4. 换成浮点求和,对比默认选项与允许浮点重结合的选项,解释结果为什么可能不同。

汇编中使用了矢量寄存器,并不能单独证明循环完成了矢量化。例如 x86 的标量浮点指令也会使用 XMM 寄存器,需要结合优化报告和具体操作判断。

验证正确性时,至少覆盖长度为 0、1、VF-1、VF、VF+1 以及较大非整倍数的情况。测试手工 SSE 版本时,还应覆盖未按 16 字节对齐但仍合法的地址,并与标量结果比较。测试含运行时别名检查的普通指针版本时,可以加入合法的重叠调用;不要把违反 restrict 承诺的调用用于检验该版本。

性能测试应使用有代表性的输入并多次测量,确保计算结果被使用,避免整个循环被优化掉。只有测量了执行时间,才能判断矢量化对实际程序的收益。

小结

自动矢量化把多个标量操作组织成 SIMD 运算,常见路线是跨迭代的循环矢量化和直线代码中的 SLP 矢量化。其核心问题依次是:依赖和语言语义是否允许转换,目标是否支持这些操作,以及转换能否带来收益。

别名分析、归纳变量分析和 SSA 为变换提供基础;运行时检查、归约识别、掩码与尾部处理让更多程序可以安全转换。理解这些条件之后,我们就能结合优化报告解释编译器的选择,并通过数据布局和接口约定帮助它生成更合适的代码。