繁体   English   中英

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

[英]Why is adding two std::vectors slower than raw arrays from new[]?

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

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

有意义的基准测试很难

来自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的内存映射到零页 ab后面的数据全部由一个填充零的只读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 没有循环开销 在基准代码中,原始数组只会受益于未初始化数据的异常情况的优化。

我有一个很好的假设。

我编写了三个版本的代码:一个使用raw 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对象都明显慢于原始版本。 为什么会这样?

我们去大会吧! (使用Godbolt.com编译,使用GCC 8.x的快照版本)

我们可以从一开始就观察到一些事情。 首先, std::unique_ptrstd::vector代码生成几乎相同的汇编代码。 std::unique_ptr<double[]>换掉newdelete new[]delete[] 由于它们的运行时间在误差范围内,我们将专注于std::unique_ptr<double[]>版本并将其与double *进行比较。

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

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

因此,所有主要差异似乎都与所讨论对象的初始分配有关。 这是在.L32 std::unique_ptr<double[]>版本的time_unique_ptr():.L32之间,以及在double *版本的time_pointer():.L22之间。

那有什么区别? 好吧,他们几乎做同样的事情。 除了在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 *版本,使其与其他两个版本相同。 您可以通过将我进行了以下更改的程序集新版本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[]>版本相同! 现在运行时具有可比性。

(注意:指针的运行时间现在比其他两个慢!我观察到第一个被调用的函数,无论哪一个,总是慢约200ms-400ms。我指责分支预测。无论哪种方式,速度应该现在在所有三个代码路径中都是相同的)。

这就是教训: std::vectorstd::unique_ptr通过阻止您在使用原始指针的代码中调用的未定义行为,使您的代码更安全一些。 结果是它也使你的代码变慢。

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

  • 在VA空间内保留一个区域 - 当分配足够大时,这就是operator new[]所做的事情(由于效率原因,较小的分配处理方式不同)
  • 在访问该地区的某些部分时,实际上用物理内存支持该区域

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

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

现在,看看数组的大小。 abca占用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 | <---
+-----------------------------------+------------+

具有未初始化数组和非临时存储的版本(使用Intel的#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由它们自己的物理内存页面备份。 当使用operator new[]时,不初始化ab会导致它们被零页面支持。

编辑:我花了这么长时间写这篇文章,同时祖兰写了一个更技术性的解释方法。

我测试了它并发现了以下内容: 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 s快约1.8。

当我初始化例如只有a “正常”而另外两个向量带有if子句时,我测量了vector运行时和运行时之间的时间,所有数组都用if子句“伪初始化”。

嗯......那很奇怪......

现在,我认为std :: vector几乎没有开销? 这里发生了什么? 我想用漂亮的STL对象......

虽然我无法解释你这种行为,但我可以告诉你,如果你使用它“正常”, std::vector并没有真正的开销。 这只是一个非常人为的案例。

编辑:

正如qPCR4vir和OP Napseis指出的那样,这可能与优化有关。 一旦我打开优化,“真正初始化”的情况就是已经提到的1.8慢的因素。 但没有它仍然慢约1.1倍。

所以我查看了汇编程序代码,但我没有看到'for'循环有任何区别......

这里要注意的主要事实是

阵列版本具有未定义的行为

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解决了整个问题,但更重要的是它验证了您的测量结果并允许您有意义地比较结果。

在这种情况下,我认为罪魁祸首是-funroll-loops,来自我在O2中使用和不使用此选项进行测试的内容。

https://gcc.gnu.org/onlinedocs/gcc-5.4.0/gcc/Optimize-Options.html#Optimize-Options

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM