這篇文章主要是探討現在的 CPU 的 cache 和記憶體系統之間的關係。

CPU 速度的進展,一直比記憶體的速度進展要來得快。在 IBM PC XT 的時代,CPU 和記憶體的速度是差不多的。不過,後來 CPU 的速度就愈來愈快。再加上 DRAM 需要 refresh 才能保存資料的特性,DRAM 很快就跟不上 CPU 的速度了。現在的 CPU 都利用了 pipeline 的方式,可以每個 cycle 都 issue 一個(甚至多個)指令,再加上現在的 CPU 時脈也比記憶體的時脈高,記憶體的速度可說是遠遠落在 CPU 之後了。

為了避免記憶體成為 CPU 速度的瓶頸,現在的 CPU 都有 cache 的設計,甚至還有多層的 cache。Cache 的原理,主要是利用到大部分的程式,在處理資料時,都有一定程度的區域性。所以,我們可以用一小塊快速的記憶體,來暫存目前需要的資料。

例如,幾乎所有的程式,大部分的執行時間是花在一些迴圈中。這些迴圈通常都不大,可能只佔整個程式空間的百分之一。如果一個程式經常要執行這段程式數千、甚至數萬次,那就可以把這一小段程式放在 cache 中,CPU 就不需要每次都到很慢的主記憶體中讀取這段程式了。很多一般用途的程式,在存取資料時,也有類似的特性。因此,cache 的幫助非常大。如果沒有 cache 的話,我們就不需要這麼快的 CPU 了,因為系統的速度會卡在記憶體的速度上面。

現在的 CPU 往往也有多層的 cache。例如,Intel 的 Pentium III 500Mhz CPU,有 32KB 的 L1 cache,和 512KB 的 L2 cache。其中,L1 cache 內建在 CPU 內部,速度非常快,而且它是 Harvard 式,即指令用的空間和資料用的空間是分開的。Pentium III 500Mhz CPU 的 L1 cache 是分成 16KB 的 I-cache 和 16KB 的 D-cache。而 L2 cache 則是在 CPU 外面,以 250Mhz 的速度運作。另外,它和 CPU 之間的 bus 也只有 64 bits 寬。L2 cache 通常就不會區分指令和資料的空間,也就是 unified cache。

Cache 對速度有什麼影響呢?這可以由 latency 來表示。CPU 在從記憶體中讀取資料(或程式)時,會需要等待一段時間,這段時間就是 latency,通常用 cycle 數表示。例如,一般來說,如果資料已經在 L1 cache 中,則 CPU 在讀取資料時(這種情形稱為 L1 cache hit),CPU 是不需要多等的。但是,如果資料不在 L1 cache 中(這種情形稱為 L1 cache miss),則 CPU 就得到 L2 cache 去讀取資料了。這種情形下,CPU 就需要等待一段時間。如果需要的資料也不在 L2 cache 中,也就是 L2 cache miss,那麼 CPU 就得到主記憶體中讀取資料了(假設沒有 L3 cache)。這時候,CPU 就得等待更長的時間。

另外,cache 存取資料時,通常是分成很多小單位,稱為 cache line。例如,Pentium III 的 cache line 長度是 32 bytes。也就是說,如果 CPU 要讀取記憶體位址 0x00123456 的一個 32 bits word(即 4 bytes),且 cache 中沒有這個資料,則 cache 會將 0x00123440 ~ 0x0012345F 之間的 32 bytes 資料(即一整個 cache line 長度)都讀入 cache 中。所以,當 CPU 讀取連續的記憶體位址時,資料都已經讀到 cache 中了。

我寫了一個小程式,用來測試 cache 的行為。這個程式會連續讀取一塊記憶體位址,並量測平均讀取時間。這個 程式的執行結果如下:

測試平台:

  • Pentium III 500Mhz, PC100 SDRAM, 440BX chipset
  • Celeron 466Mhz, PC100 SDRAM, VIA Apollo Pro 133 chipset

Latency result

程式的執行檔和原始碼可在這裡下載。

由上面的結果可以看出,當測試的區塊大小在 16KB 以下時,平均的 latency 都在 1 ~ 3 cycles 左右。這顯示出 16KB 的 L1 D-cache 的效果。在測試區塊為 1KB 和 2KB 時,因為額外的 overhead 較高,所以平均的 latency 變得較高,但是在 4KB ~ 16KB 的測試中,latency 則相當穩定。在這個範圍中,由於 Pentium III 和 Celeron 有相同的 L1 cache,所以測試結果是幾乎完全相同的。

在區塊超過 16KB 之後,就沒辦法放入 L1 D-cache 中了。但是它還是可以放在 L2 cache 中。所以,在 Pentium III 的情形下,從 32KB ~ 512KB,latency 都在 10 cycles 左右。這顯示出當 L1 cache miss 而 L2 cache hit 時,所需要的 latency。而 Celeron 的 L2 cache 只有 128KB,但是 Celeron 的 L2 cache 的 latency 則明顯的比 Pentium III 為低。這是因為 Celeron 的 L2 cache 是 on-die,以和 CPU 核心相同的速度運作。而 Pentium III 的 L2 cache 則是分開的,且以 CPU 核心速度的一半運作。

