第七章 緩存
譯者:飛龍
協(xié)議:CC BY-NC-SA 4.0
7.1 程序如何運行
為了理解緩存,你需要理解計算機如何運行程序。你應(yīng)該學(xué)習(xí)計算機體系結(jié)構(gòu)來深入理解這個話題。這一章中我的目標是給出一個程序執(zhí)行的簡單模型。
當程序啟動時,代碼(或者程序文本)通常位于硬盤上。操作系統(tǒng)創(chuàng)建新的進程來運行程序,之后“加載器”將代碼從存儲器復(fù)制到主存中,并且通過調(diào)用main
來啟動程序。
在程序運行之中,它的大部分數(shù)據(jù)都儲存在主存中,但是一些數(shù)據(jù)在寄存器中,它們是CPU上的小型儲存單元。這些寄存器包括:
- 程序計數(shù)器(PC),它含有程序下一條指令(在內(nèi)存中)的地址。
- 指令寄存器(IR),它含有當前執(zhí)行的指令的機器碼。
- 棧指針(SP),它含有當前函數(shù)棧幀的指針,其中包含函數(shù)參數(shù)和局部變量。
- 程序當前使用的存放數(shù)據(jù)的通用寄存器。
- 狀態(tài)寄存器,或者位寄存器,含有當前計算的信息。例如,位寄存器通常含有一位來存儲上個操作是否是零的結(jié)果。
在程序運行之中,CPU執(zhí)行下列步驟,叫做“指令周期”:
- 取指(Fetch):從內(nèi)存中獲取下一條指令,儲存在指令寄存器中。
- 譯碼(Decode):CPU的一部分叫做“控制單元”,將指令譯碼,并向CPU的其它部分發(fā)送信號。
- 執(zhí)行(Execute):收到來自控制單元的信號后會執(zhí)行合適的計算。
大多數(shù)計算機能夠執(zhí)行幾百條不同的指令,叫做“指令集”。但是大多數(shù)指令可歸為幾個普遍的分類:
- 加載:將內(nèi)存中的值送到寄存器。
- 算術(shù)/邏輯:從寄存器加載操作數(shù),執(zhí)行算術(shù)運算,并將結(jié)果儲存到寄存器。
- 儲存:將寄存器中的值送到內(nèi)存。
- 跳轉(zhuǎn)/分支:修改程序計數(shù)器,使控制流跳到程序的另一個位置。分支通常是有條件的,也就是說它會檢查位寄存器中的旗標,只在設(shè)置時跳轉(zhuǎn)。
一些指令集,包括普遍的x86,提供加載和算術(shù)運算的混合指令。
在每個指令周期中,指令從程序文本處讀取。另外,普通程序中幾乎一半的指令都用于儲存或讀取數(shù)據(jù)。計算機體系結(jié)構(gòu)的一個基礎(chǔ)問題,“內(nèi)存瓶頸”就在這里。
在當前的臺式機上,CPU通常為2GHz,也就是說每0.5ns就會初始化一條新的語句。但是它用于從內(nèi)存中傳送數(shù)據(jù)的時間約為10ns。如果CPU需要等10ns來抓取下一條指令,再等10ns來加載數(shù)據(jù),它可能需要40個時鐘周期來完成一條指令。
7.2 緩存性能
這一問題的解決方案,或者至少是一部分的解決方案,就是緩存。“緩存”是CPU上小型、快速的儲存空間。在當前的計算機上,儲存通常為12MiB,訪問速度為12ns。
當CPU從內(nèi)存中讀取數(shù)據(jù)時,它將一份副本存到緩存中。如果再次讀取相同的數(shù)據(jù),CPU就直接讀取緩存,不用再等待內(nèi)存了。
當最后緩存滿了的時候,為了能讓新的數(shù)據(jù)進來,我們需要將一些數(shù)據(jù)扔掉。所以如果CPU加載數(shù)據(jù)之后,過了一段時間再來讀取,數(shù)據(jù)就可能不在緩存中了。
許多程序的性能受限于緩存的效率。如果CPU所需的數(shù)據(jù)通常在緩存中,程序可以以CPU的全速來運行。如果CPU時常需要不在緩存中的數(shù)據(jù),程序就會受限于內(nèi)存的速度。
緩存的“命中率”h
,是內(nèi)存訪問時,在緩存中找到數(shù)據(jù)的比例?!叭笔省?code>m,是內(nèi)存訪問時需要訪問內(nèi)存的比例。如果Th
是處理緩存命中的時間,Tm
是緩存未命中的時間,每次內(nèi)存訪問的平均時間是:
h * Th + m * Tm
同樣,我們可以定義“缺失懲罰”,它是處理緩存未命中所需的額外時間,Tp = Tm - Th
,那么平均訪問時間就是:
Th + m * Tp
當缺失率很低時平均訪問時間趨近于Th
,也就是說,程序可以表現(xiàn)為內(nèi)存具有緩存的速度那樣。
7.3 局部性
當程序首次讀取某個字節(jié)時,緩存通常加載一“塊”或一“行”數(shù)據(jù),包含所需的字節(jié)和一些相鄰數(shù)據(jù)。如果程序繼續(xù)讀取這些相鄰數(shù)據(jù),它們就已經(jīng)在緩存中了。
例如,假設(shè)塊大小是64B,你讀取一個長度為64的字符串,字符串的首個字節(jié)恰好在塊的開頭。當你加載首個字節(jié)之后,你觸發(fā)了缺失懲罰,但是之后字符串的剩余部分都在緩存中。在讀取整個字符串之后,命中率是63/64。如果字符串被分在兩個塊中,你應(yīng)該會觸發(fā)兩次缺失懲罰。但是這個命中率是62/64,約為97%。
另一方面,如果程序不可預(yù)測地跳來跳去,從內(nèi)存中零散的位置讀取數(shù)據(jù),很少兩次訪問到相同的位置,緩存的性能就會很低。
程序使用相同數(shù)據(jù)多于一次的傾向叫做“時間局部性”。使用相鄰位置的數(shù)據(jù)的傾向叫做“空間局部性”。幸運的是,許多程序天生就帶有這兩種局部性:
- 許多程序含有非跳轉(zhuǎn)或分支的代碼塊。在這些代碼塊中指令順序執(zhí)行,訪問模式具有空間局部性。
- 在循環(huán)中,程序執(zhí)行多次相同指令,所以訪問模式具有時間局部性。
- 一條指令的結(jié)果通常用于下一指令的操作數(shù),所以數(shù)據(jù)訪問模式具有時間局部性。
- 當程序執(zhí)行某個函數(shù)時,它的參數(shù)和局部變量在棧上儲存在一起。這些值的訪問具有空間局部性。
- 最普遍的處理模型之一就是順序讀寫數(shù)組元素。這一模式也具有空間局部性。
下一節(jié)中我們會探索程序的訪問模式和緩存性能的關(guān)系。
7.4 緩存性能的度量
當我還是UC伯克利的畢業(yè)生時,我是Brian Harvey計算機體系結(jié)構(gòu)課上的助教。我最喜歡的練習(xí)之一涉及到一個迭代數(shù)組,讀寫元素并度量平均時間的程序。通過改變數(shù)組的大小,就有可能推測出緩存的大小,塊的大小,和一些其它屬性。
我的這一程序的修改版本在本書倉庫的cache
目錄下。
程序的核心部分是個循環(huán):
iters = 0;
do {
sec0 = get_seconds();
for (index = 0; index < limit; index += stride)
array[index] = array[index] + 1;
iters = iters + 1;
sec = sec + (get_seconds() - sec0);
} while (sec < 0.1);
內(nèi)部的for
循環(huán)遍歷了數(shù)組。limit
決定數(shù)組遍歷的范圍。stride
決定跳過多少元素。例如,如果limit
是16,stride
是4,循環(huán)就會訪問0、4、8、和12。
sec
跟蹤了CPU用于內(nèi)循環(huán)的的全部時間。外部循環(huán)直到sec
超過0.1秒才會停止,這對于我們計算出平均時間所需的精確度已經(jīng)足夠長了。
get_seconds
使用系統(tǒng)調(diào)用clock_gettime
,將結(jié)果換算成秒,并且以double
返回結(jié)果。
double get_seconds(){
struct timespec ts;
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts);
return ts.tv_sec + ts.tv_nsec / 1e9;
}
圖 7.1:數(shù)據(jù)大小和步長的平均缺失懲罰函數(shù)
為了將訪問數(shù)據(jù)的時間分離出來,程序運行了第二個循環(huán),它除了內(nèi)循環(huán)不訪問數(shù)據(jù)之外完全相同。它總是增加相同的變量:
iters2 = 0;
do {
sec0 = get_seconds();
for (index = 0; index < limit; index += stride)
temp = temp + index;
iters2 = iters2 + 1;
sec = sec - (get_seconds() - sec0);
} while (iters2 < iters);
第二個循環(huán)運行和第一個循環(huán)相同數(shù)量的迭代。在每輪迭代之后,它從sec
中減少了消耗的時間。當循環(huán)完成時,sec
包含了所有數(shù)組訪問的總時間,減去用于增加temp
的時間。其中的差就是所有訪問觸發(fā)的全部缺失懲罰。最后,我們將它除以訪問總數(shù)來獲取每次訪問的平均缺失懲罰,以ns為單位:
sec * 1e9 / iters / limit * stride
如果你編譯并運行cache.c
,你應(yīng)該看到這樣的輸出:
Size: 4096 Stride: 8 read+write: 0.8633 ns
Size: 4096 Stride: 16 read+write: 0.7023 ns
Size: 4096 Stride: 32 read+write: 0.7105 ns
Size: 4096 Stride: 64 read+write: 0.7058 ns
如果你安裝了Python和Matplotlib,你可以使用graph_data.py
來使結(jié)果變成圖形。圖7.1展示了我運行在Dell Optiplex 7010上的結(jié)果。要注意數(shù)組大小和步長以字節(jié)為單位表述,并不是數(shù)組元素數(shù)量。
花一分鐘來考慮這張圖片,并且看看你是否能推斷出緩存信息。下面是一些需要思考的事情:
- 程序多次遍歷并讀取數(shù)組,所以有大量的時間局部性。如果整個數(shù)組能放進緩存,平均缺失懲罰應(yīng)幾乎為0。
- 當步長是4的時候,我們讀取了數(shù)組的每個元素,所以程序有大量的空間局部性。例如,如果塊大小足以包含64個元素,即使數(shù)組不能完全放在緩存中,命中率應(yīng)為63/64。
- 如果步長等于塊的大?。ɑ蚋螅臻g局部性應(yīng)為0,因為每次我們讀取一個塊的時候,我們只訪問一個元素。這種情況下,我們會看到最大的缺失懲罰。
總之,如果數(shù)組比緩存大小更小,或步長小于塊的大小,我們認為會有良好的緩存性能。如果數(shù)組大于緩存大小,并且步長較大時,性能只會下降。
在圖7.1中,只要數(shù)組小于2 ** 22
字節(jié),緩存性能對于所有步長都很好。我們可以推測緩存大小近似4MiB。實際上,根據(jù)規(guī)范應(yīng)該是3MiB。
當步長為8、16或32B時,緩存性能良好。在64B時開始下降,對于更大的步長,平均缺失懲罰約為9ns。我們可以推斷出塊大小為128B。
許多處理器都使用了“多級緩存”,它包含一個小型快速的緩存,和一個大型慢速的緩存。這個例子中,當數(shù)組大小大于2 ** 14
B時,缺失懲罰似乎增長了一點。所以這個處理器可能也擁有一個訪問時間小于1ns的16KB緩存。
7.5 緩存友好的編程
內(nèi)存的緩存功能由硬件實現(xiàn),所以多數(shù)情況下程序員都不需要知道太多關(guān)于它的東西。但是如果你知道緩存如何工作,你就可以編寫更有效利用它們的程序。
例如,如果你在處理一個大型數(shù)組,只遍歷數(shù)組一次,在每個元素上執(zhí)行多個操作,可能比遍歷數(shù)組多次要快。
如果你處理二維數(shù)組,它以行數(shù)組的形式儲存。如果你需要遍歷元素,按行遍歷并且步長為元素大小會比按列遍歷并且步長為行的大小更快。
鏈表數(shù)據(jù)結(jié)構(gòu)并不總具有空間局部性,因為節(jié)點在內(nèi)存中并不一定是連續(xù)的。但是如果你同時分配了很多個節(jié)點,它們在堆中通常分配到一起?;蛘撸绻阋淮畏峙淞艘粋€節(jié)點數(shù)組,你應(yīng)該知道它們是連續(xù)的,這樣會更好。
類似歸并排序的遞歸策略通常具有良好的緩存行為,因為它們將大數(shù)組劃分為小片段,之后處理這些小片段。有時這些算法可以調(diào)優(yōu)來利用緩存行為。
對于那些性能至關(guān)重要的應(yīng)用,可以設(shè)計適配緩存大小、塊大小以及其它硬件特征的算法。像這樣的算法叫做“緩存感知”。緩存感知算法的明顯缺點就是它們硬件特定的。
7.6 存儲器層次結(jié)構(gòu)
在這一章的幾個位置上,你可能會有一個問題:“如果緩存比主存快得多,那為什么不使用一大塊緩存,然后把主存扔掉呢?”
在沒有深入計算機體系結(jié)構(gòu)之前,可以給出兩個原因:電子和經(jīng)濟學(xué)上的。緩存很快是由于它們很小,并且離CPU很近,這可以減少由于電容造成的延遲和信號傳播。如果你把緩存做得很大,它就變得很慢。
另外,緩存占據(jù)處理器芯片的空間,更大的處理器會更貴。主存通常使用動態(tài)隨機訪問內(nèi)存(DRAM),每位上只有一個晶體管和一個電容,所以它可以將更多內(nèi)存打包在同一空間上。但是這種實現(xiàn)內(nèi)存的方法要比緩存實現(xiàn)的方式更慢。
同時主存通常包裝在雙列直插式內(nèi)存模塊(DIMM)中,它至少包含16個芯片。幾個小型芯片比一個大型芯片更便宜。
速度、大小和成本之間的權(quán)衡是緩存的根本原因。如果有既快又大還便宜的內(nèi)存技術(shù),我們就不需要其它東西了。
與內(nèi)存相同的原則也適用于存儲器。閃存非???,但是它們比硬盤更貴,所以它們就更小。磁帶比硬盤更慢,但是它們可以儲存更多東西,相對較便宜。
下面的表格展示了每種技術(shù)通常的訪問時間、大小和成本。
設(shè)備 | 訪問時間 | 通常大小 | 成本 |
---|---|---|---|
寄存器 | 0.5 ns | 256 B | ? |
緩存 | 1 ns | 2 MiB | ? |
DRAM | 10 ns | 4 GiB | $10 / GiB |
SSD | 10 μs | 100 GiB | $1 / GiB |
HDD | 5 ms | 500 GiB | $0.25 / GiB |
磁帶 | minutes | 1–2 TiB | $0.02 / GiB |
寄存器的數(shù)量和大小取決于體系結(jié)構(gòu)的細節(jié)。當前的計算機擁有32個通用寄存器,每個都可以儲存一個“字”。在32位計算機上,一個字為32位,4個字節(jié)。64位計算機上,一個字為64位,8個字節(jié)。所以寄存器文件的總?cè)萘渴?00~300字節(jié)。
寄存器和緩存的成本很難衡量。它們包含在芯片的成本中。但是顧客并不能直接了解到其成本。
對于表中的其它數(shù)據(jù),我觀察了計算機在線商店中,通常待售的計算機硬件規(guī)格。截至你讀到這里為止,這些數(shù)據(jù)應(yīng)該已經(jīng)過時了,但是它們可以帶給你在過去的某個時間上,一些關(guān)于性能和成本差距的概念。
這些技術(shù)構(gòu)成了“存儲器體系結(jié)構(gòu)”。結(jié)構(gòu)中每一級都比它上一級大而緩慢。某種意義上,每一級都作為其下一級的緩存。 你可以認為主存是持久化儲存在SSD或HDD上的程序和數(shù)據(jù)的緩存。并且如果你需要處理磁帶上非常大的數(shù)據(jù)集,你可以用硬盤緩存一部分數(shù)據(jù)。
7.7 緩存策略
存儲器層次結(jié)構(gòu)展示了一個考慮到緩存的框架。在結(jié)構(gòu)的每一級中,我們都需要強調(diào)四個緩存的基本問題:
- 誰在層次結(jié)構(gòu)中上移或下移數(shù)據(jù)?在結(jié)構(gòu)的頂端,寄存器通常由編譯器完成分配。CPU上的硬件管理內(nèi)存的緩存。在執(zhí)行程序或打開文件的過程中,用戶可以將存儲器上的文件隱式移動到內(nèi)存中。但是操作系統(tǒng)也會將數(shù)據(jù)從內(nèi)存移動回存儲器。在層次結(jié)構(gòu)的底端,管理員在磁帶和磁盤之間顯式移動數(shù)據(jù)。
- 移動了什么東西?通常,在結(jié)構(gòu)頂端的塊大小比底端要小。在內(nèi)存的緩存中,通常塊大小為128B。內(nèi)存中的頁面可能為4KiB,但是當操作系統(tǒng)從磁盤讀取文件時,它可能會一次讀10或100個塊。
- 數(shù)據(jù)什么時候會移動?在多數(shù)的基本的緩存中,數(shù)據(jù)在首次使用時會移到緩存。但是許多緩存使用一些“預(yù)取”機制,也就是說數(shù)據(jù)會在顯式請求之前加載。我們已經(jīng)見過預(yù)取的一些形式了:在請求其一部分時加載整個塊。
- 緩存中數(shù)據(jù)在什么地方?當緩存填滿之后,我們不把一些東西扔掉就不可能放進一些東西。理想化來說,我們打算保留將要用到的數(shù)據(jù),并替換掉不會用到的數(shù)據(jù)。
這些問題的答案構(gòu)成了“緩存策略”。在靠近頂端的位置,緩存策略傾向于更簡單,因為它們非???,并由硬件實現(xiàn)。在靠近底端的位置,會有更多做決定的次數(shù),并且設(shè)計良好的策略會有很大不同。
多數(shù)緩存策略基于歷史重演的原則,如果我們有最近時期的信息,我們可以用它來預(yù)測不久的將來。例如,如果一塊數(shù)據(jù)在最近使用了,我們認為它不久之后會再次使用。這個原則展示了一種叫做“最近最少使用”的策略,即LRU。它從緩存中移除最久未使用的數(shù)據(jù)塊。更多話題請見緩存算法的維基百科。
7.8 頁面調(diào)度
在帶有虛擬內(nèi)存的系統(tǒng)中,操作系統(tǒng)可以將頁面在存儲器和內(nèi)存之間移動。像我在6.2中提到的那樣,這種機制叫做“頁面調(diào)度”,或者簡單來說叫“換頁”。
下面是工作流程:
- 進程A調(diào)用
malloc
來分配頁面。如果堆中沒有所請求大小的空閑空間,malloc
會調(diào)用sbrk
向操作系統(tǒng)請求更多內(nèi)存。 - 如果物理內(nèi)存中有空閑頁,操作系統(tǒng)會將其加載到進程A的頁表,創(chuàng)建新的虛擬內(nèi)存有效范圍。
- 如果沒有空閑頁面,調(diào)度系統(tǒng)會選擇一個屬于進程B的“犧牲頁面”。它將頁面內(nèi)容從內(nèi)存復(fù)制到磁盤,之后修改進程B的頁表來表示這個頁面“被換出”了。
- 一旦進程B的數(shù)據(jù)被寫入,頁面會重新分配給進程A。為了防止進程A讀取進程B的數(shù)據(jù),頁面應(yīng)被清空。
- 此時
sbrk
的調(diào)用可以返回了,向malloc
提供堆區(qū)額外的空間。之后malloc
分配所請求的內(nèi)存并返回。進程A可以繼續(xù)執(zhí)行。 - 當進程A執(zhí)行完畢,或中斷后,調(diào)度器可能會讓進程B繼續(xù)執(zhí)行。當它訪問到被換出的頁面時,內(nèi)存管理器單元注意到這個頁面是“無效”的,并且會觸發(fā)中斷。
- 當操作系統(tǒng)處理中斷時,它會看到頁面被換出了,于是它將頁面從磁盤傳送到內(nèi)存。
- 一旦頁面被換入之后,進程B可以繼續(xù)執(zhí)行。
當頁面調(diào)度工作良好時,它可以極大提升物理內(nèi)存的利用水平,允許更多進程在更少的空間內(nèi)執(zhí)行。下面是它的原因:
- 大多數(shù)進程不會用完所分配的內(nèi)存。
text
段的許多部分都永遠不會執(zhí)行,或者執(zhí)行一次就再也不用了。這些頁面可以被換出而不會引發(fā)任何問題。 - 如果程序泄露了內(nèi)存,它可能會丟掉所分配的空間,并且永遠不會使用它了。通過將這些頁面換出,操作系統(tǒng)可以有效填補泄露。
- 在多數(shù)系統(tǒng)中,有些進程像守護進程那樣,多數(shù)時間下都是閑置的,只在特定場合被“喚醒”來響應(yīng)事件。當它們閑置時,這些進程可以被換出。
- 另外,可能有許多進程運行同一個程序。這些進程可以共享相同的
text
段,避免在物理內(nèi)存中保留多個副本。
如果你增加分配給所有進程的總內(nèi)存,它可以超出物理內(nèi)存的大小,并且系統(tǒng)仍舊運行良好。
在某種程度上是這樣。
當進程訪問被換出的頁面時,就需要從磁盤獲取數(shù)據(jù),這會花費幾個毫秒。這一延遲通常很明顯。如果你將一個窗口閑置一段時間,之后切換回它,它可能會執(zhí)行得比較慢,并且你可能在頁面換入時會聽到磁盤工作的聲音。
像這樣偶爾的延遲可能還可以接受,但是如果你擁有很多占據(jù)大量空間的進程,它們就會相互影響。當進程A運行時,它會收回進程B所需的頁面,之后進程B運行時,它又會收回進程A所需的頁面。當這種情況發(fā)生時,兩個進程都會執(zhí)行緩慢,系統(tǒng)會變得無法響應(yīng)。這種我們不想看到的場景叫做“顛簸”。
理論上,操作系統(tǒng)應(yīng)該通過檢測調(diào)度和塊上的增長來避免顛簸,或者殺掉進程直到系統(tǒng)能夠再次響應(yīng)。但是在我看來,多數(shù)系統(tǒng)都沒有這樣做,或者做得不好。它們通常讓用戶去限制物理內(nèi)存的使用,或者嘗試在顛簸發(fā)生時恢復(fù)。