【问题标题】:Implementing a ticket lock with atomics generates extra mov使用原子实现票证锁会产生额外的 mov
【发布时间】:2016-01-21 22:06:51
【问题描述】:

我写了一个简单的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::atomics 来编写,而不是使用 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

【问题讨论】:

  • 对于那些研究这个的人,movzwl 从源读取 16 位并将其写入 32 位目标寄存器的底部,将目标中的其余位归零。
  • Could this GCC bug 报告(实际上不是错误)与它有关吗? fetch_add 在函数定义中有volatile
  • @TonyTheLion 这里没有volatile
  • @Barry 我说的是fetch_add 在其函数定义中有volatile
  • 第一个实现不会错过now_serving 成员变量的原子加载吗?第一个的反汇编看起来也像一个潜在的无限循环。如果只是比较寄存器中的数据,那么来自另一个线程的更改如何传播到等待线程?

标签: c++ multithreading x86 locking


【解决方案1】:

第一个

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

【讨论】:

  • 实际上,cmp 可以在 Intel CPU 上对内存操作数进行微融合,并且仍然可以将 jcc 宏融合到带有 reg,mem 操作数的单个比较和分支微指令中。 AMD 是一样的,只是当 cmp 使用 RIP-relative 寻址模式时,它不能融合 cmp/jcc。此外,不幸的是,这并没有真正回答为什么或如何让 gcc 不这样做的问题。 (我的答案也没有,因为我没有找到办法)
【解决方案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 或更早版本),或者您正在为 AMD 构建 /,因为您的编译器 used rep ret 即使使用 -march=native。如果您关心生成最佳代码,您应该对此做一些事情。我注意到 gcc5 有时会比 gcc 4.9 编写更好的代码。 (虽然在这种情况下它没有帮助:/)


我尝试使用 uint32_t,但没有成功。

单独进行加载和比较对性能的影响可能无关紧要,因为此函数是一个忙等待循环。

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

如果跳入循环中间,IDK 会对英特尔 SnB 系列微架构的 uop 缓存产生任何不良影响。它是一种跟踪缓存。 Agner Fog's testing 发现同一段代码如果有多个跳转入口点,在uop缓存中可以有多个入口。这个函数对 uop-cache 有点不友好,因为它以 mov r, imm / lock xadd 开头。锁 xadd 必须自己进入 uop 缓存行,因为它是微编码的(实际上超过 4 个 uops。9 个)。无条件跳转总是结束一个 uop 缓存行。我不确定一个采用的条件分支,但我猜如果它在解码时被预测采用,那么一个采用的 jcc 会结束一个缓存行。 (例如,分支预测器条目仍然很好,但旧的 uop 缓存条目已被驱逐)。

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

std::atomic 版本又是一个 uop-cache 行,用于初始 mov imm(和前面的指令),然后是 lock xadd,然后是 add / jmp,然后......呃哦,需要第 4 个缓存行对于movzx / compare-and-branch uops。所以这个版本即使内联也更有可能出现解码瓶颈。

幸运的是,在运行此代码时,前端仍然可以取得一些进展并获得排队等待 OOO 内核的指令,因为 lock xadd 是 9 微指令。这足以覆盖来自前端的一两个周期更少的微指令,以及解码和微指令缓存提取之间的切换。

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

旧版本的快速路径是 11 个融合域微指令(1 个mov imm、9 个lock xadd、1 个cmp/je 宏融合)。 cmp/je 包含一个微融合内存操作数。

新版本的快速路径是 41 个融合域微指令(1 mov imm、9 lock xadd、1 add、1 jmp、1 movzx、1 cmp/je 宏融合) .

movzx 的寻址模式中使用add 而不是仅使用8 位偏移量,这确实是自取其辱,IMO。 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

【讨论】:

    猜你喜欢
    • 2014-04-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-01-22
    • 2011-06-23
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多