SIMD 向量化
SIMD Vectorization
SIMD(单指令多数据)用一条机器指令并行处理多个数据。x86 上从 MMX 发展到 SSE(128 位,4 个 float)、AVX(256 位,8 个)和 AVX-512(512 位,16 个)。GEA 用 SSE 内建函数演示:数组相加、点积、向量×矩阵、矩阵×矩阵,强调两点——避免寄存器内的水平相加(先转置数据),以及把 SIMD 寄存器看成 N 条并行的「通道 (lane)」而不只是一个 4D 向量;条件分支则用向量谓词化 (vector predication / select) 代替。
指令集演进(卷 I 4.10)
| 指令集 | 年代/处理器 | 寄存器 | 要点 |
|---|---|---|---|
| MMX | 书中写 1994,Pentium(见下方原书问题) | 64 位 | 8 个 8 位、4 个 16 位或 2 个 32 位整数(MMX 官方是无含义的商标缩写) |
| SSE | Pentium III 起 | 128 位 XMM0–XMM15 | 可存整数或 IEEE 浮点;游戏引擎最常用 packed 32 位浮点模式,一个寄存器装 4 个 float;后续有 SSE2、SSE3、SSSE3、SSE4,AMD 2007 年推出 XOP、FMA4、CVT16(2007 年是以「SSE5」名义发布规范,实际 2011 年随 Bulldozer 架构才上市,后续 Zen 已不再支持 XOP/FMA4) |
| AVX / AVX2 | 2011 | 256 位 YMMi | 8 个 float |
| AVX-512 | 部分 Intel CPU | 512 位 ZMMi | 16 个 float |
原书问题:MMX 并非 1994 年推出。Intel 于 1996 年发布 MMX 技术,首款支持它的处理器 Pentium MMX(P55C)于 1997 年 1 月上市;1994 年的 Pentium 不支持 MMX。另:XMM0–XMM15 这 16 个寄存器只在 x86-64 模式下可用,32 位模式只有 XMM0–XMM7;AVX-512 把寄存器扩展到 32 个(ZMM0–ZMM31)。
- packed 单精度指令带
ps后缀(p = packed,s = single)。书中把寄存器四个分量称为 x y z w;多数 SSE 文档用 r0 r1 r2 r3,也有文档用 w x y z,要小心。
数据类型、对齐与内建函数(卷 I 4.10.1)
__m128表示 4 个 float(__m256、__m512分别为 8 个、16 个)。声明为自动变量或函数参数时,编译器常把它直接当作寄存器;作为全局变量、结构体成员(有时局部变量)时存为 16 字节对齐的 float 数组,参与运算前后编译器会隐式插入载入/存储指令,所以要看反汇编确认没有多余的载入存储。- 对齐:给 XMM 用的内存数据必须 16 字节对齐(YMM 32 字节、ZMM 64 字节)。编译器自动对齐
__m128类型的全局与局部变量,并对含__m128成员的结构体做填充与整体对齐;但动态分配的实例要手动对齐,给 SSE 用的 float 数组可用 C++11alignas保证(见 C++ 内存布局与对齐)。 - 内建函数 (intrinsics):看起来像 C 函数,编译器把它展开成内联汇编,多为一条指令。头文件:书中称 Visual Studio 为
<xmmintrin.h>,Clang/gcc 为<x86intrin.h>。补充:<xmmintrin.h>只声明 SSE(1)内建函数;_mm_hadd_ps(SSE3)在<pmmintrin.h>,_mm_dp_ps/_mm_blendv_ps(SSE4.1)在<smmintrin.h>,AVX 在<immintrin.h>;三家编译器都支持用<immintrin.h>一次包含全部。常用五个:_mm_set_ps(w,z,y,x)、_mm_load_ps(要求 16 字节对齐)、_mm_store_ps、_mm_add_ps、_mm_mul_ps。 _mm_set_ps参数「反着」:大概源于 Intel 是小端序——不仅每个 float 的字节,连四个 float 在内存中的顺序也与寄存器内顺序相反。这只是命名约定,寄存器里并没有「最高位 float」。书中约定以内存顺序为准,所以float v[] = {vx, vy, vz, vw}对应_mm_set_ps(w, z, y, x)。字节序见 数值表示与浮点数。- PS3 等平台用 gcc 编译 PowerPC AltiVec,可直接声明
vector float v = (vector float)(-1.0f, 2.0f, 0.5f, 1.0f);,比 VS 的_mm_set_ps(1.0f, 0.5f, 2.0f, -1.0f)直观。
向量化循环(卷 I 4.10.2)
把逐元素相加的循环改为每次 _mm_load_ps 4 个、_mm_add_ps、_mm_store_ps,即向量化。前提是数组长度相同且为 4 的倍数,由调用者补 1–3 个占位值。因为每轮都要载入与存储,加速不到 4 倍:作者在超大数组上实测,未向量化版本耗时约为向量化版本的 3.8 倍。
向量化点积(卷 I 4.10.3)
- 第一次尝试(慢):每个 4D 向量相乘后用两次
_mm_hadd_ps(SSE3 指令;水平相加,s=x+y,t=z+w)在寄存器内求和,即「跨寄存器相加」,最后_mm_store_ss取出第 0 个分量。书中把结果写作 (t,s,t,s),这是按寄存器从高到低的顺序;按书自己约定的内存顺序 r0..r3 应为 (s,t,s,t),即 Intel 文档中_mm_hadd_ps(a,b) = (a0+a1, a2+a3, b0+b1, b2+b3)。实测比不用 SSE 的参考实现还略慢。SSE4 的_mm_dp_ps(dpps)延迟稍低,但所有水平相加都很贵,应尽量避免。 - 更好的方法:先转置输入,让一个寄存器装 4 个向量的 x 分量、另一个装 y 分量……然后像标量那样
x*x + y*y + z*z + w*w,一次算出 4 个点积。乘加如此常见,有的 CPU 提供单条 madd 指令,如 AltiVec 的vec_madd()。 - 边算边转置:输入保持参考实现的布局,在函数内用
_MM_TRANSPOSE4_PS宏转置(由_mm_shuffle_ps组成,shuffle 不算贵)。实测最终版本约为参考实现的 3.5 倍快。 - shuffle 掩码:由 4 个 0–3 的整数打包而成,
SHUFMASK(p,q,r,s) = p | (q<<2) | (r<<4) | (s<<6),_mm_shuffle_ps(a, b, mask)得到(a[p], a[q], b[r], b[s])。
向量×矩阵与矩阵×矩阵(卷 I 4.10.4–4.10.5)
- 定义
union Mat44 { float c[4][4]; __m128 row[4]; };(实例总在内存中,所以可以这样用 union)。 MulVecMat_sse:用_mm_shuffle_ps(v, v, 0x00/0x55/0xAA/0xFF)把 vx、vy、vz、vw 分别复制到四条通道,相当于转置 v,然后r = vX*row0 + vY*row1 + vZ*row2 + vW*row3。向量×矩阵本来是 v 与矩阵各列做点积,这里转置 v 后与各行逐分量运算(行列约定见 齐次坐标与矩阵约定)。- 矩阵×矩阵:
R.row[i] = MulVecMat_sse(A.row[i], B),四行各做一次。
广义向量化(卷 I 4.10.6)
- 把 SSE 寄存器只当 4D 齐次向量、只用于 3D 向量数学是很局限的思路。大多数「批处理」运算(对大数据集重复同一计算)都能向量化:SIMD 寄存器的分量就是并行的通道,float 是 1 条通道,SSE 是 4 条,AVX 是 8 条,AVX-512 是 16 条。
- 推荐流程:先写单通道算法(只用 float),能跑通后再改成每次处理 N 个元素。好处是换更宽的硬件时几乎只需改每轮处理的数量。
- 多数优化编译器能自动向量化某些单通道循环——作者为了对比性能,还得费劲阻止编译器向量化他的单通道代码。仍然要看反汇编。
向量谓词化(卷 I 4.10.7)
- 例:对数组求平方根,负数输出 0。直接写
if (_mm_cmpge_ps(va, vz))行不通:比较的是 4 对值,可能部分通过部分不通过,结果不是 bool。(书中脚注:单通道版本里的if也会阻止编译器自动向量化。) - SSE 比较内建函数返回 4 个 32 位掩码:通过为全 1(0xFFFFFFFF),不通过为全 0。于是总是计算
vq = _mm_sqrt_ps(va)(负数通道得到 QNaN),再用mask = _mm_cmpge_ps(va, vz)选择(书中SqrtArray_sse代码误写成_mm_cmpge_ps(va, zero),zero未定义,应为vz):(vq & mask) | (vz & ~mask),即_mm_and_ps、_mm_andnot_ps、_mm_or_ps。这就是向量谓词化,和标量谓词化(见 并发与并行基础 的分支依赖一节)原理相同。 - 封装为 vector select:AltiVec 有
vec_sel();SSE2 没有,SSE4 加入了_mm_blendv_ps()。也可自己实现_mm_select_ps(a, b, mask):(b & mask) | (a & ~mask),或用异或((a ^ b) & mask) ^ a(异或求按位差,连续两次异或同一值还原)。
取舍小结
| 做法 | 结果 |
|---|---|
| 一个寄存器存一个 4D 向量,寄存器内水平求和 | 可能比标量更慢 |
| 转置数据(SoA 式),一次算 4 个结果 | 点积约 3.5 倍、数组相加约 3.8 倍 |
| 循环内的分支 | 改用掩码选择,两边都算 |
| 依赖编译器 | 可能自动向量化,也可能没有,要看反汇编 |
与其他来源的关系
多线程渲染与任务系统(RTR4 第 18 章)提到 SoA 便于 SIMD、但数组变大时缓存未命中增加,混合布局 {float x[4],y[4],z[4];} 可能最好;GEA 这里从指令层面说明了为什么需要「转置」后的布局:避免寄存器内的水平相加。两者结论一致,RTR4 额外提醒了缓存方面的代价。数据布局与缓存的一般讨论见 数据局部性。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.10 节