我正在编写一个简单的汇编程序,自然,它的目标是尽可能快。但是,位于最嵌套循环中的某个部分似乎并不“正确”,我相信即使没有使用条件跳转,也可以提出更聪明,更快速的实现。该代码实现了一个简单的事情:if rax < 0 then rax := 0else 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可以是负数。
最佳答案
一个比较分支和一个LEA + cmov。
将标量数据移至向量regs以获得一两个指令是不值得的,然后再将其移回。如果您一次可以有用地进行整个向量,则可以使用PMINSD/PMAXSD
将值限制在这样的范围内。
在您的原著中,有几件事显然不是最佳的。在大多数情况下,前两个仅与代码大小有关,但是对于非破坏性添加而言,LEA
是一个小而明显的胜利:cmp eax, 0
should be test eax, eax
mov rax, 0
should be xor eax, eax
。不,eax
不是rax
的错字。mov rax, r12 / dec rax
should be lea rax, [r12 - 1]
。
请参见x86 Wiki中的链接,尤其是。 Agner Fog's guides。
搜索一些内容后,我发现了a similar question about optimal x86 asm for clamping to a range。我从中得到了一些启发,但是大部分都是用cmov而不是setcc/dec/and
重写的。
我认为这对于最新的Intel或AMD CPU来说是最好的:
您需要一个保存0
的寄存器(或内存位置),否则需要一个mov reg, 0
附加指令。
...
cmp rax, r12
jae .clamp ; favour the fast-path more heavily by making it the not-taken case
.clamp_finished: ; rdx is clobbered, since the clamp code uses a scratch reg
...
ret
.clamp:
; flags still set from the cmp rax, r12
; we only get here if rax is >= r12 (`ge` signed compare), or negative (`l` rax < r12, signed)
; 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 eax, r15d ; rax=0 if orig_rax<r12 (signed), which means we got here because orig_rax<0
jmp .clamp_finished
Intel Haswell的快速性能分析:
快速路径:一个未采用的比较分支方式。 rax的延迟:0个周期。
需要夹紧的情况:进行一次比较分支运算的uop,再加上4次uops(lea,2表示cmov,另外1表示jmp。)rax的延迟:从rax和r12中的较晚者开始,经过3个周期(cmp-> flags) ,标志-> cmov)。
显然,您可以使用
jb
而不是jae
来跳过夹紧lea/cmov
,而不是将其从主流中拉出。有关动机,请参见以下部分。 (和/或看到Anatolyg的出色答案涵盖了这一点。我也从Anatolyg的答案中得到了一个很酷的技巧,即使用jb
对一个分支执行[0 .. limit]
。)我认为使用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启动了新的依赖链,因此不必等待
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中有一个快速的最小/最大值,我们可以在这里使用它。 vector-max是单指令,但标量需要更多代码。关于assembly - x86组装-将rax夹紧到[0 .. limit)的优化,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/34071144/