中EN
概念页

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 官方是无含义的商标缩写)
SSEPentium 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 / AVX22011256 位 YMMi8 个 float
AVX-512部分 Intel CPU512 位 ZMMi16 个 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++11 alignas 保证(见 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 额外提醒了缓存方面的代价。数据布局与缓存的一般讨论见 数据局部性。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.10 节

相关页面

游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏数学数值表示与浮点数Numeric Representation & Floating Point游戏引擎里到处是数值,所以要清楚它们在机器中如何存储:二进制和十六进制、补码整数、定点数、IEEE-754 浮点数(量级与精度此消彼长、次正规数、机器 epsilon、ULP),以及多字节值的大小端问题和安全的类型双关 (type punning) 方法。书中一个直观的例子是:用 32 位浮点数记录以秒为单位的游戏时钟,大约 12.14 天后,加 1/30 秒就不再改变它的值。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发数据局部性Data Locality数据局部性 (Data Locality) 是一种优化思路:CPU 读取内存时会把相邻的一整条缓存行一起载入,所以把数据按处理顺序连续存放,就能大幅减少缓存未命中。具体手段包括:组件按类型存进连续数组,不再到处跟随指针;把活跃对象排在前面;把冷热数据拆开。代价是要放弃一部分继承、接口与指针带来的抽象。游戏数学齐次坐标与矩阵约定Homogeneous Coordinates & Matrix Conventions实时渲染用 4×4 矩阵和四元素齐次坐标统一表示点和方向:方向向量 w=0,点 w=1。这样线性变换与平移可以合成为仿射变换并连乘。RTR4 采用列向量记法、右手观察坐标系;DirectX 采用行向量记法,书中强调这只是记法差异。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。图形学GPGPU 编程入门Introduction to GPGPU ProgrammingGEA 把 GPGPU 讲成「SIMD 向量化推到极致」:GPU 结合 SIMD(向量化 ALU)与 MIMD(一种抢占式多线程),即 NVIDIA 所说的 SIMT。适合 GPU 的任务必须是数据并行的——每个元素的计算不依赖其他元素的结果。写法与 CPU 向量化相反:不把循环硬编码成每次 4/8/16 个,而是保留单通道的循环体,抽成计算核函数 (compute kernel),由 GPU 替我们「跑循环」。一个「GPU 线程」其实就是一条 SIMD 通道。游戏数学点与向量运算Points & Vector Operations游戏程序员最常用的数学是三维向量与矩阵(三维线性代数)。本页按 GEA 第 5 章整理点与向量的基础:坐标系与手性、点与方向的区别、标量乘与 Hadamard 积、加减、模长与归一化、点积与投影、叉积、伪向量与外代数,以及线性插值 (lerp)。贯穿全章的两条经验是:能在二维里想清楚的问题先在二维里想,以及能不开方就不开方。游戏数学几何图元的表示Geometric Primitive Representations除点、向量、矩阵、四元数外,游戏引擎还天天用到直线/射线/线段、球、平面、AABB、OBB、视锥和凸多面体区域。本页按 GEA 整理它们在内存中如何表示以及最基本的「点在内部吗」测试;各种求交与重叠算法见 RTR4 来源的相交测试系列页面。游戏数学伪随机数生成Pseudorandom Number Generation游戏引擎到处要用随机数,但随机数生成器 (RNG) 并不真正随机:它只是给出一个复杂但完全确定、预先定义的序列,因此严格说应叫伪随机数生成器 (PRNG)。好坏取决于周期(多久重复)和能否通过各种公认的随机性测试。GEA 介绍了线性同余、梅森旋转、Marsaglia 的 Mother-of-All / Xorshift / KISS99,以及 PCG。游戏开发自定义内存分配器Custom Memory Allocators内存从两方面影响性能:动态分配(malloc / 全局 new)很慢,以及访问模式主导现代 CPU 的性能。GEA 的经验法则是「尽量少做堆分配,绝不在紧密循环里做堆分配」,并介绍游戏引擎常用的自定义分配器:栈分配器(含双端栈)、池分配器、对齐分配、单帧与双缓冲分配器;以及如何用栈/池避免碎片,或用碎片整理加指针重定位(智能指针、句柄)解决碎片。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)图形学可编程着色器与 GPU 管线Programmable Shaders & GPU PipelineGPU 把第 2 章的概念管线实现为一组硬件阶段,分为完全可编程、可配置但不可编程、完全固定三类。现代 GPU 采用统一着色器架构:各类着色器共用同一套编程模型和核心,由 GPU 自行分配负载。着色器用 HLSL、GLSL 等类 C 语言编写,输入分为 uniform 和 varying 两类。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原