Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

SIMD

在追求极致性能的道路上,多线程利用了“更多”的核心,而 SIMD (Single Instruction, Multiple Data) 技术则致力于榨干“每个”核心的计算潜力。SIMD 是一种并行计算的形式,但它发生在比多线程更低的硬件层面上。现代 CPU 的寄存器(CPU内部最快的数据存储单元)通常是128位、256位甚至512位宽,而一个标准的单精度浮点数(float)只有32位。这意味着一个128位的寄存器可以同时容纳4个浮点数。SIMD 指令集(如 Intel 的 SSE/AVX 或 ARM 的 NEON)允许我们用一条指令,对这4个浮点数同时执行相同的操作(如加法、乘法)。在物理引擎中,充斥着大量对3D向量和4x4矩阵的运算,这使其成为应用 SIMD 优化的完美场景。通过将数据打包并使用 SIMD 指令,我们可以获得数倍的性能提升。

1. SIMD 的核心概念

想象一下,你需要计算两个3D向量的和:c = a + b

传统(标量)做法:

struct Vector3 { float x, y, z; };
Vector3 a, b, c;
// ...
c.x = a.x + b.x; // 1次加法
c.y = a.y + b.y; // 1次加法
c.z = a.z + b.z; // 1次加法
// 总共需要 3 条独立的加法指令

SIMD 做法: 假设我们使用128位的 SIMD 寄存器(可以容纳4个 float)。

  1. 数据加载 (Load): 将向量 ab 的分量加载到 SIMD 寄存器中。由于向量只有3个分量,我们通常会将第4个分量(w)置为0。 __m128 reg_a = _mm_set_ps(0.0f, a.z, a.y, a.x); __m128 reg_b = _mm_set_ps(0.0f, b.z, b.y, b.x);

  2. SIMD 加法 (Add): 使用一条 SIMD 加法指令,对两个寄存器中的4对浮点数同时进行加法运算。 __m128 reg_c = _mm_add_ps(reg_a, reg_b);

  3. 数据存储 (Store): 将结果从 SIMD 寄存器中存回内存。 _mm_store_ps(&c.x, reg_c);

在这个例子中,我们用一条 _mm_add_ps 指令完成了原本需要3条指令的工作。虽然有数据加载和存储的开销,但在大量的连续计算中,这种收益会累积起来,带来巨大的性能提升。

2. 数据布局的重要性:AoS vs. SoA

SIMD 的效率高度依赖于数据在内存中的布局方式。考虑一个物理引擎,我们需要对N个刚体的状态进行积分。

AoS (Array of Structs) - 传统面向对象布局:

struct RigidBody {
    Vector3 position;
    Vector3 velocity;
};
RigidBody bodies[N];

在这种布局下,bodies[0].positionbodies[1].position 在内存中是不连续的,它们之间隔着 velocity 等其他数据。如果要对4个不同刚体的位置进行 SIMD 操作,CPU 需要从4个不连续的内存地址加载数据,这个过程被称为收集 (Gather),效率很低。

SoA (Struct of Arrays) - 数据导向布局:

struct RigidBodies {
    Vector3 positions[N];
    Vector3 velocities[N];
};

在这种布局下,所有刚体的位置都连续地存储在一起。当我们要对4个刚体的位置进行 SIMD 操作时,我们可以直接从内存中加载一个连续的块,这个块正好包含 positions[0], positions[1], positions[2], positions[3]x, y, z 分量。这种连续的内存访问模式对 CPU 的缓存预取器极为友好,并且使得 SIMD 的数据加载操作效率极高。

结论: SoA 布局是释放 SIMD 全部潜力的关键。它将需要一起处理的数据在内存中排列在一起,完美地匹配了 SIMD 的工作方式。

3. 在 C++ 中使用 SIMD:内在函数 (Intrinsics)

直接编写汇编语言来使用 SIMD 是非常困难且不可移植的。因此,编译器提供商(如 Intel, ARM)提供了一种更高层次的抽象:内在函数 (Intrinsics)

  • 什么是内在函数? 它们看起来像普通的 C++ 函数,但编译器会将它们直接翻译成一条或几条特定的 SIMD 汇编指令。
  • 示例 (SSE):
    • __m128: 一种数据类型,代表一个128位的 SIMD 寄存器。
    • _mm_load_ps(float* p): 从地址 p 加载4个 float 到一个 __m128 寄存器中(地址必须16字节对齐)。
    • _mm_add_ps(__m128 a, __m128 b): 将两个寄存器中的4对 float 分别相加。
    • _mm_mul_ps(__m128 a, __m128 b): 乘法。
    • _mm_sub_ps(__m128 a, __m128 b): 减法。
    • _mm_store_ps(float* p, __m128 a): 将寄存器 a 中的4个 float 存回地址 p

通过使用内在函数,开发者可以在 C++ 代码中直接利用 SIMD 的强大能力,同时将平台相关的汇编指令细节交给编译器处理。

4. 物理引擎中的应用场景

  • 向量/矩阵运算: 几乎所有的3D向量和4x4矩阵运算(加、减、点积、叉积、矩阵-向量乘法、矩阵-矩阵乘法)都可以被 SIMD 大幅加速。
  • 积分: 在积分阶段,更新N个刚体的位置和速度 (pos += vel * dt) 是一个完美的数据并行 SIMD 任务。
  • AABB 计算: 在宽相中,计算变换后模型的轴对齐包围盒 (AABB) 也涉及到大量的向量和矩阵运算。
  • 约束求解: 在求解器中,计算雅可比矩阵、应用冲量等步骤也包含大量的向量运算,可以从 SIMD 中受益。