到目前为止,每一种 SIMD 操作都是 逐通道 进行的。
结果中通道i的值,由输入的通道i计算得出。
不过,在很多情况下,我们需要或希望在通道之间移动值。 例如,对于我们需要执行的计算来说,通道的顺序可能是乱的。
有许多 SIMD 指令能以不同的方式跨通道移动数据。
混洗会重新排列寄存器中字节或通道的顺序,每个目标通道都从可能位于任意位置的源通道取值。 它们可以把同一个通道选到不同的目标位置,因此也能用来广播值。
混洗指令的行为会因操作数大小和执行域的不同而不同。
指令pshufd会把源操作数中的四个 32 位通道重新排列到目标操作数中。
选择信息是一个 8 位立即数,被读作四个 2 位字段,每个字段对应一个目标通道。 每个字段选择把四个源通道中的哪一个复制到对应的目标通道:
| 字段 | 通道索引 |
|---|---|
00 |
0 |
01 |
1 |
10 |
2 |
11 |
3 |
字段在立即数中的位置,从右往左读,表示所选通道插入到哪个位置。 同一个源通道可以被选择多次,这样就能把一个通道广播到整个寄存器:
; The bit fields are read right-to-left
pshufd xmm1, xmm0, 0b00_01_10_11 ; reverse: lane 0 takes source 0b11 (3), lane 1 takes source 0b10 (2), and so on
pshufd xmm3, xmm2, 0b00_00_00_00 ; broadcast source lane 0 into all four lanes
有两条浮点混洗指令,它们遵循同样的大致语法:shuf + p + 大小后缀(s或d)。
它们同样使用立即数来选择通道。
shufps使用与前面整数混洗相同的 2 位字段编码。
不过,由于 128 位操作数中只有 2 个 64 位通道,shufpd只使用 1 位字段编码。
这些指令与对应的整数指令不同:它们从 两 个操作数而不是一个操作数中取通道:
shufps xmm0, xmm1, 0b11_10_01_00 ; xmm0 = {xmm0[0], xmm0[1], xmm1[2], xmm1[3]}
shufps xmm2, xmm3, 0b00_00_00_00 ; xmm2 = {xmm2[0], xmm2[0], xmm3[0], xmm3[0]}
shufpd xmm4, xmm5, 0b0_1 ; xmm4 = {xmm4[1], xmm5[0]}
由于两个操作数都会为结果提供数据,因此可以用它们一步交错两个向量。 如果源操作数和目标操作数是同一个寄存器,那么每个通道都取自它。
pshufb是最通用的混洗。
尽管pshufb和pshufd的名字非常相似,只差一个大小后缀,但它们执行的操作却大不相同。
首先,pshufd使用立即数来选择通道位置,而pshufb使用源操作数中的控制向量。
这个控制向量是一个xmm寄存器或一个 16 字节的内存操作数。
对于 16 个目标通道中的每一个,控制向量的低四位给出一个 0 到 15 的源字节索引。
如果控制向量的通道i中存放着源字节索引j,那么目标的第j-th个通道会被移动到第i个通道。
这凸显了这两条指令之间的第二个区别。
pshufd从另一个源操作数中取参与混洗的通道,而pshufb则在目标操作数中原地完成混洗。
section .rodata
align 16
reverse: db 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
section .text
fn:
pshufb xmm0, [rel reverse]
; xmm0[0] = xmm0[reverse[0]] (xmm0[15])
; xmm0[1] = xmm0[reverse[1]] (xmm0[14])
; ...
; in the end, the bytes in xmm0 are reversed
pshufb比这还要灵活:它可以清除任意通道。
如果控制向量的通道i的最高位被置 1,那么目标的通道i-th会被置零。
这使得pshufb在一条指令中既能完成任意字节置换,又能进行选择性清零。
由于pshufb以字节为粒度操作,它还可以通过把相邻通道分组,来混洗不同大小的通道。
例如,它可以用来混洗双字:
section .rodata
align 16
input: dd 13, 25, 37, 49 ; each number takes 4 bytes
reverse_dwords: db 12, 13, 14, 15, 8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3 ; aligned too, since input spans 16 bytes
section .text
fn:
movdqa xmm0, [rel input] ; xmm0 = {13, 25, 37, 49}
pshufb xmm0, [rel reverse_dwords]
; xmm0 = {49, 37, 25, 13}
解包指令从两个操作数中取通道并将它们交织在一起,在两者之间交替。 它们有整数和浮点两种变体,大体遵循相同的语法结构,但有两个区别:
l或h后缀,用来表示它作用于每个操作数的低半部分(l)还是高半部分(h)。bw或qdq(16 字节的大小后缀是dq,正如movdqu中的那样)。punpcklwd xmm0, xmm1 ; p + unpck + l + wd
; this interleaves the first 4 words of xmm0 and xmm1 into 4 dwords
; for each dword in the result, the first word is taken from xmm0 and the second, from xmm1
; xmm0 = {xmm0[0], xmm1[0], xmm0[1], xmm1[1], xmm0[2], xmm1[2], xmm0[3], xmm1[3]}
unpckhps xmm2, xmm3 ; unpck + h + p + s
; this interleaves the high 32-bit floats of xmm2 and xmm3
; xmm2 = {xmm2[2], xmm3[2], xmm2[3], xmm3[3]}
打包指令则相反,把两个操作数的通道合并成目标操作数中宽度减半的通道。
这些指令不带p前缀。
除此之外,其语法组合了我们之前已经见过的元素:
pack。s或u,分别表示输出值是有符号还是无符号。s表示饱和,就像前面某个概念中见过的饱和算术那样。unpck的行为相反)。由于 打包 操作是收窄的,它不需要l或h后缀:
packssdw xmm0, xmm1 ; pack + s (for signed) + s (for saturating) + dw (dword to word)
packuswb xmm2, xmm3 ; pack + u (for unsigned) + s (for saturating) + wb (word to byte)
这些指令是饱和的,也就是说,输出值会被钳制到更窄的范围内。 它们的作用范围是从双字到字,以及从字到字节。 没有四字到双字的变体。
注意,输入总是被解释为有符号数。
这个类型,无论是有符号还是无符号,都是针对输出的。
它指明了要钳制到的范围。
例如,packsswb会钳制到有符号字节的范围,也就是[-128, 127]。
结果的低通道取自目标操作数,高通道取自源操作数:
packusdw xmm0, xmm1 ; 8 words, each clamped to 0..65535
; xmm0 = {xmm0[0], xmm0[1], xmm0[2], xmm0[3], xmm1[0], xmm1[1], xmm1[2], xmm1[3]}
这些指令没有对应的浮点版本。
到目前为止,我们一直用movq/movd在 SIMD 寄存器和通用寄存器之间移动数据。
这些指令只能写入或读取 SIMD 寄存器的低通道,而且在写入时会清除其他所有通道。
有些指令对任意通道都能做到这一点,而不只是第一个通道,并且不会碰其他通道:
两者都遵循整数语法:p + insr/extr + 大小后缀(b、w、d或q)。
在这两种情况下,通用寄存器通常都是 32 位宽。
只有pinsrq和pextrq需要 64 位操作数。
内存操作数的大小总是与操作大小一致:pinsrb/pextrb为 8 位,pinsrw/pextrw为 16 位,以此类推。
pinsrb xmm0, eax, 5 ; replace byte 5 of xmm0 with the low byte of eax
pextrb byte [rdx], xmm0, 5 ; copy byte 5 of xmm0 into the memory location indicated by rdx
注意,这些指令和movd、movq很像,只是简单地移动原始字节。
这意味着它们也可以用于存储在内存或通用寄存器中的浮点值。
你负责编写软件图像管线中的内层循环,也就是在纹理和图层显示到屏幕之前,对它们进行准备和合成的那个阶段。 这条管线以块为单位处理像素,对整个块应用同一种操作。
一个像素由四个 1 字节通道组成:依次为红、绿、蓝和 alpha(RGBA)。
一个块包含 4 个像素,共 16 字节。
本练习共有五项任务。
运算数通过内存地址传入,结果则通过一个结果地址写出。 本练习中所有内存地址都是 16 字节对齐的。
本练习中的运算应使用 SIMD 指令完成,而不是标量运算。
纹理以 RGBA 存储,但这条管线要绘入的帧缓冲区要求每个像素为 BGRA 顺序:红、蓝两个通道互换,绿和 alpha 通道保持不变。
图像以块序列的形式传入,每个块都按相同方式转换。
实现 to_display_order 函数,把整幅图像从 RGBA 转换为 BGRA,每次处理一个块。
你应该把通道重排的控制掩码定义为内存中的一个打包常量,并在每个块上复用它。
该函数按以下顺序接收参数:
result:转换后的块写入的缓冲区内存地址,每个块 16 字节。pixels:源块的内存地址,每个块 4 个像素,每个像素 4 字节,按 RGBA 顺序排列。block_count:块的数量,始终大于 0。pixels = {200, 64, 32, 255, 10, 20, 30, 40, 0, 0, 0, 255, 12, 34, 56, 78,
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} // 2 blocks
block_count = 2
result = {32, 64, 200, 255, 30, 20, 10, 40, 0, 0, 0, 255, 56, 34, 12, 78,
3, 2, 1, 4, 7, 6, 5, 8, 11, 10, 9, 12, 15, 14, 13, 16}
该函数没有返回值。
为了清除某个区域或绘制一段纯色,管线会把同一种颜色写入该区域的每一个像素。
实现 fill_region 函数,用一个颜色的副本填充由 block_count 个块组成的区域。
该函数按以下顺序接收参数:
result:填充后的块写入的缓冲区内存地址,每个块 16 字节。color:一个像素的内存地址,4 字节,按 RGBA 顺序排列。block_count:要填充的块的数量,始终大于 0。color = {18, 52, 86, 120}
block_count = 2
result = {18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120,
18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120, 18, 52, 86, 120}
该函数没有返回值。
两个单通道图层需要合并到一个缓冲区中,样本彼此交错。 结果中,来自第一个图层的一个样本与来自第二个图层的一个样本交替出现。
实现 weave_scanlines 函数,把两行各 16 个样本交织成一行 32 个样本。
该函数按以下顺序接收参数:
result:写入 32 个交错样本的缓冲区内存地址。first:第一行的内存地址,16 个样本,每个都是 8 位值。second:第二行的内存地址,16 个样本,每个都是 8 位值。first = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
second = {100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115}
result = {0, 100, 1, 101, 2, 102, 3, 103, 4, 104, 5, 105, 6, 106, 7, 107,
8, 108, 9, 109, 10, 110, 11, 111, 12, 112, 13, 113, 14, 114, 15, 115}
该函数没有返回值。
亮度处理以 16 位工作精度对每个样本进行缩放,因此过亮的样本可能超过 255,差值也可能降到 0 以下。
最后一个阶段把这些工作值收窄回用于显示的 8 位样本,低于 0 的一律截到 0,高于 255 的一律截到 255。
实现 pack_samples 函数,按顺序把两组各 8 个工作值收窄成一行 16 个样本。
该函数按以下顺序接收参数:
result:写入 16 个截断后样本的缓冲区内存地址,每个都是 8 位值。first:前 8 个工作值的内存地址,每个都是有符号 16 位整数。second:接下来 8 个工作值的内存地址,每个都是有符号 16 位整数。first = {300, -5, 128, 255, 0, 400, 64, 200}
second = {255, 256, -1, 100, 50, 1000, 7, 0}
result = {255, 0, 128, 255, 0, 255, 64, 200, 255, 255, 0, 100, 50, 255, 7, 0}
该函数没有返回值。
纹理和顶点数据常常是交错传入的,每个点的 x 和 y 打包在一起。
不过,为了高效处理,往往需要把它们分开:所有 x 值放在一个向量里,所有 y 值放在另一个向量里。
实现 split_coordinates 函数,把四个交错的 (x, y) 点拆分成一个 x 坐标向量和一个 y 坐标向量。
该函数按以下顺序接收参数:
xs:写入 4 个 x 坐标的缓冲区内存地址,4 个 32 位浮点数。ys:写入 4 个 y 坐标的缓冲区内存地址,4 个 32 位浮点数。first:前两个点的内存地址,4 个 32 位浮点数,形式为 {x0, y0, x1, y1}。second:接下来两个点的内存地址,4 个 32 位浮点数,形式为 {x2, y2, x3, y3}。first = {0.0, 0.5, 1.0, 1.5} // {x0, y0, x1, y1}
second = {2.0, 2.5, 3.0, 3.5} // {x2, y2, x3, y3}
xs = {0.0, 1.0, 2.0, 3.0} // {x0, x1, x2, x3}
ys = {0.5, 1.5, 2.5, 3.5} // {y0, y1, y2, y3}
该函数没有返回值。