Branchy:一个比较分支,一个 LEA + cmov。
无分支:1 个 CMP,2 个单 uop CMOV
(或者在@Martijn 的答案中使用相同的技巧完全无分支,如果超出范围的值很常见但不可预测,这可能会很好。)
将标量数据移动到向量 regs 以执行一两条指令,然后将其移回是不值得的。如果您可以一次有效地处理整个向量,那么您可以使用PMINSD/PMAXSD
将值钳制在有符号范围内。(或 2x packssdw
+packuswb
将 4 个输入向量压缩成一个 8 位元素向量,最终无符号饱和。)
在您的原作中,有几件事显然不是最理想的。大多数情况下,前两个只对代码大小很重要,但LEA
对于非破坏性添加来说,这是一个小而明显的胜利:
请参阅x86 wiki中的链接,尤其是。阿格纳雾的指南。
如果钳位不常见,则完全需要它:
您需要一个寄存器(或内存位置)来保存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
英特尔 Skylake 的快速性能分析:
快速路径:一个未采用的比较和分支微指令。rax 的延迟:0 个周期。
需要钳位的情况:一个采用比较和分支微指令,再加上 3 个微指令(lea,1 个用于 cmov,1 个用于 jmp 返回。) rax 的延迟:RAX 或 R12 准备就绪后的 2 个周期(cmp + lea 并行,然后 cmov 从 cmp 读取 FLAGS)。
在 Intel Haswell 或更早版本上,cmovl 为 2 微秒,并且在关键路径上花费了一个额外的延迟周期,因此总共 3 个。
显然,您可以使用jb
而不是jae
跳过夹紧lea/cmov
,而不是将它们拉出主流。请参阅下面的部分以了解其动机。(和/或查看 Anatolyg 的出色答案,其中涵盖了这一点。我也得到了一个很酷的技巧,即使用Anatolyg 的答案中的一个分支来完成)jb
。[0 .. limit]
我认为使用 jae/cmov 的版本是最好的选择,尽管 cmov 有很多缺点并且并不总是更快。已经需要它的输入操作数,因此即使需要钳位也不会增加太多延迟。
.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 这样做,我认为当它决定不太可能采用分支时。因此,不是让典型案例采用跳过一些指令的分支,而是让常见案例失败。通常,默认分支预测不会用于前向跳转,因此在看到不太可能的情况之前,它甚至不需要分支预测器条目。
随机想法:代码
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 是单指令,但标量需要更多代码。
相关代码审查:将整数限制在 0-255 范围内的最快方法。但那是在 C 中,编译器生成的 asm 版本都不是最佳的。尽管如此,它还是提供了一些初步的灵感。
同样相关的是,当前的 clang 使 std::clamp 悲观于存储、选择指针和重新加载。 https://bugs.llvm.org/show_bug.cgi?id=47271
TODO:使用此钳位窥视孔归档错过的优化错误报告,以便编译器可以查找它。
目前,我的版本可以无分支地钳制到[0, limit]
(两端的封闭范围,因此要限制而不是 limit-1。这需要一些技巧来完成它,同时避免cmova
/ cmovbe
(与大多数 CMOV 谓词不同,在 Intel 上仍然是 2 uops只读取 CF或一些 SPAZO 标志。)
# 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)