由于获取-释放内存顺序而错过了优化机会或所需的行为?

Missed optimization opportunity or required behavior due to acquire-release memory ordering?

我目前正在尝试提高自定义 "pseudo" 堆栈的性能,它是这样使用的(完整代码在本 post 末尾提供):

void test() {
  theStack.stackFrames[1] = StackFrame{ "someFunction", 30 };      // A
  theStack.stackTop.store(1, std::memory_order_seq_cst);           // B
  someFunction();                                                  // C
  theStack.stackTop.store(0, std::memory_order_seq_cst);           // D

  theStack.stackFrames[1] = StackFrame{ "someOtherFunction", 35 }; // E
  theStack.stackTop.store(1, std::memory_order_seq_cst);           // F
  someOtherFunction();                                             // G
  theStack.stackTop.store(0, std::memory_order_seq_cst);           // H
}

采样器线程定期挂起目标线程并读取 stackTopstackFrames 数组。

我最大的性能问题是 stackTop 的顺序一致存储,所以我试图找出是否可以将它们更改为发布存储。

中心要求是:当sampler线程挂起目标线程读取stackTop == 1时,那么stackFrames[1]中的信息需要完整存在且一致。这意味着:

  1. 当观察到B时,也必须观察到A。 ("Don't increment stackTop before putting the stack frame in place.")
  2. 当观察到E时,也必须观察到D。 ("When putting the next frame's information in place, the previous stack frame must have been exited.")

我的理解是,对 stackTop 使用释放-获取内存排序可以保证第一个要求,但不能保证第二个要求。更具体地说:

但是,没有关于按程序顺序 发布存储到 stackTop 之后发生的写入的声明。因此,我的理解是可以在观察到 D 之前观察到 E。这是正确的吗?

但如果是这样的话,编译器就不能像这样重新排序我的程序了:

void test() {
  theStack.stackFrames[1] = StackFrame{ "someFunction", 30 };      // A
  theStack.stackTop.store(1, std::memory_order_release);           // B
  someFunction();                                                  // C

  // switched D and E:
  theStack.stackFrames[1] = StackFrame{ "someOtherFunction", 35 }; // E
  theStack.stackTop.store(0, std::memory_order_release);           // D

  theStack.stackTop.store(1, std::memory_order_release);           // F
  someOtherFunction();                                             // G
  theStack.stackTop.store(0, std::memory_order_release);           // H
}

...然后组合 D 和 F,优化掉零存储?

因为如果我在 macOS 上使用系统 clang 编译上述程序,那不是我所看到的:

$ clang++ -c main.cpp -std=c++11 -O3 && objdump -d main.o

main.o: file format Mach-O 64-bit x86-64

Disassembly of section __TEXT,__text:
__Z4testv:
       0:   55  pushq   %rbp
       1:   48 89 e5    movq    %rsp, %rbp
       4:   48 8d 05 5d 00 00 00    leaq    93(%rip), %rax
       b:   48 89 05 10 00 00 00    movq    %rax, 16(%rip)
      12:   c7 05 14 00 00 00 1e 00 00 00   movl    , 20(%rip)
      1c:   c7 05 1c 00 00 00 01 00 00 00   movl    , 28(%rip)
      26:   e8 00 00 00 00  callq   0 <__Z4testv+0x2B>
      2b:   c7 05 1c 00 00 00 00 00 00 00   movl    [=12=], 28(%rip)
      35:   48 8d 05 39 00 00 00    leaq    57(%rip), %rax
      3c:   48 89 05 10 00 00 00    movq    %rax, 16(%rip)
      43:   c7 05 14 00 00 00 23 00 00 00   movl    , 20(%rip)
      4d:   c7 05 1c 00 00 00 01 00 00 00   movl    , 28(%rip)
      57:   e8 00 00 00 00  callq   0 <__Z4testv+0x5C>
      5c:   c7 05 1c 00 00 00 00 00 00 00   movl    [=12=], 28(%rip)
      66:   5d  popq    %rbp
      67:   c3  retq

具体来说,2b 处的 movl [=24=], 28(%rip) 指令仍然存在。

巧合的是,这个输出正是我所需要的。但我不知道我是否可以依赖它,因为据我了解,我选择的内存顺序不能保证它。

所以我的主要问题是:获取-释放内存顺序是否给了我另一个我不知道的(幸运的)保证?或者编译器只是偶然地做了我需要的事情/因为它没有尽可能地优化这个特殊情况?

完整代码如下:

// clang++ -c main.cpp -std=c++11 -O3 && objdump -d main.o

#include <atomic>
#include <cstdint>

struct StackFrame
{
  const char* functionName;
  uint32_t lineNumber;
};

struct Stack
{
  Stack()
    : stackFrames{ StackFrame{ nullptr, 0 }, StackFrame{ nullptr, 0 } }
    , stackTop{0}
  {
  }

