生产线

生产线

学习练习

简介

SIMD

单指令多数据(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 指令

一般来说,处理浮点值的 SIMD 指令采用双操作数形式。它们的名称遵循一定的结构:

  1. 所执行的操作,名称与非 SIMD(也称 标量)版本相同(例如add、mul等)。
  2. 一个后缀p,表示该指令处理 打包 数据,位置与之前的s(表示 标量 数据)相同。
  3. 一个后缀,表示每条通道的大小,语义与非 SIMD 版本相同(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 扩展

几十年来,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 的更新扩展。你可以随意使用它们。

Note

始终使用同一种 SIMD 变体是良好的做法。尽量不要为同一条指令混用 SSE 和 AVX 变体。

如果你的代码库混用了这些变体,良好的做法是在用完 AVX 之后、使用 SSE 之前调用vzeroupper。这样可以打断依赖链,避免 CPU 在 AVX 和 SSE 状态之间切换时出现代价高昂的停顿。

说明

你在为汽车零件工厂车间里的数据系统编写代码。 发动机和底盘部件会经过一系列加工工位。 每个工位都会产生测量数据,这些数据必须在下一阶段开始前进行合并、比较、校准或归一化。 你在这里实现的函数,每个工位对应一个。

Note

本练习中的计算应使用 SIMD 指令完成,而不是标量运算。

1. 合并两条产线的产量

工厂车间里运行着两条并行的装配线。 每个班次结束时,两条产线都会报告 4 个零件箱的产量,数值为单精度浮点数。 主管的仪表盘会显示每个零件箱的合计产量,因此要把两份报告按零件箱逐一相加。 这三个缓冲区都由报表系统管理,并且始终按 16 字节对齐。

定义一个函数sum_yields,它接收line_a和line_b中每个零件箱的产量,把按箱求和的结果写入result。 形参按顺序如下:

  1. line_a:16 字节对齐的内存地址,存放 4 个 32 位浮点数
  2. line_b:16 字节对齐的内存地址,存放 4 个 32 位浮点数
  3. 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};

2. 计算缩放后的偏差

在工序之间,一个采样工位会测量已加工的零件,并把它们与目标尺寸进行比较。 测量值会从精密量具流入一个始终 16 字节对齐的缓冲区。 目标值来自工单包,它不保证对齐。 偏差在传给下游的公差检查器之前,会先乘以每个轴的灵敏度系数。 在这个阶段,下游检查器直接从 SIMD 寄存器读取偏差,而不是从内存中读取。

定义一个函数scaled_deviation,它接收measured、target和sensitivity,把缩放后的偏差写入result。 形参按顺序如下:

  1. measured:16 字节对齐的内存地址,存放 2 个 64 位浮点数
  2. target:内存地址,存放 2 个 64 位浮点数(不保证对齐)
  3. sensitivity:内存地址,存放 2 个 64 位浮点数(不保证对齐)
  4. 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};

3. 校准一批零件

在零件进入最终检验区之前,一批 4 个零件的尺寸读数会被重新缩放到双精度,并针对已知的温度漂移进行修正。

这 4 个原始读数以单精度浮点数的形式传入。 对于每一行 2 个读数,校准过程会计算reference / (raw - offset),然后把结果乘以固定的换算系数0.5。

注意,对于这一批中的两行,reference 和 offset 都相同。 换算系数在每次校准时都相同,所以不用把它作为形参接收,而应该在内存中把它声明为一个打包的 16 字节常量。

定义一个函数calibrate_batch,它接收一批raw、reference、offset和一个result缓冲区,把校准后的值写入result。 形参按顺序如下:

  1. raw:内存地址,存放 4 个 32 位浮点数(不保证对齐)
  2. reference:16 字节对齐的内存地址,存放 2 个 64 位浮点数
  3. offset:16 字节对齐的内存地址,存放 2 个 64 位浮点数
  4. result:16 字节对齐的内存地址,函数会把 4 个 64 位浮点数写入这里(共 32 字节)

对于这一批中的两行,函数应该分别执行以下操作:

  1. 把该行中 2 个单精度原始值转换为双精度。
  2. 从转换后的这对值中减去offset。
  3. 用reference除以得到的结果。
  4. 把每个 64 位浮点数逐通道乘以固定的换算系数0.5。
  5. 把这 2 个 64 位浮点数存入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};

4. 沿产线归一化分数

在产线的末端,每个零件都有一个质量分数,在上报之前需要进行归一化。 每个分数先乘以它自己对应的每个零件的增益,再除以每个轴的生产缩放系数。 增益缓冲区为每个零件保存一个增益。

生产缩放系数对于整轮运行都是相同的,通过一个存放 2 个 64 位浮点数的内存地址传入。 分数是原地更新的:原来存放原始分数的同一个缓冲区,现在存放归一化后的分数。

定义一个函数normalize_scores,它接收scores数组、gains数组、scale和计数n,原地归一化这些分数。 形参按顺序如下:

  1. scores:16 字节对齐的内存地址,存放n个 64 位浮点数(原地修改)
  2. gains:16 字节对齐的内存地址,存放n个 64 位浮点数
  3. scale:16 字节对齐的内存地址,存放 2 个 64 位浮点数
  4. 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};
通过 GitHub 编辑 链接将在新窗口或新标签页中打开
x86-64 Assembly Exercism

准备好开始 生产线 了吗?

注册 Exercism,借助 22 个概念130 个练习 和真人导师指导,学习并掌握 x86-64 Assembly,全部免费。