對於現代cpu而言,性能瓶頸則是對於內存的訪問。cpu的速度往往都比主存的高至少兩個數量級。因此cpu都引入了L1_cache與L2_cache,更加高端的cpu還加入了L3_cache.很顯然,這個技術引起了下一個問題:
如果一個cpu在執行的時候需要訪問的內存都不在cache中,cpu必須要通過內存總線到主存中取,那么在數據返回到cpu這段時間內(這段時間大致為cpu執行成百上千條指令的時間,至少兩個數據量級)干什么呢? 答案是cpu會繼續執行其他的符合條件的指令。比如cpu有一個指令序列 指令1 指令2 指令3 …, 在指令1時需要訪問主存,在數據返回前cpu會繼續后續的和指令1在邏輯關系上沒有依賴的”獨立指令”,cpu一般是依賴指令間的內存引用關系來判斷的指令間的”獨立關系”,具體細節可參見各cpu的文檔。這也是導致cpu亂序執行指令的根源之一。
以上方案是cpu對於讀取數據延遲所做的性能補救的辦法。對於寫數據則會顯得更加復雜一點:
當cpu執行存儲指令時,它會首先試圖將數據寫到離cpu最近的L1_cache, 如果此時cpu出現L1未命中,則會訪問下一級緩存。速度上L1_cache基本能和cpu持平,其他的均明顯低於cpu,L2_cache的速度大約比cpu慢20-30倍,而且還存在L2_cache不命中的情況,又需要更多的周期去主存讀取。其實在L1_cache未命中以后,cpu就會使用一個另外的緩沖區,叫做合並寫存儲緩沖區。這一技術稱為合並寫入技術。在請求L2_cache緩存行的所有權尚未完成時,cpu會把待寫入的數據寫入到合並寫存儲緩沖區,該緩沖區大小和一個cache line大小,一般都是64字節。這個緩沖區允許cpu在寫入或者讀取該緩沖區數據的同時繼續執行其他指令,這就緩解了cpu寫數據時cache miss時的性能影響。
當后續的寫操作需要修改相同的緩存行時,這些緩沖區變得非常有趣。在將后續的寫操作提交到L2緩存之前,可以進行緩沖區寫合並。 這些64字節的緩沖區維護了一個64位的字段,每更新一個字節就會設置對應的位,來表示將緩沖區交換到外部緩存時哪些數據是有效的。當然,如果程序讀取已被寫入到該緩沖區的某些數據,那么在讀取緩存數據之前會先去讀取本緩沖區的。
經過上述步驟后,緩沖區的數據還是會在某個延時的時刻更新到外部的緩存(L2_cache).如果我們能在緩沖區傳輸到緩存之前將其盡可能填滿,這樣的效果就會提高各級傳輸總線的效率,以提高程序性能。
從下面這個具體的例子來看吧:
下面一段測試代碼,從代碼本身就能看出它的基本邏輯。
#include <unistd.h>
#include <stdio.h>
#include <sys/time.h>
#include <stdlib.h>
#include <limits.h>
static const int iterations = INT_MAX;
static const int items = 1<<24;
static int mask;
static int arrayA[1<<24];
static int arrayB[1<<24];
static int arrayC[1<<24];
static int arrayD[1<<24];
static int arrayE[1<<24];
static int arrayF[1<<24];
static int arrayG[1<<24];
static int arrayH[1<<24];
double run_one_case_for_8()
{
double start_time;
double end_time;
struct timeval start;
struct timeval end;
int i = iterations;
gettimeofday(&start, NULL);
while(--i != 0)
{
int slot = i & mask;
int value = i;
arrayA[slot] = value;
arrayB[slot] = value;
arrayC[slot] = value;
arrayD[slot] = value;
arrayE[slot] = value;
arrayF[slot] = value;
arrayG[slot] = value;
arrayH[slot] = value;
}
gettimeofday(&end, NULL);
start_time = (double)start.tv_sec + (double)start.tv_usec/1000000.0;
end_time = (double)end.tv_sec + (double)end.tv_usec/1000000.0;
return end_time - start_time;
}
double run_two_case_for_4()
{
double start_time;
double end_time;
struct timeval start;
struct timeval end;
int i = iterations;
gettimeofday(&start, NULL);
while(--i != 0)
{
int slot = i & mask;
int value = i;
arrayA[slot] = value;
arrayB[slot] = value;
arrayC[slot] = value;
arrayD[slot] = value;
}
i = iterations;
while(--i != 0)
{
int slot = i & mask;
int value = i;
arrayG[slot] = value;
arrayE[slot] = value;
arrayF[slot] = value;
arrayH[slot] = value;
}
gettimeofday(&end, NULL);
start_time = (double)start.tv_sec + (double)start.tv_usec/1000000.0;
end_time = (double)end.tv_sec + (double)end.tv_usec/1000000.0;
return end_time - start_time;
}
int main()
{
mask = items -1;
int i;
printf("test begin---->\n");
for(i=0;i<3;i++)
{
printf(" %d, run_one_case_for_8: %lf\n", i, run_one_case_for_8());
printf(" %d, run_two_case_for_4: %lf\n", i, run_two_case_for_4());
}
printf("test end");
return 0;
}
相信很多人會認為run_two_case_for_4 的運行時間肯定要比run_one_case_for_8的長,因為至少前者多了一遍循環的i++操作。但是事實卻不是這樣:下面是運行的截圖:
測試環境: fedora 20 64bits, 4G DDR3內存,CPU:Inter® Core™ i7-3610QM cpu @2.30GHZ.
結果是令人吃驚的,他們的性能差距居然達到了1倍,太神奇了。
原理:上面提到的合並寫存入緩沖區離cpu很近,容量為64字節,很小了,估計很貴。數量也是有限的,我這款cpu它的個數為4。個數時依賴cpu模型的,intel的cpu在同一時刻只能拿到4個。
因此,run_one_case_for_8函數中連續寫入8個不同位置的內存,那么當4個數據寫滿了合並寫緩沖時,cpu就要等待合並寫緩沖區更新到L2cache中,因此cpu就被強制暫停了。然而在run_two_case_for_4函數中是每次寫入4個不同位置的內存,可以很好的利用合並寫緩沖區,因合並寫緩沖區滿到引起的cpu暫停的次數會大大減少,當然如果每次寫入的內存位置數目小於4,也是一樣的。雖然多了一次循環的i++操作(實際上你可能會問,i++也是會寫入內存的啊,其實i這個變量保存在了寄存器上), 但是它們之間的性能差距依然非常大。
從上面的例子可以看出,這些cpu底層特性對程序員並不是透明的。程序的稍微改變會帶來顯著的性能提升。對於存儲密集型的程序,更應當考慮到此到特性。
希望這篇文章能該大家帶來一些幫助,也能可做性能優化的同事帶來參考。