计算数组中“少于x”的元素

时间:2019-01-20 23:07:28

标签: performance search assembly optimization x86

假设您要在排序数组中查找值 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; adcadc和进位标志在这方面是特殊的。

我对元素没有特定顺序的一般情况感兴趣,而且在这种情况下,如果排序假设导致实现更简单或更快速的实现,则对数组进行排序。


1 或,如果该值不存在,则是第一个更大的值。也就是说,这就是所谓的“下界”搜索。

2 无分支方法每次都必须完成相同的工作量-在这种情况下,检查整个数组,因此这种方法仅在数组较小时才有意义,因此分支的成本相对于总搜索时间而言,错误预测会很大。

4 个答案:

答案 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不能。我们只有cmovccsetcc才能将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进行一次。


您可以将SSE2或MMX用于标量材料

这两个都是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”)。

  • (无符号)计数元素
  • (无符号)计算> = 0x80000000的元素
  • 添加结果
  • 如果针数<0,则从结果中减去阵列长度

下面的代码可能有很多要优化的地方。我对此很生疏。

                          ; 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