PCMPGT + PADDD 或 PSUBD 对于大多数 CPU 来说可能是一个非常好的主意,即使对于小尺寸 CPU,也可能需要简单的标量清理。或者甚至只是纯标量,使用 movd 负载,见下文。
对于标量整数,避免 XMM regs,使用 SETCC 根据您想要的任何标志条件创建一个 0/1 整数。如果您想使用 32 位或 64 位 ADD 指令而不是仅 8 位,则将 tmp 寄存器(可能在循环之外)和 SETCC 设置为低 8 位。
cmp/adc reg,0 基本上是针对below / carry-set 条件的窥孔优化。 AFAIK,没有什么比符号比较条件更有效的了。 cmp/setcc/add 最多 3 微秒,而 cmp/adc 最多 2 微秒。因此展开以隐藏循环开销更为重要。
请参阅What is the best way to set a register to zero in x86 assembly: xor, mov or and? 的底部部分,了解有关如何有效地零扩展SETCC r/m8 而不会导致部分寄存器停顿的更多详细信息。请参阅 Why doesn't GCC use partial registers? 以了解跨 uarches 的部分注册行为。
是的,CF 对于很多事情都是特别的。它是唯一具有设置/清除/补码 (stc/clc/cmc) 指令的条件标志1。 bt/bts/等是有原因的。指令集 CF,并且移位指令移入其中。是的,ADC/SBB 可以将它直接添加/子到另一个寄存器中,这与任何其他标志不同。
OF 可以用 ADOX 类似地读取(Intel 自 Broadwell,AMD 自 Ryzen),但这仍然对我们没有帮助,因为它是严格的 OF,而不是 SF!=OF 签名-小于条件。
这对于大多数 ISA 来说是典型的,而不仅仅是 x86。 (AVR 和其他一些可以设置/清除任何条件标志,因为他们有an instruction that takes an immediate bit-position in the status register。但他们仍然只有 ADC/SBB 用于直接将进位标志添加到整数寄存器。)
ARM 32 位可以使用任何条件代码(包括带符号的小于号)来执行谓词 addlt r0, r0, #1,而不是直接为 0 的加法进位。ARM 确实有 ADC-immediate,您可以将其用于此处为 C 标志,但不在 Thumb 模式下(避免使用 IT 指令来断言 ADD 很有用),因此您需要一个归零的寄存器。
AArch64 可以做一些谓词的事情,包括使用任意条件谓词增加 cinc。
但 x86 不能。我们只有 cmovcc 和 setcc 可以将 CF==1 以外的条件转换为整数。(或者使用 ADOX,用于 OF==1。)
脚注 1:EFLAGS 中的一些状态标志,如中断 IF (sti/cli)、方向 DF (std/cld) 和对齐检查 (stac/clac) 具有设置/清除指令,但不是条件标志 ZF/SF/OF/PF 或 BCD 进位 AF。
cmp [rdi + 4 * rsi], edx 即使在 Haswell/Skylake 上也不会层压,因为它是索引寻址模式,并且它没有读/写目标寄存器(所以它不像 add reg, [mem] .)
如果仅针对 Sandybridge 系列进行调整,您不妨只增加一个指针并减少大小计数器。尽管这确实为 RS 大小的效果节省了后端(未融合域)微指令。
实际上,您希望使用指针增量展开。
您提到了从 0 到 32 的大小,因此如果 RSI = 0,我们需要跳过循环。您问题中的代码只是 do{}while,它不会这样做。 NEG 根据结果设置标志,所以我们可以 JZ 。您希望它可以进行宏融合,因为 NEG 与从 0 开始的 SUB 完全一样,但根据 Agner Fog 的说法,它不会在 SnB/IvB 上。因此,如果您确实需要处理 size=0,那么这会让我们在启动过程中花费另一个 uop。
使用整数寄存器
实现integer += (a < b) 或任何其他标志条件的标准方法是编译器所做的(Godbolt):
xor edx,edx ; can be hoisted out of a short-running loop, but compilers never do that
; but an interrupt-handler will destroy the rdx=dl status
cmp/test/whatever ; flag-setting code here
setcc dl ; zero-extended to a full register because of earlier xor-zeroing
add eax, edx
有时编译器(尤其是 gcc)会使用 setcc dl / movzx edx,dl,这会将 MOVZX 置于关键路径上。这对延迟不利,并且当 Intel CPU 对两个操作数使用(部分)相同的寄存器时,移动消除在 Intel CPU 上不起作用。
对于小型数组,如果您不介意只有一个 8 位计数器,您可以只使用 8 位加法,这样您就不必担心 内部 的零扩展循环。
; slower than cmp/adc: 5 uops per iteration so you'll definitely want to unroll.
; requires size<256 or the count will wrap
; use the add eax,edx version if you need to support larger size
count_signed_lt: ; (int *arr, size_t size, int key)
xor eax, eax
lea rdi, [rdi + rsi*4]
neg rsi ; we loop from -length to zero
jz .return ; if(-size == 0) return 0;
; xor edx, edx ; tmp destination for SETCC
.loop:
cmp [rdi + 4 * rsi], edx
setl dl ; false dependency on old RDX on CPUs other than P6-family
add al, dl
; add eax, edx ; boolean condition zero-extended into RDX if it was xor-zeroed
inc rsi
jnz .loop
.return:
ret
或者使用 CMOV,使循环携带的 dep 链长 2 个周期(或在 Broadwell 之前的 Intel 上为 3 个周期,其中 CMOV 为 2 微指令):
;; 3 uops without any partial-register shenanigans, (or 4 because of unlamination)
;; but creates a 2 cycle loop-carried dep chain
cmp [rdi + 4 * rsi], edx
lea ecx, [rax + 1] ; tmp = count+1
cmovl eax, ecx ; count = arr[i]<key ? count+1 : count
所以充其量(循环展开和指针增量允许cmp 微熔断)每个元素需要 3 微秒而不是 2。
SETCC 是单个 uop,因此这是循环内的 5 个融合域 uop。这在 Sandybridge/IvyBridge 上要糟糕得多,并且在后来的 SnB 系列上仍然以低于每时钟 1 次的速度运行。 (一些古老的 CPU 的 setcc 速度很慢,比如 Pentium 4,但它在我们仍然关心的所有事情上都很高效。)
展开时,如果您希望它的运行速度快于每时钟 1 个cmp,您有两种选择:为每个 setcc 目标使用单独的寄存器,为 false 创建多个 dep 链依赖关系,或者在循环中使用一个xor edx,edx 将循环携带的错误依赖关系分解为多个短的dep 链,这些链只耦合附近加载的setcc 结果(可能来自同一缓存行)。您还需要多个累加器,因为add 延迟为 1c。
显然,您需要使用指针增量,以便cmp [rdi], edx 可以使用非索引寻址模式进行微融合,否则 cmp/setcc/add 总共为 4 uop,这就是 Intel CPU 上的管道宽度.
即使在 P6 系列上,调用方在写入 AL 后读取 EAX 也不会出现部分寄存器停顿,因为我们首先对其进行了异或归零。 Sandybridge 不会将它与 RAX 分开重命名,因为 add al,dl 是一个读-修改-写,而 IvB 和以后永远不会将 AL 与 RAX 分开重命名(仅 AH/BH/CH/DH)。 P6 / SnB 系列以外的 CPU 根本不进行部分寄存器重命名,只进行部分标志。
这同样适用于在循环内读取 EDX 的版本。但是使用 push/pop 保存/恢复 RDX 的中断处理程序会破坏其异或零状态,从而导致 P6 系列的每次迭代都出现部分寄存器停顿。这是灾难性的,所以这是编译器从不提升异或归零的原因之一。他们通常不知道循环是否会长时间运行,也不会冒险。 手动,您可能希望每个展开的循环体展开一次并异或零一次,而不是每个cmp/setcc 一次。
您可以将 SSE2 或 MMX 用于标量内容
两者都是 x86-64 的基线。由于将负载折叠到cmp 中没有任何好处(在SnB 系列上),因此您不妨使用标量movd 负载到XMM 寄存器中。 MMX 的优点是代码更小,但完成后需要 EMMS。它还允许未对齐的内存操作数,因此对于更简单的自动矢量化可能很有趣。
在 AVX512 之前,我们只有大于可用的比较,因此需要额外的 movdqa xmm,xmm 指令来执行 key > arr[i] 而不会破坏密钥,而不是 arr[i] > key。 (这是 gcc 和 clang 在自动矢量化时所做的)。
AVX 会很好,因为 vpcmpgtd xmm0, xmm1, [rdi] 可以做 key > arr[i],就像 gcc 和 clang 与 AVX 一起使用。但这是一个 128 位的负载,我们希望保持简单和标量。
我们可以递减key 并使用(arr[i]<key) = (arr[i] <= key-1) = !(arr[i] > key-1)。我们可以计算数组大于key-1 的元素,然后从大小中减去它。所以我们可以只使用 SSE2 而无需额外的指令。
如果key 已经是最负数(所以key-1 会换行),那么没有数组元素可以小于它。如果这种情况实际上是可能的,这确实会在循环之前引入一个分支。
; signed version of the function in your question
; using the low element of XMM vectors
count_signed_lt: ; (int *arr, size_t size, int key)
; actually only works for size < 2^32
dec edx ; key-1
jo .key_eq_int_min
movd xmm2, edx ; not broadcast, we only use the low element
movd xmm1, esi ; counter = size, decrement toward zero on elements >= key
;; pxor xmm1, xmm1 ; counter
;; mov eax, esi ; save original size for a later SUB
lea rdi, [rdi + rsi*4]
neg rsi ; we loop from -length to zero
.loop:
movd xmm0, [rdi + 4 * rsi]
pcmpgtd xmm0, xmm2 ; xmm0 = arr[i] gt key-1 = arr[i] >= key = not less-than
paddd xmm1, xmm0 ; counter += 0 or -1
;; psubd xmm1, xmm0 ; -0 or -(-1) to count upward
inc rsi
jnz .loop
movd eax, xmm1 ; size - count(elements > key-1)
ret
.key_eq_int_min:
xor eax, eax ; no array elements are less than the most-negative number
ret
这应该与英特尔 SnB 系列 CPU 上的循环速度相同,外加一点点额外开销。它是 4 个熔断器域微指令,因此每个时钟可以发出 1 个。一个movd 加载使用常规加载端口,并且至少有2 个向量ALU 端口可以运行PCMPGTD 和PADDD。
哦,但是在 IvB/SnB 上,宏融合的 inc/jnz 需要端口 5,而 PCMPGTD / PADDD 都只在 p1/p5 上运行,因此端口 5 的吞吐量将是一个瓶颈。在 HSW 和更高版本上,分支在端口 6 上运行,因此我们可以满足后端吞吐量。
在内存操作数 cmp 可以使用索引寻址模式而不受惩罚的 AMD CPU 上情况更糟。 (在 Intel Silvermont 和 Core 2 / Nehalem 上,内存源 cmp 可以是具有索引寻址模式的单个 uop。)
在 Bulldozer 系列中,一对整数内核共享一个 SIMD 单元,因此坚持使用整数寄存器可能是一个更大的优势。这也是为什么 intXMM movd/movq 有更高的延迟,再次伤害了这个版本。
其他技巧:
PowerPC64 的 Clang(包含在 Godbolt 链接中)向我们展示了一个巧妙的技巧:零或符号扩展为 64 位,减去,然后将结果的 MSB 作为 0/1 整数获取,并将其添加到 @ 987654390@。 PowerPC 具有出色的位域指令,包括rldicl。在这种情况下,它被用于向左旋转 1,然后将其上方的所有位归零,即将 MSB 提取到另一个寄存器的底部。 (请注意,PowerPC 文档用 MSB=0、LSB=63 或 31 编号位。)
如果您不禁用自动矢量化,它会使用带有 vcmpgtsw / vsubuwm 循环的 Altivec,我假设它可以满足您对名称的期望。
# PowerPC64 clang 9-trunk -O3 -fno-tree-vectorize -fno-unroll-loops -mcpu=power9
# signed int version
# I've added "r" to register names, leaving immediates alone, because clang doesn't have `-mregnames`
... setup
.LBB0_2: # do {
lwzu r5, 4(r6) # zero-extending load and update the address register with the effective-address. i.e. pre-increment
extsw r5, r5 # sign-extend word (to doubleword)
sub r5, r5, r4 # 64-bit subtract
rldicl r5, r5, 1, 63 # rotate-left doubleword immediate then clear left
add r3, r3, r5 # retval += MSB of (int64_t)arr[i] - key
bdnz .LBB0_2 # } while(--loop_count);
如果使用算术(符号扩展)负载,我认为 clang 可以避免循环内的 extsw。唯一更新地址寄存器(保存增量)的lwa 似乎是索引形式lwaux RT, RA, RB,但如果clang 将4 放在另一个寄存器中,它可以使用它。 (似乎没有lwau 指令。)可能lwaux 很慢,或者可能是错过了优化。我使用了-mcpu=power9,所以即使该指令是 POWER-only,它也应该可用。
这个技巧可能对 x86 有所帮助,至少对于一个汇总循环。
每次比较需要 4 微秒,不计算循环开销。尽管 x86 的位域提取能力很差,但我们真正需要的只是逻辑右移来隔离 MSB。
count_signed_lt: ; (int *arr, size_t size, int key)
xor eax, eax
movsxd rdx, edx
lea rdi, [rdi + rsi*4]
neg rsi ; we loop from -length to zero
.loop:
movsxd rcx, dword [rdi + 4 * rsi] ; 1 uop, pure load
sub rcx, rdx ; (int64_t)arr[i] - key
shr rcx, 63 ; extract MSB
add eax, ecx ; count += MSB of (int64_t)arr[i] - key
inc rsi
jnz .loop
ret
这没有任何错误的依赖关系,但 4-uop xor-zero / cmp / setl / add 也没有。 only 的优点是即使使用索引寻址模式也是 4 微指令。一些 AMD CPU 可能通过 ALU 和加载端口运行 MOVSXD,但 Ryzen 的延迟与常规加载相同。
如果您的迭代次数少于 64 次,那么如果只考虑吞吐量而不是延迟,您就可以这样做。 (但你可能仍然可以使用setl 做得更好)
.loop
movsxd rcx, dword [rdi + 4 * rsi] ; 1 uop, pure load
sub rcx, rdx ; (int64_t)arr[i] - key
shld rax, rcx, 1 ; 3 cycle latency
inc rsi / jnz .loop
popcnt rax, rax ; turn the bitmap of compare results into an integer
但是shld 的 3 周期延迟使其成为大多数用途的最佳选择,即使它只是 SnB 系列的一个微指令。 rax->rax 依赖是循环携带的。