![](/img/trans.png)
[英]Why is this rangev3 implementation of vectors summation slower than the STD equivalent?
[英]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的內存映射到零頁 。 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
沒有循環開銷 。 在基准代碼中,原始數組只會受益於未初始化數據的異常情況的優化。
我有一個很好的假設。
我編寫了三個版本的代碼:一個使用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_ptr
和std::vector
代碼生成幾乎相同的匯編代碼。 std::unique_ptr<double[]>
換掉new
並delete
new[]
和delete[]
。 由於它們的運行時間在誤差范圍內,我們將專注於std::unique_ptr<double[]>
版本並將其與double *
進行比較。
從.L5
和.L22
,代碼似乎完全相同。 唯一的主要區別是在double *
版本中進行delete[]
調用之前的額外指針運算,以及.L34
( std::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::vector
和std::unique_ptr
通過阻止您在使用原始指針的代碼中調用的未定義行為,使您的代碼更安全一些。 結果是它也使你的代碼變慢。
觀察到的行為不是特定於OpenMP的,而是與現代操作系統管理內存的方式有關。 內存是虛擬的,這意味着每個進程都有自己的虛擬地址(VA)空間,並且使用特殊的轉換機制將該VA空間的頁面映射到物理內存的幀。 因此,內存分配分兩個階段執行:
operator new[]
所做的事情(由於效率原因,較小的分配處理方式不同) 該過程分為兩部分,因為在許多情況下,應用程序不會立即使用它們保留的所有內存,並且使用物理內存備份整個預留可能會導致浪費(與虛擬內存不同,物理內存是非常有限的資源)。 因此,在進程首次寫入分配的存儲空間的區域時,按需執行對物理存儲器的后備保留。 該過程被稱為故障內存區域,因為在大多數體系結構中它涉及軟頁面錯誤,觸發OS內核內的映射。 每當您的代碼第一次寫入仍未由物理內存支持的內存區域時,就會觸發軟頁面錯誤,操作系統會嘗試映射物理頁面。 該過程很慢,因為它涉及在流程頁表上查找空閑頁面和修改。 除非有某種大頁面機制,例如Linux上的透明大頁面機制,否則該過程的典型粒度為4 KiB。
如果您是第一次從一個從未寫過的頁面中讀取,會發生什么? 同樣,發生軟頁面錯誤,但Linux內核不是映射物理內存幀,而是映射一個特殊的“零頁面”。 頁面以CoW(寫時復制)模式映射,這意味着當您嘗試編寫它時,映射到零頁面將被映射到新的物理內存幀。
現在,看看數組的大小。 a
, b
和c
每a
占用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 | <---
+-----------------------------------+------------+
具有未初始化數組和非臨時存儲的版本(使用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時,在您的問題中提供的兩個版本的反匯編表明,兩個循環被轉換為完全相同的匯編指令序列,而不是代碼地址。 執行時間不同的唯一原因是如上所述的存儲器訪問。 調整向量的大小會用零初始化它們,這會導致a
和b
由它們自己的物理內存頁面備份。 當使用operator new[]
時,不初始化a
和b
會導致它們被零頁面支持。
編輯:我花了這么長時間寫這篇文章,同時祖蘭寫了一個更技術性的解釋方法。
我測試了它並發現了以下內容: 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'循環有任何區別......
這里要注意的主要事實是
如果評估產生不確定的值,則行為未定義
這正是該行中發生的事情:
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.