計算機科學基礎知識(一)The Memory Hierarchy
一、前言
最近一個問題經常縈繞在我的腦海:一個學習電子工程的機械師如何稱為優秀的程序員?(註:本文作者本科學習機械設計,研究生轉到電子工程系學習,畢業後卻選擇了系統程序員這樣的職業)。經過思考,我認為阻擋我稱為一個優秀程序員的障礙是計算機科學的理論知識。自然辯證法告訴我們:理論源於實踐,又指導實踐,她們是相輔相成的關系。雖然從業十余年,閱code無數,但計算機的理論不成體系,無法指導工程面具體技能的進一步提升。
計算機科學博大精深,CPU體系結構、離散數學、編譯器原理、軟件工程等等。最終選擇從下面這本書作為起點:
本文就是在閱讀了該書的第六章的一個讀數筆記,方便日後查閱。
二、存儲技術
本節主要介紹SRAM,SDRAM,FLASH以及磁盤這集中存儲技術,這些技術是後面學習的基礎。
1、SRAM
SRAM是RAM的一種,和SDRAM不同的是SRAM不需要refresh的動作,只要保持供電,其memory cell保存的數據就不會丟失。一個SRAM的memory cell是由六個場效應管組成,如下:
具體bit的信息是保存在M1、M2、M3、M4這四個場效應管中。M1和M2組成一個反相器,我們稱之C1。Q(有上劃線的那個Q)是C1的輸出。M3和M4組成另外一個反相器C2,Q是C2的輸出。C1的輸出連接到C2的輸入,C2的輸出連接到C1的輸入,通過這樣的方法實現兩個反相器的輸出狀態的鎖定、保存,即儲存了1個bit的數據。M5和M6是用來控制數據訪問的。一個SRAM的memory cell有三個狀態:
(1)idle狀態。這種狀態下,Word Line(圖中標識為WL)為低電平的時候,M5和M6都處於截止狀態,保存bit信息的cell和外界是隔絕的。這時候,只有有供電,cell保持原來的狀態。
(2)reading狀態。我們假設cell中保存的數據是1(Q點是高電平),當進行讀操作的時候,首先把兩根bit line(BL和BL)設置為高電平。之後assert WL,以便導通M5和M6。M5和M6導通之後,我們分成兩個部分來看。右邊的BL和Q都是高電平,因此狀態不變。對於左邊,BL是高電平,而Q是低電平,這時候,BL就會通過M5、M1進行放電,如果時間足夠長,BL最終會變成低電平。cell保存數據0的情況是類似的,只不過這時候最終BL
(3)writing狀態。假設我們要向cell中寫入1,首先將BL設定為高電平,BL設定為低電平。之後assert WL,以便導通M5和M6。M5和M6導通之後,如果原來cell保存1,那麽狀態不會變化。如果原來cell保存0,這時候Q是低電平,M1截止,M2導通,Q是高電平,M4截止,M3導通。一旦assert WL使得M5和M6導通後,Q變成高電平(跟隨BL點的電平),從而導致M1導通,M2截止。一旦M1導通,原來Q點的高電平會通過M1進行放電,使Q點變成低電平。而Q點的低電平又導致M4導通,M3截止,使得Q點鎖定在高電平上。將cell的內容從1變成0也是相似的過程,這裏不再詳述。
了解了一個cell的結構和操作過程之後,就很容易了解SRAM芯片的結構和原理了。一般都是將cell組成陣列,再加上一些地址譯碼邏輯,數據讀寫buffer等block。
2、SDRAM。具體請參考SDRAM internals。
3、Flash。具體請參考FLASH internals。
4、Disk(硬盤)
嵌入式軟件工程師多半對FLASH器件比較熟悉,而對Hard Disk相對陌生一些。這裏我們只是簡單介紹一些基本的信息,不深入研究。保存數據的硬盤是由一個個的“盤子”(platter)組成,每個盤子都有兩面(surface),都可以用來保存數據。磁盤被堆疊在一起沿著共同的主軸(spindle)旋轉。每個盤面都有一個磁頭(header)用來讀取數據,這些磁頭被固定在一起可以沿著盤面徑向移動。盤面的數據是存儲在一個一個的環形的磁道(track)上,磁道又被分成了一個個的sector。還有一個術語叫做柱面(cylinder),柱面是由若幹的track組成,這些track分布在每一個盤面上,有共同的特點就是到主軸的距離相等。
我們可以從容量和存取速度兩個方面來理解Disk Drive。容量計算比較簡單,特別是理解了上面描述的硬盤的幾何結構之後。磁盤容量=(每個sector有多少個Byte)x(每個磁道有多少個sector)x(每個盤面有多少個磁道)x(每個盤子有多少個盤面)x(該硬盤有多少個盤子)。
由於各個盤面的讀取磁頭是固定在一起的,因此,磁頭的移動導致訪問的柱面的變化。因此,同一時刻,我們可以同時讀取位於同一柱面上的sector的數據。對於硬盤,數據的訪問是按照sector進行的,當我們要訪問一個sector的時候需要考慮下面的時間:
(1)Seek time。這個時間就是磁頭定位到磁道的時間。這個時間和上次訪問的磁道以及移動磁頭的速度有關。大約在10ms左右。
(2)Rotational latency。磁頭移動到了磁道後,還不能讀取sector的數據,因為保存數據的盤面都是按照固定的速率旋轉的,有可能我們想要訪問的sector剛好轉過磁頭,這時候,只能等下次旋轉到磁頭位置的時候才能開始數據讀取。這個是時間和磁盤的轉速有關,數量級和seek time類似。
(3)Transfer time。當想要訪問的sector移動到磁頭下面,數據訪問正式啟動。這時候,數據訪問的速度是和磁盤轉速以及磁道的sector數目相關。
舉一個實際的例子可能會更直觀:Seek time:9ms,Rotational latency:4ms,Transfer time:0.02ms。從這些數據可以知道,硬盤訪問的速度主要受限在Seek time和Rotational latency,一旦磁頭和sector相遇,數據訪問就非常的快了。此外,我們還可以看出,RAM的訪問都是ns級別的,而磁盤要到ms級別,可見RAM的訪問速度要遠遠高於磁盤。
三、局部性原理(Principle of Locality)
好的程序要展現出好的局部性(locality),以便讓系統(軟件+硬件)展現出好的性能。到底是memory hierarchy、pipeline等硬件設計導致軟件必須具備局部性,還是本身軟件就是具有局部性特點從而推動硬件進行相關的設計?這個看似雞生蛋、蛋生雞的問題,我傾向於邏輯的本身就是有序的,是局部性原理的本質。此外,局部性原理不一定涉及硬件。例如對於AP軟件和OS軟件,OS軟件會把AP軟件最近訪問的virtual address space的數據存在內存中作為cache。
局部性被分成兩種類型:
(1)時間局部性(Temporal Locality)。如果程序有好的時間局部性,那麽在某一時刻訪問的memory,在該時刻隨後比較短的時間內還多次訪問到。
(2)空間局部性(Spatial Locality)。如果程序有好的空間局部性,那麽一旦某個地址的memory被訪問,在隨後比較短的時間內,該memory附近的memory也會被訪問。
1、數據訪問的局部性分析
int sumvec(int v[N]) { int i, sum = 0; for (i = 0; i < N; i++) sum += v[i]; return sum; }
i和sum都是棧上的臨時變量,由於i和sum都是標量,不可能表現出好的空間局部性,不過對於sumvec的主loop,i、sum以及數組v都可以表現出很好的時間局部性。雖然,i和sum沒有很好的空間局部性,不過編譯器會把i和sum放到寄存器中,從而優化性能。數組v在loop中是順序訪問的,因此可以表現出很好的空間局部性。總結一下,上面的程序可以表現出很好的局部性。
按照上面的例子對數組v進行訪問的模式叫做stride-1 reference pattern。下面的例子可以說明stride-N reference pattern:
int sumarraycols(int v[M][N]) { int i, j; sum = 0; for(j = 0; j < N; j++) for (i = 0; i < M; i++) sum += a[i][j]; return sum; }
二維數組v[i][j]是一個i行j列的數組,在內存中,v是按行存儲的,首先是第1行的j個列數據,之後是第二行的j個列數據……依次排列。在實際的計算機程序中,我們總會有計算數組中所有元素的和的需求,在這個問題上有兩種方式,一種是按照行計算,另外一種方式是按照列計算。按照行計算的程序可以表現出好的局部性,不過sumarraycols函數中是按照列進行計算的。在內循環中,累計一個列的數據需要不斷的訪問各個行,這就導致了數據訪問不是連續的,而是每次相隔N x sizeof(int),這種情況下,N越大,空間局部性越差。
2、程序訪問的局部性分析
對於指令執行而言,順序執行的程序具備好的空間局部性。我們可以回頭看看sumvec函數的執行情況,這個程序中,loop內的指令都是順序執行的,因此,有好的空間局部性,而loop被多次執行,因此同時又具備了好的時間局部性。
3、經驗總結
我們可以總結出3條經驗:
(1)不斷的重復訪問同一個變量的程序有好的時間局部性
(2)對於stride-N reference pattern的程序,N越小,空間局部性越好,stride-1 reference pattern的程序最優。好的程序要避免以跳來跳去的方式來訪問memory,這樣程序的空間局部性會很差
(3)循環有好的時間和空間局部性。循環體越小,循環次數越多,局部性越好
四、存儲體系
1、分層的存儲體系
現代計算機系統的存儲系統是分層的,主要有六個層次:
(1)CPU寄存器
(2)On-chip L1 Cache (一般由static RAM組成,size較小,例如16KB)
(3)Off-chip L2 Cache (一般由static RAM組成,size相對大些,例如2MB)
(4)Main memory(一般是由Dynamic RAM組成,幾百MB到幾個GB)
(5)本地磁盤(磁介質,幾百GB到若幹TB)
(6)Remote disk(網絡存儲、分布式文件系統)
而決定這個分層結構的因素主要是:容量(capacity),價格(cost)和訪問速度(access time)。位於金字塔頂端的CPU寄存器訪問速度最快(一個clock就可以完成訪問)、容量最小。金字塔底部的存儲介質訪問速度最慢,但是容量可以非常的大。
2、存儲體系中的cache
緩存機制可以發生在存儲體系的k層和k+1層,也就是說,在k層的存儲設備可以作為low level存儲設備(k+1層)的cache。例如訪問main memory的時候,L2 cache可以緩存main memory的部分數據,也就是說,原來需要訪問較慢的SDRAM,現在可以通過L2 cache直接命中,提高了效率。同樣的道理,訪問本地磁盤的時候,linux內核也會建立page cache、buffer cache等用來緩存disk上數據。
下面我們使用第三層的L2 cache和第四層的Main memory來說明一些cache的基本概念。一般而言,Cache是按照cache line組織的,當訪問主存儲器的時候,有可能數據或者指令位於cache line中,我們稱之cache hit,這種情況下,不需要訪問外部慢速的主存儲器,從而加快了存儲器的訪問速度。也有可能數據或者指令沒有位於cache line中,我們稱之cache miss,這種情況下,需要從外部的主存儲器加載數據或指令到cache中來。由於時間局部性(tmpporal locality)和空間局部性(spatial locality)原理,load 到cache中的數據和指令往往是最近要使用的內容,因此可以提高整體的性能。當cache miss的時候,我們需要從main memory加載cache,如果這時候cache已經滿了(畢竟cache的size要小於main memory的size),這時候還要考慮替換算法。比較簡單的例子是隨機替換算法,也就是隨機的選擇一個cache line進行替換。也可以采用Least-recently used(LRU)算法,這種算法會選擇最近最少使用的那個cache line來加載新的cache數據,cache line中舊的數據就會被覆蓋。
cache miss有三種:
(1)在系統初始化的時候,cache中沒有任何的數據,這時候,我們稱這個cache是cold cache。這時候,由於cache還沒有warn up導致的cache miss叫做compulsory miss或者cold miss。
(2)當加載一個cache line的時候,有兩種策略,一種是從main memory加載的數據可以放在cache中的任何一個cache line。這個方案判斷cache hit的開銷太大,需要scan整個cache。因此,在實際中,指定的main memory的數據只能加載到cache中的一個subset中。正因此如此,就產生了另外一種cache miss叫做conflict miss。也就是說,雖然目前cache中仍然有空閑的cacheline,但是由於main memory要加載的數據映射的那個subset已經滿了,這種情況導致的cache miss叫做conflict miss。
(3)對於程序的執行,有時候會在若幹個指令中循環,而在這個循環中有可能不斷的反復訪問一個或者多個數據block(例如:一個靜態定義的數組)。這些數據block就叫做這個循環過程的Working Set。當Working Set的size大於cache的size之後,就會產生capacity miss。加大cache的size是解決capacit miss的唯一方法(或者減少working set的size)。
前面我們已經描述過,分層的memory hierarchy的精髓就是每層的存儲設備都是可以作為下層設備的cache,而在每一層的存儲設備都要有一些邏輯(可以是軟件的,也可以是硬件的)來管理cache。例如:cache的size是多少?如何定義k層的cache和k+1層存儲設備之間的transfer block size(也就是cache line),如何確定cache hit or miss,替換策略為何?對於cpu register而言,編譯器提供了了cache的管理策略。對於L1和L2,cache管理策略是由HW logic來控管,不過軟件工程師在編程的時候需要了解這個層次上的cache機制,以便寫出比較優化的代碼。我們都有用瀏覽器訪問Internet的經歷,我們都會有這樣的常識,一般最近訪問的網頁都會比較快,因此這些網頁是從本地加載而不是遠端的主機。這就是本地磁盤對網絡磁盤的cache機制,是用軟件邏輯來控制的。
五、cache內幕
本節用ARM926的cache為例,描述一些cache memory相關的基礎知識,對於其他level上的cache,概念是類似的。
1、ARM926 Cache的組織
ARM926的地址線是32個bit,可以訪問的地址空間是4G。現在,我們要設計CPU寄存器和4G main memory空間之間的一個cache。毫無疑問,我們的cache不能那麽大,因此我們考慮設計一個16K size的cache。首先考慮cache line的size,一般選擇32個Bytes,這個也是和硬件相關,對於支持burst mode的SDRAM,一次burst可以完成32B的傳輸,也就是完成了一次cache line的填充。16K size的cache可以分成若幹個set,對於ARM926,這個數字是128個。綜上所述,16KB的cache被組織成128個cache set,每個cache set中有4個cache line,每個cache line中保存了32B字節的數據block。了解了Cache的組織,我們現在看看每個cache line的組成。一個cache line由下面的內容組成:
1、該cache line是否有效的標識。
2、Tag。聽起來很神秘,其實一般是地址的若幹MSB部分組成,用來判斷是否cache hit的
3、數據塊(具體的數據或者指令)。如果命中,並且有效,CPU就直接從cache中取走數據,不必再去訪問memory了。
了解了上述信息之後,我們再看看virutal memory address的組成,具體如下圖所示:
當CPU訪問一個32 bit的地址的時候,首先要去cache中查看是否hit。由於cache line的size(指數據塊部分,不包括tag和flag)是32字節,因此最低的5個bits是用來定位cache line offset的。中間的7個bit是用來尋找cache set的。7個bit可以尋址128個cache set。找到了cache set之後,我們就要對該cache set中的四個cache line進行逐一比對,是否valid,如果valid,那麽要訪問地址的Tag是否和cache line中的Tag一樣?如果一樣,那麽就cache hit,否則cache miss,需要發起訪問main memory的操作。
總結一下:一個cache的組織可以由下面的四個參數組來標識(S,E,B,m)。S是cache set的數目;E是每個cache set中cache line的數目;B是一個cache line中保存的數據塊的字節數;m是物理地址的bit數目。
2、Direct-Mapped Cache和Set Associative Cache
如果每個cache set中只有一個cache line,也就是說E等於1,這樣組織的cache就叫做Direct-Mapped Cache。這種cache的各種操作比較簡單,例如判斷是否cache hit。通過Set index後就定位了一個cache set,而一個cache set只有一個cache line,因此,只有該cache line的valid有效並且tag是匹配的,那麽就是cache hit,否則cache miss。替換策略也簡單,因為就一個cache line,當cache miss的時候,只能把當前的cache line換出。雖然硬件設計比較簡單了,但是conflict Miss會比較突出。我們可以舉一個簡單的例子:
float dot_product(float x[8], float y[8])
{
int i; float sum = 0.0;
for(i=0; i<8; i++)
{
sum += x[i]*y[i];
}
return sum;
}
上面的程序是求兩個向量的dot product,這個程序有很好的局部性,按理說應該有較高的cache hit,但是實際中未必總是這樣。假設32 byte的cache被組織成2個cache set,每個cache line是16B。假設x數組放在0x0地址開始的32B中,4B表示一個float數據,y數組放在0x20開始的地址中。第一個循環中,當訪問x[0]的時候(set index等於0),cache的set 0被加載了x[0]~x[3]的數據。當訪問y[0]的時候,由於set index也是0,因此y[0]~y[3]被加載到set 0,從而替換了之前加載到set 0的x[0]~x[3]數據。第二個循環的時候,當訪問x[1],不能cache命中,於是重新將x[0]~x[3]的數據載入set 0,而訪問y[1]的時候,仍然不能cache hit,因為y[0]~y[3]已經被flush掉了。有一個術語叫做Thrashing就是描述這種情況。
正是因為E=1導致了cache thrashing,加大E可以解決上面的問題。當一個cache set中有多於1個cache line的時候,這種cache就叫做Set Associative Cache。ARM926的cache被稱為four-way set associative cache,也就是說一個cache set中包括4個cache line。一旦有了多個cache line,判斷cache hit就稍顯麻煩了,因為這時候必須要逐個比對了,直到找到匹配的Tag並且是valid的那個cache line。如果cache miss,這時候就需要從main memory加載cache,如果有空當然好,選擇那個flag是invalid的cache line就OK了,如果所有的cache line都是有效的,那麽替換哪一個cache line呢?當然,硬件設計可以有多種選擇,但是毫無疑問增加了復雜度。
還有一種cache被叫做fully Associative cache,這種cache只有一個cache set。這種cache匹配非常耗時,因為所有的cache line都在一個set中,硬件要逐個比對才能判斷出cache miss or hit。這種cache只適合容量較小的cache,例如TLB。
3、寫操作帶來的問題
上面的章節主要描述讀操作,對於寫操作其實也存在cache hit和cache miss,這時候,系統的行為又是怎樣的呢?我們首先來看看當cache hit時候的行為(也就是說想要寫入數據的地址單元已經在cache中了)。根據寫的行為,cache分成三種類型:
(1)write through。CPU向cache寫入數據時,同時向memory也寫一份,使cache和memory的數據保持一致。優點是簡單,缺點是每次都要訪問memory,速度比較慢。
(2)帶write buffer的write through。策略同上,只不過不是直接寫memory,而是把更新的數據寫入到write buffer,在合適的時候才對memory進行更新。
(3)write back。CPU更新cache line時,只是把更新的cache line標記為dirty,並不同步更新memory。只有在該cache line要被替換掉的時候,才更新 memory。這樣做的原因是考慮到很多時候cache存入的是中間結果(根據局部性原理,程序可能隨後還會訪問該單元的數據),沒有必要同步更新memory(這可以降低bus transaction)。優點是CPU執行的效率提高,缺點是實現起來技術比較復雜。
在write操作時發生cache miss的時候有兩種策略可以選擇:
(1)no-write-allocate cache。當write cache miss的時候,簡單的寫入main memory而沒有cache的操作。一般而言,write through的cache會采用no-write-allocate的策略。
(2)write allocate cache。當write cache miss的時候,分配cache line並將數據從main memory讀入,之後再進行數據更新的動作。一般而言,write back的cache會采用write allocate的策略。
4、物理地址還是虛擬地址?
當CPU發出地址訪問的時候,從CPU出去的地址是虛擬地址,經過MMU的映射,最終變成物理地址。這時候,問題來了,我們是用虛擬地址還是物理地址(中的cache set index)來尋找cache set?此外,當找到了cache set,那麽我們用虛擬地址還是物理地址(中的Tag)來匹配cache line?
根據使用的是物理地址還是虛擬地址,cache可以分成下面幾個類別:
(1)VIVT(Virtual index Virtual tag)。尋找cache set的index和匹配cache line的tag都是使用虛擬地址。
(2)PIPT(Physical index Physical tag)。尋找cache set的index和匹配cache line的tag都是使用物理地址。
(3)VIPT(Virtual index Physical tag)。尋找cache set的index使用虛擬地址,而匹配cache line的tag使用的是物理地址。
對於一個計算機系統,CPU core、MMU和Cache是三個不同的HW block。采用PIPT的話,CPU發出的虛擬地址要先經過MMU翻譯成物理地址之後,再輸入到cache中進行cache hit or miss的判斷,毫無疑問,這個串行化的操作損害了性能。但是這樣簡單而粗暴的使用物理地址沒有歧義,不會有cache alias。VIVT的方式毫無疑問是最快的,不需要MMU的翻譯直接進入cache判斷hit or miss,不過會引入其他問題,例如:一個物理地址的內容可以出現在多個cache line中,這就需要更多的cache flush操作。反而影響了速度(這就是傳說中的cache alias,具體請參考下面的章節)。采用VIPT的話,CPU輸出的虛擬地址可以同時送到MMU(進行翻譯)和cache(進行cache set的選擇)。這樣cache 和MMU可以同時工作,而MMU完成地址翻譯後,再用物理的tag來匹配cache line。這種方法比不上VIVT 的cache 速度, 但是比PIPT 要好。在某些情況下,VIPT也會有cache alias的問題,但可以用巧妙的方法避過,後文會詳細描述。
對於ARM而言,隨著技術進步,MMU的翻譯速度提高了,在cache 用index查找cache set的過程中MMU已經可以完成虛擬地址到物理地址的轉換工作,因此在cache比較tag的時候物理地址已經可以使用了,就是說采用 physical tag可以和cache並行工作,不會影響cache的速度。因此,在新的ARM構建中(如ARMv6和ARMv7中),采用了VIPT的方式。
5、cache alias
在linux內核中可能有這樣的場景:同一個物理地址被映射到多個不同的虛擬地址上。在這樣的場景下,我們可以研究一下cache是如何處理的。
對於PIPT,沒有alias,因為cache set selection和tag匹配都是用物理地址,對於一個物理地址,cache中只會有一個cache line的數據與之對應。
對於VIPT的cache system,雖然在匹配tag的時候使用physical address tag,但是卻使用virtual address的set index進行cache set查找,這時候由於使用不同的虛擬地址而導致多個cache line針對一個物理地址。對於linux的內存管理子系統而言,virtual address space是通過4k的page進行管理的。對於物理地址和虛擬地址,其低12 bit是完全一樣。 因此,即使是不同的虛擬地址映射到同一個物理地址,這些虛擬地址的低12bit也是一樣的。在這種情況下,如果查找cache set的index位於低12 bit的範圍內,那麽alais不會發生,因為不同的虛擬地址對應同一個cache line。當index超過低12 bit的範圍,就會產生alais。在實際中,cache line一般是32B,占5個bit,在VIPT的情況下,set index占用7bit(包括7bit)一下,VIPT就不存在alias的問題。在我接觸到的項目中,ARM的16K cache都是采用了128個cache set,也就是7個bit的set index,恰好滿足了no alias的需求。
對於VIVT,cache中總是存在多於一個的cache line 包含這個物理地址的數據,總是存在cache alias的問題。
cache alias會影響cache flush的接口,特別是當flush某個或者某些物理地址的時候。這時候,系統軟件需要找到該物理地址對應的所有的cache line進行flush的動作。
6、Cache Ambiguity
Cache Ambiguity是指將不同的物理地址映射到相同的虛擬地址而造成的混亂。這種情況下在linux內核中只有在不同進程的用戶空間的頁面才可能發生。 Cache Ambiguity會造成同一個cache line在不同的進程中代表不同的數據, 切換進程的時候需要進行處理。
對於PIPT,不存在Cache Ambiguity,雖然虛擬地址一樣,但是物理地址是不同的。對於VIPT,由於使用物理地址來檢查是否cache hit,因此不需要在進程切換的時候flush用戶空間的cache來解決Cache Ambiguity的問題。VIVT會有Cache Ambiguity的問題,一般會在進程切換或者exit mm的時候flush用戶空間的cache
計算機科學基礎知識(一)The Memory Hierarchy