【问题标题】:Counting elements "less than x" in an array计算数组中“小于 x”的元素
【发布时间】:2019-06-14 08:41:09
【问题描述】:

假设您想在排序数组中查找第一次出现的值1。对于小型数组(例如二分查找没有回报),您可以通过简单地计算小于该值的值的数量来实现这一点:结果就是您所追求的索引。

在 x86 中,您可以使用 adc(带进位相加)来实现该方法的有效无分支2 实现(在rdi 中的开始指针在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 结尾。如果您展开它(或者如果您有一个固定的、已知的输入大小),则只有 cmp; adc 对指令会重复,因此每次比较的开销接近 2 个简单指令(有时还有融合负载)。 Which Intel microarchitecture introduced the ADC reg,0 single-uop special case?

但是,这只适用于 unsigned 比较,其中进位标志保存比较的结果。 是否有任何等效的有效序列来计算签名比较?不幸的是,似乎没有“如果小于则加 1”指令:adcsbb 和进位标志在这方面是特殊的。

我对元素没有特定顺序的一般情况感兴趣,并且在这种情况下,数组是排序的,在这种情况下,排序假设会导致更简单或更快的实现。


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

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

【问题讨论】:

  • @zch - 是的,在我感兴趣的情况下,数组很小并且元素位置不可预测,因此单个错误预测最终会比无分支方法慢。
  • @Veedrac - 在高层次上,只是为了限制问题的范围并保持“签名”与“未签名”部分的相关性。我认为理想的 SIMD 解决方案很简单:cmpgt(如果您需要无符号,可能会调整以考虑无符号值)、pmovmskpopcnt 或类似的东西,具有一个重叠未对齐负载和屏蔽的常见技巧以某种方式排除重叠的结果,以允许数组不是向量大小的倍数。如果您对此感兴趣,我可能会再问一个问题。
  • 我注意到你似乎对二分搜索有误解;我强烈推荐阅读pvk.ca/Blog/2012/07/03/…。 “线性搜索只应在预期快速退出时使用。该属性最有用的情况涉及未缓存的中等大小的向量。”
  • @Veedrac - 我很清楚那个帖子,它很棒!我不认为这与我所说的任何内容相矛盾。 Paul 实际上正在考虑“分支”线性搜索与分支和无分支二进制搜索,并得出您引用的结论。还有我在这里谈论的无分支线性搜索。它在具有可预测大小的非常小的数组中获胜(由于访问之间的数据依赖性,无分支二分搜索失败,而分支搜索失败,因为即使一个错误预测比其他搜索的总时间长)。
  • @Veedrac 在 0 到 32 的范围内。

标签: performance search assembly optimization x86


【解决方案1】:

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) 指令的条件标志1bt/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 不能。我们只有 cmovccsetcc 可以将 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 &gt; arr[i] 而不会破坏密钥,而不是 arr[i] &gt; key。 (这是 gcc 和 clang 在自动矢量化时所做的)。

AVX 会很好,因为 vpcmpgtd xmm0, xmm1, [rdi] 可以做 key &gt; arr[i],就像 gcc 和 clang 与 AVX 一起使用。但这是一个 128 位的负载,我们希望保持简单和标量。

