我正在寻找OpenMP,部分原因是我的程序需要添加非常大的向量(数百万个元素)。但是,如果我使用std :: vector或raw数组,我会看到相当大的差异。我无法解释。 我坚持认为差异仅在于循环,而不是初始化。
我所指的时间差异,只是添加的时间,特别是不考虑矢量,数组等之间的任何初始化差异。我只是在谈论总和部分。在编译时不知道向量的大小。
我在Ubuntu 16.04上使用g++
5.x。
编辑:我测试了@Shadow说的话,它让我思考,是否有一些优化正在进行?如果我使用-O2
进行编译,那么,使用初始化的原始数组,我会回到使用线程数进行循环扩展。但是对于-O3
或-funroll-loops
,就好像编译器会提前启动并在看到编译指示之前进行优化。
我想出了以下简单测试:
#define SIZE 10000000
#define TRIES 200
int main(){
std::vector<double> a,b,c;
a.resize(SIZE);
b.resize(SIZE);
c.resize(SIZE);
double start = omp_get_wtime();
unsigned long int i,t;
#pragma omp parallel shared(a,b,c) private(i,t)
{
for( t = 0; t< TRIES; t++){
#pragma omp for
for( i = 0; i< SIZE; i++){
c[i] = a[i] + b[i];
}
}
}
std::cout << "finished in " << omp_get_wtime() - start << std::endl;
return 0;
}
我用
编译 g++ -O3 -fopenmp -std=c++11 main.cpp
获得一个帖子
>time ./a.out
finished in 2.5638
./a.out 2.58s user 0.04s system 99% cpu 2.619 total.
对于两个线程,循环需要1.2s,总共1.23。
现在,如果我使用原始数组:
int main(){
double *a, *b, *c;
a = new double[SIZE];
b = new double[SIZE];
c = new double[SIZE];
double start = omp_get_wtime();
unsigned long int i,t;
#pragma omp parallel shared(a,b,c) private(i,t)
{
for( t = 0; t< TRIES; t++)
{
#pragma omp for
for( i = 0; i< SIZE; i++)
{
c[i] = a[i] + b[i];
}
}
}
std::cout << "finished in " << omp_get_wtime() - start << std::endl;
delete[] a;
delete[] b;
delete[] c;
return 0;
}
我得到(1个主题):
>time ./a.out
finished in 1.92901
./a.out 1.92s user 0.01s system 99% cpu 1.939 total
std::vector
慢了33%!
对于两个主题:
>time ./a.out
finished in 1.20061
./a.out 2.39s user 0.02s system 198% cpu 1.208 total
作为比较,使用Eigen或Armadillo进行完全相同的操作(使用c = a + b带矢量对象的重载),我获得总实时~2.8s。它们不是用于向量添加的多线程。
现在,我认为std::vector
几乎没有开销?这里发生了什么?我想使用漂亮的标准库对象。
我在这样的简单例子中找不到任何参考。
答案 0 :(得分:4)
Xirema的答案已经详细列出了代码中的差异。 std::vector::reserve
将数据初始化为零,而new double[size]
则不然。请注意,您可以使用new double[size]()
强制初始化。
但是,您的测量不包括初始化,并且重复次数太高,即使在Xirema的示例中,循环成本也应该超过小型初始化。那么为什么循环中的相同指令需要更多时间,因为数据被初始化了?
让我们用一个动态确定内存是否初始化的代码来挖掘它的核心(基于Xirema,但只对循环本身进行计时)。
#include <vector>
#include <chrono>
#include <iostream>
#include <memory>
#include <iomanip>
#include <cstring>
#include <string>
#include <sys/types.h>
#include <unistd.h>
constexpr size_t size = 10'000'000;
auto time_pointer(size_t reps, bool initialize, double init_value) {
double * a = new double[size];
double * b = new double[size];
double * c = new double[size];
if (initialize) {
for (size_t i = 0; i < size; i++) {
a[i] = b[i] = c[i] = init_value;
}
}
auto start = std::chrono::steady_clock::now();
for (size_t t = 0; t < reps; t++) {
for (size_t i = 0; i < size; i++) {
c[i] = a[i] + b[i];
}
}
auto end = std::chrono::steady_clock::now();
delete[] a;
delete[] b;
delete[] c;
return end - start;
}
int main(int argc, char* argv[]) {
bool initialize = (argc == 3);
double init_value = 0;
if (initialize) {
init_value = std::stod(argv[2]);
}
auto reps = std::stoll(argv[1]);
std::cout << "pid: " << getpid() << "\n";
auto t = time_pointer(reps, initialize, init_value);
std::cout << std::setw(12) << std::chrono::duration_cast<std::chrono::milliseconds>(t).count() << "ms" << std::endl;
return 0;
}
结果是一致的:
./a.out 50 # no initialization
657ms
./a.out 50 0. # with initialization
1005ms
使用优秀的Linux perf
工具:
$ perf stat -e LLC-loads -e dTLB-misses ./a.out 50
pid: 12481
626ms
Performance counter stats for './a.out 50':
101.589.231 LLC-loads
105.415 dTLB-misses
0,629369979 seconds time elapsed
$ perf stat -e LLC-loads -e dTLB-misses ./a.out 50 0.
pid: 12499
1008ms
Performance counter stats for './a.out 50 0.':
145.218.903 LLC-loads
1.889.286 dTLB-misses
1,096923077 seconds time elapsed
随着重复次数的增加,线性缩放也告诉我们,差异来自循环内部。但是为什么初始化内存会导致更多的最后一级缓存加载和数据TLB未命中?
要理解这一点,我们需要了解内存的分配方式。仅仅因为malloc
/ new
返回一些指向虚拟内存的指针,并不意味着它背后有物理内存。虚拟内存可以位于不受物理内存支持的页面中 - 物理内存仅在第一页故障时分配。现在这里是page-types
(来自linux/tools/vm
- 我们显示的pid作为输出派上用场。在长期执行我们的小基准测试期间查看页面统计信息:
flags page-count MB symbolic-flags long-symbolic-flags
0x0000000000000804 1 0 __R________M______________________________ referenced,mmap
0x000000000004082c 392 1 __RU_l_____M______u_______________________ referenced,uptodate,lru,mmap,unevictable
0x000000000000086c 335 1 __RU_lA____M______________________________ referenced,uptodate,lru,active,mmap
0x0000000000401800 56721 221 ___________Ma_________t___________________ mmap,anonymous,thp
0x0000000000005868 1807 7 ___U_lA____Ma_b___________________________ uptodate,lru,active,mmap,anonymous,swapbacked
0x0000000000405868 111 0 ___U_lA____Ma_b_______t___________________ uptodate,lru,active,mmap,anonymous,swapbacked,thp
0x000000000000586c 1 0 __RU_lA____Ma_b___________________________ referenced,uptodate,lru,active,mmap,anonymous,swapbacked
total 59368 231
大部分虚拟内存位于普通mmap,anonymous
区域 - 映射到物理地址的内容。
flags page-count MB symbolic-flags long-symbolic-flags
0x0000000001000000 1174 4 ________________________z_________________ zero_page
0x0000000001400000 37888 148 ______________________t_z_________________ thp,zero_page
0x0000000000000800 1 0 ___________M______________________________ mmap
0x000000000004082c 388 1 __RU_l_____M______u_______________________ referenced,uptodate,lru,mmap,unevictable
0x000000000000086c 347 1 __RU_lA____M______________________________ referenced,uptodate,lru,active,mmap
0x0000000000401800 18907 73 ___________Ma_________t___________________ mmap,anonymous,thp
0x0000000000005868 633 2 ___U_lA____Ma_b___________________________ uptodate,lru,active,mmap,anonymous,swapbacked
0x0000000000405868 37 0 ___U_lA____Ma_b_______t___________________ uptodate,lru,active,mmap,anonymous,swapbacked,thp
0x000000000000586c 1 0 __RU_lA____Ma_b___________________________ referenced,uptodate,lru,active,mmap,anonymous,swapbacked
total 59376 231
现在,只有1/3的内存由专用物理内存支持,2/3内存映射到zero page。 a
和b
背后的数据全部由一个填充零的只读4kiB页面支持。 c
(以及其他测试中的a
,b
)已经写入,因此必须拥有自己的内存。
现在它可能看起来很奇怪:这里的一切都是零 1 - 为什么它变成零怎么回事?无论您是memset(0)
,a[i] = 0.
还是std::vector::reserve
- 一切都会导致显式写入内存,因此如果您在零页面上执行此操作会导致页面错误。我不认为你可以/应该阻止那时的物理页面分配。您可以为memset
/ reserve
做的唯一事情是使用calloc
显式请求零内存,这可能由zero_page
支持,但我怀疑它完成了(或很有意义)。请记住,对于new double[size];
或malloc
,无保证您获得了哪种内存,但这包括零内存的可能性。
1 :请记住,double 0.0将所有位设置为零。
最后,性能差异确实来自仅来自循环,但是由初始化引起的。 std::vector
为循环带来无开销。在基准代码中,原始数组只是从未初始化数据的异常情况的优化中受益。
答案 1 :(得分:2)
我有一个很好的假设。
我编写了三个版本的代码:一个使用原始double *
,一个使用std::unique_ptr<double[]>
个对象,一个使用std::vector<double>
,并比较了每个版本的运行时间代码。出于我的目的,我使用了单线程版本的代码来尝试简化案例。
#include<vector>
#include<chrono>
#include<iostream>
#include<memory>
#include<iomanip>
constexpr size_t size = 10'000'000;
constexpr size_t reps = 50;
auto time_vector() {
auto start = std::chrono::steady_clock::now();
{
std::vector<double> a(size);
std::vector<double> b(size);
std::vector<double> c(size);
for (size_t t = 0; t < reps; t++) {
for (size_t i = 0; i < size; i++) {
c[i] = a[i] + b[i];
}
}
}
auto end = std::chrono::steady_clock::now();
return end - start;
}
auto time_pointer() {
auto start = std::chrono::steady_clock::now();
{
double * a = new double[size];
double * b = new double[size];
double * c = new double[size];
for (size_t t = 0; t < reps; t++) {
for (size_t i = 0; i < size; i++) {
c[i] = a[i] + b[i];
}
}
delete[] a;
delete[] b;
delete[] c;
}
auto end = std::chrono::steady_clock::now();
return end - start;
}
auto time_unique_ptr() {
auto start = std::chrono::steady_clock::now();
{
std::unique_ptr<double[]> a = std::make_unique<double[]>(size);
std::unique_ptr<double[]> b = std::make_unique<double[]>(size);
std::unique_ptr<double[]> c = std::make_unique<double[]>(size);
for (size_t t = 0; t < reps; t++) {
for (size_t i = 0; i < size; i++) {
c[i] = a[i] + b[i];
}
}
}
auto end = std::chrono::steady_clock::now();
return end - start;
}
int main() {
std::cout << "Vector took " << std::setw(12) << time_vector().count() << "ns" << std::endl;
std::cout << "Pointer took " << std::setw(12) << time_pointer().count() << "ns" << std::endl;
std::cout << "Unique Pointer took " << std::setw(12) << time_unique_ptr().count() << "ns" << std::endl;
return 0;
}
测试结果:
Vector took 1442575273ns //Note: the first one executed, regardless of
//which function it is, is always slower than expected. I'll talk about that later.
Pointer took 542265103ns
Unique Pointer took 1280087558ns
因此,所有STL对象都明显慢于原始版本。为什么会这样?
Let's go to the Assembly!(使用Godbolt.com编译,使用GCC 8.x的快照版本)
我们可以从一开始就观察到一些事情。首先,std::unique_ptr
和std::vector
代码生成几乎相同的汇编代码。 std::unique_ptr<double[]>
为new
和delete
换出new[]
和delete[]
。由于他们的运行时间在误差范围内,我们将专注于std::unique_ptr<double[]>
版本并将其与double *
进行比较。
从.L5
和.L22
开始,代码似乎完全相同。唯一的主要区别是在delete[]
版本中进行double *
调用之前的额外指针运算,以及.L34
(std::unique_ptr<double[]>
版本末尾的一些额外堆栈清理代码),double *
版本不存在。这些似乎都不会对代码速度产生强烈影响,因此我们暂时忽略它们。
相同的代码似乎是直接负责循环的代码。您会注意到不同的代码(我将暂时得到)不包含任何跳转语句,这些语句是循环的组成部分。
因此,所有主要差异似乎都与所讨论对象的初始分配有关。对于time_unique_ptr():
版本,此值介于.L32
和std::unique_ptr<double[]>
之间,time_pointer():
版本介于.L22
和double *
之间。
那有什么区别?好吧,他们几乎做同样的事情。除了std::unique_ptr<double[]>
版本中显示的几行代码,但未显示在double *
版本中:
std::unique_ptr<double[]>
:
mov edi, 80000000
mov r12, rax
call operator new[](unsigned long)
mov edx, 80000000
mov rdi, rax
xor esi, esi //Sets register to 0, which is probably used in...
mov rbx, rax
call memset //!!!
mov edi, 80000000
call operator new[](unsigned long)
mov rdi, rax
mov edx, 80000000
xor esi, esi //Sets register to 0, which is probably used in...
mov rbp, rax
call memset //!!!
mov edi, 80000000
call operator new[](unsigned long)
mov r14, rbx
xor esi, esi //Sets register to 0, which is probably used in...
mov rdi, rax
shr r14, 3
mov edx, 80000000
mov r13d, 10000000
and r14d, 1
call memset //!!!
double *
:
mov edi, 80000000
mov rbp, rax
call operator new[](unsigned long)
mov rbx, rax
mov edi, 80000000
mov r14, rbx
shr r14, 3
call operator new[](unsigned long)
and r14d, 1
mov edi, 80000000
mov r12, rax
sub r13, r14
call operator new[](unsigned long)
你会好好看看那个!对memset
的一些意外调用不属于double *
代码!很明显std::vector<T>
和std::unique_ptr<T[]>
签约以“初始化”他们分配的内存,而double *
没有这样的合同。
所以这基本上是一种非常非常全面的方法来验证Shadow观察到的内容:当你没有尝试“零填充”数组时,编译器将
double *
做任何事情(节省宝贵的CPU周期),std::vector<double>
和std::unique_ptr<double[]>
(花费时间初始化所有内容)。但是当你做添加零填充时,编译器会认识到它将“重复”,优化std::vector<double>
和std::unique_ptr<double[]>
的第二个零填充(导致代码不变)并将其添加到double *
版本,使其与其他两个版本相同。您可以通过将我进行了以下更改的new version of the assembly与double *
版本进行比较来确认这一点:
double * a = new double[size];
for(size_t i = 0; i < size; i++) a[i] = 0;
double * b = new double[size];
for(size_t i = 0; i < size; i++) b[i] = 0;
double * c = new double[size];
for(size_t i = 0; i < size; i++) c[i] = 0;
果然,程序集现在将这些循环优化为memset
次调用,与std::unique_ptr<double[]>
版本相同! And the runtime is now comparable.
(注意:指针的运行时间现在比其他两个慢!我观察到第一个被调用的函数,无论哪一个,总是慢约200ms-400ms。我指责分支预测。无论哪种方式,现在所有三个代码路径的速度应相同。
这就是上课:std::vector
和std::unique_ptr
通过阻止您在使用原始指针的代码中调用的未定义行为,使您的代码更安全一些。结果是它也使你的代码变慢。
答案 2 :(得分:2)
观察到的行为不是特定于OpenMP的,而是与现代操作系统管理内存的方式有关。内存是虚拟的,这意味着每个进程都有自己的虚拟地址(VA)空间,并且使用特殊的转换机制将该VA空间的页面映射到物理内存的帧。因此,内存分配分两个阶段进行:
operator new[]
在分配足够大时所做的事情(由于效率原因,处理的分配较小)这个过程分为两部分,因为在很多情况下,应用程序并不真正使用它们预留的所有内存,并且用物理内存支持整个预留可能会导致浪费(与虚拟内存不同,物理内存非常有限)资源)。因此,在进程首次写入分配的存储空间的区域时,按需执行对物理存储器的后备保留。该过程被称为故障内存区域,因为在大多数体系结构中它涉及软页面错误,触发OS内核内的映射。每当您的代码第一次写入仍未由物理内存支持的内存区域时,就会触发软页面错误,操作系统会尝试映射物理页面。该过程很慢,因为它涉及在流程页表上查找空闲页面和修改。除非有某种大页面机制,例如Linux上的透明大页面机制,否则该过程的典型粒度为4 KiB。
如果您是第一次从一个从未写入的页面中读取,会发生什么?同样,发生软页面错误,但Linux内核不是映射物理内存帧,而是映射一个特殊的“零页面”。页面以CoW(写时复制)模式映射,这意味着当您尝试编写它时,映射到零页面将被映射到新的物理内存帧。
现在,看看数组的大小。 a
,b
和c
中的每一个占用80 MB,超过了大多数现代CPU的缓存大小。因此,并行循环的一次执行必须从主存储器带来160MB的数据并写回80MB。由于系统缓存的工作原理,写入c
实际上只读取一次,除非使用非时间(缓存旁路)存储,因此读取240 MB数据并写入80 MB数据。乘以200次外迭代,总共可以读取48 GB的数据和16 GB的数据。
如果a
和b
未初始化,即a
和b
被简单分配的情况,则上述情况不使用operator new[]
。由于在这种情况下的读取导致访问零页面,并且物理上只有一个零页面容易适合CPU高速缓存,因此不必从主存储器引入实际数据。因此,只需要读入16 GB的数据然后再写回。如果使用非临时存储,则根本不读取任何内存。
使用LIKWID(或任何其他能够读取CPU硬件计数器的工具)可以很容易地证明这一点:
std::vector<double>
版本:
$ likwid-perfctr -C 0 -g HA a.out
...
+-----------------------------------+------------+
| Metric | Core 0 |
+-----------------------------------+------------+
| Runtime (RDTSC) [s] | 4.4796 |
| Runtime unhalted [s] | 5.5242 |
| Clock [MHz] | 2850.7207 |
| CPI | 1.7292 |
| Memory read bandwidth [MBytes/s] | 10753.4669 |
| Memory read data volume [GBytes] | 48.1715 | <---
| Memory write bandwidth [MBytes/s] | 3633.8159 |
| Memory write data volume [GBytes] | 16.2781 |
| Memory bandwidth [MBytes/s] | 14387.2828 |
| Memory data volume [GBytes] | 64.4496 | <---
+-----------------------------------+------------+
带有未初始化数组的版本:
+-----------------------------------+------------+
| Metric | Core 0 |
+-----------------------------------+------------+
| Runtime (RDTSC) [s] | 2.8081 |
| Runtime unhalted [s] | 3.4226 |
| Clock [MHz] | 2797.2306 |
| CPI | 1.0753 |
| Memory read bandwidth [MBytes/s] | 5696.4294 |
| Memory read data volume [GBytes] | 15.9961 | <---
| Memory write bandwidth [MBytes/s] | 5703.4571 |
| Memory write data volume [GBytes] | 16.0158 |
| Memory bandwidth [MBytes/s] | 11399.8865 |
| Memory data volume [GBytes] | 32.0119 | <---
+-----------------------------------+------------+
包含未初始化数组和非临时存储的版本(使用英特尔&#39; #pragma vector nontemporal
):
+-----------------------------------+------------+
| Metric | Core 0 |
+-----------------------------------+------------+
| Runtime (RDTSC) [s] | 1.5889 |
| Runtime unhalted [s] | 1.7397 |
| Clock [MHz] | 2530.1640 |
| CPI | 0.5465 |
| Memory read bandwidth [MBytes/s] | 123.4196 |
| Memory read data volume [GBytes] | 0.1961 | <---
| Memory write bandwidth [MBytes/s] | 10331.2416 |
| Memory write data volume [GBytes] | 16.4152 |
| Memory bandwidth [MBytes/s] | 10454.6612 |
| Memory data volume [GBytes] | 16.6113 | <---
+-----------------------------------+------------+
使用GCC 5.3时,在您的问题中提供的两个版本的反汇编表明,两个循环被转换为完全相同的汇编指令序列,而不同的代码地址。执行时间不同的唯一原因是如上所述的存储器访问。调整向量的大小会使用零初始化它们,这会导致a
和b
由其自己的物理内存页备份。在使用a
时,不初始化b
和operator new[]
会导致他们在零页面上进行备份。
编辑:我花了很长时间才写到这一点,与此同时,祖兰已经写了更多的技术解释。
答案 3 :(得分:1)
我对它进行了测试并发现了以下内容:vector
案例的运行时间比原始数组大约长1.8倍。 但这只是我没有初始化原始数组的情况。在时间测量之前添加一个简单循环以使用0.0
初始化所有条目后,原始数组的大小写与vector
大小写一样长。
仔细观察并做了以下事情: 我没有像
那样初始化原始数组for (size_t i{0}; i < SIZE; ++i)
a[i] = 0.0;
但是这样做了:
for (size_t i{0}; i < SIZE; ++i)
if (a[i] != 0.0)
{
std::cout << "a was set at position " << i << std::endl;
a[i] = 0.0;
}
(相应的其他数组)。
结果是我没有从初始化数组中得到控制台输出,它再次没有初始化那么快,比vector
快约1.8。
当我仅举例说明a
&#34;正常&#34;另外两个带有if
子句的向量我测量了vector
运行时和运行时之间的时间与所有数组&#34;假初始化&#34;使用if
子句。
嗯......那很奇怪......
现在,我认为std :: vector几乎没有开销?这里发生了什么?我想使用漂亮的STL对象......
虽然我无法解释你这种行为,但我可以告诉你,std::vector
如果你使用它并且#34;正常&#34;则没有真正的开销。这只是一个非常人为的案例。
修改强>
由于qPCR4vir和OP Napseis指出这可能与优化有关。一旦我打开优化,就会实现真正的初始化&#34;案件是关于已经提到的1.8慢的因素。但没有它仍然慢了1.1倍。
所以我查看了汇编程序代码,但我没有看到&#39;对于&#39;环...
答案 4 :(得分:1)
这里要注意的主要事实是
如果评估产生不确定的值,则行为未定义
这正是该行中发生的事情:
c[i] = a[i] + b[i];
a[i]
和b[i]
都是不确定值,因为数组是默认初始化的。
UB完美地解释了测量结果(无论它们是什么)。
UPD :根据@HristoIliev和@Zulan的回答,我想再次强调语言POV。
为编译器读取未初始化内存的UB本质上意味着它总是可以假设内存已初始化,因此无论操作系统如何处理C ++都可以,即使操作系统对该情况有一些特定的行为。
事实证明它确实存在 - 你的代码没有读取物理内存,你的测量结果与之相符。
可以说生成的程序不会计算两个数组的总和 - 它计算两个更容易访问的模拟的总和,而C ++正好因为UB。如果它做了别的事情,它仍然会完全没问题。
所以最后你有两个程序:一个加起来两个向量,另一个只做一些未定义的东西(从C ++的角度来看)或不相关的东西(从OS的角度来看)。测量他们的时间并比较结果有什么意义?
修复UB解决了整个问题,但更重要的是它验证了您的测量结果并允许您有意义地比较结果。
答案 5 :(得分:-1)
在这种情况下,我认为罪魁祸首是-funroll-loops,来自我在O2中使用和不使用此选项进行测试。
funroll-loops:展开循环,其迭代次数可以在编译时或进入循环时确定。 -funroll-loops意味着-frerun-cse-after-loop。它还打开完全循环剥离(即完全去除具有小的恒定迭代次数的循环)。此选项使代码变大,可能会也可能不会使代码运行得更快。