我写了一个简单的ticket lock的简单实现。锁定部分看起来像:
struct ticket {
uint16_t next_ticket;
uint16_t now_serving;
};
void lock(ticket* tkt) {
const uint16_t my_ticket =
__sync_fetch_and_add(&tkt->next_ticket, 1);
while (tkt->now_serving != my_ticket) {
_mm_pause();
__asm__ __volatile__("":::"memory");
}
}
然后我意识到,不是使用gcc内在函数,我可以用std::atomic
s来写这个:
struct atom_ticket {
std::atomic<uint16_t> next_ticket;
std::atomic<uint16_t> now_serving;
};
void lock(atom_ticket* tkt) {
const uint16_t my_ticket =
tkt->next_ticket.fetch_add(1, std::memory_order_relaxed);
while (tkt->now_serving.load(std::memory_order_relaxed) != my_ticket) {
_mm_pause();
}
}
这些生成几乎相同的程序集,但后者生成一个额外的movzwl
指令。为什么会有这个额外的mov
?是否有更好,更正确的方式来撰写lock()
?
汇编输出-march=native -O3
:
0000000000000000 <lock(ticket*)>:
0: b8 01 00 00 00 mov $0x1,%eax
5: 66 f0 0f c1 07 lock xadd %ax,(%rdi)
a: 66 39 47 02 cmp %ax,0x2(%rdi)
e: 74 08 je 18 <lock(ticket*)+0x18>
10: f3 90 pause
12: 66 39 47 02 cmp %ax,0x2(%rdi)
16: 75 f8 jne 10 <lock(ticket*)+0x10>
18: f3 c3 repz retq
1a: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
0000000000000020 <lock(atom_ticket*)>:
20: ba 01 00 00 00 mov $0x1,%edx
25: 66 f0 0f c1 17 lock xadd %dx,(%rdi)
2a: 48 83 c7 02 add $0x2,%rdi
2e: eb 02 jmp 32 <lock(atom_ticket*)+0x12>
30: f3 90 pause
=> 32: 0f b7 07 movzwl (%rdi),%eax <== ???
35: 66 39 c2 cmp %ax,%dx
38: 75 f6 jne 30 <lock(atom_ticket*)+0x10>
3a: f3 c3 repz retq
为什么不直接cmp (%rdi),%dx
?
答案 0 :(得分:2)
首先,我认为您需要使用std::memory_order_acquire
,因为您正在获取锁定。如果您使用mo_relaxed
,您可能会在之前的锁定持有者所做的某些商店之前看到陈旧数据。 Jeff Preshing's blog is excellent, and he has a post on release/acquire semantics
在x86上,只有在编译器重新命令加载和存储时才会发生这种情况,mo_relaxed
告诉它允许它。获取负载在x86上编译与轻松负载相同,但不重新排序。 每个 x86 asm加载已经是一个获取。在需要它的弱有序架构上,您将获得负载获取所需的任何指令。 (在x86上,你只是阻止编译器重新排序)。
我将代码on godbolt的一个版本用各种编译器来查看asm。
很明显,这看起来像gcc优化失败,仍然至少在6.0中出现(使用Wandbox检查,使用main
执行return execlp("objdump", "objdump", "-Mintel", "-d", argv[0], NULL);
转储自身的反汇编输出,包括我们感兴趣的功能。
看起来clang 3.7在这方面做得更糟。它执行16位负载,然后进行零扩展,然后进行比较。
gcc特别处理原子载荷,显然没有注意到它可以将它折叠到比较中。可能已经完成的优化过程,而原子载荷仍然表示与常规载荷或其他东西不同。我不是一个gcc黑客,所以这主要是猜测。
我怀疑你有一个旧的gcc(4.9.2或更高版本),或者你正在构建on / for AMD,因为你的编译器used rep ret
甚至是-march=native
。如果你关心生成最佳代码,你应该做些什么。我注意到gcc5有时比gcc 4.9制作更好的代码。 (不过它在这种情况下有所帮助,但是:/)
我尝试使用uint32_t,没有运气。
执行加载和单独比较的性能影响可能无关紧要,因为此函数是忙等待循环。
快速路径(解锁情况,第一次迭代时循环条件为假)仍然只有一个采用分支和一个ret。但是,在std:atomic版本中,快速路径通过循环分支。因此,不是两个单独的分支预测器条目(一个用于快速路径,一个用于自旋循环),现在旋转可能会在下一个未锁定的情况下导致分支错误预测。这可能不是问题,新代码确实需要少一个分支预测器条目。
如果跳到循环中间,IDK对Intel SnB系列微体系结构的uop缓存有任何不良影响。它是一种跟踪缓存。 Agner Fog's testing发现,如果uop缓存中有多个跳转入口点,则同一段代码可以有多个条目。这个函数已经有点uop-cache不友好,因为它以mov r, imm / lock xadd
开头。锁xadd必须单独进入uop缓存行,因为它是微编码的(实际上超过4 uops.9)。无条件跳转始终结束uop缓存行。我不确定采用条件分支,但我猜测如果在解码时预测采用了jcc,则jcc结束缓存行。 (例如,分支预测器条目仍然很好,但旧的uop缓存条目被逐出)。
因此,第一个版本可能是快速路径的3 uops缓存行:一个mov
(如果内联,希望大部分时间充满前面的指令),一个lock xadd
,一个宏融合cmp/je
跟随代码(如果没有,如果没有,则跳转目标是ret
,这可能最终成为这个32字节代码块的第4个缓存行,这是不允许的。所以非内联版本可能总是每次都要重新解码?)
std :: atomic版本又是初始mov imm
(及前面的说明)的一个uop-cache行,然后是lock xadd
,然后是add / jmp
,然后......呃哦,movzx / compare-and-branch
uops所需的第4个缓存行。因此,即使内联,此版本也更有可能出现解码瓶颈。
幸运的是,在运行此代码时,前端仍然可以获得一些基础并获得排队等待OOO内核的指令,因为lock xadd
是9 uops。这足以覆盖前端的一个或两个较少的uop,以及解码和uop-cache获取之间的切换。
这里的主要问题只是代码大小,因为你有问题。想要这个内联。速度方面,快速路径只是稍微差一点,非快速路径无论如何都是自旋循环,所以它并不重要。
快速路径是旧版本的11个融合域uops(1 mov imm
,9 lock xadd
,1 cmp/je
宏融合)。 cmp/je
包含一个微融合内存操作数。
快速路径是新版本的41个融合域uops(1 mov imm
,9 lock xadd
,1 add
,1 jmp
,1 {{1 },1 movzx
宏融合。)
在cmp/je
的寻址模式下使用add
而不是仅使用8位偏移实际上是在脚下,IMO。 IDK,如果gcc认为远远超出了这样的选择,让循环分支目标在16B边界出现,或者这只是运气不好。
使用OP代码对Godbolt进行编译器识别实验:
movzx
,即使使用rep ret
(在Haswell上),也只使用-march=native -mtune=core2
。-march=core2
和rep ret
,可能是因为Haswell太新了。 -march=native
仅使用-march=native -mtune=haswell
,因此它确实知道名称ret
。haswell
与ret
一起使用(在Haswell上)。在-march=native
未指定时仍使用rep ret
。答案 1 :(得分:0)
在第一个
12: 66 39 47 02 cmp %ax,0x2(%rdi)
cmp是mov和cmp指令的组合(它很可能在microarchitecture指令集中生成两条指令)
原子变体正在使用
对now_serving进行单独读取32: 0f b7 07 movzwl (%rdi),%eax
然后与
进行比较35: 66 39 c2 cmp %ax,%dx