为什么从new []添加两个std :: vectors比原始数组慢?

时间:2017-06-01 16:08:22

标签: c++ openmp linear-algebra compiler-optimization

我正在寻找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几乎没有开销?这里发生了什么?我想使用漂亮的标准库对象。

我在这样的简单例子中找不到任何参考。

6 个答案:

答案 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 pageab背后的数据全部由一个填充零的只读4kiB页面支持。 c(以及其他测试中的ab)已经写入,因此必须拥有自己的内存。

0!= 0

现在它可能看起来很奇怪:这里的一切都是零 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>,并比较了每个版本的运行时间代码。出于我的目的,我使用了单线程版本的代码来尝试简化案例。

Total Code:

#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_ptrstd::vector代码生成几乎相同的汇编代码。 std::unique_ptr<double[]>newdelete换出new[]delete[]。由于他们的运行时间在误差范围内,我们将专注于std::unique_ptr<double[]>版本并将其与double *进行比较。

.L5.L22开始,代码似乎完全相同。唯一的主要区别是在delete[]版本中进行double *调用之前的额外指针运算,以及.L34std::unique_ptr<double[]>版本末尾的一些额外堆栈清理代码),double *版本不存在。这些似乎都不会对代码速度产生强烈影响,因此我们暂时忽略它们。

相同的代码似乎是直接负责循环的代码。您会注意到不同的代码(我将暂时得到)不包含任何跳转语句,这些语句是循环的组成部分。

因此,所有主要差异似乎都与所讨论对象的初始分配有关。对于time_unique_ptr():版本,此值介于.L32std::unique_ptr<double[]>之间,time_pointer():版本介于.L22double *之间。

那有什么区别?好吧,他们几乎做同样的事情。除了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 assemblydouble *版本进行比较来确认这一点:

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::vectorstd::unique_ptr通过阻止您在使用原始指针的代码中调用的未定义行为,使您的代码更安全一些。结果是它也使你的代码变慢。

答案 2 :(得分:2)

观察到的行为不是特定于OpenMP的,而是与现代操作系统管理内存的方式有关。内存是虚拟的,这意味着每个进程都有自己的虚拟地址(VA)空间,并且使用特殊的转换机制将该VA空间的页面映射到物理内存的帧。因此,内存分配分两个阶段进行:

  • 预留VA区域内的区域 - 这是operator new[]在分配足够大时所做的事情(由于效率原因,处理的分配较小)
  • 实际上在访问该区域的某个部分时使用物理内存支持该区域

这个过程分为两部分,因为在很多情况下,应用程序并不真正使用它们预留的所有内存,并且用物理内存支持整个预留可能会导致浪费(与虚拟内存不同,物理内存非常有限)资源)。因此,在进程首次写入分配的存储空间的区域时,按需执行对物理存储器的后备保留。该过程被称为故障内存区域,因为在大多数体系结构中它涉及软页面错误,触发OS内核内的映射。每当您的代码第一次写入仍未由物理内存支持的内存区域时,就会触发软页面错误,操作系统会尝试映射物理页面。该过程很慢,因为它涉及在流程页表上查找空闲页面和修改。除非有某种大页面机制,例如Linux上的透明大页面机制,否则该过程的典型粒度为4 KiB。

如果您是第一次从一个从未写入的页面中读取,会发生什么?同样,发生软页面错误,但Linux内核不是映射物理内存帧,而是映射一个特殊的“零页面”。页面以CoW(写时复制)模式映射,这意味着当您尝试编写它时,映射到零页面将被映射到新的物理内存帧。

现在,看看数组的大小。 abc中的每一个占用80 MB,超过了大多数现代CPU的缓存大小。因此,并行循环的一次执行必须从主存储器带来160MB的数据并写回80MB。由于系统缓存的工作原理,写入c实际上只读取一次,除非使用非时间(缓存旁路)存储,因此读取240 MB数据并写入80 MB数据。乘以200次外迭代,总共可以读取48 GB的数据和16 GB的数据。

如果ab未初始化,即ab被简单分配的情况,则上述情况使用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时,在您的问题中提供的两个版本的反汇编表明,两个循环被转换为完全相同的汇编指令序列,而不同的代码地址。执行时间不同的唯一原因是如上所述的存储器访问。调整向量的大小会使用零初始化它们,这会导致ab由其自己的物理内存页备份。在使用a时,不初始化boperator 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)

这里要注意的主要事实是

数组版本具有未定义的行为

dcl.init #12州:

  

如果评估产生不确定的值,则行为未定义

这正是该行中发生的事情:

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中使用和不使用此选项进行测试。

jsFiddle example

funroll-loops:展开循环,其迭代次数可以在编译时或进入循环时确定。 -funroll-loops意味着-frerun-cse-after-loop。它还打开完全循环剥离(即完全去除具有小的恒定迭代次数的循环)。此选项使代码变大,可能会也可能不会使代码运行得更快。