AIGC标识 SIMD

SIMD:一条指令铺满一个向量寄存器

SIMD,AVX2,数据布局,浮点重结合。

把 \(w\) 个同类型数据装进一个宽寄存器,让一条指令对它们同时做同一种运算,这就是 SIMD(Single Instruction Multiple Data,单指令多数据)做的事。x86 上这个宽度是一路涨过来的:MMX 是 \(64\) 位,SSE(Streaming SIMD Extensions)到 \(128\) 位,AVX(Advanced Vector Extensions)和 AVX2 到 \(256\) 位,AVX-512 到 \(512\) 位,Intel 的 Intrinsics Guide 就按这几个家族分组列指令和它们的语义[1] 。寄存器被切成的那些等宽小格一般叫 lane,一条 _mm256_add_ps 就是 \(8\) 个 float 同时相加。

一条指令里的多个通道

先看省下来的是什么。下标方向的循环:

for (int i = 0; i < 8; ++i) {
    c[i] = a[i] + b[i];
}

编译出来是 \(8\) 条加法加上对应的加载和存储。改成 AVX2:

__m256 va = _mm256_loadu_ps(a);
__m256 vb = _mm256_loadu_ps(b);
__m256 vc = _mm256_add_ps(va, vb);
_mm256_storeu_ps(c, vc);

一条加法指令吃掉 \(8\) 个元素。省下来的是指令条数,不是执行资源:向量加法和标量加法(scalar,一次只处理一个元素)占的是同一个执行端口——端口可以理解成执行单元的发射入口,宽度是执行单元本身给的。所以 SIMD 提高的是每周期完成的元素数,前提是数据已经在寄存器里、运算单元也真的有那么宽。\(512\) 位的指令在某些微架构上要拆成两个 \(256\) 位的操作执行,这种时候指令条数少了,实际峰值吞吐并没有跟着翻倍。

宽度和元素宽度的换算是固定的:\(256\) 位寄存器装 \(8\) 个 float、\(4\) 个 double、\(16\) 个 short。同一个寄存器在不同类型下 lane 数不同,这也是写这类代码时最容易混的地方。

__m256 这种类型和 _mm256_add_ps 这种函数都是编译器直接映射到指令上的内建接口,统称 intrinsic。命名有固定拼法:类型名里的数字是位宽,__m128 就是 \(128\) 位版本;函数名由 _mm 前缀、位宽、操作名和一个后缀拼起来,后缀 ps 表示 packed single(一整个寄存器的单精度浮点),pd 是 packed double,ss 是只动最低那个 lane 的标量单精度。认下这套拼法,见到没见过的名字也能猜出它大概干什么。

纵向便宜,横向贵

SIMD 指令的语义里,lane 之间互不影响。凡是能沿着 lane 方向直接做的运算——加法、乘法、比较、按位与——都只花一条指令;一旦要把某个 lane 的值搬到另一个 lane,就得走 shuffle、permute 这类 lane 重排指令,代价比一条纵向加法高。

这条判断基本决定了什么代码适合向量化。逐元素的数组运算天生纵向,几乎没有额外开销;归约(reduction,把一整组数按某个算子合并成一个数)、转置、前缀和这类要跨 lane 交互的操作,纵向部分照旧便宜,收尾部分贵。

以 \(8\) 个 float 求和为例,纵向累加是每轮一条加法,最后要把 \(8\) 个 lane 折成一个标量:

__m128 lo = _mm256_castps256_ps128(acc);
__m128 hi = _mm256_extractf128_ps(acc, 1);   // 跨 128 位 lane 的搬运
lo = _mm_add_ps(lo, hi);
lo = _mm_add_ps(lo, _mm_movehl_ps(lo, lo));
lo = _mm_add_ss(lo, _mm_shuffle_ps(lo, lo, 1));
float sum = _mm_cvtss_f32(lo);

\(8\) 个 lane 要折 \(3\) 轮,每轮都带一次 shuffle,而这些 shuffle 大多要跨 \(128\) 位通道搬运——AVX 的 \(256\) 位寄存器由两个 \(128\) 位半区拼成,跨半区的重排要多花指令——比一次纵向加法贵得多。结论很直接:水平归约(horizontal reduction,跨 lane 做的那一步归约)只允许出现一次,不能放进循环体里,否则每轮多出来的收尾开销会把纵向省下的部分吃回去。_mm256_hadd_pd 这类现成的水平加法指令在不少微架构上也是拆成若干条 shuffle 加加法实现的,用它并不比手写更省。

数据得先排成向量想要的样子

一条 load 要装满寄存器,得连着从内存里取 \(8\) 个 float。数组天然满足这个条件,结构体数组就不一定:

