使用原子实现票据锁会产生额外的mov

Implementing a ticket lock with atomics generates extra mov

本文关键字:mov 实现      更新时间:2023-10-16

我编写了一个简单票证锁的简单实现。锁定部分如下:

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::atomics来写:

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的博客非常棒,他有一篇关于发布/获取语义的文章。

在x86上,这只能发生在编译器重新排序加载和存储时,mo_relaxed告诉它允许这样做。获取加载与x86上的轻松加载编译相同,但没有重新排序。每个x86 asm加载已经是一个获取。在需要它的弱有序体系结构上,您将获得加载获取所需的任何指令。(在x86上,你只会阻止编译器重新排序)。


我把一个版本的代码放在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上构建的,因为你的编译器使用rep ret甚至-march=native。如果你想生成最优的代码,你应该做点什么。我注意到有时gcc5编写的代码比gcc 4.9更好。(虽然在这种情况下没有帮助:/)


我尝试使用uint32_t,没有运气。

分别执行load和compare对性能的影响可能无关紧要,因为这个函数是一个忙等待循环。

快速路径(未锁定的情况下,在第一次迭代时循环条件为false)仍然只有一个获取分支和一个ret。然而,在std:atomic版本中,快速路径经过循环分支。因此,不是两个单独的分支预测项(一个用于快速路径,一个用于自旋循环),现在旋转可能会导致下一个解锁情况下的分支预测错误。这可能不是问题,并且新代码确实减少了一个分支预测器条目。

IDK如果跳到循环中间对英特尔snb家族微架构的上层缓存有任何不良影响。这是一种跟踪缓存。Agner Fog的测试发现,如果同一段代码有多个跳转入口点,它可以在顶部缓存中有多个条目。这个函数已经有点对上缓存不友好了,因为它以mov r, imm / lock xadd开头。锁xadd必须单独放在一个顶部缓存行中,因为它是微编码的(超过4个顶部)。事实上;无条件跳转总是在顶部缓存行结束。我不确定是否采取了条件分支,但我猜想,如果在解码时预测采取了jcc,则采取jcc结束缓存行。(例如,分支预测器项仍然有效,但旧的上层缓存项被清除)。

因此,第一个版本可能是快速路径的3 up缓存行:一个mov(如果内联,希望大部分都充满了以前的指令),一个lock xadd单独,一个宏融合cmp/je到以下代码(如果内联)。如果不是,则跳转的目标是ret,它可能最终成为这个32字节代码块的第4个缓存行,这是不允许的。所以这个非内联版本可能每次都要重新解码?)

std::atomic版本对于初始的mov imm(和前面的指令),然后是lock xadd,然后是add / jmp,然后…哦,movzx / compare-and-branchup需要的第4条缓存行。因此,即使内联,这个版本也更有可能出现解码瓶颈。

幸运的是,在运行这段代码时,前端仍然可以获得一些优势,并为OOO内核获得排队指令,因为lock xadd是9 up。这足以覆盖一个或两个周期,从前端较少的上行,以及解码和上行缓存获取之间的切换。

这里的主要问题只是代码大小,因为您的问题。想要这个内联。在速度方面,快速路径只是稍微差一点,而非快速路径无论如何都是自旋循环,所以这无关紧要。

旧版本的快速路径是11个融合域(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宏融合)。

movzx的寻址模式下使用add而不是仅仅使用8位偏移量真的是搬起石头砸自己的脚。IDK如果gcc考虑得足够超前,做出这样的选择,让循环分支目标出现在16B边界,或者如果这只是愚蠢的运气。


使用OP代码的godbolt编译器识别实验:

  • gcc 4.8及更早版本:当它是分支目标时总是使用rep ret,即使是-march=native -mtune=core2(在Haswell上),或者只使用-march=core2
  • gcc 4.9:在Haswell上使用rep ret-march=native,可能是因为Haswell对它来说太新了。-march=native -mtune=haswell只使用ret,所以它知道名称haswell
  • gcc 5.1及以后:使用ret-march=native(在Haswell上)。当没有指定-march时,仍然使用rep ret

第一个

12:   66 39 47 02             cmp    %ax,0x2(%rdi)

cmp是mov和cmp指令的组合(在微架构指令集中很可能产生两条指令)

原子变量正在对

的now_serving进行单独读取
32:   0f b7 07                movzwl (%rdi),%eax

做相同的比较
35:   66 39 c2                cmp    %ax,%dx