x86 汇编 - 将 rax 夹紧到 [ 0 .. limit ) 的优化

x86 assembly - optimization of clamping rax to [ 0 .. limit )

我正在编写一个简单的汇编程序,其目标自然是尽可能快。然而,位于最嵌套循环中的某个部分似乎并不 'right' 我相信可以想出更聪明和更快的实现,甚至可能不使用条件跳转。代码实现了一个简单的事情:

if rax < 0 then rax := 0 else if rax >= r12 then rax := r12 - 1

这是我天真的实现:

cmp rax, 0
jge offsetXGE
   mov rax, 0
   jmp offsetXReady
offsetXGE:
   cmp rax, r12
   jl offsetXReady
   mov rax, r12
   dec rax
offsetXReady:

欢迎任何想法,即使是那些使用 MMX 和一些掩蔽技巧的想法。

编辑:回答评论中的一些问题——是的,我们可以假设 r12 > 0 但 rax 可以是负数。

跳得少,我觉得更干净:

xor  rdx, rdx
test rax, rax
js   OK
lea  rdx, [r12 - 1]
cmp  rax, r12
jge  OK
mov  rdx, rax
OK:
mov  rax, rdx

一个常见的技巧(编译器使用它)是进行无符号比较:

    cmp rax, r12
    jb done
    ...
    ...
done:

这里,如果rax是负数,当解释为无符号数时(按jb,"jump if below")看起来像一个大数(大于263),因此无符号比较将两种 "exceptional" 情况混为一谈(小于 0 且太大)。

如果异常情况很少见,...表示的代码的性能无关紧要,通常情况包含一个条件分支,通常采用。如果你想进一步改进它,你可以像这样重新排列代码:

    cmp rax, r12
    jb work_needed
done:
    (your code continued here)

work_needed:
    jl upper_limit_done
    lea rax, [r12 - 1]
upper_limit_done:
    test rax, rax
    jns lower_limit_done
    xor rax, rax
lower_limit_done:
    jmp done

这里,通常的路径包含一个通常不走的分支。这可能会提供一些小的改进,但代价是异常情况变慢。

分支:一个比较分支,一个 LEA + cmov。

无分支:1个CMP,2个单uop CMOV

(或者使用 中的相同技巧完全无分支,如果超出范围的值很常见但不可预测,这可能会很好。)


不值得将标量数据移动到向量 regs 以执行一两个指令,然后再将其移回。如果您一次可以有效地处理整个向量,那么您可以使用 PMINSD/PMAXSD 将值限制在一个带符号的范围内。 (或 2x packssdw + packuswb 将 4 个输入向量压缩为一个 8 位元素向量,最终无符号饱和。)

在您的原文中,有几件事显然不是最理想的。大多数情况下,前两个只对代码大小有影响,但 LEA 对于非破坏性添加来说是一个小而明显的胜利:

  • cmp eax, 0

  • mov rax, 0。不,eax 不是 rax.

    的拼写错误
  • mov rax, r12 / dec rax should be lea rax, [r12 - 1].

查看 wiki, esp. Agner Fog's guides 中的链接。


如果夹紧不常见,则完全需要分支:

您需要一个保存 0 的寄存器(或内存位置),或者一个额外的指令 mov reg, 0

    ...
    cmp  rax, r12
    jae  .clamp      ; not-taken fast-path = no clamping
.clamp_finished:

    ...
    ret

.clamp:   
    ; flags still set from the cmp rax, r12
    ; we only get here if rax is >= r12 (`ge` signed compare), or negative (so `l` rax < r12 indicates rax<0)

    ; mov r15d, 0        ; or zero it outside the loop so it can be used when needed.  Can't xor-zero because we need to preserve flags

    lea    rax, [r12-1]  ; still doesn't modify flags
    cmovl  rax, r15      ; rax=0 if  orig_rax<r12 (signed), which means we got here because orig_rax<0
    jmp  .clamp_finished

Intel Skylake 的快速性能分析:

  • 快速路径:一个未采用的比较分支微指令。 rax 延迟:0 个周期。

  • 需要钳位的情况:一个采用比较和分支微指令,再加上 3 个微指令(lea,1 个用于 cmov,1 个用于 jmp 返回。)rax 的延迟:2 个周期从稍后 RAX 或 R12 准备就绪(cmp + lea 并行,然后 cmov 从 cmp 读取 FLAGS)。

    在 Intel Haswell 或更早版本上,cmovl 为 2 微指令,并在关键路径上花费额外的延迟周期,因此总共 3 个。