struct Tick {
    float price;
    float qty;
};

按 AoS(Array of Structures,数组的结构体,同一个元素的所有字段挨在一起)存下来,相邻两个 price 之间隔着一个 qty,想凑出一条向量只能一个个往寄存器里塞:

// 代价是多条插入指令,编译器未必愿意这么干
__m256 p = _mm256_set_ps(t[7].price, t[6].price, t[5].price, t[4].price,
                         t[3].price, t[2].price, t[1].price, t[0].price);

换成 SoA(Structure of Arrays,结构体的数组,同一个字段在各自数组里连续排列)就没有这个问题:

struct Ticks {
    float price[8];
    float qty[8];
};

__m256 p = _mm256_loadu_ps(ticks.price);   // 一条加载指令

所以决定上 SIMD 之前,先看数据布局。AoS 到 SoA 的转换本身有代价,只有在同一批数据要反复做向量运算时才划得来;如果只是顺手过一遍就转回去,转换的开销往往比省下的运算还多。

非单位步长的访问在硬件上没有通用的廉价支持。x86 有 gather 类指令,按索引把散落在内存里的元素收进一个寄存器,但它的代价明显高于连续加载;GCC 在向量化文档里把跨步访问单列成一类,说明只有在目标支持带交错语义的访存指令时才谈得上便宜,例如 Arm 的 NEON(\(128\) 位 SIMD 扩展)里那组 vldN,N 表示一次装载几路交错的数据[2] 。混合数据类型也一样,窄类型拼进宽类型要做 unpack(把窄元素摊开填进更宽的 lane),宽类型往回收要做 pack,都是额外的指令。

对齐、掩码与尾巴

对齐这条约束来自指令本身。_mm256_load_ps 要求地址是 \(32\) 字节对齐,不满足会直接触发异常;带 u 的 _mm256_loadu_ps 不要求对齐,但也不承诺同样的效率。选哪个取决于数据从哪来:自己用 aligned_alloc 或 alignas(32) 申请出来的内存可以用对齐版本,外部传进来的指针只能按最保守的假设走。

栈上要放向量变量,问题多一层。x86-64 的 ABI(Application Binary Interface,应用二进制接口,规定函数调用、寄存器用途和栈布局的那套约定)只要求 \(16\) 字节栈对齐,GCC 的 -mpreferred-stack-boundary 默认值就是 \(4\),也就是 \(16\) 字节[3] 。\(32\) 字节对齐的 __m256 局部变量能落到位,靠的是编译器自己把栈指针再往下压一段,而不是 ABI 给的保证。GCC 文档里对 -mpreferred-stack-boundary=3 的警告说的就是这件事:各编译单元的假设一旦不一致,向量访存就可能落到未对齐地址上,所以这个值和 -mstackrealign 一样,要求所有模块包括系统库统一。

要拿对齐的好处,循环通常切成三段:

std::size_t i = 0;

// 头部:把指针推到 32 字节边界,最多消耗 7 个元素
for (; i < count && (reinterpret_cast<std::uintptr_t>(lhs + i) & 31u) != 0; ++i) {
    sum += lhs[i] * rhs[i];
}

// 主体:每轮 8 个
for (; i + 8 <= count; i += 8) {
    acc = _mm256_add_ps(acc, _mm256_mul_ps(_mm256_load_ps(lhs + i),
                                           _mm256_load_ps(rhs + i)));
}

// 尾巴:剩下的不足 8 个
for (; i < count; ++i) {
    sum += lhs[i] * rhs[i];
}

头部那一段最多浪费 \(7\) 个元素的标量时间,尾巴同理。这两个开销是固定的,不随 \(n\) 增长,所以数据量越大越不值一提;反过来,\(n\) 只比一个向量宽一点的时候,它俩就是主要成本。

条件分支是另一半问题。带 if 的循环体没法直接换成向量指令,得先把分支变成无条件运算——GCC 的说法是 if-conversion,把 if (x) { c = a + b } 压成一条带条件的运算,再映射到目标支持的掩码、谓词或选择指令上[4] 。AVX-512 把这件事做进了指令编码,用专门的掩码寄存器(mask register,每个 bit 管一个 lane 是否参与运算),一条指令带一个掩码就能只让部分 lane 生效;更早的 SSE、AVX2 上没有这个设施,只能靠 blend(逐 lane 从两个向量里挑一个)之类的指令拼,或者干脆放弃向量化。

掩码还有一个用法是处理尾巴。数据量不是 lane 数整数倍时,与其退化成标量循环,不如把最后一批用掩码屏蔽掉多余的 lane,代价是构造掩码的指令。两种做法各有各的账,取哪个还是得看编译出来的东西。

