标量代码依赖各种指令设置的标志位,以便根据某个条件进行分支。 不过,打包值并行表示的并非一个值,而是许多值。 同一个条件可能对某个通道不成立,而对另一个通道成立。
这就是 SIMD 代码默认采用_无分支_方式的原因。
打包比较通常不依赖标志位,而是直接在目标操作数中生成一个掩码。
对于每个通道,比较结果为真时,会用 1 填满整个通道;为假时,则用 0 填充。
若按有符号整数解读,为真的通道是-1,为假的通道是0。
然后可以用位运算组合这个掩码,以筛选特定的通道。
标量cmp是通用的,因为它可以同时设置多个标志位。
然后,另一条指令可以读取这些标志位,进行分支或执行计算。
然而,打包比较既要检查条件,又要计算掩码,因此并不通用。
必须向比较指令提供正在测试的确切条件。
有两种做法:
eq表示相等,gt表示大于。
其他变体则通过组合其中一种比较的结果来构造。除了整数比较使用特定的条件后缀之外,语法遵循我们已见过的相同结构:
p + cmp + 条件 + 大小(b、w、d或q)。cmp + p + 大小(s或d)。
条件作为额外操作数放在立即数中传递。如前所述,整数比较只有相等和大于两种:
| 指令 | 描述 |
|---|---|
pcmpeqb, pcmpeqw, pcmpeqd, pcmpeqq
|
逐通道相等 |
pcmpgtb, pcmpgtw, pcmpgtd, pcmpgtq
|
逐通道有符号大于 |
movdqa xmm0, [rel scores]
pcmpgtd xmm0, [rel threshold] ; lane i = 0xFFFFFFFF (-1) if scores[i] > threshold[i], else 0
要创建小于比较,请交换操作数并使用gt:a < b == b > a。
注意,比较是有符号的。
要执行无符号比较,请翻转两个操作数的最高位。
可以通过与一个只在最高位上置位的掩码做异或来实现。
两个常用的惯用法是:
例如:
pxor xmm4, xmm4 ; xmm4 = all zeros
pcmpeqd xmm7, xmm7 ; xmm7 = all ones
全 0 和全 1 是常见的掩码,分别用于编码“处处为假”和“处处为真”。
它们也可以用来表示打包的0或打包的-1,这是常见的哨兵值。
例如,标记字符串末尾的 NUL 就是0。
浮点通道使用不同的形式:一条指令cmpps(64 位通道则用cmppd),条件通过立即数给出:
movaps xmm0, [rel readings]
cmpps xmm0, [rel limits], 1 ; condition 1 is "less than": lane i = all ones if readings[i] < limits[i]
NASM 还提供了伪指令,它们会映射到正确的立即数,也更容易记住。
在以下所有内容中,px中的x可以是s(32 位浮点数)或d(64 位浮点数):
| 伪指令 | 立即数 | 比较 |
|---|---|---|
cmpeqpx |
0 | a == b |
cmpltpx |
1 | a < b |
cmplepx |
2 | a <= b |
cmpunordpx |
3 | a 是 NaN 或 b 是 NaN |
cmpneqpx |
4 | a != b |
cmpnltpx |
5 | a >= b |
cmpnlepx |
6 | a > b |
cmpordpx |
7 | a 和 b 都不是 NaN |
掩码编码了条件的结果。 然后,可以根据该条件,逐通道地在两组值之间进行选择。 掩码为真时,我们从一组值中取该通道;为假时,从另一组值中取:
; result = (a AND mask) OR (b AND NOT mask)
movdqa xmm2, xmm0 ; xmm0 holds the mask, keep a copy
pand xmm2, xmm3 ; xmm2 = a AND mask: lanes of a where mask is true
pandn xmm0, xmm4 ; xmm0 = NOT mask AND b: lanes of b where mask is false
por xmm2, xmm0 ; combine the two halves
注意,pandn的不对称性在这里很有用:掩码位于目标位置,会被取反,并在一条指令中从b里选择。
这种模式是无分支选择的打包形式。
每个通道都会被计算,仅由掩码决定哪个值保留下来,完全不需要jcc。
有些指令可以直接执行相同的选择,从掩码寄存器中按元素读取一位。 它们被称为混合指令:
| 指令 | 元素 | 掩码来源 |
|---|---|---|
pblendvb |
字节 | 隐式xmm0
|
blendvps |
32 位通道 | 隐式xmm0
|
blendvpd |
64 位通道 | 隐式xmm0
|
注意,第一条指令遵循整数语法,而另外两条遵循浮点语法。
不过,由于这些指令只是选择原始字节,因此它们中的任何一条都可以用于整数和浮点数。
对于每个元素,当对应掩码元素的最高位为 0 时,混合指令保留目标;为 1 时,则取源。
只查看那个最高位,比较掩码正好满足这一点,因为它的通道要么全为 1,要么全为 0。
掩码寄存器始终是xmm0,它是隐式的:
movaps xmm0, [rel mask] ; the selecting mask must be in xmm0
movaps xmm1, [rel b] ; destination: kept where the mask bit is clear
blendvps xmm1, [rel a] ; source: taken where the mask bit is set
也可以使用pblendvb从比较掩码中选择任意其他大小的通道。
由于为真的通道中所有字节都是全 1,pblendvb会把它们全部选中。
这些指令都在所执行的操作(blend)之后加上v。
这个v代表_变量_,因为这种选择不是静态的:它取决于一个寄存器。
也有不带v的变体,它们根据立即数进行选择。
它们遵循相同的模式:如果立即数的第i位被置位,就选择通道i。
尽管 SIMD 代码很强大,但它缺少标量代码的许多灵活性。
在许多情况下,需要从打包寄存器回到标量指令的世界。
movmsk系列指令充当这两个世界之间的桥梁。
这些指令将每个通道的最高位提取到一个通用寄存器中:
| 指令 | 收集 | 结果宽度 |
|---|---|---|
pmovmskb |
16 个字节各自的最高位 | 16 位 |
movmskps |
4 个双字各自的最高位 | 4 位 |
movmskpd |
2 个四字各自的最高位 | 2 位 |
如果在比较之后使用,每个被置位的位表示一个“true”通道,每个被清零的位表示一个“false”通道。
然后,可以像往常一样用标量指令操作这个结果。
例如,popcnt统计匹配的数量,tzcnt查找第一个匹配。
通用寄存器可以是 32 位或 64 位宽。
test标量指令还有一个打包变体:ptest。
它与标量对应指令类似,会对两个操作数执行 AND 运算,但不修改它们。
与test不同,ptest还会执行 ANDN 运算,对第一个操作数取反。
因此,可以把ptest看作pand和pandn的非破坏性版本,它会根据结果设置标志位。
与这两条指令大致相同,ptest把整个 SIMD 寄存器当作单个通道,因此不接受大小前缀。
如果 AND 运算的结果为0,则设置ZF;如果 ANDN 运算的结果为0,则设置CF。
这意味着ptest可以用来检查全 1 掩码和全 0 掩码:
ptest用于寄存器自身时,只有当寄存器全为 0 才设置ZF。
这模仿了常见的标量惯用法:将test用于寄存器自身来检查是否为0。ptest用于寄存器与全 1 掩码时,只有当寄存器全为 1 才设置CF。
此外,只有当寄存器全为 0 才设置ZF,因此可以同时检查两种掩码。pxor xmm0, xmm0 ; all zeros
pcmpeqb xmm1, xmm1 ; all ones
pcmpeqb xmm2, xmm2
ptest xmm0, xmm0 ; ZF set: a register against itself detects all zeros
ptest xmm0, xmm1 ; ZF set, CF clear: xmm0 is all zeros, not all ones
ptest xmm2, xmm1 ; CF is set only if xmm2 is all ones
ptest的结果可以像往常一样用于分支,或用于setcc、cmovcc之类的无分支指令。
你为一所学校负责评分站,每次为一个块给班级成绩打分。
每个块包含 4 个成绩,评分站对块中的每个成绩执行同样的操作。 分数是一个 32 位浮点数。 若干步骤会用到掩码:一个由 4 个通道组成的块,每个通道要么全为 1(表示该成绩的_是_),要么全为 0(表示_否_)。
你有 5 个任务。 操作数通过内存地址传入。 有些任务把答案写到结果地址,有些则直接返回。
本练习中的所有内存地址都是 16 字节对齐的。
本练习中的计算应使用 SIMD 指令完成。
第一步根据阈值对每个成绩进行判定。 当某个成绩的分数严格大于阈值时,该成绩才达标。 分数小于或等于阈值的成绩则不达标。
实现 flag_above_threshold 函数,它为每个高于其阈值的分数构建一个通道全为 1 的掩码,否则该通道全为 0。
这个函数按以下顺序接收实参:
result:缓冲区的内存地址,4 个掩码通道写入其中。scores:分数的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。thresholds:每个通道的阈值的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。scores = {72.0, 55.0, 90.0, 40.0}
thresholds = {60.0, 60.0, 60.0, 60.0}
result = {0xFFFFFFFF, 0x00000000, 0xFFFFFFFF, 0x00000000}
这个函数没有返回值。
另一份报告会突出显示满分的成绩,也就是达到最高可能分数的成绩。
实现 flag_perfect 函数,它为每个等于其最高分的分数构建一个通道全为 1 的掩码,否则该通道全为 0。
这个函数按以下顺序接收实参:
result:缓冲区的内存地址,4 个掩码通道写入其中。scores:分数的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。maxima:每个通道的最高分的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。scores = {100.0, 88.0, 100.0, 73.0}
maxima = {100.0, 100.0, 100.0, 100.0}
result = {0xFFFFFFFF, 0x00000000, 0xFFFFFFFF, 0x00000000}
这个函数没有返回值。
每个分数获得 1 到 3 的等级:
50.0时,等级为 1。实现 assign_ranks 函数,它写入每个分数的等级。
你应该把及格阈值和各等级的取值定义为内存中的打包常量。 前 2 个任务中的函数可以复用:分数高于阈值时至少为等级 2,等于最高分时为等级 3。
这个函数按以下顺序接收实参:
result:缓冲区的内存地址,4 个等级写入其中,每个等级是一个 32 位无符号整数。scores:分数的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。maxima:每个通道的最高分的内存地址,包含 4 个 32 位规格化浮点数(从不为NaN)。scores = {40.0, 75.0, 100.0, 60.0}
maxima = {100.0, 100.0, 100.0, 100.0}
result = {1, 2, 3, 2}
这个函数没有返回值。
一年下来,每个学生会不断累积一个总等级。 评分站会统计全体学生中有多少个等级低于及格阈值,以便规划要额外开多少节课。
实现 count_failures 函数,它返回所有块中严格低于及格阈值的等级数量。
阈值以由 4 个相同通道组成的块给出,因此你只需加载一次,就能在每个块中重复使用。
这个函数按以下顺序接收实参:
ranks:等级的内存地址,由整数个 4 通道块组成,每个等级是一个 32 位无符号整数。block_count:4 通道块的数量,始终大于0。pass_threshold:及格阈值的内存地址,包含 4 个相同的 32 位整数。ranks = {1, 2, 3, 1, 2, 2, 1, 3} // 2 blocks
block_count = 2
pass_threshold = {2, 2, 2, 2}
// => 3
这个函数以有符号 32 位整数返回计数。
在记录归档之前,评分站会检查全体学生是否全部通过:只要没有任何一个成绩在任何一个块中不及格,就算通过。
实现 all_passed 函数,如果所有学生都及格则返回1,否则返回0。
当某个学生在 failing 数组中对应的通道全为 0 时,该学生及格。
这个函数按以下顺序接收实参:
failing:不及格掩码的内存地址,由整数个 4 通道块组成,每个通道要么全为 1,要么全为 0。block_count:4 通道块的数量,始终大于0。failing = {0x00000000, 0x00000000, 0x00000000, 0x00000000,
0x00000000, 0x00000000, 0x00000000, 0x00000000} // 2 blocks
block_count = 2
// => 1
这个函数以有符号 32 位整数返回答案,即1或0。