2013-10-27 98 views
17

我正在試驗AVX-AVX2指令集以查看連續陣列上的流式傳輸性能。所以我有下面的例子,我做基本的內存讀取和存儲。Haswell內存訪問

#include <iostream> 
#include <string.h> 
#include <immintrin.h> 
#include <chrono> 
const uint64_t BENCHMARK_SIZE = 5000; 

typedef struct alignas(32) data_t { 
    double a[BENCHMARK_SIZE]; 
    double c[BENCHMARK_SIZE]; 
    alignas(32) double b[BENCHMARK_SIZE]; 
} 
data; 

int main() { 
    data myData; 
    memset(&myData, 0, sizeof(data_t)); 

    auto start = std::chrono::high_resolution_clock::now(); 

    for (auto i = 0; i < std::micro::den; i++) { 
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) { 
     myData.b[i] = myData.a[i] + 1; 
    } 
    } 
    auto end = std::chrono::high_resolution_clock::now(); 
    std::cout << (end - start).count()/std::micro::den << " " << myData.b[1] 
      << std::endl; 
} 

並與 編譯後克++ - 4.9 -ggdb -march =芯AVX2 -std = C++ 11 struct_of_arrays.cpp -O3 -o struct_of_arrays

我看每循環性能相當不錯指令和時間,對於基準尺寸4000.然而,一旦我將基準尺寸增加到5000,我看到每個週期的指令顯着下降,並且延遲跳躍。 現在我的問題是,雖然我可以看到性能下降 似乎與L1緩存有關,但我無法解釋爲什麼會這麼突然發生。

爲了讓更多的有識之士,如果我跑PERF與基準尺寸4000和5000

| Event        | Size=4000 | Size=5000 | 
|-------------------------------------+-----------+-----------| 
| Time        | 245 ns | 950 ns | 
| L1 load hit       | 525881 | 527210 | 
| L1 Load miss      |  16689 |  21331 | 
| L1D writebacks that access L2 cache | 1172328 | 623710387 | 
| L1D Data line replacements   | 1423213 | 624753092 | 

所以我的問題是,爲什麼這種影響正在發生的事情,考慮的Haswell應該能夠提供2 * 32個字節的讀取,每個週期存儲32個字節?

EDIT 1

我與此代碼的gcc實現巧妙消除訪問,因爲它被設置爲0爲了避免這種情況的myData.a我沒有另一個基準,這是稍有不同,其中一個顯式設置。

#include <iostream> 
#include <string.h> 
#include <immintrin.h> 
#include <chrono> 
const uint64_t BENCHMARK_SIZE = 4000; 

typedef struct alignas(64) data_t { 
    double a[BENCHMARK_SIZE]; 
    alignas(32) double c[BENCHMARK_SIZE]; 

    alignas(32) double b[BENCHMARK_SIZE]; 

} 
data; 

int main() { 
    data myData; 
    memset(&myData, 0, sizeof(data_t)); 
    std::cout << sizeof(data) << std::endl; 
    std::cout << sizeof(myData.a) << " cache lines " << sizeof(myData.a)/64 
      << std::endl; 
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) { 
    myData.b[i] = 0; 
    myData.a[i] = 1; 
    myData.c[i] = 2; 
    } 

    auto start = std::chrono::high_resolution_clock::now(); 
    for (auto i = 0; i < std::micro::den; i++) { 
    for (uint64_t i = 0; i < BENCHMARK_SIZE; i += 1) { 
     myData.b[i] = myData.a[i] + 1; 
    } 
    } 
    auto end = std::chrono::high_resolution_clock::now(); 
    std::cout << (end - start).count()/std::micro::den << " " << myData.b[1] 
      << std::endl; 
} 

第二個例子將有一個數組正在被讀取,另一個數組正在被寫入。 而這一次產生以下輸出PERF的不同尺寸:如在回答中指出,隨着 數據集大小的數據不適合於再L1和L2變爲瓶頸

| Event   | Size=1000 | Size=2000 | Size=3000 | Size=4000  | 
|----------------+-------------+-------------+-------------+---------------| 
| Time   | 86 ns  | 166 ns  | 734 ns  | 931 ns  | 
| L1 load hit | 252,807,410 | 494,765,803 | 9,335,692 | 9,878,121  | 
| L1 load miss | 24,931  | 585,891  | 370,834,983 | 495,678,895 | 
| L2 load hit | 16,274  | 361,196  | 371,128,643 | 495,554,002 | 
| L2 load miss | 9,589  | 11,586  | 18,240  | 40,147  | 
| L1D wb acc. L2 | 9,121  | 771,073  | 374,957,848 | 500,066,160 | 
| L1D repl.  | 19,335  | 1,834,100 | 751,189,826 | 1,000,053,544 | 

再次相同的模式看出。 也是有趣的是,預取似乎沒有幫助,L1錯過 大大增加。雖然,我認爲考慮到讀入L1的每個緩存行至少有50%的命中率,對於第二次訪問(64字節緩存行32字節在每次迭代中讀取)將是命中 。但是,一旦數據集溢出到L2,似乎L1命中率下降到2%。考慮到數組並不真正與L1緩存大小重疊,這應該不是因爲緩存衝突。所以這部分對我來說仍然沒有意義。