让编译器自己决定

手写 intrinsic 不是唯一的路。现在这份 GCC 文档里,-O2 已经打开了 -ftree-loop-vectorize 和 -ftree-slp-vectorize,前者处理循环,后者在直线代码里找可以打包的相邻运算;-O3 在此基础上把代价模型从 very-cheap 换成 dynamic,让向量化更容易被判为划算[5] 。手上编译器版本更旧的话,这两个 pass 默认关着,以自己那份手册为准。

自动向量化失败的原因基本固定,GCC 的向量化页面把可向量化和不可向量化的循环分别列了例子[6] 。常见的几条:

  • 指针别名。编译器不敢确定两个指针不指向同一块内存,就不敢重排访存。__restrict__ 或者把数组作为参数传递能说明这件事。
  • 循环携带依赖。第 \(i\) 轮写的位置第 \(i + 1\) 轮要读,这种循环在语义上就没法并行。
  • 循环次数不可数或者上界未知。可数的循环边界能生成尾循环,不可数的直接出局。
  • 浮点归约。求和、求最值这类归约默认不满足结合律,需要 -ffast-math 或 -fassociative-math 才允许重排。
  • 循环体里有函数调用,或者访问步长不是 \(1\)。

与其猜哪条卡住了,不如让编译器说出来:

g++ -O2 -march=native -fopt-info-vec-missed -c kernel.cpp

-fopt-info-vec-missed 会逐条报告哪些循环没能向量化以及原因,-fopt-info-vec-optimized 则报告成功的那些[7] 。真正难的是从这些信息反推到源码上该改哪一处。

重结合与可复现性

浮点加法不满足结合律。向量化归约必然把求和顺序改成先按 lane 分 \(w\) 组各自累加、最后再折起来,这跟原来的 \(x_{0} + x_{1} + x_{2} + \cdots\) 不是同一个算式,结果可能差一个舍入。编译器默认不做这个重排,只有打开 -ffast-math 或 -fassociative-math 之后才允许[8] 。

同一个算式换成 FMA(Fused Multiply-Add,融合乘加)也有类似的效应。_mm256_fmadd_ps(a, b, c) 计算 \(a \times b + c\) 只做一次舍入,而 _mm256_add_ps(_mm256_mul_ps(a, b), c) 是两次。前者精度更好,但结果和标量版对不上——标量版如果编译成 FMA 也一样,取决于 -ffp-contract 的取值。想在向量版和标量版之间比对结果的话,这一层要先对齐。

整数没有这个问题。模 \(2^{k}\) 的加法和乘法满足结合律和交换律,怎么重排、怎么分组,结果都是确定的。同一份代码用不同 -march 编出来结果是否一致,整数运算答得了一致,浮点不行。

总操作量、深度和带宽

指令条数是最容易算的一项。\(n\) 个元素的逐元素运算,标量版是 \(n\) 条,向量版是 \(\frac{n}{w}\) 条,加上头部和尾巴的常数项。元素运算的总次数没有变,打包只是把 \(w\) 次运算塞进一条指令。

依赖深度分两种情况。lane 之间没有交互时,每条向量指令覆盖 \(w\) 个元素,链长直接除以 \(w\);要标量结果的话再加一次水平归约:

\[D_{\text{scalar}} = n, \qquad D_{\text{simd}} = \frac{n}{w} + \lceil \log_2 w \rceil \]

后面那项是收尾的开销,\(w = 8\) 时是 \(3\) 轮。这个式子的前提是每个 lane 内部那条累加链独立,也就是常说的多累加器结构——单条累加链的情况下,向量版和标量版的链长一样,只是链上每一步干的活多了 \(w\) 倍,延迟一点没省。

误差这边也能算一个界。按 \(\text{fl}(a + b) = (a + b)(1 + \delta)\)、\(|\delta| \le \varepsilon\)、\(\gamma_{k} = \frac{k\varepsilon}{1 - k\varepsilon}\) 的常规模型,标量累加的误差界正比于链长,向量化把一条链拆成 \(w\) 条、每条长度降到 \(\frac{n}{w}\),界也跟着降,再加上收尾水平归约那几轮:

\[|S - \hat{S}_{\text{scalar}}| \le \gamma_{n - 1} \sum_{i} |x_{i}|, \qquad |S - \hat{S}_{\text{simd}}| \le \gamma_{\lceil \frac{n}{w} \rceil + \lceil \log_2 w \rceil} \sum_{i} |x_{i}| \]