我们可以递减key 并使用(arr[i]&lt;key) = (arr[i] &lt;= key-1) = !(arr[i] &gt; 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 依赖是循环携带的。

【讨论】:

  • 优秀的答案!我曾假设cmp; setcc; add 的基本情况是 3 微秒,但正如您指出的那样,将setcc 目标归零、中断和错误依赖关系很复杂。啊! sub + shld 解决方案非常有趣。我可以假设使用多个累加器来隐藏shld 延迟。你的其他观点都很好。在破坏 dest 时,我没有考虑 SSE 中的“唯一 gt”问题。
  • @BeeOnRope:我认为最好的办法是在不展开的情况下进行矢量化,使用 128 位矢量,并为小尺寸设计启动/清理。 (使用 AVX2 vpmaskmovd 我们可以避免加载超出缓冲区的末尾,而无需对奇数大小和小于 4 的大小进行太多棘手的处理)。使用vpcmpgtd xmm0, xmm4, [rdi] 的AVX1,非常好。处理另一个答案的代码。 :P Scalar 可能仍然会在大小为 3 或 4 时获胜,但 SIMD 可能会在大小 >= 8 时获胜。如果我们可以假设 sizepsadbw 进行 hsum 而不是 unpack / paddd 一步。跨度>
  • vpmaskmovd 是一个有趣的想法。另一个想法就是以保证不会跨页的方式加载。我一直在考虑处理这些小(小于向量)数组的最佳方法:似乎有很多解决方案,包括额外对齐的负载、各种未对齐的负载、屏蔽负载以及这些的各种条件组合(例如,总是只加载传递的指针,除非在页面末尾附近,在这种情况下进行调整加载,数组结束在向量的末尾)。我想知道是否有一种整体上最好的策略?
  • 顺便说一句,Agner 显示 VPMASKMOVD/Q 的延迟周期比 VMASKMOVPS/D 多一个。似乎不太可能?
  • @BeeOnRope:我不认为它更慢,我只是说如果循环一个大数组,整个循环中的 每个 存储都会受到这种影响原因实际上在硬件中是只读的。回复:避免弄脏:我写了一个测试程序(godbolt.org/z/Wna0wD),但没有完成对我发现的 SO 答案的编辑。我认为我实际上没有测试过脏位,只是mmap(MAP_ANONYMOUS),然后在循环之前写入或不写入一次。不写它,就像我使用 PROT_READ 一样。我认为我之前的评论记错了我测试的内容。
【解决方案2】:

有一个技巧可以通过切换最高位将有符号比较转换为无符号比较,反之亦然

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

并且必须做更多的位操作来更正结果

【讨论】:

    【解决方案3】:

    在保证对数组进行排序的情况下,可以使用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 个为假但最后一个为真,您将添加 4,而不是 1。我错过了什么吗?或者这仍然假设一个排序数组,就像你提到的问题中的用例之一?这至少需要在这个答案中发表评论,IMO。我怀疑如果您要引入展开 4 的小情况开销,那么使用 SSE2 movdqu + pcmpgtd 是值得的,除非您正在调整具有缓慢未对齐负载的 CPU。
    • @PeterCordes - 是的,你是对的,这假设一个排序数组。那是我最初的激励用例,但后来我使标题更笼统,然后在文本中使用排序排序作为我的示例,所以我同意不清楚你是否可以假设排序数组!我会澄清的。
    【解决方案4】:

    假设数组已排序,您可以为正负针创建单独的代码分支。一开始您将需要一个分支指令,但之后,您可以使用与无符号数相同的无分支实现。我希望这是可以接受的。

    针 >= 0:

    • 按升序遍历数组
    • 首先计算每个负数组元素
    • 继续使用正数,就像在未签名场景中一样

    • 按降序遍历数组
    • 首先跳过每个正数组元素
    • 像在未签名场景中一样处理负数

    不幸的是,在这种方法中,您无法展开循环。 另一种方法是遍历每个数组两次;一次用针,然后再次找到正或负元素的数量(使用与最小有符号整数匹配的“针”)。

    • (无符号)计算元素
    • (无符号)计算元素数 >= 0x80000000
    • 添加结果
    • 如果 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
    

    【讨论】:

    • 这需要从arr[i] &lt; 0 循环切换到arr[i] &lt; needle 循环的分支(例如,对于正needle)。如果数组内容保持不变(并且您在同一个数组上重复使用,而不是相同大小的不同数组),那么这是一个选项。但可能比 xor eax,eax / cmp / setl al 更糟糕,从编译器将生成的符号比较结果创建整数。 2 条额外指令与无符号循环,或者 1 如果您将 tmp 的异或零提升到循环之外。如果不是为了打败循环展开,可能没问题。 (我正在用这个写一个答案。)
    • @PeterCordes 我添加了一种没有“开关”的替代方法,但是是的,我担心它仍然可能无法满足 OP 的性能要求。
    • 这很有创意,但比使用 setcc 的正常循环差很多,就像编译器所做的那样。例如gcc 为count += (arr[i] &lt; key); 的明显 C 实现创建了一个 6-uop 循环。您的循环在 Intel Sandybridge 系列上是 8 微指令,在 AMD 上是 7。 (英特尔 P6 系列上的 9 微指令,其中adc-0 不是特殊情况下的 1 微指令而不是 2)。如果您可以通过使用sbb 或 CMOV 来避免 CMC,则 IDK,但加载两次并不是很好。而且两者都没有通过 RAX 进行 2 周期循环携带的依赖。展开无法隐藏这一点。
    • 顺便说一句,您在循环外的设置会比xor eax,eax / test edx,edx / cmovs eax, esi 更有效。您可以在循环内使用cmp 和直接80000000h。 (或者对于额外的 tmp reg,使用 ECX 而不是 EBX。EBX 在大多数调用约定中都是调用保留的。)
    • 哦,是的,我认为您可以通过 cmp ebx, [rdi + 4 * rsi] 避免 CMC。 CMP r/m32 有 2 个操作码,让您在左侧或右侧拥有内存操作数。我认为您需要将0x80000000 调整为0x7fffffff,因为您想为任何带有MSB 集的元素进位。或者,您可以mov ecx, [rdi + 4 * rsi]cmp ecx,edx / adc,然后shr ecx,31 / add eax,ecx 通过移位而不是通过CF 提取高位。这仍然是每个元素 5 uops,比我的回答中的 3 或 4 差,但它与 GCC 的循环一样好。
    猜你喜欢
    • 2022-01-06
    • 1970-01-01
    • 2014-07-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-05-12
    • 1970-01-01
    • 2021-11-22
    相关资源
    最近更新 更多