我写了一个简单的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");
}
}
然后我意识到,可以使用
std::atomic
编写此代码,而不是使用gcc内部函数: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
? 最佳答案
首先,我认为您需要使用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或更早版本),或者您正在基于AMD进行开发,因为您的编译器used rep ret
甚至带有-march=native
。如果您关心生成最佳代码,则应该对此做些事情。我注意到gcc5有时会比gcc 4.9编写更好的代码。 (尽管不是这样,它在这种情况下有所帮助://)
我尝试使用uint32_t,但没有运气。
分开进行加载和比较对性能的影响可能无关紧要,因为此函数是一个忙等待循环。
快速路径(在未锁定的情况下,循环条件在第一次迭代中为false)仍然仅是一个分支和一个ret。但是,在std:atomic版本中,快速路径通过循环分支。因此,现在旋转可能会在下一个未锁定的情况下导致分支错误预测,而不是两个单独的分支预测程序条目(一个用于快速路径,一个用于自旋循环)。这可能不是问题,新代码确实需要少一个分支预测器条目。
如果IDK跳入循环中间,则会对Intel SnB系列微体系结构的uop缓存产生任何不良影响。这是一个跟踪缓存。 Agner Fog's testing发现,如果同一段代码具有多个跳转入口点,则它可以在uop缓存中包含多个条目。由于此函数以mov r, imm / lock xadd
开头,因此在uop-cache中已经不友好了。 xadd锁必须自己进入uop缓存行,因为它是微编码的(实际上超过4 uops。实际上是9)。无条件跳转总是结束uop缓存行。我不确定采用的条件分支,但是我猜想,如果在解码时预测到采用jcc,则采用jcc会结束缓存行。 (例如,分支预测变量条目仍然不错,但旧的uop缓存条目被逐出了)。
因此,第一个版本的快速路径可能包含3个微妙的高速缓存行:一个mov
(并且如果是内联的,希望大多数情况下充满了先前的指令),一个lock xadd
,一个宏融合的cmp/je
到后续代码(如果内联的话)。跳转针对的是ret
,它可能会终止于此32字节代码块的第4个缓存行,这是不允许的。因此,此非内联版本可能总是每次都必须重新解码?)
std::atomic版本仍然是用于初始mov imm
(和前面的指令)的一个uop缓存行,然后是lock xadd
,然后是add / jmp
,然后……呃,这是movzx / compare-and-branch
uops所需的第四个缓存行。因此,即使内联,此版本也更有可能存在解码瓶颈。
幸运的是,由于lock xadd
为9 oups,因此在运行此代码时,前端仍然可以赢得一席之地,并使指令排队等待OOO内核。这足以覆盖来自前端的一两个周期或更少的微指令,以及在解码和uop缓存获取之间的切换。
自从您提出问题以来,这里的主要问题只是代码大小。想要这个内联。在速度方面,快速路径仅稍差一些,而非快速路径无论如何都是自旋循环,因此无关紧要。
快速路径是旧版本的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 movzx
,1 cmp/je
宏融合了)。
在add
的寻址模式下,使用movzx
而不是仅使用8bit偏移量确实是在打招呼,IMO。 IDK如果gcc有足够的前瞻性来做出这样的选择,以使循环分支目标出现在16B边界处,或者这只是一个愚蠢的运气。
使用OP的代码在Godbolt上进行编译器识别实验:
rep ret
,即使使用-march=native -mtune=core2
(在Haswell上),也可以仅使用-march=core2
。 rep ret
和-march=native
,可能是因为Haswell太新了。 -march=native -mtune=haswell
仅使用ret
,因此它确实知道名称haswell
。 ret
和-march=native
(在Haswell上)。未指定rep ret
时仍使用-march
。