SIMD Basic Using AVX and AVX2
SIMD 的出发点很简单:一段程序常常要对许多数据执行同一种运算。如果一次只处理一个元素,就要反复完成取数、运算、写回和循环控制。Intel CPU 的 SSE、AVX 和 AVX2 允许一条向量指令描述多个元素的运算,让程序一次处理一组数据。
但“256 位寄存器一次装 8 个 float”只是起点。真正写对 SIMD 程序,需要知道这 256 位怎样划分、每条指令允许数据流向哪里、比较结果是什么位型,以及多个向量批次怎样组成完整算法。本文从这些基础建立模型,再逐步实现数组运算、字节处理、数据重排和点积。
示例使用 Intel x86-64 CPU、C++17、GCC / Clang。图中统一把元素 0、低地址和寄存器低位画在左侧;YMM 中间的竖线表示 128-bit 边界。每张操作图都给出输入、索引或控制值,以及实际输出。完整代码和编译运行记录见文末。
1. 从一个标量循环理解 SIMD
先看数组相加:
|
每次迭代读取一个 a[i] 和一个 b[i],做一次加法,再写一个 out[i]。相邻迭代之间没有数据依赖:计算 out[3] 不需要等待 out[2]。因此,我们可以把连续 8 次迭代放进一组,分别加载 8 个 a 和 8 个 b,用一次 packed 加法描述这 8 对元素的运算,再把 8 个结果写回。
这里的 SIMD 是 Single Instruction, Multiple Data:一条指令应用于多份数据。例如一条 vaddps ymm0, ymm1, ymm2 可以表达 8 个单精度浮点加法。指令名字中的 packed,强调多个元素被一起组织在向量中;每个元素仍然有自己的值和运算结果。
SIMD 与多核、多线程是两层并行
一个线程就可以使用 SIMD。它仍然沿着一条指令流执行,只是其中某些指令处理多个元素。多线程则让不同线程拥有各自的指令流和执行状态;多核让这些线程可以在多个核心上运行。二者可以叠加:先把数组分成多个大块交给不同线程,再在每个线程内部用 AVX2 处理连续的小批次。
也不要把 SIMD 与 CPU 的超标量执行混为一谈。超标量和乱序执行可以重叠执行多条彼此独立的指令;SIMD 描述的是一条指令内部的数据宽度。现代 Intel CPU 同时使用这些机制。
什么样的循环可以直接向量化
数组加法、乘以常量、阈值比较和字节变换,通常具有独立的逐元素工作,非常适合先尝试 SIMD。下面的递推则不同:
|
第 i 个结果依赖刚刚产生的第 i−1 个结果,不能直接把 8 次迭代当成互不相关的 8 个 lane。前缀和等算法仍可以 SIMD 化,但需要重新组织依赖和跨元素通信,而不是机械替换加法指令。
别名也会改变这个判断。如果 out 与 a 完全相同,逐批原位数组相加可以成立;如果 out == a + 1,写入结果可能覆盖下一次迭代的输入。开始优化前,必须先明确输入输出的重叠规则。
2. 一条 SIMD 指令在 CPU 中怎样执行
程序中的 intrinsic、汇编指令和芯片内的执行资源,是三个不同层次。理解它们的关系,可以避免“一条指令就是一个周期”“256 位一定比 128 位快一倍”这样的误解。
CPU 前端取得并解码机器指令。许多指令会被分解成一个或多个微操作(micro-op,µop),交给后端调度。调度器在输入操作数就绪、相应执行资源可用时安排执行。寄存器重命名帮助 CPU 区分不同版本的值,避免仅由重复使用相同寄存器名字造成的假依赖。
向量加法、整数运算、shuffle 和 load/store 并不一定使用相同的硬件资源。某些 Intel 微架构可以每周期启动不止一条独立的向量算术指令,但某些重排指令可能受到更少的执行端口限制。一条架构上为 256 位的指令,也可能在某些处理器或某些操作上分成更小的内部工作。
寄存器、缓存和内存各负责什么
向量寄存器保存正在参与计算的一小组位。L1、L2、末级缓存和内存则保存更大规模的数据。load 把指定地址的内容带入寄存器,算术和重排操作使用寄存器中的值,store 把结果写到指定地址。实际执行时,CPU 可以重叠多批数据的这些步骤;带内存操作数的机器指令也可能把加载和运算结合在同一条指令表示中。
寄存器不是自动缓存整个数组的容器。数组有一百万个 float,程序仍要分批访问它们;换成 AVX 后,逻辑数据量没有减少。数组相加对每个 float 至少涉及两次读和一次写,逻辑流量是 12 字节。若主要瓶颈已经是内存带宽,减少循环指令并不会让这 12 字节消失。
Latency 与 throughput 先有一个直观区别
Latency 是从输入就绪到输出可被依赖指令使用的延迟。Throughput 描述有许多独立指令时,处理器能够多快地持续启动或完成这些工作。一条指令可以有数个周期的 latency,却允许每周期启动新的独立操作。
因此,acc = acc + x 连续重复形成的依赖链,与同时更新多个独立 accumulator,可能有不同的性能。后面的点积和性能章节会把这个区别落实到代码,而不是只比较 intrinsic 的数量。
3. SSE、AVX、AVX2 与 XMM / YMM 寄存器
SSE 系列使用 128 位的 XMM 寄存器。AVX 引入 256 位的 YMM 寄存器和 VEX 指令编码,显著扩展了浮点向量运算。AVX2 在此基础上扩展了大量 256 位整数运算、整数比较、重排、变量移位和 gather。Intel 的 Sandy Bridge 开始支持 AVX,Haswell 开始支持 AVX2;这些是代际背景,程序运行时仍应检查实际暴露的 CPU 特性。
| 能力 | 常见数据与操作 | 本文示例 |
|---|---|---|
| SSE / SSE2 等 | 128 位浮点和整数操作,具体指令分属不同扩展 | 归约的最后几步使用 XMM |
| AVX | 256 位 float / double 算术、浮点比较、搬运和部分重排 | 8 个 float 相加、SAXPY、浮点点积 |
| AVX2 | 广泛的 256 位整数算术、比较、字节 shuffle、更多 permute、gather | 阈值计数、ASCII 转换、整数和字节操作 |
| FMA3 | 融合乘加,乘法与加法合为单次最终舍入 | 单独讲解和检测的 FMA 示例 |
FMA 不是 AVX2 名称所保证的功能。 虽然常见 Intel CPU 同时提供二者,它们仍是分别检测、分别启用的指令集能力。POPCNT、BMI 等标量扩展也有各自的特性位。AVX-512 的 mask registers、compress 或 scatter 不属于本文的 AVX2 编程模型。
同一组位可以有不同的元素解释
256 位等于 32 字节,因此可以容纳:32 个 8 位元素、16 个 16 位元素、8 个 32 位元素,或者 4 个 64 位元素。元素数量由寄存器宽度 / 元素宽度决定。
| C++ intrinsic 类型 | 常见解释 | 256 位中元素数 |
|---|---|---|
__m256 |
32 位 float | 8 |
__m256d |
64 位 double | 4 |
__m256i |
整数或原始位串 | 取决于使用的指令 |
__m256i 没有运行时的“这是 8 个 int32”标签。_mm256_add_epi32 按 8 个 32 位整数解释它,_mm256_add_epi16 按 16 个 16 位整数解释它,_mm256_and_si256 则对相应位做 AND。更换指令不会自动把原有数值转换成另一种数值类型。
在 x86-64 的 AVX / AVX2 VEX 编程模型中,可以使用编号 0..15 的 16 个向量寄存器。xmm0 与 ymm0 的低 128 位重叠,它们不是两块相互独立的存储。C++ 中声明三个 __m256 变量,也不意味着它们永久占据某三个固定编号的 YMM:寄存器分配、常量折叠和必要时的 spill 都由编译器决定。
元素 lane 与 128-bit 子块
“Lane”在不同资料里可能指一个数据元素,也可能指较大的硬件或指令分区。本文用元素 lane表示数组中的一个位置,例如 8 个 float 的 lane 0..7;用128-bit 子块表示 YMM 的低半和高半。
这是 AVX/AVX2 最关键的限制之一:许多 256 位 shuffle、unpack、pack 和 horizontal 指令,其实在两个 128-bit 子块中分别工作。它们可以有八个结果,但并不允许任意一个输入元素流向任意一个结果位置。跨过中间边界需要选择相应的 permute 指令。
VEX 三操作数与高半状态
传统 SSE 指令经常采用覆盖输入的形式,例如概念上的 xmm0 = xmm0 + xmm1。许多 AVX 算术指令可以分别指定目的和两个来源,例如 ymm0 = ymm1 + ymm2。独立的目的操作数减少了仅为保留输入而复制寄存器的需要。
写一个 XMM 的 legacy SSE 指令与 VEX 编码的 128 位指令,对 YMM 高半的处理也不一样:legacy SSE 通常保留高半,VEX.128 的向量目的写入通常清零相应 YMM 高半。这不等于所有 XMM intrinsic 都会使用 legacy SSE 编码;编译选项和上下文决定实际机器指令。跨 AVX 与 legacy SSE 边界的性能问题,留到编译与分派章节说明。
4. 第一个可以运行的 AVX 程序
先用一个固定的八元素例子,把“内存 → 寄存器 → 运算 → 内存”走完。下面的入门程序面向已经确认支持 AVX 的本机实验,编译时允许整个程序使用 AVX;文末的完整示例则采用函数级 target 和运行时检测。
|
g++ -std=c++17 -O2 -mavx first_avx.cpp -o first_avx
./first_avx
输出是 10 12 14 16 18 20 22 24。第一条 load 产生 [0,1,2,3 | 4,5,6,7],第二条产生 [10,11,12,13 | 14,15,16,17],加法把相同位置相加,store 再按 lane 顺序写回数组。加载和写回的单位是完整的 8 个 float,而不是把一个 float 拆成八份。
怎样读 intrinsic 的名字
以 _mm256_add_epi32 为例:256 表示向量宽度,add 表示操作,epi32 表示 packed 32-bit integer 元素。ps 表示 packed single-precision float,pd 表示 packed double;ss、sd 通常描述只对低位标量元素计算的浮点形式。
名字是线索,不是完整规格。_mm256_movemask_epi8 返回的是一个标量 int,不是 __m256i;_mm256_cvtepu32_epi64 从一个 128 位来源取四个 uint32,产生四个 uint64。每次使用新指令,都应检查输入类型、输出类型、ISA、元素宽度和索引作用范围。
Intrinsic 也是编译器接口,不保证与机器指令严格一对一。构造常量的 set 可能变成常量加载,cast 可能不产生指令,加载可能与算术指令结合,未使用的结果会被优化掉。最终判断实际执行路径,要看编译后的汇编。
广播、构造与观察结果
|
这里 a 和 b 相同。set 的参数从高元素写到低元素,setr 按 lane 0 开始写,适合教学与构造索引。set1 将同一个标量广播到所有元素。64 位整数构造常见的名字带 epi64x,例如 _mm256_setr_epi64x,不要只靠字符串拼接猜 API。
调试时最直接的做法是 store 到正确类型、足够长度的数组,再逐个打印。不要把对向量变量地址的随意指针强转和解引用,当成通用的 C++ 类型转换方法;intrinsic 的 cast、数值 conversion 与普通 C++ 对象别名规则需要分别理解。
5. Load / Store:把数组组织成批次
对 float 数组,_mm256_loadu_ps(p) 读取从 p 开始的 32 字节,解释为 8 个 float。对整数,常见写法是 _mm256_loadu_si256(reinterpret_cast<const __m256i*>(p));它同样加载 32 字节,后续指令再决定这些位按什么整数宽度使用。
u 表示这里不要求地址满足 32 字节对齐,不表示可以读取无效地址。对象末尾还剩 3 个 float 时,一个普通 8-float load 仍然会越过有效范围,即使你之后只使用前 3 个结果。
| 操作 | Float 例子 | 地址与范围要求 |
|---|---|---|
| 未对齐连续加载 | _mm256_loadu_ps(p) |
完整 32 字节有效,不要求起点 32 字节对齐 |
| 对齐连续加载 | _mm256_load_ps(p) |
完整 32 字节有效,起点 32 字节对齐 |
| 未对齐存储 | _mm256_storeu_ps(p, v) |
完整 32 字节可写 |
| 对齐存储 | _mm256_store_ps(p, v) |
完整 32 字节可写,起点 32 字节对齐 |
alignas(32) float a[16] 保证 a 的起点对齐,但 a + 1 相对起点偏移 4 字节,不再满足 32 字节对齐。地址对齐、对象范围有效和缓存命中是三件不同的事,不能互相替代。
完整的数组相加循环
下面的函数约定三个数组各有 n 个可访问元素。输出可以与某一个输入完全相同,但不允许破坏遍历正确性的任意部分重叠。TARGET_AVX 是示例中的 __attribute__((target("avx"), noinline)),用于把这个函数单独编译为 AVX 路径。
|
向量循环处理完整的 8 元素批次;末尾不足 8 个元素时,用标量循环完成。n - i >= 8 在这里依赖始终成立的不变量 i <= n,同时避免用 i + 8 <= n 时极端整数溢出的表达问题。
向量循环的 i 每次增加 8,但这是八个元素,不是八个字节。a + i 的地址增量还要乘以 sizeof(float)。构造 gather 的 scale 时会再次遇到“元素索引”与“字节地址”的区别。
对齐为什么可能影响性能
现代 Intel CPU 上,一个碰巧对齐的 loadu 往往并不比同地址的 aligned load 多付固定代价。更重要的因素可能是一次访问是否跨 cache line、是否跨页、数据是否在 L1,以及目标微架构怎样处理拆分访问。因此不要把“未对齐总是慢一倍”当规律。
Little endian 描述的是一个多字节值内部的字节顺序。例如 uint32 的 0x11223344 在低地址处保存字节 44。它不会把数组 [a0,a1,a2,a3] 的元素顺序自动反转;向量 load 的 lane 0 仍来自起始地址对应的第一个元素。
6. 算术:每个 lane 独立计算,但类型语义不能混用
最直接的一类 SIMD 指令是垂直操作:两个输入的相同位置产生一个输出位置。Float 的 add、sub、mul、div、sqrt 都属于这个思路。各 lane 的数据不会因为做了一次普通加法就互相交换。
对于整数,必须进一步问:每个元素多宽、溢出如何处理、结果保留多少位。以 8 位加法为例,250 + 20 = 270 已经超过 uint8 范围:
| 运算 | 结果 | 含义 |
|---|---|---|
_mm256_add_epi8 |
14,即 270 mod 256 |
保留低 8 位,环绕 |
_mm256_adds_epu8 |
255 | unsigned 饱和到上界 |
_mm256_adds_epi8 |
不能把输入 250 当作正的 signed int8 | 按 signed int8 解释并饱和 |
普通 add_epi8/16/32/64 的低位加法不区分符号;同一位串按 signed 或 unsigned 解释,低位结果一致。饱和加法则需要明确符号:signed int16 的 30000 + 10000,环绕后的位型解释为 −25536,signed 饱和加法返回 32767。AVX2 的常用饱和加减主要针对 8/16 位元素,不要假设所有宽度都有相同名字的版本。
乘法的“低位”和“扩宽”不是一回事
_mm256_mullo_epi32(a,b) 对八对 int32 做乘法,每个结果保留低 32 位,仍然得到八个元素。_mm256_mul_epi32(a,b) 做 signed 32×32→64 扩宽乘法,但只使用每个输入的 0、2、4、6 号元素,结果是四个 int64。
a = [a0,a1,a2,a3 | a4,a5,a6,a7]
b = [b0,b1,b2,b3 | b4,b5,b6,b7]
mul_epi32(a,b) = [a0*b0, a2*b2 | a4*b4, a6*b6] // 四个64-bit结果
寄存器总宽度没有变,所以不能同时容纳八个 64 位乘积。若需要所有八个扩宽结果,必须另行处理奇数元素或拆成两组。无符号扩宽乘法用 _mm256_mul_epu32。AVX2 也没有像浮点 div_ps 那样通用的逐元素整数除法 intrinsic,通常需要常量除法变换、其他算法或标量处理。
浮点操作仍然遵循浮点规则
_mm256_mul_ps 产生八个独立的 float 乘积,每个结果按相应浮点规则舍入。NaN、无穷、舍入模式以及异常控制,并不会因为使用向量而消失。特别是 reduction 改变加法分组、FMA 改变舍入次数时,结果可能与逐元素标量表达式不逐位相同。后面会单独推导这两种情况。
一个直接的应用是 SAXPY,即 out[i] = alpha * x[i] + y[i]。先广播 alpha,再对各 lane 相乘并相加:
|
这份演示用 -ffp-contract=off 保持显式的乘法加法分开,便于与 FMA 比较。输入输出的重叠约定与数组相加相同;向量版本的主要变化是每次处理八项,不是更换数学问题。
7. 比较、Mask 与条件选择
标量程序用 if (x > 5) 选择一个执行分支。向量比较一次产生八个条件,但 AVX2 并没有为八个 lane 各启动一条独立指令流。常见做法是先生成 mask,再用 mask 选择数据或收集条件结果。
对 _mm256_cmpgt_epi32(x, limit),每个为真的结果是 32 个 1,即 0xffffffff;每个为假的结果是 32 个 0。把结果作为 int32 打印时,真通常显示为 −1,不是 1。这个位型让逐位选择非常自然。
以 x = [-3,9,0,12 | 7,-1,20,4] 为例,x > 5 在 lane 1、3、4、6 成立。对应 mask 是 [0,-1,0,-1 | -1,0,-1,0]。如果需要把不满足条件的值设为零,可以直接 and(x, mask)。更一般的条件选择是:
|
这等价于逐位表达式 (mask & a) | (~mask & b)。andnot(a,b) 的顺序尤其容易写反:它是 (~a) & b。
Blend 检查哪些位
_mm256_blendv_ps(a,b,m) 对每个 32-bit float 元素检查 mask 的最高位:置位选 b,否则选 a。_mm256_blendv_epi8 则每个字节检查一个最高位。它们不会要求 mask 的其他位也全为 1,所以不能把一个随意的 mask 同时当作完整的逐位选择条件。
例如 int32 mask 为 1,即位型 0x00000001,它不是比较指令生成的真值 mask:用它做 AND 只保留最低位,用它做 blendv_ps 也不会选择 b。先说清楚 mask 的粒度和位型,再决定用哪种操作。
Blend 同时接收两个已经计算好的候选向量。它并不保证不被选择的一侧“没有执行”:如果你先计算 a / b 再 blend,除法已经是此前操作的一部分。需要屏蔽无效访问时,也不能依靠事后 blend。
Movemask:把向量条件带回标量控制流
_mm256_movemask_ps 从八个 32-bit 元素各取最高位,返回一个 8-bit 有效位图。lane 0 对应最低位 bit 0,lane 7 对应 bit 7。上例得到 0b01011010 = 0x5a,因此:
bits != 0:至少一个满足;bits == 0xff:八个全满足;- 对 bits 做 popcount:满足条件的元素个数,这里是 4。
_mm256_castsi256_ps(mask) 只是让同一位串传给这个 float 形式的 intrinsic,没有把整数 −1 数值转换成 −1.0f。
_mm256_movemask_epi8 则从 32 个字节各取一个最高位。如果输入仍然是上述 int32 比较结果,每个真元素贡献连续四个 1,结果为 0x0f0ff0f0。直接 popcount 得到的是 16 个真字节,而不是 4 个真 int32。按元素宽度选择合适的提取方式,通常比再修正计数更清楚。
浮点比较要选择 NaN 语义
Float 比较可以用 _mm256_cmp_ps(x, limit, _CMP_GT_OQ)。这里选择 ordered、quiet 的 greater-than 语义:有 NaN 的 lane 不满足这个比较;quiet 谓词对 QNaN 不按 signaling 谓词处理,但 SNaN 仍可能触发 invalid。不要认为 float 比较只有一个无需选择的“大于”。
比较结果仍然是全一/全零位串。尤其不要把全一的结果按 float 数值参与算术,它不是数值 1.0。需要 0.0/1.0 时,可以与广播的 1.0f 做 bitwise AND,或使用 blend 明确选值。
8. 从 Signed 比较得到完整的 uint32 比较
AVX2 的 _mm256_cmpgt_epi32 是 signed 比较。若把 uint32 的 0xffffffff 直接传给它,该位串会被解释为 −1,于是它会“小于”0;但 uint32 语义下它应是最大的值。
一个通用变换是让两个操作数都异或符号位 0x80000000,然后做 signed 比较:
unsigned 的升序:0x00000000, ... , 0x7fffffff, 0x80000000, ... , 0xffffffff
XOR 0x80000000:0x80000000, ... , 0xffffffff, 0x00000000, ... , 0x7fffffff
按 signed 解释: -2147483648 ... -1, 0 ... 2147483647
这不是只对“小于 2³¹ 的正常值”生效的技巧,而是覆盖全部 32 位无符号范围的保序映射。相等比较本来就只比较位串,不需要这一步;如果条件由 > 改成 >=,还要显式纳入相等,不能只换一个阈值就忽略边界溢出。
下面是完整的 unsigned 阈值计数。阈值只广播和变换一次,每批处理八项,最后处理尾部:
|
memcpy 用来把 threshold 的位型放进 int32,避免依赖 C++17 中超范围 unsigned→signed 数值转换的实现选择。计数用一个短的标量清位循环,每轮 bits &= bits - 1 删除一个最低置位。性能代码可以使用 popcount,但 POPCNT 也有独立的 CPU 特性要求;不要因为检测了 AVX2 就手写一条未经检测的 POPCNT 指令。
有符号输入不需要 bias;可下载代码中的 count_gt_i32_avx2 展示了这条更短的路径。对两者都应验证恰好等于阈值、0、最大值以及跨越符号位的值。
9. Shuffle 与 Permute:先确定元素能到达哪里
向量算法经常需要把元素重新排列。重排指令不是一组可以互换的“洗牌”按钮:有些只在每个 128-bit 子块中选位置,有些交换整个半区,有些允许八个 32-bit 元素全局选择。理解其输入到输出的映射范围,比背名字更有用。
Immediate 是编译时的控制位
_MM_SHUFFLE(z,y,x,w) 生成一个 8-bit 控制常量:(z<<6)|(y<<4)|(x<<2)|w。每两位编码一个 0..3 的索引。按输出 lane 0→3 来读时,索引顺序是 w、x、y、z,与宏的参数书写方向相反。
_mm256_permute_ps(a, _MM_SHUFFLE(0,1,2,3)) 把每个半区的四个元素各自反转:
输入: [0,1,2,3 | 4,5,6,7]
输出: [3,2,1,0 | 7,6,5,4]
控制 immediate 通常要求编译时常量,不能把一个任意运行时整数直接传进要求 imm8 的位置。需要每次迭代都变化的索引时,应寻找接受向量索引的变量版本;它的允许范围也可能不同。
双输入 shuffle:每半区从 a 取两项、从 b 取两项
_mm256_shuffle_ps(a,b,imm) 在每个半区产生四项:输出前两项从 a 选,后两项从 b 选。控制位最低两组索引 a,最高两组索引 b。
|
当 a=0..7、b=10..17 时,输出是 [0,2,10,12 | 4,6,14,16]。特别注意高半从 a 的 4..7 与 b 的 14..17 选,而不是重新使用整个输入的前四项。
交换半区与全局逐元素选择
_mm256_permute2f128_ps(a,b,imm) 的单位是整个 128-bit 半区。其低位控制输出低半,高位控制输出高半;每组选择编号 0/1/2/3 分别表示 a低/a高/b低/b高,另有置零控制位。例如 permute2f128_ps(a,a,0x01) 输出 [a高 | a低]。
AVX2 的 _mm256_permutevar8x32_ps(a,index) 则让每个输出元素按 index 的低 3 位,从整个 a 的八个元素中选择。index=[7,6,5,4,3,2,1,0] 才得到整体逆序 [7,6,5,4,3,2,1,0]。
输出索引允许重复,如 [0,0,0,0,7,7,7,7]。这个操作不是 scatter:它不对内存写入,不要求输入与输出一一对应,也没有重复目标地址冲突。其他名字相近的变量 permute 可能仍局限于半区,不能仅凭“variable”判断可以跨半区。
10. 字节 Shuffle、Unpack、Pack 与移位
Byte shuffle:两个独立的 16 项查找表
_mm256_shuffle_epi8(table, control) 对 32 个输出字节分别查表,但每个字节只能读取自己所在的 128-bit 子块。可以把低、高半分别看作一个 16 字节表。
对一个 control 字节:最高位 bit 7 为 1 时输出零;否则取低 4 位作为 0..15 索引。bit 4..6 不参与选择。因此 0x10 仍选择本半区的第 0 项,0x80 则输出零;高半的 0x00 选择整个 YMM 的 byte 16,而不是 byte 0。
这个指令很适合对 nibble(4 位数)查表。例如计算一个字节的置位数,可以拆成高、低 nibble,分别在 16 项表中查出 0..4,再相加。两份 LUT 要在 YMM 的两个半区各放一份。第 14 章会给出完整应用。
Unpack 是交织,Pack 是收窄
_mm256_unpacklo_epi32(a,b) 把每个半区中较低的两项交织起来;unpackhi 使用每半区较高的两项。对 a=0..7、b=10..17:
unpacklo → [0,10,1,11 | 4,14,5,15]
unpackhi → [2,12,3,13 | 6,16,7,17]
“lo”不是取整个 256 位输入的前四项,也不是把每个 int32 拆开。元素宽度仍为 32 位,只改变来源顺序。
_mm256_packs_epi32(a,b) 则把两个输入的 signed int32 饱和收窄为 int16。16 个结果仍按两个 128-bit 子块组织:低半包含 a低的四项、b低的四项;高半包含 a高的四项、b高的四项。
如果你期望输出是“a 的八项,然后 b 的八项”,还要进行一次适当重排。packus_epi32 的来源依然按 signed int32 解释,只是结果饱和到 uint16 范围 0..65535;不要把名字中的 unsigned 当成来源也变为 uint32。
逐元素移位与整字节位移
_mm256_slli_epi32(v, k) 对每个 32-bit 元素左移 k 位,低位补零,溢出的高位丢弃;相邻元素不会互相接收移出的位。srli 是逻辑右移,srai 是算术右移,后者复制符号位。
AVX2 的 sllv_epi32、srlv_epi32、srav_epi32 接受每个元素自己的移位数。对于 32-bit 元素,逻辑移位数 ≥32 时结果为零;算术右移会填成符号值。AVX2 不提供对应的逐元素 64-bit 变量算术右移 srav_epi64。
名字易混的 _mm256_slli_si256(v,1) 是字节位移:每个 128-bit 子块独立向更高字节位置移动一个字节,最低位置补零。本文低位在左,所以图上数据向右移动;这不改变指令称为 left shift 的位权含义。该字节位移的 immediate ≥16 时整个结果为零。
这些是指令的定义,不能用普通 C++ uint32_t(1) << 32 作为对照程序;C++ 中移位数达到类型宽度有未定义行为。标量参考应先判断 count,再执行合法移位。
11. 数值转换与位型 Cast
_mm256_castsi256_ps 保留 256 位不变,只改变编译器接口中的解释类型。比如整数 1 的位型 0x00000001 被当作 float,是一个很小的非规格化数;它不会变成浮点数 1.0 的位型 0x3f800000。
_mm256_cvtepi32_ps 才把八个 signed int32 数值转换为八个 float。由于 float 只有 24 位有效二进制精度,所有 int32 都在其数值范围内,但并非都能精确表示。超过 2²⁴ 后,相邻整数可能转换成同一个 float。
反方向也要区分:_mm256_cvttps_epi32 向零截断,_mm256_cvtps_epi32 使用 MXCSR 指定的舍入模式,默认通常是 round-to-nearest、ties-to-even。
| 输入 | −1.9 | −1.1 | −0.5 | 0 | 0.5 | 1.1 | 1.9 | 2.5 |
|---|---|---|---|---|---|---|---|---|
| 向零截断 cvtt | −1 | −1 | 0 | 0 | 0 | 1 | 1 | 2 |
| 最近偶数舍入 cvt | −2 | −1 | 0 | 0 | 0 | 1 | 2 | 2 |
NaN 或超出可表示范围的转换会产生 invalid;在异常被屏蔽的常见模式下,结果为 integer indefinite 0x80000000。它与合法结果 INT_MIN 的位型相同,因此不能只检查结果是否 INT_MIN 来判断转换有效。需要先按应用规则检查输入范围与 NaN,再决定如何处理。
扩宽转换也会改变元素数量。例如 _mm256_cvtepu8_epi32 从一个 XMM 输入的低 8 字节取数,零扩展为八个 uint32,填满一个 YMM;它不会一次转换 YMM 中的全部 32 字节。cvtepi8_epi32 则对这八项做符号扩展。AVX2 也没有直接对应 _mm256_cvtepu32_ps 的完整 uint32→float 转换,需拆分或采用经过验证的组合算法。
12. Horizontal Reduction:怎样把八项变成一个数
逐元素加法每个 lane 独立;求和则必须把不同 lane 的结果汇集到一起。这叫 horizontal reduction。它既会引入跨元素通信,也会改变运算分组。
_mm256_hadd_ps(a,b) 这个名字容易让人以为“一次得到总和”,实际上它仍按半区做相邻配对。令 v=[1,2,3,4 | 5,6,7,8]:
hadd(v,v) = [3,7,3,7 | 11,15,11,15]
再 hadd 自身 = [10,10,10,10 | 26,26,26,26]
此时低半的和是 10,高半是 26,两个半区还没有相加。无论指令名字多像“求和”,都应把实际输出写下来。
下面这段使用 AVX 加上 XMM 操作,完整地把八项归约为一个标量:
|
对 1..8,第一步相加 low/high 得到 [6,8,10,12]。movehl 把高两项复制到低两项,下一次加法后是 [16,20,20,24]。这里只需要低两项:最后 add_ss 将 16+20 放在 lane 0,再提取 36。其余 lane 的中间值不是额外的输出,不应被误当成已经正确归约的总和。
长数组先垂直累加,最后再横向合并
如果对每批八项都立即归约,循环中就会反复付出 shuffle、跨半区搬运和标量依赖的成本。更常见的方式是让多个向量 accumulator 各自累积,循环结束后合并,再归约一次。第 14 章的点积使用两个独立 accumulator;完整源码也包含数组求和版本。
浮点加法不满足实数意义下的结合律。(a+b)+c 与 a+(b+c) 可能因为中间舍入不同而产生不同结果。向量归约、分块并行和 FMA 都可能改变分组,因此正确性判断要采用应用需要的误差界;要求逐位复现顺序标量结果时,不能无条件替换求和顺序。
整数也有类似的容量问题。先用 int32 累加到溢出,再把结果转换为 int64,无法找回已经丢弃的高位。需要根据输入范围和批次数估计最坏和,在溢出发生前扩宽或分段归约。
13. 尾部:不足一个寄存器时怎么办
设 n=19,每批处理 8 个 float。完整向量循环处理下标 0..7 和 8..15,留下 16..18 三项。最清楚的通用策略是用标量循环完成这三项;它也自然覆盖 n=0 和 n<8。
AVX 提供 float/double maskload/maskstore,AVX2 扩展了 32/64-bit 整数版本。Mask 的每个元素最高位控制是否访问该位置。Maskload 的禁用元素在结果中为零;maskstore 的禁用元素不会写入,目标原有内容保持不变。
|
这个辅助函数的输出 out8 必须始终有八个可写元素,因为它最后用了一个普通的完整 store。Maskload 只解决输入加载的边界,不会自动改变之后 store 的范围。若结果也写回短尾部,要用相应 maskstore、标量存储,或者足够大的临时缓冲区。
掩掉的零值还可能影响计算。例如只剩三项时加载成 [x0,x1,x2,0,0,0,0,0],随后计算 x >= 0,禁用位置也会得到真。涉及计数、min/max 或异常敏感操作时,要把有效性 mask 继续带入算法,或选择适合操作的填充值。maskload 不是一个自动消除所有尾部问题的开关。
AVX2 没有同样形式的任意字节 maskload。对不足 32 字节的文本,通常用标量尾循环,或复制有效字节到固定大小临时数组,并保证填充值不会被算入最终答案。只有在调用契约明确提供了可访问 padding 时,才能使用依赖 padding 的完整加载。
本教程的验证包含紧贴不可访问页的整数尾部加载。这样可以检查实现是否真正避免访问禁用位置。普通 load 后再 AND/blend,不会撤销已经发生的越界读取。
14. 把操作组合成完整应用
下面的应用都包含循环边界和标量尾部。示例重点是可解释的语义;完整源码同时保留标量参考来检查输出,未宣称这些教学实现已经是每个 CPU 上的最优版本。
14.1 ASCII 小写转大写:范围 mask 加逐字节修正
ASCII 小写字母位于 0x61..0x7a,对应大写字母恰好少 0x20。先判断每个字节在不在这个范围内,再只对满足条件的字节减 32。
输入字节: 'a' 'Z' 'z' '0' '{' 0xff
范围 mask: ff 00 ff 00 00 00
减去 delta: 20 00 20 00 00 00
输出字节: 'A' 'Z' 'Z' '0' '{' 0xff
|
这里使用 signed byte 比较仍然正确:ASCII 小写范围完全落在 0..127;所有 128..255 字节按 signed 解释为负数,第一项 x > 'a'-1 就为假,最终 mask 为零。两个条件的 AND 保证高位字节不被误改。
函数原位处理任意字节串,仅把 ASCII a..z 转为 A..Z,其余字节保持原样。它不实现 Unicode 大小写规则,也不依赖输入以 NUL 结尾;调用者显式给出长度。
14.2 图像亮度:用饱和加法表达边界
对 uint8 灰度值增加 delta,希望结果最多为 255:
输入: [0, 10, 120, 240, 250, 255]
delta=20: [20,30,140,255,255,255]
普通环绕加: [20,30,140, 4, 14, 19] // 亮点反而变暗
|
adds_epu8 正好表达 min(255, unsigned(x)+delta),省去显式比较再选择。但它只适用于我们确实想要饱和边界的应用;哈希、整数模运算等可能需要环绕,不能把饱和当作通用的“更安全加法”。这里直接处理字节强度,不涉及颜色空间或 gamma 校正。
14.3 AoS → SoA:先局部选出 x/y,再修正跨半区顺序
假设输入保存二维点:[x0,y0,x1,y1,...],希望输出两个连续数组 x、y。这是从 Array of Structures 的交错布局转为 Structure of Arrays。
一次处理八个点,需要加载 16 个 float,放进两个 YMM。先用双输入 shuffle 取偶数位置,就能收集 x,但其顺序是 [x0,x1,x4,x5 | x2,x3,x6,x7]。原因正是 shuffle 在每个半区独立工作。再用 AVX2 的全局 permute,索引 [0,1,4,5,2,3,6,7],才得到连续的 x0..x7。
|
输出 x、y 各有 n 个位置,输入 xy 有 2n 个位置,且这三个缓冲区互不重叠。2*n 的大小计算必须在调用方分配时有效。这条两步重排很适合练习:仅检查输出是否包含了所有 x 还不够,还必须检查顺序。
SoA 的价值在于后续只处理 x 时,可以连续加载纯 x 数据,减少反复提取。但如果每次工作都需要整个点,或转换成本高于后续收益,AoS 也可能更合适。布局选择取决于实际的访问模式。
14.4 点积:两个 accumulator 与一次归约
点积 sum(x[i]*y[i]) 同时包含逐元素乘法和跨元素累加。向量循环把这两层拆开:先算八个独立乘积,分别累加到对应 lane;循环结束后再横向求和。
|
这里每轮处理 16 项,用 acc0、acc1 保存两条独立累加链。这样 CPU 不必让每一条向量加法都等待同一个前驱结果。多 accumulator 不是越多越好:更多寄存器、展开代码和最后的合并也有成本,具体数量应按处理器和数据规模测量。
这份代码使用分离的 mul/add,没有要求 FMA。测试输入包含有界整数形式的 float,使本次语义验证中的期望和可以精确比较;真实浮点数据仍需按应用需求设置误差容限,不能把这种测试特例当成任意输入下的逐位一致保证。
14.5 字节位计数:把 byte shuffle 变成小型 LUT
一个字节最多有八个置位。把它分为两个 nibble:lo = x & 0x0f,hi = (x >> 4) & 0x0f。每个 nibble 只有 16 种值,查表后相加即可。
x = 0xb3 = 1011 0011
lo = 3 → LUT[3] = 2
hi = 11 → LUT[11] = 3
这个字节的 popcount = 5
|
代码用 16-bit 逻辑右移生成高 nibble,再做逐字节 & 0x0f。右移过程中从邻字节进入的位会被这一步掩掉,因此每个字节最终只留下自己的高四位。AVX2 没有普通的逐字节右移指令,组合操作的正确性要由位流推导出来。
_mm256_sad_epu8(eachByte, zero) 把每组八个非负字节与零的绝对差相加,产生四个 uint64 分组和,再累加进标量 uint64。这里每字节的中间结果最大为 8,不会发生 byte add 溢出。演示每批就归约,便于理解;性能实现可以在证明中间值不溢出的前提下进一步批量累加。最终总位数也必须能被返回的 uint64 容纳。
15. FMA:少一次舍入,不只是少一个指令
_mm256_fmadd_ps(a,b,c) 计算逐元素 a*b+c,乘法与加法融合,只有最终结果按目标精度舍入。分离的 mul 与 add 则先把乘积舍入成 float,再参与加法。
用一组可以手算的 binary32 数值说明:令 a=1+2⁻¹³、b=1−2⁻¹³、c=−1。a、b、c 都能精确表示,精确乘积是 1−2⁻²⁶。
1 附近、从下方接近时,binary32 相邻可表示值间隔是 2⁻²⁴,乘积距离 1 只有 2⁻²⁶,因此分离乘法把它舍入为 1,再加 −1 得到 0。FMA 则先在融合运算中消去 1,再把 −2⁻²⁶ 作为最终结果,它恰好可表示。
FMA 往往能减少某些算法的舍入误差,但同时改变了与旧实现逐位复现的行为。编译器是否允许把普通 a*b+c 收缩为 FMA,也取决于编译选项和目标支持。本教程比较分离与融合操作时使用 -ffp-contract=off;显式 FMA intrinsic 仍表达融合语义。
FMA 函数使用单独的 target("avx,fma"),调用前单独检查 FMA 支持。不能只因 AVX2 路径可运行,就把未检查的 FMA 混入其中。
16. 数据布局、依赖和带宽怎样决定收益
Gather 可以按索引取数,但不能保证连续加载的成本
AVX2 gather 从多个地址加载元素,例如 _mm256_i32gather_ps(base,index,4) 按 base + signed_index[i] * 4 的字节偏移读取八个 float。Scale 是编译时常量 1/2/4/8;这里用 4 对应 sizeof(float),不是“每次加载四个元素”。每个启用地址都必须有效。
Gather 便于表达查表和稀疏访问,但实际成本取决于地址分布、cache line 数、缓存命中与 CPU 型号。AVX2 没有对应的通用 scatter。想要更快,往往应先问能否整理布局、批次或索引,让读取变得连续,而不是默认 gather 一定胜过标量加载。
多条独立链帮助隐藏 latency
如果每条加法都更新同一个 acc,后一条必须等前一条产生结果。若准备多个独立 acc,调度器可以在等待 acc0 时执行 acc1、acc2 的工作。足够的独立工作有助于接近吞吐上限,但最终仍受到执行端口、load/store、寄存器和前端资源限制。
重排也有代价。某个算法算术很少,却每批做多次 shuffle、extract 和 insert,其瓶颈可能是重排资源而不是加法器。把 scalar 指令条数与 SIMD intrinsic 条数直接相除,不能得出加速比。
带宽和工作集
数组相加每元素一加,逻辑读取两个 float、写出一个 float,算术强度约为 1 FLOP / 12 B。内存系统还可能有写分配等额外流量。大数组若由带宽主导,扩大向量宽度对最终耗时的改善可能很有限;小数组在 L1 中则可能呈现完全不同的瓶颈。
高强度向量工作还可能影响某些 Intel 型号的频率和功耗状态;具体是否发生、程度多大,必须结合 SKU、指令混合和持续时间判断。本文不提供脱离处理器条件的固定加速倍数。
先建立可信的测量方法
先写清楚、无未定义行为的标量参考,并查看编译器是否已经自动向量化。GCC 的 -fopt-info-vec-optimized / -fopt-info-vec-missed、Clang 的 -Rpass=loop-vectorize / -Rpass-missed=loop-vectorize 可以帮助定位机会和障碍。Intrinsic 应解决已知问题,而不是替代对循环依赖与数据布局的分析。
测量时让输入在运行时生成,消费输出以避免整段被优化掉;分别测量 L1、缓存和大于末级缓存的规模,并包含不同尾长和对齐位置。记录 CPU、编译器、选项、数据规模、重复次数和波动,同时验证输出。分配、初始化、计时和 I/O 不应混入想测的热循环,除非这些本来就是目标负载的一部分。
17. 编译、运行时检测与分派边界
编译器能生成 AVX2,并不代表运行它的 CPU 和操作系统支持 AVX2。AVX 涉及扩展寄存器状态,操作系统必须启用 XSAVE 对相关状态的保存恢复。手写检测时要正确结合 CPU 特性、OSXSAVE 和 XGETBV 的状态;只检查一个 AVX CPUID 位不够。
对于本文采用的现代 GCC / Clang x86-64 环境,通常使用编译器的运行时特性检测设施,让它处理可用状态判断,再把专用函数放在独立 target 中。普通 main 及分派函数保持基线 ISA。示例采用:
|
|
静态函数指针在首次调用时选定路径,后续不反复检测。示例在普通 main 执行阶段使用编译器已初始化的检测设施;如果在更早的 ifunc resolver 中使用 GCC builtin,需要遵循其 __builtin_cpu_init() 规则。不同编译器和构建环境的分派机制并不完全相同,应查对应文档。
带标量回退的整个程序不要全局使用 -mavx2 或 -march=native。 否则编译器可能在进入检测之前就生成 AVX2 指令,回退分支失去意义。函数级 target 或独立编译单元,才给不同 ISA 路径提供明确边界。跨边界尽量传普通指针、长度和标量返回值,避免把 YMM 参数/返回值放入基线函数的 ABI。
代码中的 noinline 便于教学时观察专用函数及其汇编,不代表生产程序必须对所有小辅助函数禁用内联。向量类型辅助函数要与调用路径使用兼容的 target;实际项目可用单独翻译单元或谨慎设置内联策略。
AVX 与 legacy SSE 交界
某些 Intel 微架构在 AVX YMM 操作后遇到保留高半状态的 legacy SSE 指令,会有状态转换代价。vzeroupper 清零向量寄存器高半,常用于不再需要这些值的函数边界,编译器通常会在适当位置插入。
不要在仍然使用活跃 YMM 高半时手动插入它。也不要把 C++ 中每个 _mm_* intrinsic 都判定为 legacy SSE:在 AVX 目标函数中,许多 128 位操作会用 VEX 编码。要判断是否存在交界问题,应查看实际汇编,而不是只看变量叫 XMM 还是 YMM。
18. 可运行代码、验证范围与继续阅读
下载 第一个 AVX 程序 和 完整 C++17 示例及检查程序。完整版包含数组相加、signed/unsigned 阈值计数、求和、SAXPY、ASCII 转换、饱和亮度、AoS→SoA、点积、字节位计数、maskload 和 FMA,以及相应标量对照与指令语义检查。
# 完整版保持基线编译,专用函数通过 target attribute 启用 ISA。
g++ -std=c++17 -O3 -Wall -Wextra -Werror -ffp-contract=off \
simd_examples.cpp -o simd_examples
./simd_examples
clang++ -std=c++17 -O3 -Wall -Wextra -Werror -ffp-contract=off \
simd_examples.cpp -o simd_examples_clang
./simd_examples_clang
# 检查越界及未定义行为。
g++ -std=c++17 -O1 -g -ffp-contract=off \
-fsanitize=address,undefined -fno-omit-frame-pointer \
simd_examples.cpp -o simd_examples_sanitize
ASAN_OPTIONS=detect_leaks=0 ./simd_examples_sanitize
2026-09-19 在 Intel Xeon Platinum 8260(KVM 暴露的 x86-64 环境)实际编译并运行:GCC 12.2、Clang 22.1.8 的优化构建,以及 GCC AddressSanitizer + UndefinedBehaviorSanitizer 构建均通过。运行时检测结果为 AVX=1、AVX2=1、FMA=1。
- 4,626 组应用检查:长度 0..513,覆盖所有相关尾长、偏移起点、SAXPY、signed 计数、ASCII、饱和亮度、AoS→SoA、点积与位计数。
- 1,548 组 unsigned 阈值计数检查:长度 0..257,六个阈值,并另查符号位边界及相等情况。
- 指令语义检查:元素顺序、shuffle/permute、pack/unpack、mask/bitmask、移位、转换、归约、FMA;ASCII 另查全部 256 种字节值。
- 边界检查:maskload 的 0..8 项尾部,包含紧贴不可访问页的输入。
程序输出:
PASS: 4626 application cases; lengths 0..513; exact lane diagrams; ASCII all256 values
PASS: 1548 count cases; lengths 0..257; AVX=1 AVX2=1 FMA=1; lane, tail, mask, shuffle and reduction checks
这份程序验证的是教学算法和关键指令语义,并没有给出性能基准结果;它也不替代对所有浮点特殊值、任意缓冲区重叠或其他 CPU 微架构的系统测试。当前机器支持 AVX、AVX2、FMA,缺少这些特性的机器上的实际回退启动仍需要相应硬件或虚拟机另测。
读完后可以手算的练习
set_ps(7,6,5,4,3,2,1,0)store 到数组,首元素是什么?0。- 输入 0..7,用半区内 reverse,结果是什么?
[3,2,1,0 | 7,6,5,4]。 - int32 比较得到两个真元素,
movemask_epi8的 popcount 是多少?8;若用 cast 后的movemask_ps,则是 2。 - byte shuffle 的高半 control=0x10,会读整个 YMM 的哪个字节?byte 16;control=0x80 时输出 0。
- 对 1..8 连做两次
hadd(v,v),lane 0 是总和吗?不是,低半为 10,高半为 26,还差跨半区相加。 - n=19 时,从第 16 项开始普通加载八个 float,然后把后五项清零,是否安全?不安全,加载时已经越界。
参考资料
- Intel Intrinsics Guide:逐条检查语义、操作数、ISA 与伪代码。
- Intel Software Developer Manuals:指令定义、状态与异常语义。
- Intel Optimization Reference Manual:微架构与优化方法。
- GCC x86 Function Attributes 与 x86 Built-in Functions:target 与 CPU 检测。
- Clang Loop Vectorization:自动向量化、诊断与限制。
- uops.info:按具体微架构查询实测 latency、throughput 与端口使用,避免把某一个型号的数据当成所有 Intel CPU 的常量。