第2章:数据类型与寄存器——SSE 的三驾马车

做 SSE 优化,第一个门槛就是搞懂那三个看着眼生的类型。__m128、__m128d、__m128i,还有它们背后的 XMM 寄存器。你想想看,寄存器就那么多,怎么把不同类型的数据塞进去?又怎么保证不出错?我当年第一次写 SSE 代码时,就是因为混用了类型,结果跑出来的数据全是乱码,查了两天才发现——嗯,类型不匹配。

2.1 三个核心类型:专治各种数据

SSE 指令集的本质,就是一次性操作 128 位的数据。但 128 位能装的东西太多样了:4 个 float、2 个 double、16 个 byte、8 个 short、4 个 int、2 个 long long……所以 Intel 给了我们三个 C 语言层面的“包装类型”。

类型容纳内容对应寄存器视作常用场景
__m1284 × 单精度浮点 (float)128 位,4 个 32 位 float图形、音频浮点运算
__m128d2 × 双精度浮点 (double)128 位,2 个 64 位 double科学计算、高精度模拟
__m128i整数(任意子宽度的整数)128 位,内部格式由你定整数向量化、编解码、哈希

关键区别:__m128 和 __m128d 参与算术运算时,编译器直接帮你排好顺序;__m128i 是“裸”的 128 位,你得自己告诉指令怎么切分(比如 _mm_add_epi32 认为切成了 4 个 int32)。

我的经验:如果数据全是 float,就老老实实用 __m128。别为了省代码把 float 强转成 __m128i 再做算术——不仅慢,还容易踩不对齐的坑。我曾经在优化一个 3D 变换库时试过,结果每条指令都得加 cast,寄存器分配也乱套了,后来全部改回 __m128,性能反而提升了 15%。

2.2 XMM 寄存器:真家伙长什么样

你写的 __m128 a;,编译器会在背后帮你分配一个 XMM 寄存器(xmm0 ~ xmm15,64 位模式下)。每个 XMM 寄存器物理上就是 128 位,不带类型。类型信息只在 C 语言的编译期有用,运行时它就是一串 0 和 1。

所以使用规范第一条:

  • 不能乱取地址:&__m128 不是返回 XMM 寄存器的地址,而是内存中的临时变量。真正的寄存器没有内存地址。
  • 传递参数要小心:按值传递 __m128 会触发寄存器拷贝(movaps / movdqa),性能损失不大,但引用传递可能导致编译器无法内联。
  • 避免混合类型赋值:__m128 a = __m128i b; 不合法,必须用 _mm_castsi128_ps 系列函数做 bitcast。

千万不要这样做:

__m128i vi = _mm_set1_epi32(42);
__m128  vf = (__m128)vi;   // 直接强转?编译器可能报错或生成错误的mov

正确写法是 vf = _mm_castsi128_ps(vi);,它在寄存器层面什么都不做,只是告诉编译器“把里面的位当作浮点数”。

2.3 数据类型、对齐与内存操作

SSE 对内存对齐非常敏感。默认情况,__m128 对象按 16 字节对齐,如果你用 malloc(只保证 8 字节对齐)然后强转成 __m128*,那就能品尝到 segfault 的滋味了。我有个血泪教训:优化网络包解析时,用 new 分配了一个结构体,里面嵌了 __m128,结果 VS 没给我自动对齐,debug 版跑得好好的,release 版一跑就崩,查了一天发现是 movaps 要求的对齐没能满足。

所以内存操作的规矩就三条:

  1. 尽量用栈上的局部变量(编译器自动对齐)。
  2. 必须用堆时,用 _mm_malloc 或 aligned_alloc,指定 16 字节(或 32 字节,如果你在走 AVX)。
  3. 加载/存储用匹配的 intrinsic:_mm_load_ps 要求地址 16 字节对齐,_mm_loadu_ps 不要求,但慢一点。

速记:对齐 → load / store;不对齐 → loadu / storeu。如果你确保对齐,就用不带 u 的版本,一条指令完成;否则用带 u 的,可能会多几条 uop。我一般在热路径里尽量做对齐,实在不行才用 loadu。

2.4 一张图理顺关系

SSE 数据类型与 XMM 寄存器关系图 SSE 数据类型与 XMM 寄存器关系 原始数据 4×float / 2×double / 整数 __m128 __m128d __m128i XMM0 ~ XMM15(128 位 / 每个) 物理上无类型,运行时全看指令怎么解释 内存 注意:__m128 与 __m128d 不能随意 bitcast;__m128i 需要配合指令指定切分方式

2.5 实战中我怎么选

讲个实际案例。我在做视频编码的色彩空间转换时,数据流是这样的:

  • 像素值用 uint8_t 存储,但运算需要 int16 或 float。
  • 如果做整数变换(比如色度子采样),我用 __m128i 配合 _mm_loadu_si128 加载 16 个 byte,然后 _mm_unpacklo_epi8/_mm_unpackhi_epi8 转成 16 位。
  • 如果做浮点色彩校正,我先用 _mm_cvtepu8_epi32 把字节扩展到 int32,再转成 float,装进 __m128 去算。

你看,__m128i 更像一个“中间载体”,__m128 才是计算主力。双精度场景我用得少,但做物理模拟的同学天天跟它打交道——两倍精度换来一半的向量宽度,取舍你要想清楚。

我最常用的几个 intrinsic(新手必背):_mm_set1_ps / _mm_loadu_ps / _mm_add_ps / _mm_mul_ps / _mm_storeu_ps。先练熟这五个,再深入其他。数据类型搞对了,性能调优就成功了一半。