调音台

调音台

学习练习

简介

SIMD:整数

之前的概念介绍过打包浮点值:多个数字存放在同一个 xmm 寄存器中,按通道并行运算。 同样的 xmm 寄存器也可以存放打包的_整数_。

语法

SIMD 浮点模型的大部分内容原封不动地适用于打包整数:

  • 128 位寄存器被划分为若干通道
  • 指令会并行地作用于处于相同位置的各个通道
  • 内存操作数遵循相同的 16 字节对齐规则

不过,语法稍有不同:

  1. 一个_前缀_ p,用来表示该指令操作的是_打包_数据。
  2. 所执行的操作,名称与其非 SIMD(也叫_标量_)版本相同(例如 add、mul 等)。
  3. 一个后缀,用来表示每个通道的大小。

打包整数有四种通道宽度,每种都有自己的后缀:

通道宽度 字节数 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 位,不对数据作任何解释。 以整数命名的那一对按惯例用于整数数据,但这并不是硬性要求。

Note

这里的 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。

当通道存放的是有界量时,饱和就很重要,例如像素通道或音频采样。 回绕会把过亮的像素变成暗色,而钳制会让它保持在最大亮度,这才是你想要的结果。

Note

饱和加法与减法只针对字节和字通道提供,双字和四字通道没有。

乘法

两个 N 位值相乘可能得到 2N 位的积,但目标通道只有 N 位宽。 SIMD 运算通过指定保留积的哪一半来解决这个问题:要么保留低 N 位,要么保留高 N 位。

对于 16 位通道,会用到三条指令:

指令 含义
pmullw mul,16 位通道,保留每个积的低 16 位
pmulhw mul,16 位通道,保留高 16 位,操作数为有符号
pmulhuw mul,16 位通道,保留高 16 位,操作数为无符号

无论操作数按有符号还是无符号读取,积的低 16 位都相同,所以只有一条 pmullw。

但高 16 位会随结果的有符号性而变化。 因此高半部分乘法分别提供了有符号和无符号两种形式:

  1. pmulhw,用于有符号乘法。
  2. pmulhuw,多了一个 u,用于无符号乘法。

注意语法:

  1. 一个 p,表示打包整数。
  2. 所执行的操作 mul。
  3. 一个 h,表示选取结果中“较高”的那部分位。
  4. 一个可选的 u,表示结果应按无符号解释(也就是说,没有进行符号扩展)。
  5. 最后是大小后缀 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
Caution

用 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

说明

你正在为录音棚里的一台小型调音台编写数字核心部分。

声音以一连串采样的形式传入。 每个采样都是一个数字,描述某个瞬间扬声器纸盆的位置。 调音台会一次处理整块采样,对每个采样应用相同的运算。

你有五个任务,每个任务都对内存中的一块采样进行运算。 你通过内存地址接收操作数,并通过结果地址写出答案。

Note

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

1. 混合两条音轨

录音棚有时会把不同乐器录在不同的音轨上。 为了让它们通过同一组扬声器播放,你要把两条音轨相加为一条。

实现 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}

这个函数没有返回值。

2. 消除串音

当两支麦克风同时录音时,每一支都会拾取到另一支所录声源的微弱副本。 这种不需要的副本叫做_串音_。 一旦测出这种串音,就必须把它减掉,以清理音轨。

实现 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}

这个函数没有返回值。

3. 合并电平表

每个声道的上方都有一个电平表,一个小条,其亮度表示该声道有多响。 前面板上有一个主电平表,它的亮度应当相当于两个声道合并起来的亮度。 你把两个电平表块相加来驱动它。

注意,明亮的电平表会保持最亮,而不会回绕变暗,所以求和是饱和的。

实现 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}

这个函数没有返回值。

4. 应用淡出

歌曲不应该戛然而止,而应在结尾处淡出。 你有一块_增益系数_,每个采样对应一个,它们逐步递减,越来越轻。

每个系数都是一个以 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}
Note

保留高半部分会向负无穷取整。 这就是为什么 20000 * 32767 缩放后是 9999,而 -20000 * 32767 缩放后是 -10000 而不是 -9999。

这个函数没有返回值。

5. 衰减音轨

鼓手录得声音太大了。 为了把整条音轨的音量降下来,你要把每个采样都除以一个常数。

实现 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}

这个函数没有返回值。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
x86-64 Assembly Exercism

准备好开始 调音台 了吗?

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