在區塊超過 512KB 之後,L2 cache 就不夠大了(Pentium III 500Mhz 只有 512KB 的 L2 cache)。這時,顯示出來的就是 L1 cache miss 且 L2 cache miss 時,所需要的 latency。在 1024KB 或更大的區塊中,Pentium III 的 latency 都大約是 28 cycles 左右,而 Celeron 的 latency 則超過 70 cycles。這是 CPU 讀取主記憶體時,平均的 latency。而 Celeron 的 latency 較高,應該是因為其外頻較低,而倍頻數較高的緣故(Pentium III 500Mhz 為 5 倍頻,而 Celeron 466 為 7 倍頻)。另外,晶片組的差異也可能是原因之一。

Cache 的效果十分明顯。不過,有時候 cache 是派不上用場的。例如,當資料完全沒有區域性,或是資料量太大的時候,都會讓 cache 的效果降低。例如,在進行 MPEG 壓縮時,存取的資料量很大,而且資料的重複利用率很低,所以 cache 的幫助就不大。另外,像是 3D 遊戲中,如果每個 frame 的三角面個數太多,也會超過 cache 能夠處理的範圍。

現在的電腦愈來愈朝向「多媒體應用」,需要處理的資料量也愈來愈大,因此,要如何善用 cache 就成了一個重要的問題。一個非常重要的方法,就是把讀取主記憶體的 latency 和執行運算的時間重疊,就可以把 latency「藏」起來。通常這會需要 prefetch 的功能,也就是 AMD 在 K6-2 及之後的 CPU,和 Intel 在 Pentium III 之後的 CPU 加入的新功能。在下一篇文章中,我們會討論 prefetch 的原理和用途。

在上一篇文章中,已經簡單討論過 CPU 的 cache 和其對 latency 的影響。在這篇文章中,我們就以一個較為實際的例子,並說明 prefetch 的原理和用途。

這裡要用的「實際例子」,其實還是很理想化的。為了和 3D 繪圖扯上一點關係,這裡就用「4×4 的矩陣和 4 維向量相乘」做為例子。不過,一般在 3D 繪圖中,都是用 single precision 的浮點數(每個數需要 32 bits),而這裡為了讓記憶體的因素更明顯,我們使用 double precision 的浮點數(每個數需要 64 bits),也就是一個 4 維向量剛好需要 32 bytes。

在這個例子中,我們採取一個 3D 繪圖中,相當常見的動作,也就是把一大堆 4 維向量,乘上一個固定的 4×4 矩陣。如果向量的個數非常多,超過 CPU 的 cache 所能負擔,那麼 CPU 的表現就會大幅下降。

為了讓大家心裡有個底,這裡先把執行的結果列出來:

測試平台: Pentium III 500Mhz, PC100 SDRAM, 440BX chipset

Vector test result

程式集可以下載程式的原始碼和執行檔。

首先,我們來看沒有使用 prefetch 指令的結果。事實上,結果相當符合預測。在 L1 D-cache 的範圍內(即小於 16KB 的情形),平均的運算時間相當的穩定,約在 51 ~ 52 cycles 左右。這也是 Pentium III 在計算一個 4×4 矩陣和 4 維向量相乘時(使用 double precision 浮點數),可能達到的最快速度。當然,這個程式是用 C 寫成的。如果直接用手寫組合語言,可能還可以再快個 5 ~ 10 cycles。

當資料量超過 L1 D-cache 的範圍,但是還在 L2 cache 的範圍之內時,所需的時間提高到約 60 cycles 左右。在 Part 1 中,我們已經知道 Pentium III 500Mhz 的 L2 cache 大約有 10 cycles 的 latency,所以這個結果也是相當合理的。

當資料量超過 L2 cache 的範圍時,所有的資料就需要從主記憶體中取得了。從圖上可以很容易的看到,每次運算所需的時間增加到 145 ~ 150 cycles。這有點出乎意料之外:在 Part 1 中,讀取主記憶體的 latency 只有 30 cycles 左右,但是在這裡,latency 增加了約 100 cycles。不過,這個結果並不奇怪。因為在運算結束後,運算的結果必須要寫回記憶體中,而寫回記憶體的動作,需要很多時間。

從這裡可以看到,在資料量超過 L2 cache 的範圍時,CPU 可說是被記憶體的速度限制住了。事實上,如果記憶體的速度不變,那即使是用兩倍快的 CPU,速度的增加也會非常有限。以 3D 遊戲的角度來說,1024KB 或 2048KB 這樣的資料量並不算少見,因為一個 single precision 浮點數的 4 維向量,就需要 16 bytes 的空間。65,536 個 4 維向量就需要 1MB 的空間了。