前提还是 \(n\) 能被 \(w\) 整除,不然最后那批不满一个向量的元素会走标量收尾,链长比 \(\frac{n}{w}\) 长一点。界变小不代表结果更接近真值,只说明这次重排引入的最坏情况舍入更少;换个 \(w\)、换个分块方式,结果还是会变。

真正的上限往往在内存上。归约类算子每个元素读 \(4\) byte、做 \(1\) 次加法,算术强度(arithmetic intensity,每从内存搬一个字节能换到多少次运算)是每字节 \(\frac{1}{4}\) 次浮点运算;点积稍好,两个数组读 \(8\) byte、做 \(2\) 次运算,比值还是 \(\frac{1}{4}\)。这种量级在现在的处理器上属于典型的 memory bound(瓶颈在内存带宽,运算单元大部分时间在等数据),向量化能让运算不再拖后腿,但墙钟时间取决于加载速度,不会因为宽度翻倍而翻倍。想再快就得改算法,比如把同一个数组上的多个统计量合并到一趟遍历里。

宽度也不是越大越好。AVX-512 的 \(512\) 位指令在早期支持它的服务器上会让全核频率掉下来,Intel 自己的支持文档里就有用户报告跑重向量代码时频率减半的条目[9] ,后续几代才把降频的深度压下去[10] 。GCC 为此提供了 -mprefer-vector-width=128|256|512,让代码在能跑 \(512\) 位的情况下主动只用 \(256\) 位[11] 。另一头是 AVX 和 SSE 混用的转换惩罚,GCC 默认在函数返回前插入 vzeroupper,也就是 -mvzeroupper,专门用来消掉这个惩罚。

适用场景

划算的场合有几个共同点:数据连续放在内存里,元素之间没有依赖,同一个算子能在整段数据上重复,每个元素的运算量够多、能摊掉加载存储。数据量也要够——元素数只比一个向量宽一点的时候,头部对齐和尾部循环的固定开销会把收益吃掉。

不划算的场合同样明确。指针追逐类的数据结构,链表、树、开放寻址的探测,访存地址本身依赖上一步的结果,凑一个向量比直接算还慢。分支无法 if-convert 的逻辑,比如每个元素的行为都不一样,掩码能救一部分,救不回来的部分只能留在标量路径上。要求逐位可复现的场景,前面说过,浮点重排和 FMA 都会改结果,要么固定宽度和分块方式,要么放弃向量化。跨平台分发也要算进去:-march=native 编出来的东西换台机器可能直接非法指令退出,要保证兼容就得把基线压到 psABI(processor-specific ABI,x86-64 平台在通用 ABI 之外补充的那部分约定,其中定义了 x86-64-v2 到 v4 几档微架构级别)的微架构级别上,-march=x86-64-v3 对应 AVX2 那一档,-march=x86-64-v4 才是 AVX-512[12] 。

Arm 那边的宽度不是编译期常量。NEON 固定 \(128\) 位,SVE(Scalable Vector Extension,可伸缩向量扩展)的向量长度由硬件决定,GCC 的 -msve-vector-bits 允许取 scalable(默认,生成与长度无关的代码)或者钉死 \(128\)、\(256\)、\(512\)、\(1024\)、\(2048\) 中的某一个[13] 。默认的 scalable 写法下,循环步长要按运行时读到的向量长度算,不能直接按每轮 \(8\) 个来写。这也意味着前面对 \(w\) 做的那些推导,在 SVE 上得把 \(w\) 当成运行时才知道的量。

完整实现

完整的 AVX2 点积实现:

点击查看代码
// 四条独立累加链 + 一次水平归约收尾,尾部用标量循环处理
#include <cstddef>

#include <immintrin.h>

namespace {

constexpr std::size_t kLanesPerVector = 8;      // 256 位一条指令容纳的 float 个数
constexpr std::size_t kAccumulatorCount = 4;    // 互相独立的累加链条数,用来填满流水线
constexpr std::size_t kUnroll = kLanesPerVector * kAccumulatorCount;

/**
 * @brief 把一个 256 位累加器里的 8 个 lane 折成一个标量
 * @param value 待归约的向量
 * @return 8 个 lane 的和
 * @note 水平归约要跨 128 位通道搬运,代价高于纵向加法,只应在循环外调用一次
 */
float horizontalSum(__m256 value) {
    __m128 low = _mm256_castps256_ps128(value);
    __m128 high = _mm256_extractf128_ps(value, 1);

    low = _mm_add_ps(low, high);
    low = _mm_add_ps(low, _mm_movehl_ps(low, low));
    low = _mm_add_ss(low, _mm_shuffle_ps(low, low, 1));
    return _mm_cvtss_f32(low);
}

}   // namespace