显然你可以使用 jb 而不是 jae 来跳过限制 lea/cmov,而不是将它们从主流中拉出来。请参阅下面的部分以了解其动机。 (And/or 请参阅 Anatolyg 的出色回答,其中涵盖了这一点。我得到了一个很酷的技巧,即使用 jb 来执行 [0 .. limit] 以及来自 Anatolyg 的回答的一个分支。

我认为使用 jae/cmov 的版本是最好的选择,even though cmov has a lot of downsides and isn't always faster。已经需要它的输入操作数,因此即使需要钳位也不会增加太多延迟。

不需要清零寄存器的 .clamp 代码块的另一种分支实现是:

.clamp:
    lea    rax, [r12-1]
    jge  .clamp_finished
    xor    eax, eax
    jmp  .clamp_finished

它仍然会计算一个它可能会丢弃的结果,cmov 风格。但是,下面的 xor 启动了一个新的依赖链,因此如果执行 xor-zeroing,它不必等待 lea 写入 rax


一个重要的问题是您希望多久进行一次这些分支。如果有一个常见的情况(例如,无夹紧情况),请将其设为快速路径代码(尽可能少的指令和尽可能少的分支)。根据采用分支的频率,将不常见情况的代码放在函数末尾是值得的。

func:
    ...
    test
    jcc .unlikely
    ...        
.ret_from_unlikely:
    ...
    ... ;; lots of code
    ret

.unlikely:
    xor   eax,eax
    jmp .ret_from_unlikely   ;; this extra jump makes the slow path slower, but that's worth it to make the fast path faster.

Gcc 这样做,我认为当它决定一个分支不太可能被采用时。因此,不是让典型案例采用跳过某些指令的分支,而是常见案例失败了。通常,default branch prediction is not-taken for forward jumps,所以在它看到不太可能的情况之前,它甚至不需要分支预测器条目。


随想:代码

if (eax < 0) { eax = 0; }
else if (eax >= r12) { eax := r12 - 1 }  // If r12 can be zero, the else matters

等同于

eax = min(eax, r12-1);
eax = max(eax, 0);

r12 不能为负,但是OP没有说不能为零。此排序保留 if/else 语义。 (编辑:实际上 OP 确实说过你可以假设 r12>0,而不是 >=0。)如果我们在 asm 中有一个快速的 min/max,我们可以在这里使用它。 vector-max 是单指令,但标量需要更多代码。


相关代码审查:Fastest way to clamp an integer to the range 0-255。但那是在 C 中,编译器生成的 asm 版本中的 none 是最佳的。尽管如此,它还是提供了一些初步的灵感。

此外,当前的 clang 将 std::clamp 悲观化为存储、选择指针和重新加载。 https://bugs.llvm.org/show_bug.cgi?id=47271

待办事项:使用此夹具窥视孔记录优化错误错误报告,以便编译器可以查找它。

目前,我有无分支夹紧到 [0, limit] 的版本(两端封闭范围,因此要限制而不是 limit-1。这需要一些技巧才能完成,同时避免 cmova / cmovbe(在 Intel 上仍然是 2 微指令,不像大多数只读取 CF 一些 SPAZO 标志的 CMOV 谓词。)

# gcc -nostdlib -static testloop.S -o testloop && 
# taskset -c 3 perf stat --all-user -etask-clock:u,context-switches:u,cpu-migrations:u,page-faults:u,cycles:u,branches:u,instructions:u,uops_issued.any:u,uops_executed.thread:u,idq.dsb_uops:u -r1 ./testloop

# or idq.mite_uops to make sure it's low

.intel_syntax noprefix
.global _start
_start:

    mov     edi, 34
    mov     esi, 100
    xor     ecx, ecx

    mov     ebp, 100000000

.p2align 6
.loop:

# ~3.10 cycles latency, with unroll or an imul to give OoO scheduling an easier time. Can be 3.3c in worse cases.
.macro  clamp0n  dst, n, high
    xor    \dst, \dst
    cmp    \n, \high
    cmovg  \dst, \high       # prepare clamped value: n>high [signed] ? high : 0
    cmovbe \dst, \n          # copy original if n <= high  [unsigned], i.e. in range
.endm

# ~4.00 cycles latency, no ILP for 2-uop cmovbe
.macro  clamp0n_rev  dst, n, high
    xor    \dst, \dst
    cmp    \n, \high
    cmovbe \dst, \n          # copy original if n <= high [unsigned]; no clamping
    cmovg  \dst, \high       # high if n>high (replacing 0), else leave orig
.endm

# ~3.00 cycles latency, only single-uop CMOV
.macro  clamp0n_rev_intel  dst, n, high
    xor    \dst, \dst
    cmp    \n, \high
    cmovb  \dst, \n          # copy original if n < high [unsigned]; no clamping.  (cmovbe is 2 uops on Intel, let next insn handle that case)
    cmovge \dst, \high       # high if n>=high (replacing 0), else leave orig.  copy on equal restores the value destroyed by cmovb
.endm

# ~3.1 to 3.3 cycle latency
.macro  clamp0n_inplace_destroy_zero  n, high, tmp
    xor    \tmp, \tmp
    cmp    \n, \high
    cmovg  \tmp, \high       # prepare clamped value: 0 or high, per signed compare.
    cmova  \n, \tmp         # if clamping needed at all, apply clamped value
.endm

# 4.0 cycles latency.
.macro  clamp0n_inplace  n, high, zero
    cmp    \n, \high
    cmova  \n, \zero        # if clamping needed at all, apply 0.  2 uops on Intel.  could be 2nd if we destroy \high?
    cmovg  \n, \high        # if signed greater than limit, clamp to high
.endm

# 3.0 cycles latency, only single uop CMOV.
.macro  clamp0n_inplace_intel  n, high, zero
    cmp    \n, \high
    cmovae  \n, \zero        # if clamping needed at all, apply 0.  (or on equal, to avoid 2-uop cmov)
    cmovge  \n, \high        # if signed greater than limit, clamp to high. (or on equal, to restore the correct value)
.endm

#define CLAMP_INPLACE clamp0n_inplace_intel
#define CLAMP_COPY    clamp0n_rev_intel

   CLAMP_INPLACE  edi, esi, ecx
   CLAMP_INPLACE  edi, esi, ecx
   CLAMP_INPLACE  edi, esi, ecx
   CLAMP_INPLACE  edi, esi, ecx
#   imul     edi, edi

#if 0
//#define clamp0n clamp0n_rev        // use the slow version
   CLAMP_COPY   eax, edi, esi
   and      eax, edi
   imul     edi, eax, 123
#endif

#if 0
   CLAMP_COPY  edi, eax, esi

   CLAMP_COPY  eax, edi, esi
   CLAMP_COPY  edi, eax, esi

   CLAMP_COPY  rax, rdi, rsi    # 64-bit for REX prefixes, keep dec/jnz off a 32-byte boundary so uop cache works (JCC erratum mitigation)
   CLAMP_COPY  rdi, rax, rsi
#endif

#nop  # pad the loop up to 32 total uops.  Tiny benefit on skylake in this artifical fully latency-bound case.
    dec ebp
    jnz .loop

    xor edi,edi
    mov eax,231   # __NR_exit_group  from /usr/include/asm/unistd_64.h
    syscall       # sys_exit_group(0)

编辑: 正如 Peter Cordes 所指出的,CMOVxx 使事情变得更短。我在下面包含了我的原始答案,以表明它确实可以在没有 CMOVxx 的情况下完成。

可以使用条件移动在没有分支的情况下完成:

lea   rbx, [r12-1]
cmp   rax, r12
cmovge rax, rbx       ; clamp to r12-1 if it was too high
xor   rbx, rbx
test  rax, rax
cmovl rax, rbx        ; clamp to 0 if it's <= 0

或者优化为只比较一次,使用来自 Peter 答案的相同有符号 + 无符号比较技巧:

xor    edx, edx
lea    rcx, [r12-1]
cmp    rax, r12
cmovae rax, rdx        ; clamp to 0 if unsigned >= r12
cmovge rax, rcx        ; clamp to r12-1 if signed >= r12, replacing 0. Else leave orig RAX

如果rax >=(unsigned) r12,则该值肯定需要限制为 0 或 r12-1。

对于负的RAX输入,只有ae条件为真,而不是ge,所以最终结果为0。但是对于太高的有符号正输入,ge 将是真实的。 ae 也为真并不重要,第二个 cmov 将覆盖 0。否则(对于负 RAX),它将单独保留 0。

r12 已知为正符号,因此 ge 为真意味着 ae 为真。

在具有 1 个周期延迟的 CPU 上 cmovae / ge(Broadwell 及更高版本,以及 AMD:https://uops.info/),这具有 3 个周期延迟RAX 输入到 RAX 输出。 (cmova 是 2 微指令,在现代 Intel 上读取 ZF 和 CF 时有 2 个周期的延迟,但幸运的是我们不需要它。)


使用老式(1990 年代)技术的原始答案:

有一个没有任何分支的答案。它有点复杂,因为计算了每一个可能的结果,即使它没有被使用。我不知道它如何将性能与分支代码进行比较。但是没有分支惩罚。

避免分支的诀窍是通过使用寄存器的减法借位将进位标志 (CF) 变成位掩码。之后,您可以将该寄存器用作和掩码以 select 获得所需的结果。

; trick #1: rbx made 0 when rax negative, else 0xFFFFFFFFFFFFFFFF

mov rbx, rax
shl rbx, 1      ; negative => CF
cmc             ; CF 0 when rax negative
sbb rbx, rbx    ; this sets CF into all bits of rbx

; trick #2: rcx made 0xFFFFFFFFFFFFFFFF if less than r12, or else 0
; note that r12 should be positive for this to make sense

cmp rax, r12    ; CF 1 when rax < r12
sbb rcx, rcx    ; this sets CF into all bits of rcx

and rax, rcx    ; use rcx mask on rax

; calculate highest allowed value
mov rdx, r12
dec rdx
not rcx         ; invert rcx mask
and rdx, rcx    ; apply to replacement value

or rax, rdx
and rax, rbx