使用atomics实现票证锁会产生额外的mov

时间:2015-10-22 15:01:49

标签: c++ multithreading x86 locking

我写了一个简单的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

2 个答案:

答案 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进行编译器识别实验:

  • gcc 4.8及更早版本:当它是分支目标时始终使用movzx,即使使用rep ret(在Haswell上),也只使用-march=native -mtune=core2
  • gcc 4.9:在Haswell上使用-march=core2rep ret,可能是因为Haswell太新了。 -march=native仅使用-march=native -mtune=haswell,因此它确实知道名称ret
  • gcc 5.1及更高版本:将haswellret一起使用(在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