回答

18

摘要:
不同的緩存水平可以維持不同的峯值帶寬爲基本相同的工作量,所以有不同大小的數據集可以極大地影響性能。

更詳細的解釋:
這不是很奇怪考慮到Haswell的,根據this article用於例如可以

維持2個載荷和每個週期

1個店,但這只是說要申請L1。如果你在閱讀你看到L2

可以提供一個完整64B線數據或指令緩存每個週期

因爲你需要一個負載,對每個迭代一個店,將數據集駐留在L1中將允許您享受L1帶寬並可能達到每次循環的吞吐量,同時將數據集溢出到L2會迫使您等待更長時間。這取決於你的系統有多大,但是你的結果表明它可能是32位,所以4000 * 2數組* 4字節= 32k,恰好是L1大小,而5000就超過了這個數。

現在有這種情況發生,一旦你開始超過進入下一個高速緩存級別兩件事情:

  1. L1-回寫:請注意,文章中沒有提到回寫這是你必須追加處罰用帶寬來支付(從你的perf輸出中可以看出 - 儘管看起來有點陡峭)。將數據保存在L1中意味着您不必進行任何驅逐,而在L2中具有一些數據意味着從L2讀取的每條線都必須從L1中拋出現有線路 - 其中一半被修改爲你的代碼並需要明確的回寫。這些事務必須在讀取每次迭代使用的兩個數據元素的值之前完成 - 請記住,商店還必須先讀取舊數據,因爲該行的一部分未被使用並且需要合併。

  2. 緩存替換策略 - 注意,由於緩存組相聯,並最有可能使用的LRU方案,因爲你去了你的陣列順序,緩存的使用模式很可能是灌裝頭相關聯的方式,然後等到第二條路完成時,如果L2中仍然存在所需的數據(在大數據集的情況下),那麼您可能會從第一條路開始逐出所有線路他們是最近使用最少的,即使這也意味着他們是你將要使用的下一個。這是數據集大於緩存的LRU的缺點。

這就解釋了爲什麼在性能上的下降是如此突然,因爲這種訪問模式,一旦你通過一個單一的方式(L1緩存的1/8)至少在大小超出了高速緩存大小。

最後一個關於性能結果的評論 - 你預計L1的命中率會下降到5000個元素的情況下,我相信它的確如此。然而,HW預取可以使它看起來像仍然在L1中一樣,因爲它在實際數據讀取之前運行。您仍然必須等待這些預取才能將數據帶過來,更重要的是,由於您正在測量帶寬 - 它們仍然佔用與實際加載/存儲相同的帶寬,但它們不被perf所佔,導致您相信你一直有L1命中。這至少是我最好的猜測 - 你可以通過禁用預取和再次測量來檢查(我似乎經常給予這種建議,對於成爲這樣的拖延感到抱歉)。


EDIT 1(以下你的)

關於消除陣列,其解決關於雙尺寸謎大抓 - 這是確實的64位,所以無論是4000層的元件,或2個陣列的一個陣列每個2000個元素(在你修復之後)和你在L1中所能達到的一樣多。現在溢出發生在3000個元素。現在L1的命中率很低,因爲L1無法發出足夠的預取以在您的兩個不同的流之前運行。對於期望每個負載都會帶來64個字節的行來進行2次迭代 - 我看到一些非常有趣的事情 - 如果您總結從內存單元發出的負載數量(L1命中+ L1未命中),那麼您會發現2000個元素的情況幾乎是1000個元素的兩倍,但是3000個和4000個個案不是3個和4個,而是一半。具體來說,每個數組有3000個元素,與2000個元素相比,訪問次數更少!
這讓我懷疑內存單元能夠將每個2個負載合併到一個單獨的內存訪問中,但只有在進入L2及更高版本時纔可以。當你想到這一點時,這是有道理的,如果你已經有一條線路待命,那麼沒有理由再發出查詢L2的訪問權限,並且這是一種緩解該級別帶寬較低的可行方法。 我猜測,由於某種原因,第二次加載甚至沒有作爲L1查找計算,並且不利於您希望看到的命中率(您可以檢查指示有多少負載正在通過執行的計數器 - 應該可能是真的)。這只是一個預感,雖然我不確定計數器是如何定義的,但它確實符合我們所看到的訪問次數。

+1

+1。我唯一要補充的是,在我見過的每一個x86平臺上,一個double都是8個字節。 –

+0

事實上,如果他們不在L1中,你是否正確回寫以及如何消費帶寬。如果數據不在L1中,那麼不能利用處理單元的能力是有點令人失望的(對於大於L1的任何流式使用情況,情況幾乎總是如此)。 – edorado

+1

這就是爲什麼性能關鍵算法經常將他們的工作集分成可以適應較小緩存的子集的原因(請參閱例如緩存切片技術)。根據文章L2帶寬也增加了相比較老的CPU,我想這只是很難趕上L1的改進 – Leeor