【问题标题】:Why is adding two std::vectors slower than raw arrays from new[]?为什么添加两个 std::vectors 比来自 new[] 的原始数组慢?
【发布时间】:2017-11-02 21:23:21
【问题描述】:

我正在研究 OpenMP,部分原因是我的程序需要添加非常大的向量(数百万个元素)。但是,如果我使用 std::vector 或原始数组,我会看到很大的差异。我无法解释。 我坚持认为区别仅在于循环,当然不是初始化。

我所指的时间差异,只是时间加法,特别是没有考虑向量、数组等之间的任何初始化差异。我真的只在谈论求和部分。向量的大小在编译时是未知的。 我在 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.8 秒。它们不是用于向量添加的多线程。

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

在这样的简单示例中,我在任何地方都找不到任何参考。

【问题讨论】:

  • 如果您在编译时知道确切的大小,只需使用 std::array 它可能比在向量上调用 resize 来获得这么大的容量要快一些。
  • 您使用什么编译器版本?什么处理器/内存/操作系统?可能需要使用非预定为零的值初始化数据以获得可比较的结果。
  • 你好。我所指的计时器,至于 33%,只是计时加法,尤其是不考虑任何初始化。我真的只谈论总和。大小在编译时是未知的。我在 Ubuntu 16.04 上使用 g++ 5.something。至于处理器,我不明白这有什么关系。我认为这是在 i7 6500U 上。 4M L3 缓存,当然。
  • 顺便说一句:我不认为这是一个 OpenMP 问题,因为您可以省略编译指示并且行为仍然相同。而且您甚至没有将使用 std::vector 运行的两个线程与使用原始数组运行的两个线程进行比较。
  • 我做了,只是没有发布。 1个线程2.6s,2个1.2..

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


【解决方案1】:

这里要注意的主要事情是

数组版本有未定义的行为

dcl.init #12 状态:

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

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

c[i] = a[i] + b[i];

a[i]b[i] 都是不确定的值,因为数组是默认初始化的。

UB 完美地解释了测量结果(无论它们是什么)。

UPD:鉴于@HristoIliev 和@Zulan 的回答,我想再次强调语言POV。

为编译器读取未初始化内存的 UB 本质上意味着它始终可以假定内存已初始化,因此无论操作系统做什么都可以使用 C++,即使操作系统针对这种情况有一些特定的行为。

事实证明确实如此-您的代码没有读取物理内存,而您的测量结果与之相对应。

可以说生成的程序不计算两个数组的总和 - 它计算两个更容易访问的模拟的总和,并且完全因为 UB 而使用 C++ 很好。如果它做其他事情,它仍然会很好。

所以最后你有两个程序:一个将两个向量相加,另一个只是做一些未定义的事情(从 C++ 的角度来看)或不相关的事情(从操作系统的角度来看)。测量它们的时间并比较结果有什么意义?

修复 UB 解决了整个问题,但更重要的是,它可以验证您的测量结果并让您能够有意义地比较结果。

【讨论】:

  • " 因为数组是默认初始化的" 我认为它们根本没有被 初始化?我认为很明显,不初始化数组会导致 UB,但在 Zulan 的回答之前,我认为它只是在“数组的元素可以保持任何值”方面未定义。
  • @Shadow default-initializatied 表示非类对象“未初始化”:dcl.init #7.3
  • 啊,好的。感谢您的澄清。但是,如果我得到cppreference.com 对,这是用 C++11 引入的吗? “具有动态存储持续时间的标量和 POD 类型被认为未初始化(从 C++11 开始,这种情况被重新归类为默认初始化的一种形式)。”
  • @Shadow 哦,是的,他们改变了措辞,但底层行为没有改变 - 数组内存未初始化,从中读取的是 UB
【解决方案2】:

我有一个很好的假设。

我已经编写了三个版本的代码:一个使用原始double *,一个使用std::unique_ptr&lt;double[]&gt; 对象,一个使用std::vector&lt;double&gt;,并比较了每个代码版本的运行时间。出于我的目的,我使用了代码的单线程版本来尝试简化案例。

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&lt;double[]&gt;newdelete 替换为 new[]delete[]。由于它们的运行时间在误差范围内,我们将关注std::unique_ptr&lt;double[]&gt; 版本并将其与double * 进行比较。

.L5.L22 开头,代码似乎相同。唯一的主要区别是在double * 版本中进行delete[] 调用之前的额外指针运算,以及.L34std::unique_ptr&lt;double[]&gt; 版本)末尾的一些额外堆栈清理代码,这对于double * 版本。这些似乎都不会对代码速度产生很大影响,所以我们现在将忽略它们。

相同的代码似乎是直接负责循环的代码。您会注意到,不同的代码(我稍后会介绍)不包含任何跳转语句,这些语句是循环不可或缺的。

因此,所有主要差异似乎都特定于相关对象的初始分配。对于std::unique_ptr&lt;double[]&gt; 版本,这介于time_unique_ptr():.L32 之间,对于double * 版本,介于time_pointer():.L22 之间。

那么有什么区别呢?嗯,他们几乎在做同样的事情。除了在std::unique_ptr&lt;double[]&gt; 版本中出现而在double * 版本中没有出现的几行代码:

std::unique_ptr&lt;double[]&gt;:

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&lt;T&gt;std::unique_ptr&lt;T[]&gt; 被约定“初始化”它们分配的内存,而double * 没有这样的约定。

