J. *_*Doe 12 c++ performance multithreading atomic openmp
我试图确定std::atomic在我的系统(八核x64)上无条件内存写入的开销.这是我的基准程序:
#include <atomic>
#include <iostream>
#include <omp.h>
int main() {
std::atomic_int foo(0); // VERSION 1
//volatile int foo = 0; // VERSION 2
#pragma omp parallel
for (unsigned int i = 0; i < 10000000; ++i) {
foo.store(i, std::memory_order_relaxed); // VERSION 1
//foo = i; // VERSION 2
}
std::cout << foo << std::endl;
}
Run Code Online (Sandbox Code Playgroud)
该程序将按原样进行基准测试std::atomic_int,并对标记VERSION 1的行进行注释并取消注释标记的VERSION 2行将volatile int在其位置进行测试.即使不同步,两个程序的输出也应为10000000 - 1.
这是我的命令行:
g++ -O2 -std=c++11 -fopenmp test.c++
Run Code Online (Sandbox Code Playgroud)
atomic_int在我的系统上使用的版本需要2到3秒,而使用的版本volatile int几乎总是在不到十分之一秒内完成.
装配中的显着差异是这个(输出diff --side-by-side):
volatile int atomic_int
.L2: .L2:
mov DWORD PTR [rdi], eax | mov rdx, QWORD PTR [rdi]
> mov DWORD PTR [rdx], eax
add eax, 1 add eax, 1
cmp eax, 10000000 cmp eax, 10000000
jne .L2 jne .L2
rep ret rep ret
Run Code Online (Sandbox Code Playgroud)
rdi是这个并行运行的函数的第一个参数(它不会在函数中的任何地方修改),它显然是一个指针(指向第二列的指针)整数foo.我不相信这个额外的东西mov是原子性保证不可或缺的atomic_int.
额外mov的确是减速的源头atomic_int; 将它移动到上面L2允许两个版本达到相同的性能并输出正确的数字.
何时foo成为全局变量,atomic_int获得相同的性能提升volatile int.
我的问题是:为什么编译器在堆栈分配的情况下传递指针指针,atomic_int但在全局atomic_int或堆栈分配的情况下只传递指针volatile int; 为什么它会在循环的每次迭代中加载该指针,因为它是(我相信)循环不变的代码; 我可以做什么样的变化,以C++源代码有atomic_int比赛volatile int在这个基准?
运行这个程序:
#include <atomic>
#include <iostream>
#include <thread>
//using T = volatile int; // VERSION 1
using T = std::atomic_int; // VERSION 2
void foo(T* ptr) {
for (unsigned int i = 0; i < 10000000; ++i) {
//*ptr = i; // VERSION 1
ptr->store(i, std::memory_order_relaxed); // VERSION2
}
}
int main() {
T i { 0 };
std::thread threads[4];
for (auto& x : threads)
x = std::move(std::thread { foo, &i });
for (auto& x : threads)
x.join();
std::cout << i << std::endl;
}
Run Code Online (Sandbox Code Playgroud)
对于版本1和版本2产生相同的,改进的性能,这使我相信它是OpenMP的一个特性,迫使更糟糕的性能atomic_int.OpenMP是正确的,还是生成次优代码?
如果你看一下程序的中间表示(-fdump-tree-all是你的朋友)而不是程序集输出,事情会变得容易理解.
为什么编译器在堆栈分配的情况下将指针传递给指针,
atomic_int但在全局atomic_int或堆栈分配的情况下只传递指针volatile int;
这是一个实现细节.GCC通过将并行区域概括为单独的函数来转换并行区域,然后作为唯一参数接收包含所有共享变量的结构,也包含变量firstprivate最终值的初始值和占位符lastprivate.如果foo只是一个整数并且不存在隐式或显式flush区域,则编译器会将参数中的副本传递给概述函数:
struct omp_data_s
{
int foo;
};
void main._omp_fn.0(struct omp_data_s *omp_data_i)
{
...
omp_data_i->foo = i;
...
}
int main() {
volatile int foo = 0;
struct omp_data_s omp_data_o;
omp_data_o.foo = foo;
GOMP_parallel(main._omp_fn.0, &omp_data_o, 0, 0);
foo = omp_data_o.foo;
...
}
Run Code Online (Sandbox Code Playgroud)
omp_data_i传递(通过rdix86-64 ABI)并omp_data_i->foo = i;简单地编译movl %rax, %(rdi)(给定i存储rax),因为它foo是结构的第一个(也是唯一的)元素.
当foo是std::atomic_int,它不再是一个整数,但是缠绕所述整数值的结构.在这种情况下,GCC在参数结构中传递指针而不是值本身:
struct omp_data_s
{
struct atomic_int *foo;
};
void main._omp_fn.0(struct omp_data_s *omp_data_i)
{
...
__atomic_store_4(&omp_data_i->foo._M_i, i, 0);
...
}
int main() {
struct atomic_int foo;
struct omp_data_s omp_data_o;
omp_data_o.foo = &foo;
GOMP_parallel(main._omp_fn.0, &omp_data_o, 0, 0);
...
}
Run Code Online (Sandbox Code Playgroud)
在这种情况下,附加汇编指令(movq %(rdi), %rdx)是第一个指针(对OpenMP数据结构)的取消引用,第二个是原子写入(在x86-64上只是一个存储).
何时foo是全局的,它不作为参数结构的一部分传递给概述的代码.在该特定情况下,代码接收NULL指针,因为参数结构为空.
void main._omp_fn.0(void *omp_data_i)
{
...
__atomic_store_4(&foo._M_i, i, 0);
...
}
Run Code Online (Sandbox Code Playgroud)
为什么它会在循环的每次迭代中加载该指针,因为它是(我相信)循环不变的代码;
指针参数本身(值为rdi)是循环不变的,但指向的值可能会在函数外部发生变化,因为foo它是共享变量.实际上,GCC对待使用OpenMP数据共享类的所有变量shared作为volatile.同样,这是一个实现细节,因为OpenMP标准允许宽松的一致性内存模型,其中对共享变量的写入在其他线程中不可见,除非在编写flush器和读取器中都使用该构造.GCC实际上利用这种宽松的一致性来通过传递一些共享变量的副本而不是指向原始变量的指针来优化代码(从而节省了一个取消引用).如果flush您的代码中存在某个区域,则显式
foo = i;
#pragma omp flush(foo)
Run Code Online (Sandbox Code Playgroud)
或隐含的
#pragma omp atomic write
foo = i;
Run Code Online (Sandbox Code Playgroud)
GCC本来会通过一个指针,foo如另一个答案所示.原因是flush构造将线程的内存视图与全局视图同步,其中共享foo引用原始变量(因此指向它而不是副本的指针).
我可以做什么样的变化,以C++源代码有
atomic_int比赛volatile int在这个基准?
除了切换到不同的编译器,我想不出任何便携式的改变.GCC将结构类型的共享变量(std::atomic是一个结构)作为指针传递,就是这样.
OpenMP是正确的,还是生成次优代码?
OpenMP是正确的.它是一个多平台规范,定义了GCC遵循的特定(和有意广泛的)内存和操作语义.它可能并不总能为特定平台上的特定情况提供最佳性能,但随后代码是可移植的,并且通过添加单个编译指示从串行到并行相对容易.
当然,GCC人员当然可以学会更好地进行优化 - 英特尔C++编译器已经做到了:
# LOE rdx ecx
..B1.14: # Preds ..B1.15 ..B1.13
movl %ecx, %eax #13.13
movl %eax, (%rdx) #13.13
# LOE rdx ecx
..B1.15: # Preds ..B1.14
incl %ecx #12.46
cmpl $10000000, %ecx #12.34
jb ..B1.14 # Prob 99% #12.34
Run Code Online (Sandbox Code Playgroud)