事實上,記憶體的速度雖慢,但是要完成一個 32 bytes(一個四維向量的大小)的讀寫動作,也只需要 60 ~ 70 cycles 而已(以 Pentium III 500Mhz 配合 PC100 SDRAM 的情形來算)。而在不用 prefetch 的情形下,CPU 的動作類似下圖所示:

Vector computation diagram 1

在上圖中,CPU 的運算單元(即圖中的 Execution Units)大部分的時間都在等待資料輸入。而 Load/Store Unit 也有不少時間是不動作的。這顯然不是最好的方法,因為 CPU 的兩個單元都不是全速運作。

如果我們在 CPU 的運算單元進行計算工作時,就把下一個要計算的資料先載入到 CPU 的 cache 中,那麼,CPU 的動作就會變成類似下圖所示:

Vector computation diagram 2

現在,Load/Store Unit 變成全速運作了。Execution Units 還是沒有全速運作,但是這是沒辦法的。這種情形,就表示出瓶頸是在 Load/Store Unit,也就是在主記憶體的速度。已經沒有任何方法可以加快執行的速度了(除非加快記憶體的速度)。

要注意的一點是,上面的情形是很少發生的真實世界中的。實際的程式,通常瓶頸都是在運算單元。不過,我們的例子則剛好不是這樣(因為矩陣和向量相乘是很簡單的運算),而是類似圖中的情形。

要怎麼告訴 CPU,在計算的同時將下一個資料載入到 cache 中呢?這時就要用到 prefetch 的指令了。在我們的程式中,執行向量運算的程式如下:

  • for(i = 0; i < buf_size; i += 4) {
    • double r1, r2, r3, r4;
    • // 執行矩陣乘法
    • r1 = m[0] * v[i] + m[1] * v[i+1] + m[2] * v[i+2] + m[3] * v[i+3];
    • r2 = m[4] * v[i] + m[5] * v[i+1] + m[6] * v[i+2] + m[7] * v[i+3];
    • r3 = m[8] * v[i] + m[9] * v[i+1] + m[10] * v[i+2] + m[11] * v[i+3];
    • r4 = m[12] * v[i] + m[13] * v[i+1] + m[14] * v[i+2] + m[15] * v[i+3];
    • // 寫回計算結果
    • v[i] = r1;
    • v[i+1] = r2;
    • v[i+2] = r3;
    • v[i+3] = r4;
  • }

現在,我們在矩陣乘法的前面插入一個 prefetch 指令,變成:

  • for(i = 0; i < buf_size; i += 4) {
    • double r1, r2, r3, r4;
    • // 執行矩陣乘法
    • r1 = m[0] * v[i] + m[1] * v[i+1] + m[2] * v[i+2] + m[3] * v[i+3];
    • // 前一行執行完後,整個 4 維向量已經載入到 cache 中。
    • // 所以,現在用 prefetch 指令載入下一個 4 維向量。
    • prefetch(v + i + 4);
    • // 繼續進行計算
    • r2 = m[4] * v[i] + m[5] * v[i+1] + m[6] * v[i+2] + m[7] * v[i+3];
    • r3 = m[8] * v[i] + m[9] * v[i+1] + m[10] * v[i+2] + m[11] * v[i+3];
    • r4 = m[12] * v[i] + m[13] * v[i+1] + m[14] * v[i+2] + m[15] * v[i+3];
    • // 寫回計算結果
    • v[i] = r1;
    • v[i+1] = r2;
    • v[i+2] = r3;
    • v[i+3] = r4;
  • }

這段程式中的 prefetch 函式,裡面執行的是 SSE 的 prefetchnta 指令。Pentium III 和 Athlon 都支援這個指令(AMD 的 K6-2 中另外有一個 prefetch 指令,是 3DNow! 指令的一部分)。這個指令會將指定的資料載入到離 CPU 最近的 cache 中(在 Pentium III 即為 L1 cache)。

只不過加上這樣一行程式,執行結果就有很大的不同。回到前面的測試結果,我們可以看出,prefetch 指令,在資料已經存在 cache 中的時候,會有相當程度的 overhead(在這裡是大約 10 cycles)。但是,當資料不在 cache 中的時候,效率就有明顯的改善。特別是在資料量為 1024 KB 時,所需時間約為 70 cycles,說明了瓶頸確實是在 Load/Store Unit。在 1024 KB 之後,所需的 cycle 的增加,則是因為在多工系統中難以避免的 task switch 所產生的 overhead。

由此可知,prefetch 指令對於多媒體及 3D 遊戲等資料量極大的應用,是非常重要的。也可以預料,將來的程式一定會更加善用這類的功能,以達到最佳的效率。

Source: http://www.csie.ntu.edu.tw/~r89004/hive/cache/page_2.html