假设您要在排序数组中查找值 1 的第一个匹配项。对于小型数组(二元搜索之类的方法无法奏效),您可以通过简单地计算小于该值的值的数量来实现此目的:结果是您要寻找的索引。
在x86中,您可以使用adc
(带有进位)来实现该方法的高效无分支 2 实现(起始指针的长度为rdi
,{ {1}}和要在rsi
中搜索的值):
edx
答案以 xor eax, eax
lea rdi, [rdi + rsi*4] ; pointer to end of array = base + length
neg rsi ; we loop from -length to zero
loop:
cmp [rdi + 4 * rsi], edx
adc rax, 0 ; only a single uop on Sandybridge-family even before BDW
inc rsi
jnz loop
结尾。如果您展开该操作(或者如果您具有固定的已知输入大小),则仅重复rax
对指令,因此每次比较的开销接近2条简单指令(有时会合并负载)。 Which Intel microarchitecture introduced the ADC reg,0 single-uop special case?
但是,这仅适用于 unsigned 比较,其中进位标志保存比较结果。 是否有等效的计数签名比较的有效序列?不幸的是,似乎没有“如果少于则加1”指令:cmp; adc
,adc
和进位标志在这方面是特殊的。
我对元素没有特定顺序的一般情况感兴趣,而且在这种情况下,如果排序假设导致实现更简单或更快速的实现,则对数组进行排序。
1 或,如果该值不存在,则是第一个更大的值。也就是说,这就是所谓的“下界”搜索。
2 无分支方法每次都必须完成相同的工作量-在这种情况下,检查整个数组,因此这种方法仅在数组较小时才有意义,因此分支的成本相对于总搜索时间而言,错误预测会很大。
答案 0 :(得分:4)
对于大多数CPU而言,PCMPGT + PADDD或PSUBD可能是一个不错的主意,即使是小尺寸的CPU,也可能需要简单的标量清理。甚至使用movd
加载,甚至是纯标量,请参见下文。
对于标量整数,避免使用XMM规则,使用SETCC根据您想要的任何标志条件创建一个0/1整数。如果您想使用32位或64位ADD指令而不是仅使用8位,则将tmp寄存器(可能在循环外部)异或为零,并将SETCC设置为低8位。
cmp
/ adc reg,0
基本上是针对b
下/ c
预先设定条件的窥孔优化。 AFAIK,没有任何东西对于有符号比较的条件非常有效。 cmp / setcc / add的最佳速度为3微秒,而cmp / adc的最佳速度为2微秒。因此展开以隐藏循环开销显得尤为重要。
有关如何有效地将SETCC r/m8
零扩展而不引起部分寄存器停顿的更多详细信息,请参见What is the best way to set a register to zero in x86 assembly: xor, mov or and?的底部。并请参见Why doesn't GCC use partial registers?,以提醒您整个uarch中的部分注册行为。
是的,CF在很多方面都很特殊。这是唯一具有置位/清除/补码(stc
/ clc
/ cmc
)指令 1 的条件标志。 bt
/ bts
/ etc是有原因的。指令设置CF,然后将移位指令移入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可以直接将进位标志添加到整数寄存器。)
32位ARM可以使用任何条件代码(包括带符号小于号)来做谓词addlt r0, r0, #1
,而不是立即数为0的带进位加法。ARM确实有ADC-immediate可以在此处用于C标志,但不能用于Thumb模式(在该模式下,避免使用IT指令来断言ADD是很有用的),因此您需要将寄存器清零。
AArch64可以做一些谓词操作,包括使用带有任意条件谓词的cinc
递增。
但是x86不能。我们只有cmovcc
和setcc
才能将CF == 1以外的条件转换为整数。(或对于OF==1
使用ADOX)
脚注1:EFLAGS中的某些状态标志,例如中断IF(sti/cli
),方向DF(std
/ cld
)和对齐检查(stac
/ { {1}}已设置/清除了指令,但没有条件标志ZF / SF / OF / PF或带有BCD的AF。
clac
甚至会在Haswell / Skylake上取消分层,这是因为采用了索引寻址模式,并且它没有读/写目标寄存器(所以它不像{ {1}}。)
如果仅针对Sandybridge系列进行调整,则最好增加一个指针并减少大小计数器。尽管这样做确实为RS大小的效果节省了后端(未融合域)信息。
在实践中,您希望以指针增量展开。
您提到的大小从0到32,所以如果RSI = 0,我们需要跳过循环。您问题中的代码只是一个cmp [rdi + 4 * rsi], edx
,不会这样做。 NEG根据结果设置标志,因此我们可以对此进行JZ。您希望它可以进行宏融合,因为NEG就像从0开始的SUB,但是根据Agner Fog的说法,它不在SnB / IvB上。如果您确实需要处理size = 0,那么这将使我们在启动时付出另一笔麻烦。
实现add reg, [mem]
或任何其他标志条件的标准方法是编译器的工作(Godbolt):
do{}while
有时,编译器(尤其是gcc)将使用integer += (a < b)
/ 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
,这会将MOVZX置于关键路径上。这对延迟不利,并且当Intel CPU对两个操作数使用(部分)相同寄存器时,移动消除在Intel CPU上不起作用。
对于小型数组,如果您不介意仅使用8位计数器,则可以使用8位加法,因此不必担心内部零扩展 循环。
setcc dl
或者使用CMOV,使循环承载的dep链延长2个周期(或在Broadwell之前的Intel上为3个周期,其中CMOV为2 uops):
movzx edx,dl
因此,充其量(循环展开和指针递增允许; 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
进行微熔丝)充其量每个元素需要3 uops,而不是2。
SETCC是单个uop,因此这是循环内的5个融合域uops。在Sandybridge / IvyBridge上,这种情况要严重得多,在以后的SnB系列中,每个时钟的运行速度仍低于1。 (某些古老的CPU的setcc速度很慢,例如奔腾4,但是在我们仍然关心的所有事情上它的效率很高。)
展开时,如果希望每个时钟运行速度快于1 ;; 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
目标使用单独的寄存器,创建多个dep链以获取虚假依赖关系,或者在循环内部使用一个cmp
来将循环承载的虚假依赖关系分解为多个短的dep链,这些链仅耦合附近负载的setcc结果(可能来自同一缓存行)。您还需要多个累加器,因为setcc
延迟是1c。
很显然,您需要使用指针增量,以便xor edx,edx
可以在非索引寻址模式下进行微熔,否则cmp / setcc / add总共为4 uops,这就是流水线宽度英特尔CPU。
即使在P6-系列上,在写AL之后,调用方读取EAX时也不会出现部分寄存器停顿的情况,因为我们先对其进行了异或归零。 Sandybridge不会将其与RAX分开重命名,因为add
是读-修改-写操作,并且IvB和更高版本永远不会将RAL与RAX分开重命名(仅AH / BH / CH / DH)。 P6 / SnB系列以外的CPU根本不执行部分寄存器重命名,只执行部分标志。
对于在循环内读取EDX的版本也是如此。但是使用push / pop来保存/恢复RDX的中断处理程序会破坏其Xor归零状态,从而导致P6-系列上每次迭代都会导致部分寄存器停顿。这是灾难性的糟糕,因此,这是编译器从未执行异或归零的原因之一。他们通常不知道循环是否会长时间运行,也不会冒险。 通过手工,您可能想对每个展开的循环主体进行一次展开和零归零操作,而不是每个cmp [rdi], edx
/ add al,dl
进行一次。
这两个都是x86-64上的基准。由于将负载折叠到cmp
中并没有获得任何收益(在SnB系列上),因此不妨在XMM寄存器中使用标量setcc
负载。 MMX具有较小的代码大小的优势,但完成后需要EMMS。它还允许未对齐的内存操作数,因此对于更简单的自动矢量化来说可能很有意思。
在AVX512之前,我们只能进行大于可用的比较,因此将花费额外的cmp
指令来执行movd
而不会破坏键,而不是movdqa xmm,xmm
。 (这是gcc和clang在自动矢量化时所做的事情。)
AVX很好,key > arr[i]
可以arr[i] > key
,例如gcc和clang与AVX一起使用。但这是一个128位的负载,我们希望保持其简单和标量。
我们可以递减vpcmpgtd xmm0, xmm1, [rdi]
并使用key > arr[i]
= key
= (arr[i]<key)
。我们可以计算数组大于(arr[i] <= key-1)
的元素,然后从大小中减去元素。因此,我们可以仅使用SSE2进行操作,而无需花费额外的说明。
如果!(arr[i] > key-1)
已经是最负数(因此key-1
会换行),则数组元素不能少于它。如果确实有可能,这会在循环之前引入分支。
key
这应该与您在Intel SnB系列CPU上的循环速度相同,外加一点额外的开销。它是4个熔丝域微指令,因此每个时钟可以发出1次。 key-1
加载使用常规加载端口,并且至少有两个矢量ALU端口可以运行PCMPGTD和PADDD。
哦,但是在IvB / SnB上,宏融合的inc / jnz需要端口5,而PCMPGTD / PADDD都只能在p1 / p5上运行,因此端口5的吞吐量将成为瓶颈。在HSW及更高版本上,该分支运行在端口6上,因此我们可以实现后端吞吐量。
在AMD CPU上,更糟糕的是,内存操作数cmp可以使用索引寻址模式而不会受到损失。 (在Intel Silvermont和Core 2 / Nehalem上,内存源cmp可以是具有索引寻址模式的单个uop。)
在Bulldozer系列上,一对整数内核共享一个SIMD单元,因此坚持使用整数寄存器可能会带来更大的优势。这也是为什么int <-> XMM ; 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
/ movd
具有更高的延迟,再次损害了该版本的原因。
PowerPC64的Clang(包含在Godbolt链接中)向我们展示了一个巧妙的技巧:将零或符号扩展为64位,相减,然后获取结果的MSB,作为您添加到{的0/1整数{1}}。 PowerPC具有出色的位域指令,包括rldicl
。在这种情况下,它被用于向左旋转1,然后将其上的所有位清零,即将MSB提取到另一个寄存器的底部。 (请注意,PowerPC文档对MSB = 0,LSB = 63或31的位进行编号。)
如果您不禁用自动矢量化功能,它将使用带有movd
/ movq
循环的Altivec,我认为它可以实现您期望的名称。
counter
我认为clang如果使用算术(符号扩展)负载,则可以避免循环内的vcmpgtsw
。唯一更新地址寄存器(节省增量)的vsubuwm
似乎是索引形式lwaux RT, RA, RB
,但是如果clang将# 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);
放在另一个寄存器中,它可以使用它。 (似乎没有extsw
指令。)也许lwa
很慢,或者可能是错过了优化。我使用了4
,所以即使该指令仅适用于POWER,它也应该可用。
此技巧可能会对x86有所帮助,至少对于汇总循环而言。 这种方式每次比较需要4微秒,而 not 则没有计算循环开销。尽管x86的位域提取功能很差,但我们实际需要的只是逻辑右移以隔离MSB。
lwau
这没有任何虚假的依赖关系,但是4-uop lwaux
-zero / -mcpu=power9
/ 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
/ xor
也没有。这里的 only 优势在于,即使采用索引寻址模式,也只有4 oups。某些AMD CPU可能通过ALU和加载端口运行MOVSXD,但Ryzen的延迟与常规加载相同。
如果迭代次数少于64次,那么如果只考虑吞吐量而不考虑延迟,则可以执行类似的操作。 (但您可能仍可以使用cmp
做得更好)
setl
但是add
的3个周期的延迟使它成为大多数用途的首选,即使它只是SnB系列中的单个uop。 rax-> rax依赖项是循环携带的。
答案 1 :(得分:3)
有一个技巧可以通过切换最高位将符号比较转换为无符号比较,反之亦然
bool signedLessThan(int a, int b)
{
return ((unsigned)a ^ INT_MIN) < b; // or a + 0x80000000U
}
之所以起作用,是因为2的补码中的范围仍然是线性的,只是交换了有符号和无符号空间。因此,最简单的方法可能是在比较之前进行异或运算
xor eax, eax
xor edx, 0x80000000 ; adjusting the search value
lea rdi, [rdi + rsi*4] ; pointer to end of array = base + length
neg rsi ; we loop from -length to zero
loop:
mov ecx, [rdi + 4 * rsi]
xor ecx, 0x80000000
cmp ecx, edx
adc rax, 0 ; only a single uop on Sandybridge-family even before BDW
inc rsi
jnz loop
如果您可以修改数组,则只需在检查之前进行转换
在ADX中,有ADOX使用OF的进位。不幸的是,签名比较也需要SF而不是OF,因此您不能像这样使用它
xor ecx, ecx
loop:
cmp [rdi + 4 * rsi], edx
adox rax, rcx ; rcx=0; ADOX is not available with an immediate operand
,并且必须进行一些其他操作才能更正结果
答案 2 :(得分:2)
在保证对数组进行排序的情况下,可以使用cmovl
,其“立即”值表示要添加的正确值。 cmovl
没有立即数,因此您必须事先将它们加载到寄存器中。
这种技术在展开时很有意义,例如:
; load constants
mov r11, 1
mov r12, 2
mov r13, 3
mov r14, 4
loop:
xor ecx, ecx
cmp [rdi + 0], edx
cmovl rcx, r11
cmp [rdi + 4], edx
cmovl rcx, r12
cmp [rdi + 8], edx
cmovl rcx, r13
cmp [rdi + 12], edx
cmovl rcx, r14
add rax, rcx
; update rdi, test loop condition, etc
jcc loop
每个比较有2个微秒,加上开销。 cmovl
指令之间有一个4周期(BDW和更高版本)的依赖关系链,但未携带。
一个缺点是您必须在循环外部设置1,2,3,4常量。如果未展开,它也不会很好地工作(您需要摊分add rax, rcx
积累)。
答案 3 :(得分:1)
假设数组已排序,则可以为正针和负针建立单独的代码分支。一开始您将需要一个分支指令,但是之后,您可以使用与无符号数字相同的无分支实现。我希望这是可以接受的。
针头> = 0:
针<0:
不幸的是,使用这种方法无法展开循环。 另一种选择是遍历每个数组两次。一次使用针,再一次查找正或负元素的数量(使用与最小有符号整数匹配的“ needle”)。
下面的代码可能有很多要优化的地方。我对此很生疏。
; NOTE: no need to initialize eax here!
lea rdi, [rdi + rsi*4] ; pointer to end of array = base + length
neg rsi ; we loop from -length to zero
mov ebx, 80000000h ; minimum signed integer (need this in the loop too)
cmp edx, ebx ; set carry if needle negative
sbb eax, eax ; -1 if needle negative, otherwise zero
and eax, esi ; -length if needle negative, otherwise zero
loop:
cmp [rdi + 4 * rsi], edx
adc rax, 0 ; +1 if element < needle
cmp [rdi + 4 * rsi], ebx
cmc
adc rax, 0 ; +1 if element >= 0x80000000
inc rsi
jnz loop