  StackFrame stackFrames[2];
  std::atomic<uint32_t> stackTop;
};

Stack theStack;

void someFunction();
void someOtherFunction();

void test() {
  theStack.stackFrames[1] = StackFrame{ "someFunction", 30 };
  theStack.stackTop.store(1, std::memory_order_release);
  someFunction();
  theStack.stackTop.store(0, std::memory_order_release);

  theStack.stackFrames[1] = StackFrame{ "someOtherFunction", 35 };
  theStack.stackTop.store(1, std::memory_order_release);
  someOtherFunction();
  theStack.stackTop.store(0, std::memory_order_release);
}

/**
 * // Sampler thread:
 *
 * #include <chrono>
 * #include <iostream>
 * #include <thread>
 *
 * void suspendTargetThread();
 * void unsuspendTargetThread();
 * 
 * void samplerThread() {
 *   for (;;) {
 *     // Suspend the target thread. This uses a platform-specific
 *     // mechanism:
 *     //  - SuspendThread on Windows
 *     //  - thread_suspend on macOS
 *     //  - send a signal + grab a lock in the signal handler on Linux
 *     suspendTargetThread();
 * 
 *     // Now that the thread is paused, read the leaf stack frame.
 *     uint32_t stackTop =
 *       theStack.stackTop.load(std::memory_order_acquire);
 *     StackFrame& f = theStack.stackFrames[stackTop];
 *     std::cout << f.functionName << " at line "
 *               << f.lineNumber << std::endl;
 * 
 *     unsuspendTargetThread();
 * 
 *     std::this_thread::sleep_for(std::chrono::milliseconds(1));
 *   }
 * }
 */

而且,为了满足好奇心,如果我使用顺序一致的存储,这就是程序集:

$ clang++ -c main.cpp -std=c++11 -O3 && objdump -d main.o

main.o: file format Mach-O 64-bit x86-64

Disassembly of section __TEXT,__text:
__Z4testv:
       0:   55  pushq   %rbp
       1:   48 89 e5    movq    %rsp, %rbp
       4:   41 56   pushq   %r14
       6:   53  pushq   %rbx
       7:   48 8d 05 60 00 00 00    leaq    96(%rip), %rax
       e:   48 89 05 10 00 00 00    movq    %rax, 16(%rip)
      15:   c7 05 14 00 00 00 1e 00 00 00   movl    , 20(%rip)
      1f:   41 be 01 00 00 00   movl    , %r14d
      25:   b8 01 00 00 00  movl    , %eax
      2a:   87 05 20 00 00 00   xchgl   %eax, 32(%rip)
      30:   e8 00 00 00 00  callq   0 <__Z4testv+0x35>
      35:   31 db   xorl    %ebx, %ebx
      37:   31 c0   xorl    %eax, %eax
      39:   87 05 20 00 00 00   xchgl   %eax, 32(%rip)
      3f:   48 8d 05 35 00 00 00    leaq    53(%rip), %rax
      46:   48 89 05 10 00 00 00    movq    %rax, 16(%rip)
      4d:   c7 05 14 00 00 00 23 00 00 00   movl    , 20(%rip)
      57:   44 87 35 20 00 00 00    xchgl   %r14d, 32(%rip)
      5e:   e8 00 00 00 00  callq   0 <__Z4testv+0x63>
      63:   87 1d 20 00 00 00   xchgl   %ebx, 32(%rip)
      69:   5b  popq    %rbx
      6a:   41 5e   popq    %r14
      6c:   5d  popq    %rbp
      6d:   c3  retq

仪器将 xchgl 指令确定为最昂贵的部分。

你可以这样写:

void test() {
  theStack.stackFrames[1] = StackFrame{ "someFunction", 30 };      // A
  theStack.stackTop.store(1, std::memory_order_release);           // B
  someFunction();                                                  // C
  theStack.stackTop.exchange(0, std::memory_order_acq_rel);        // D

  theStack.stackFrames[1] = StackFrame{ "someOtherFunction", 35 }; // E
  theStack.stackTop.store(1, std::memory_order_release);           // F
  someOtherFunction();                                             // G
  theStack.stackTop.exchange(0, std::memory_order_acq_rel);        // H
}

这应该提供您正在寻找的第二个保证,即在 D 之前可能不会观察到 E。否则我认为编译器将有权按照您的建议重新排序指令。

由于采样器线程 "acquires" stackTop 并且它在读取之前挂起目标线程,这应该提供额外的同步,所以当 stackTop 为 1 时它应该总是看到有效数据。

如果你的采样器没有挂起目标线程,或者如果挂起不等待线程实际挂起(检查这个),我认为需要互斥锁或等效物来防止采样器读取陈旧数据在将堆栈顶部读取为一个之后(例如,如果它在错误的时刻被调度程序暂停)。

如果你可以依靠挂起来提供同步并且只需要通过编译器来约束重新排序,你应该看看std::atomic_signal_fence