之前的概念介绍过打包浮点值:多个数字存放在同一个 xmm 寄存器中,按通道并行运算。
同样的 xmm 寄存器也可以存放打包的_整数_。
SIMD 浮点模型的大部分内容原封不动地适用于打包整数:
不过,语法稍有不同:
p,用来表示该指令操作的是_打包_数据。add、mul 等)。打包整数有四种通道宽度,每种都有自己的后缀:
| 通道宽度 | 字节数 | 128 位中的通道数 | 后缀 |
|---|---|---|---|
| 字节 | 1 | 16 | b |
| 字 | 2 | 8 | w |
| 双字 | 4 | 4 | d |
| 四字 | 8 | 2 | q |
例如:
| 指令 | 含义 |
|---|---|
paddb |
打包 add,8 位通道(16 个通道) |
paddw |
打包 add,16 位通道(8 个通道) |
paddd |
打包 add,32 位通道(4 个通道) |
paddq |
打包 add,64 位通道(2 个通道) |
有些指令输入的通道是一种大小,输出的却是另一种大小。 它们遵循同样的总体约定,但带有_两个_大小后缀。 第一个表示输入通道的大小,第二个表示输出通道的大小:
| 指令 | 含义 |
|---|---|
pmovsxwd |
打包 movsx,从 16 位通道到 32 位通道 |
pmuldq |
打包 mul,从 32 位通道到 64 位通道 |
有两条以整数命名的指令在 xmm 寄存器与内存之间复制 128 位数据:
| 指令 | 说明 |
|---|---|
movdqa |
将打包整数复制到_已对齐_的位置,或从该位置复制 |
movdqu |
将打包整数复制到_未对齐_的位置,或从该位置复制 |
它们的行为与 movaps 和 movups 类似:movdqa 在地址未对齐时会触发错误,而 movdqu 则接受任意地址。
这四条指令都原样复制 128 位,不对数据作任何解释。
以整数命名的那一对按惯例用于整数数据,但这并不是硬性要求。
这里的 dq 代表 double-qword,也就是 128 位(16 字节)。
加法和减法遵循上述命名规则:
paddb xmm0, xmm1 ; 16 lanes: each 8-bit, xmm0 += xmm1
paddw xmm2, xmm3 ; 8 lanes: each 16-bit, xmm2 += xmm3
psubd xmm4, xmm5 ; 4 lanes: each 32-bit, xmm4 -= xmm5
psubq xmm6, xmm7 ; 2 lanes: each 64-bit, xmm6 -= xmm7
没有单独的有符号和无符号版本。
在补码表示中,无论通道按有符号还是无符号读取,加法和减法产生的位都相同,所以一条指令就能同时满足两种用途。
如何解释由你决定,和标量的 add 与 sub 完全一样。
这些指令在溢出时会回绕,和它们的标量版本一样。
8 位通道存放的值以 256 取模,所以 paddb 计算 200 + 100 会得到 300 - 256 = 44,放不下的位会被丢弃。
注意,这些多出来的位不会进位到下一个通道。 每个通道都与其他通道分开运算,尽管它们共用同一个寄存器。
整数 SIMD 增加了一种浮点 SIMD 没有的运算:饱和加法与减法,它们会_钳制_结果而不是回绕。 结果高于通道范围时取该通道能存放的最大值;低于范围时取最小值。
饱和形式会在大小后缀之前插入 s(有符号)或 us(无符号):
| 指令 | 含义 |
|---|---|
paddsb |
add,饱和,有符号 8 位通道 |
paddusb |
add,饱和,无符号 8 位通道 |
psubsw |
sub,饱和,有符号 16 位通道 |
psubusw |
sub,饱和,无符号 16 位通道 |
钳制范围是相应大小和有符号性的整数所能表示的全部范围。 对于字节:
[0, 255]:paddusb 计算 200 + 100 得到 255,psubusb 计算 5 - 10 得到 0。[-128, 127]:paddsb 计算 100 + 50 得到 127。当通道存放的是有界量时,饱和就很重要,例如像素通道或音频采样。 回绕会把过亮的像素变成暗色,而钳制会让它保持在最大亮度,这才是你想要的结果。
饱和加法与减法只针对字节和字通道提供,双字和四字通道没有。
两个 N 位值相乘可能得到 2N 位的积,但目标通道只有 N 位宽。 SIMD 运算通过指定保留积的哪一半来解决这个问题:要么保留低 N 位,要么保留高 N 位。
对于 16 位通道,会用到三条指令:
| 指令 | 含义 |
|---|---|
pmullw |
mul,16 位通道,保留每个积的低 16 位 |
pmulhw |
mul,16 位通道,保留高 16 位,操作数为有符号 |
pmulhuw |
mul,16 位通道,保留高 16 位,操作数为无符号 |
无论操作数按有符号还是无符号读取,积的低 16 位都相同,所以只有一条 pmullw。
但高 16 位会随结果的有符号性而变化。 因此高半部分乘法分别提供了有符号和无符号两种形式:
pmulhw,用于有符号乘法。pmulhuw,多了一个 u,用于无符号乘法。注意语法:
p,表示打包整数。mul。h,表示选取结果中“较高”的那部分位。u,表示结果应按无符号解释(也就是说,没有进行符号扩展)。w,表示这是一次字(16 位)运算。字的打包乘法完全遵循上面的规则。
双字(32 位)乘法也遵循该规则,但只有选取低半部分的变体:pmulld。
双字乘法没有选取高位的变体。 不过,有一些变体会_拓宽_乘法,存放_偶数索引_通道(也就是位置 0 和 2 的通道)完整的 64 位积:
| 指令 | 含义 |
|---|---|
pmuludq |
将偶数索引的 32 位元素按无符号相乘,得到 2 个完整的 64 位积 |
pmuldq |
将偶数索引的 32 位元素按有符号相乘,得到 2 个完整的 64 位积 |
注意语法中使用了 dq,无符号乘法时它前面可能还有一个 u。
这是因为这些指令接收 dword 通道,输出 qword 通道。
没有打包整数除法。 需要它的代码会先把值转换成浮点数,相除,再转换回来。
movsx 和 movzx 都有对应的打包版本。
它们遵循我们前面提到的语法,适用于输入通道大小与输出不同的指令:
pmovsxwd xmm0, xmm1 ; 4 words -> 4 dwords, sign-extended
pmovzxbw xmm0, xmm1 ; 8 bytes -> 8 words, zero-extended
注意,通道数量由较大的一方(输出)宽度决定。
指令会从源数据的低位部分读取这么多个通道。
这与我们前面见过的单精度和双精度浮点数之间的打包 cvt 指令语义相同。
有一些指令可以在 32 位有符号整数与浮点通道之间转换。
它们遵循 cvt 和 cvtt 指令的常见语法,只是用 dq 而不是 si 来表示打包整数:
cvtdq2ps xmm0, xmm1 ; convert 32-bit signed integers in xmm1 to 32-bit floats in xmm0
cvtdq2pd xmm2, xmm3 ; convert 32-bit signed integers in xmm3 to 64-bit floats in xmm2
cvtps2dq xmm4, xmm5 ; convert 32-bit floats in xmm5 to 32-bit signed integers in xmm4
用 pi 表示打包整数的指令会写入旧式的 mmx 寄存器,在 x86-64 上实际上已被弃用。
请优先使用 dq 形式,它们使用 xmm 寄存器。
前面关于浮点数与整数之间标量转换的说明在这里同样适用。
浮点数会按照一个名为 MXCSR 的特殊寄存器进行舍入,函数入口处不能假定它的模式。
还有一个 cvtt 变体(多了一个 t),它总是截断结果。
也可以在转换之前用 round 让打包浮点值处于已知状态。
舍入控制值与标量 round 相同,打包浮点值的语法也一样:
roundps xmm0, xmm1, 1 ; xmm0 = floor(xmm1), packed 32-bit floats
roundpd xmm2, xmm3, 2 ; xmm2 = ceil(xmm3), packed 64-bit floats
你正在为录音棚里的一台小型调音台编写数字核心部分。
声音以一连串采样的形式传入。 每个采样都是一个数字,描述某个瞬间扬声器纸盆的位置。 调音台会一次处理整块采样,对每个采样应用相同的运算。
你有五个任务,每个任务都对内存中的一块采样进行运算。 你通过内存地址接收操作数,并通过结果地址写出答案。
这个练习中的计算应当使用 SIMD 指令完成,而不是标量运算。
录音棚有时会把不同乐器录在不同的音轨上。 为了让它们通过同一组扬声器播放,你要把两条音轨相加为一条。
实现 mix_tracks 函数,它逐采样地把两条音轨相加。
每条音轨都是一块 8 个有符号 16 位采样。
超出有符号 16 位范围的求和结果必须钳位,而不是回绕。
这个函数按以下顺序接收实参:
result:一段 16 字节对齐的内存地址,指向写入 8 个混合后采样的缓冲区。track_a:第一条音轨的 16 字节对齐内存地址,包含 8 个有符号 16 位采样。track_b:第二条音轨的 16 字节对齐内存地址,包含 8 个有符号 16 位采样。track_a = {10000, 30000, 20000, -30000, -20000, -100, 100, 0}
track_b = { 5000, 5000, 20000, -5000, -20000, 100, -100, 0}
result = {15000, 32767, 32767, -32768, -32768, 0, 0, 0}
这个函数没有返回值。
当两支麦克风同时录音时,每一支都会拾取到另一支所录声源的微弱副本。 这种不需要的副本叫做_串音_。 一旦测出这种串音,就必须把它减掉,以清理音轨。
实现 remove_bleed 函数,它逐采样地从音轨块中减去串音块。
每个块都是 8 个有符号 16 位采样,减法必须饱和。
这个函数按以下顺序接收实参:
result:写入 8 个清理后采样的内存地址(不保证对齐)。track:所录音轨的 16 字节对齐内存地址,8 个有符号 16 位采样。bleed:要消除的串音的内存地址,8 个有符号 16 位采样(不保证对齐)。track = {10000, -30000, 5000, 32767, 0, 200, -200, 15000}
bleed = { 2000, 5000, 5000, -5, 0, 300, 300, 5000}
result = { 8000, -32768, 0, 32767, 0, -100, -500, 10000}
这个函数没有返回值。
每个声道的上方都有一个电平表,一个小条,其亮度表示该声道有多响。 前面板上有一个主电平表,它的亮度应当相当于两个声道合并起来的亮度。 你把两个电平表块相加来驱动它。
注意,明亮的电平表会保持最亮,而不会回绕变暗,所以求和是饱和的。
实现 combine_meters 函数,它逐采样地把两个电平表块相加。
这个函数按以下顺序接收实参:
result:写入 16 个合并后数值的 16 字节对齐内存地址。meter_a:第一个声道的电平表块的 16 字节对齐内存地址,包含 16 个打包的无符号 8 位值。meter_b:第二个声道的电平表块的 16 字节对齐内存地址,包含 16 个打包的无符号 8 位值。每个块都是 16 个无符号 8 位值。 它们应当相加,并饱和到无符号范围。
meter_a = {200, 100, 50, 255, 0, 128, 10, 240, 1, 2, 3, 4, 5, 6, 7, 8}
meter_b = {100, 100, 200, 1, 0, 128, 10, 20, 1, 2, 3, 4, 5, 6, 7, 8}
result = {255, 200, 250, 255, 0, 255, 20, 255, 2, 4, 6, 8, 10, 12, 14, 16}
这个函数没有返回值。
歌曲不应该戛然而止,而应在结尾处淡出。 你有一块_增益系数_,每个采样对应一个,它们逐步递减,越来越轻。
每个系数都是一个以 65536 为分母的分数。
例如,如果一个采样的值是 16384,它实际表示 16384 / 65536,也就是 1 / 4。
可用的最大值 32767 略小于二分之一。
由于 65536 是 2^16,把采样乘以它的系数并保留乘积的_高半部分_,就得到 sample * gain / 65536。
实现 apply_fade 函数,它按系数缩放每个采样。
这个函数按以下顺序接收实参:
result:写入所有缩放后采样的 16 字节对齐内存地址。track:音轨的 16 字节对齐内存地址,包含 n 个有符号 16 位采样。gains:增益系数的 16 字节对齐内存地址,包含 n 个有符号 16 位值,每个都在 0 到 32767 范围内。n:采样的数量,始终非零且是 8 的倍数,为 64 位无符号整数。注意,音轨是一个数组,存放 n 个有符号 16 位采样,其中 n 永不为零,且始终是 8 的倍数。
对每个采样来说,缩放后的结果是该有符号乘积的高 16 位:sample * gain。
track = {20000, 20000, 20000, 20000, -20000, -20000, -20000, -20000}
gains = {32767, 24576, 16384, 8192, 32767, 24576, 16384, 8192}
result = { 9999, 7500, 5000, 2500, -10000, -7500, -5000, -2500}
保留高半部分会向负无穷取整。
这就是为什么 20000 * 32767 缩放后是 9999,而 -20000 * 32767 缩放后是 -10000 而不是 -9999。
这个函数没有返回值。
鼓手录得声音太大了。 为了把整条音轨的音量降下来,你要把每个采样都除以一个常数。
实现 attenuate_track 函数。
这个函数按以下顺序接收实参:
result:写入结果的 16 字节对齐内存地址,结果为 32 位有符号整数。samples:采样的 16 字节对齐内存地址,n 个有符号 16 位值。divisor:除数的 4 个相同副本的 16 字节对齐内存地址,为 32 位整数。n 是采样的数量,始终非零且是 4 的倍数,为 64 位无符号整数。注意,samples 是一个数组,存放 n 个有符号 16 位采样,其中 n 永不为零,且始终是 4 的倍数。
除数以同一个 32 位整数的 4 个副本提供,已经复制好,以便一次填满全部四个通道。
整数 SIMD 没有打包除法,所以这项工作用打包浮点数完成,然后再转换回打包的 32 位整数,存入 result。
转换回来时向零截断,所以商 2.3 变成 2,-2.3 变成 -2。
samples = {300, 150, 90, -120, 100, 50, -50, 7}
divisor = {3, 3, 3, 3}
result = {100, 50, 30, -40, 33, 16, -16, 2}
这个函数没有返回值。