/**
 * @brief 计算两个等长 float 数组的点积
 * @param lhs 左数组首地址
 * @param rhs 右数组首地址
 * @param count 元素个数
 * @return 点积结果,任一参数为空或 count 为 0 时返回 0
 * @note 累加顺序与标量实现不同,结果不保证逐位一致
 * @note 全部使用未对齐加载:外部传入的指针无法假设 32 字节对齐
 */
float dotProduct(const float* lhs, const float* rhs, std::size_t count) {
    if (!lhs || !rhs || count == 0) {
        return 0.0f;
    }

    __m256 acc0 = _mm256_setzero_ps();
    __m256 acc1 = _mm256_setzero_ps();
    __m256 acc2 = _mm256_setzero_ps();
    __m256 acc3 = _mm256_setzero_ps();

    std::size_t i = 0;
    for (; i + kUnroll <= count; i += kUnroll) {
        // 四条链之间没有依赖,流水线可以持续填满
#if defined(__FMA__)
        acc0 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i),
                               _mm256_loadu_ps(rhs + i), acc0);
        acc1 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i + 8),
                               _mm256_loadu_ps(rhs + i + 8), acc1);
        acc2 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i + 16),
                               _mm256_loadu_ps(rhs + i + 16), acc2);
        acc3 = _mm256_fmadd_ps(_mm256_loadu_ps(lhs + i + 24),
                               _mm256_loadu_ps(rhs + i + 24), acc3);
#else
        // 没有 FMA 时乘加分成两条指令,结构不变
        acc0 = _mm256_add_ps(acc0, _mm256_mul_ps(_mm256_loadu_ps(lhs + i),
                                                 _mm256_loadu_ps(rhs + i)));
        acc1 = _mm256_add_ps(acc1, _mm256_mul_ps(_mm256_loadu_ps(lhs + i + 8),
                                                 _mm256_loadu_ps(rhs + i + 8)));
        acc2 = _mm256_add_ps(acc2, _mm256_mul_ps(_mm256_loadu_ps(lhs + i + 16),
                                                 _mm256_loadu_ps(rhs + i + 16)));
        acc3 = _mm256_add_ps(acc3, _mm256_mul_ps(_mm256_loadu_ps(lhs + i + 24),
                                                 _mm256_loadu_ps(rhs + i + 24)));
#endif
    }

    // 不足以填满四条链的部分,按单向量宽度继续
    for (; i + kLanesPerVector <= count; i += kLanesPerVector) {
        acc0 = _mm256_add_ps(acc0, _mm256_mul_ps(_mm256_loadu_ps(lhs + i),
                                                 _mm256_loadu_ps(rhs + i)));
    }

    // 四条链两两合并,水平归约只在这里出现一次
    acc0 = _mm256_add_ps(acc0, acc1);
    acc2 = _mm256_add_ps(acc2, acc3);
    acc0 = _mm256_add_ps(acc0, acc2);

    float sum = horizontalSum(acc0);

    // 不足一个向量的尾巴,标量收尾
    for (; i < count; ++i) {
        sum += lhs[i] * rhs[i];
    }
    return sum;
}

  1. https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html ↩︎

  2. https://gcc.gnu.org/projects/tree-ssa/vectorization.html ↩︎

  3. https://gcc.sourceware.org/onlinedocs/gcc/x86-Options.html ↩︎

  4. https://gcc.gnu.org/projects/tree-ssa/vectorization.html ↩︎

  5. https://gcc.sourceware.org/onlinedocs/gcc/Optimize-Options.html ↩︎

  6. https://gcc.gnu.org/projects/tree-ssa/vectorization.html ↩︎

  7. https://gcc.sourceware.org/onlinedocs/gcc/Developer-Options.html ↩︎

  8. https://gcc.sourceware.org/onlinedocs/gcc/Optimize-Options.html ↩︎

  9. https://www.intel.com/content/www/us/en/support/articles/000036924/processors/intel-xeon-processors.html ↩︎

  10. https://www.intel.co.jp/content/dam/www/central-libraries/us/en/documents/cryptography-processing-with-3rd-gen-intel-xeon-scalable-processors-19-may-2021.pdf ↩︎

  11. https://gcc.sourceware.org/onlinedocs/gcc/x86-Options.html ↩︎

  12. https://gcc.sourceware.org/onlinedocs/gcc/x86-Options.html ↩︎

  13. https://gcc.sourceware.org/onlinedocs/gcc/AArch64-Options.html ↩︎

posted @ 2026-09-22 16:14  Ke_scholar  阅读(5)  评论(0)    收藏  举报