单指令多数据(SIMD) 是一种硬件层面的并行方式。CPU 会同时对多个元素执行相同的计算。
例如,考虑把两个各含四个 32 位浮点数的数组逐元素相加。标量实现会发出四条独立的addss指令,每个元素一条。SIMD 实现只用一条addps就并行完成全部四次加法。这样每条指令完成的工作量就变成了四倍。
在 x86-64 汇编中,SIMD 指令通常使用xmm寄存器来执行。其中一些指令处理整数,另一些处理浮点数。
这些整数或浮点数被称为打包数据。每个值都占据同一寄存器中的一块空间,称为通道。SIMD 指令通常会并行处理两个源操作数中处于相同通道位置的值。每次运算的结果存储在目标操作数中相同位置的通道里。
通道并不是硬性限制。它是每条指令对一段字节序列给出的解读。一个 128 位操作数包含 16 个字节。根据指令的不同,这些字节可以被解读为 16 个 1 字节 通道、8 个 2 字节 通道、4 个 4 字节 通道、2 个 8 字节 通道,甚至单个 16 字节 通道。
例如,addps指令把打包的单精度(4 字节)浮点值相加。对这条指令而言,操作数中每个 4 字节的数据块就是一条不同的通道。在一个 128 位宽(16 字节)的操作数中:
| 操作数 | 第 4 通道 | 第 3 通道 | 第 2 通道 | 第 1 通道 |
|---|---|---|---|---|
| 源 1 | 1.5 | 2.1 | 6.2 | 3.4 |
| + | + | + | + | + |
| 源 2 | 0.5 | 8.26 | -13.4 | 22.9 |
| = | = | = | = | = |
| 结果 | 2.0 | 10.36 | -7.2 | 26.3 |
而在mulpd的情况下,一个 128 位操作数会被解读为只有 2 条通道。这是因为该指令对双精度(8 字节)值做打包乘法:
| 操作数 | 第 2 通道 | 第 1 通道 |
|---|---|---|
| 源 1 | 4.2 | 2.1 |
| * | * | * |
| 源 2 | 1.0 | 92.4 |
| = | = | = |
| 结果 | 4.2 | 194.04 |
本节我们将重点介绍一小部分 SIMD 指令。它们使用xmm寄存器对打包的浮点值进行运算。
一般来说,处理浮点值的 SIMD 指令采用双操作数形式。它们的名称遵循一定的结构:
add、mul等)。p,表示该指令处理 打包 数据,位置与之前的s(表示 标量 数据)相同。s表示单精度,d表示双精度)。因此,打包名称可以由标量名称得到:把助记符中间的s(标量)替换为p(打包)。操作名和大小后缀保持不变:
| 标量 | 打包 | 含义 |
|---|---|---|
addss |
addps |
加法,单精度(32 位通道) |
addsd |
addpd |
加法,双精度(64 位通道) |
mulss |
mulps |
乘法,单精度 |
mulsd |
mulpd |
乘法,双精度 |
算术 SIMD 指令遵循上面的规则。来看几个例子:
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
还有一些 SIMD 指令,用于在打包的单精度浮点值和打包的双精度浮点值之间转换。它们遵循相同的命名规则,把表示 标量 的s替换为表示 打包 的p:
| 标量 | 打包 | 含义 |
|---|---|---|
cvtss2sd |
cvtps2pd |
把单精度转换为双精度 |
cvtsd2ss |
cvtpd2ps |
把双精度转换为单精度 |
这些指令印证了前面提到的观点:通道是一种解读,而不是硬性限制。源操作数和目标操作数的通道大小不同,因此通道数量也不同。
对于cvtps2pd,源操作数(ps)有 4 条单精度通道。目标操作数(pd)有 2 条双精度通道。只会读取源操作数的低 2 条通道,高 2 条则被忽略:
| 操作数 | 第 4 通道 | 第 3 通道 | 第 2 通道 | 第 1 通道 |
|---|---|---|---|---|
源操作数(ps) |
忽略 | 忽略 | 2.5 | 1.5 |
目标操作数(pd) |
2.5 | 1.5 |
对于cvtpd2ps,源操作数(pd)有 2 条双精度通道。目标操作数(ps)有 4 条单精度通道。源操作数的两条通道都会被转换,并写入目标操作数的低 2 条通道。目标操作数的高 2 条通道则置为零:
| 操作数 | 第 4 通道 | 第 3 通道 | 第 2 通道 | 第 1 通道 |
|---|---|---|---|---|
源操作数(pd) |
4.0 | 3.0 | ||
目标操作数(ps) |
0 | 0 | 4.0 | 3.0 |
来看几个例子:
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
上面这些 SIMD 指令展示的都是寄存器到寄存器的操作数。它们也可以把内存操作数作为源操作数(但不能作为目标操作数)。
从内存读取的字节数由指令决定。例如,addps读取 16 个字节(4 个单精度浮点数)。相比之下,cvtps2pd只读取 8 个字节(2 个单精度浮点数),因为它需要的输入就这么多。
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
当内存操作数宽 16 字节时,它必须_按 16 字节对齐_。所谓对齐的内存位置,是指地址为该操作大小(这里是 16 字节)整数倍的位置。
要声明对齐的内存,可以在 section .data 和 .rodata 上使用align指示符,或在 section .bss 上使用alignb:
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
小于 16 字节的内存操作数,例如cvtps2pd的 8 字节源操作数,没有对齐要求。
有四条指令用于复制 128 位的打包浮点值。它们在两个xmm寄存器之间操作,或者在一个xmm寄存器和一个内存位置之间操作:
| 指令 | 说明 |
|---|---|
movaps |
把打包的单精度数据复制到_对齐_的内存位置,或从那里复制出来 |
movups |
把打包的单精度数据复制到_未对齐_的内存位置,或从那里复制出来 |
movapd |
把打包的双精度数据复制到_对齐_的内存位置,或从那里复制出来 |
movupd |
把打包的双精度数据复制到_未对齐_的内存位置,或从那里复制出来 |
这些指令都可以用来在两个xmm寄存器之间复制数值。
如果操作涉及内存位置,movaps和movapd要求它必须是_对齐_的。如果给出的地址未对齐,movaps和movapd会触发故障。它们同时也起到安全检查的作用,确保你的数据布局和你想的一致。
而movups和movupd则可以用于任何内存位置,无论是否对齐。
在 NASM 中,16 字节值的大小前缀是oword。
几十年来,SIMD 一直是 x86-64 生态中活跃开发的领域。架构的每一次扩展都会带来新指令,或者让旧指令有新用法。
具体来说,AVX 和 AVX2 扩展带来了一些相关的变化。
第一,支持名为ymm的 256 位 SIMD 寄存器。它们扩展了对应的xmm寄存器,就像rax包含eax那样。
第二,对于大多数操作xmm(以及ymm)寄存器的指令,它们支持三操作数变体。这不仅包括 SIMD 指令,也包括处理标量浮点值的指令。这些变体的记法与对应的 SSE 版本相同,只是在前面加上一个v:
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
第三,对 SIMD 内存操作数的对齐要求放宽了。只有显式对齐的移动指令,例如vmovaps,才要求这种对齐。
在本课程中,我们只限定使用到此前 SSE 扩展为止的指令和寄存器。这足以讲解基础知识,并确保与众多 CPU 兼容。
不过,测试运行器完全支持 AVX 和 AVX2,甚至支持属于 AVX-512 的更新扩展。你可以随意使用它们。
始终使用同一种 SIMD 变体是良好的做法。尽量不要为同一条指令混用 SSE 和 AVX 变体。
如果你的代码库混用了这些变体,良好的做法是在用完 AVX 之后、使用 SSE 之前调用vzeroupper。这样可以打断依赖链,避免 CPU 在 AVX 和 SSE 状态之间切换时出现代价高昂的停顿。
你在为汽车零件工厂车间里的数据系统编写代码。 发动机和底盘部件会经过一系列加工工位。 每个工位都会产生测量数据,这些数据必须在下一阶段开始前进行合并、比较、校准或归一化。 你在这里实现的函数,每个工位对应一个。
本练习中的计算应使用 SIMD 指令完成,而不是标量运算。
工厂车间里运行着两条并行的装配线。 每个班次结束时,两条产线都会报告 4 个零件箱的产量,数值为单精度浮点数。 主管的仪表盘会显示每个零件箱的合计产量,因此要把两份报告按零件箱逐一相加。 这三个缓冲区都由报表系统管理,并且始终按 16 字节对齐。
定义一个函数sum_yields,它接收line_a和line_b中每个零件箱的产量,把按箱求和的结果写入result。
形参按顺序如下:
line_a:16 字节对齐的内存地址,存放 4 个 32 位浮点数line_b:16 字节对齐的内存地址,存放 4 个 32 位浮点数result:16 字节对齐的内存地址,函数会把 4 个 32 位浮点数写入这里这个函数没有返回值,结果存放在传入的缓冲区(result)中:
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
在工序之间,一个采样工位会测量已加工的零件,并把它们与目标尺寸进行比较。 测量值会从精密量具流入一个始终 16 字节对齐的缓冲区。 目标值来自工单包,它不保证对齐。 偏差在传给下游的公差检查器之前,会先乘以每个轴的灵敏度系数。 在这个阶段,下游检查器直接从 SIMD 寄存器读取偏差,而不是从内存中读取。
定义一个函数scaled_deviation,它接收measured、target和sensitivity,把缩放后的偏差写入result。
形参按顺序如下:
measured:16 字节对齐的内存地址,存放 2 个 64 位浮点数target:内存地址,存放 2 个 64 位浮点数(不保证对齐)sensitivity:内存地址,存放 2 个 64 位浮点数(不保证对齐)result:缓冲区的内存地址,函数会把 2 个 64 位浮点数写入其中(不保证对齐)缩放后的偏差为(measured - target) * sensitivity,逐通道计算。
这个函数没有返回值,结果以一对 64 位浮点数的形式存放在传入的缓冲区(result)中。
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
在零件进入最终检验区之前,一批 4 个零件的尺寸读数会被重新缩放到双精度,并针对已知的温度漂移进行修正。
这 4 个原始读数以单精度浮点数的形式传入。
对于每一行 2 个读数,校准过程会计算reference / (raw - offset),然后把结果乘以固定的换算系数0.5。
注意,对于这一批中的两行,reference 和 offset 都相同。 换算系数在每次校准时都相同,所以不用把它作为形参接收,而应该在内存中把它声明为一个打包的 16 字节常量。
定义一个函数calibrate_batch,它接收一批raw、reference、offset和一个result缓冲区,把校准后的值写入result。
形参按顺序如下:
raw:内存地址,存放 4 个 32 位浮点数(不保证对齐)reference:16 字节对齐的内存地址,存放 2 个 64 位浮点数offset:16 字节对齐的内存地址,存放 2 个 64 位浮点数result:16 字节对齐的内存地址,函数会把 4 个 64 位浮点数写入这里(共 32 字节)对于这一批中的两行,函数应该分别执行以下操作:
offset。reference除以得到的结果。0.5。result对应的 16 字节中。这个函数没有返回值,结果存放在传入的缓冲区(result)中:
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
在产线的末端,每个零件都有一个质量分数,在上报之前需要进行归一化。 每个分数先乘以它自己对应的每个零件的增益,再除以每个轴的生产缩放系数。 增益缓冲区为每个零件保存一个增益。
生产缩放系数对于整轮运行都是相同的,通过一个存放 2 个 64 位浮点数的内存地址传入。 分数是原地更新的:原来存放原始分数的同一个缓冲区,现在存放归一化后的分数。
定义一个函数normalize_scores,它接收scores数组、gains数组、scale和计数n,原地归一化这些分数。
形参按顺序如下:
scores:16 字节对齐的内存地址,存放n个 64 位浮点数(原地修改)gains:16 字节对齐的内存地址,存放n个 64 位浮点数scale:16 字节对齐的内存地址,存放 2 个 64 位浮点数n:一个 64 位正偶数(最小为2),需要归一化的分数个数缩放系数的两个通道分别作用于每一对中的两个元素。
第一个通道缩放每一对中的第一个元素(偶数位置上的通道),第二个通道缩放第二个元素(奇数位置上的通道)。
每个分数先乘以对应的增益,再除以相应的缩放通道:score * gain / scale_lane。
这个函数没有返回值,结果存放在输入缓冲区(scores)中,位置与被归一化的那个score相同:
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};