所以这基本上是验证Shadow 观察到的内容的一种非常非常迂回的方法:当您不尝试“零填充”数组时,编译器将

  • 什么都不做 double *(节省宝贵的 CPU 周期),并且
  • 在不提示std::vector&lt;double&gt;std::unique_ptr&lt;double[]&gt; 的情况下进行初始化(初始化所有内容会花费时间)。

但是当您添加零填充时,编译器会识别出它即将“重复自身”,优化 std::vector&lt;double&gt;std::unique_ptr&lt;double[]&gt; 的第二个零填充(这会导致代码不变)并将其添加到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&lt;double[]&gt; 版本相同! And the runtime is now comparable.

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

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

【讨论】:

  • 嗨,感谢您的详细回答。在我看来,它解释了总执行时间的差异,但不是循环部分?我理解你的意思了吗?然而,我不清楚为什么初始化会改变循环部分。你用什么版本和什么优化编译的?
  • @Napseis 循环根本没有改变。该程序集在所有 6 个代码版本的循环中的功能相同(3 个没有显式零填充,3 个有)。不同之处在于memset 不是“免费”操作,std::vectorstd::make_unique 调用它(耗费大量时间)而new double[] 没有。
  • 这里的问题在于打开的 mp。在我看来,优化在 openmp pragma 之前就开始了。
  • 我很困惑。这是否意味着memset 与计时器的调用重叠? (反之亦然)
  • @Shadow 如果代码有 UB,测量代码的哪一部分并不重要
【解决方案3】:

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

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

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

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

现在,看看数组的大小。 abc 各占 80 MB,超过了大多数现代 CPU 的缓存大小。因此,并行循环的一次执行必须从主存储器带入 160 MB 数据并写回 80 MB。由于系统缓存的工作方式,写入c 实际上会读取一次,除非使用非临时(缓存绕过)存储,因此读取 240 MB 数据并写入 80 MB 数据。乘以 200 次外部迭代,总共有 48 GB 的数据读取和 16 GB 的数据写入。

上述情况不是ab 未初始化的情况,即ab 仅使用operator new[] 分配的情况。由于在这种情况下读取会导致访问零页,并且物理上只有一个零页很容易放入 CPU 缓存中,因此不必从主存储器中引入实际数据。因此,只需读入然后写回 16 GB 的数据。如果使用非临时存储,则根本不会读取内存。

这可以使用 LIKWID(或任何其他能够读取 CPU 硬件计数器的工具)轻松证明:

std::vector&lt;double&gt;版本:

$ 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 会导致它们被零页支持。

编辑:我花了很长时间才写这个,同时祖兰写了一个更技术性的解释。

【讨论】:

  • 我在这里有点推动“你不应该衡量和比较 UB”,但你的回答和解释真的令人印象深刻
【解决方案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:记住双0.0的所有位都设置为零。

最终,性能差异确实仅来自循环,但是由初始化引起的std::vector 为循环带来无开销。在基准代码中,原始数组只是受益于未初始化数据异常情况的优化。

【讨论】:

    【解决方案5】:

    在这种情况下,我认为罪魁祸首是 -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。它还开启了完全循环剥离(即,以少量恒定迭代次数完全去除循环)。此选项使代码更大,可能会也可能不会使其运行得更快。

    【讨论】:

    • 我无法确认这一点。结果对我来说是相同的(至少是比率),并且无论有没有-funroll-loops 标志,汇编代码看起来都是一样的。顺便说一句,使用O3(或更低的选择级别)时不包含此标志...
    • 在 gcc 5 中确实如此,我更正了链接。你用的是什么编译器?
    • 5.4。也许我看错了。但是仅仅添加 -funroll-loops 而没有任何优化标志并没有为我带来加速。 (也没有使用 opt 标志)
    • 这太奇怪了,我确认这对我来说会改变,当然是 -O2。
    • -funroll-loops 通常需要附带 --param max-unroll-times=4 之类的。并行化时过度展开循环的启动时间会变得更加突出。
    【解决方案6】:

    我对其进行了测试并发现了以下内容: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;
        }
    

    (相应的其他数组)。
    结果是我在初始化数组时没有得到控制台输出,而且它的速度与完全没有初始化时一样快,比vectors 快了大约 1.8。

    当我仅初始化 a "normal" 和其他两个向量时使用 if 子句,我测量了 vector 运行时和使用 @987654332 的所有数组“假初始化”的运行时之间的时间@ 子句。

    嗯……这很奇怪……

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

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

    编辑:

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

    所以我查看了汇编代码,但没有发现“for”循环有任何区别...

    【讨论】:

    • 我不相信你写的,所以我尝试了。这也发生在我身边。如果我初始化 c 数组,那么 openmp 循环需要更长的时间。这显然是不可理解的。另外,并行循环不再显示 c 数组的增益!
    • 正在优化。再次进行测试,但这次使用 -O0 或 1 或 2...
    • 是的,我已经在测试不同的设置了。我想我会在几分钟内进行编辑。
    • 我已经通过分析程序集确认 std::vectorstd::unique_ptr 正在将 Zero-Fill 添加到代码中,这在 double * 中不存在版本。详情可以看我的回答。
    猜你喜欢
    • 2017-12-16
    • 2015-04-18
    • 2020-02-20
    • 1970-01-01
    • 2017-08-15
    • 1970-01-01
    • 2010-09-17
    相关资源
    最近更新 更多