我的博客: https://www.luozhiyun.com/
浮點數和定點數
我們先來看一個問題,在Chrome瀏覽器里面通過開發者工具,打開瀏覽器里的Console,在里面輸入“0.3 + 0.6”:
>>> 0.3 + 0.6
0.8999999999999999
下面我們來一步步解釋,為什么會這樣。
定點數
如果我們用32個比特表示整數,用4個比特來表示0~9的整數,那么32個比特就可以表示8個這樣的整數。
然后我們把最右邊的2個0~9的整數,當成小數部分;把左邊6個0~9的整數,當成整數部分。這樣,我們就可以用32個比特,來表示從0到999999.99這樣1億個實數了。
這種用二進制來表示十進制的編碼方式,叫作BCD編碼。這種小數點固定在某一位的方式,我們也就把它稱為定點數。
缺點:
第一,這樣的表示方式有點“浪費”。本來32個比特我們可以表示40億個不同的數,但是在BCD編碼下,只能表示1億個數。
第二,這樣的表示方式沒辦法同時表示很大的數字和很小的數字。
浮點數
我們在表示一個很大的數的時候,通常可以用科學計數法來表示。
在計算機里,我也可以用科學計數法來表示實數。浮點數的科學計數法的表示,有一個IEEE的標准,它定義了兩個基本的格式。一個是用32比特表示單精度的浮點數,也就是我們常常說的float或者float32類型。另外一個是用64比特表示雙精度的浮點數,也就是我們平時說的double或者float64類型。
單精度
單精度的32個比特可以分成三部分。

第一部分是一個符號位,用來表示是正數還是負數。我們一般用s來表示。在浮點數里,我們不像正數分符號數還是無符號數,所有的浮點數都是有符號的。
接下來是一個8個比特組成的指數位。我們一般用e來表示。8個比特能夠表示的整數空間,就是0~255。我們在這里用1~254映射到-126~127這254個有正有負的數上。
最后,是一個23個比特組成的有效數位。我們用f來表示。綜合科學計數法,我們的浮點數就可以表示成下面這樣:
$(-1)s×1.f×2e$
特殊值的表示:

以0.5為例子。0.5的符號為s應該是0,f應該是0,而e應該是-1,也就是
$0.5= (-1)0×1.0×2{-1}=0.5$,對應的浮點數表示,就是32個比特。
不考慮符號的話,浮點數能夠表示的最小的數和最大的數,差不多是$1.17×10{-38}$和$3.40×10{38}$。
回到我們最開頭,為什么我們用0.3 + 0.6不能得到0.9呢?這是因為,浮點數沒有辦法精確表示0.3、0.6和0.9。
浮點數的二進制轉化
我們輸入一個任意的十進制浮點數,背后都會對應一個二進制表示。
比如:9.1,那么,首先,我們把這個數的整數部分,變成一個二進制。這里的9,換算之后就是1001。
接着,我們把對應的小數部分也換算成二進制。和整數的二進制表示采用“除以2,然后看余數”的方式相比,小數部分轉換成二進制是用一個相似的反方向操作,就是乘以2,然后看看是否超過1。如果超過1,我們就記下1,並把結果減去1,進一步循環操作。在這里,我們就會看到,0.1其實變成了一個無限循環的二進制小數,0.000110011。這里的“0011”會無限循環下去。

結果就是:$1.0010$$0011$$0011… × 2^3$
這里的符號位s = 0,對應的有效位f=001000110011…。因為f最長只有23位,那這里“0011”無限循環,最多到23位就截止了。於是,f=00100011001100110011 001。最后的一個“0011”循環中的最后一個“1”會被截斷掉。
對應的指數為e,代表的應該是3。因為指數位有正又有負,所以指數位在127之前代表負數,之后代表正數,那3其實對應的是加上127的偏移量130,轉化成二進制,就是130,對應的就是指數位的二進制,表示出來就是10000010。

最終得到的二進制表示就變成了:
010000010 0010 0011001100110011 001
如果我們再把這個浮點數表示換算成十進制, 實際准確的值是9.09999942779541015625。
浮點數的加法和精度損失
浮點數的加法是:先對齊、再計算。
那我們在計算0.5+0.125的浮點數運算的時候,首先要把兩個的指數位對齊,也就是把指數位都統一成兩個其中較大的-1。對應的有效位1.00…也要對應右移兩位,因為f前面有一個默認的1,所以就會變成0.01。然后我們計算兩者相加的有效位1.f,就變成了有效位1.01,而指數位是-1,這樣就得到了我們想要的加法后的結果。

其中指數位較小的數,需要在有效位進行右移,在右移的過程中,最右側的有效位就被丟棄掉了。這會導致對應的指數位較小的數,在加法發生之前,就丟失精度。
指令周期(Instruction Cycle)
計算機每執行一條指令的過程,可以分解成這樣幾個步驟。
- Fetch(取得指令),也就是從PC寄存器里找到對應的指令地址,根據指令地址從內存里把具體的指令,加載到指令寄存器中,然后把PC寄存器自增,好在未來執行下一條指令。
- Decode(指令譯碼),也就是根據指令寄存器里面的指令,解析成要進行什么樣的操作,是R、I、J中的哪一種指令,具體要操作哪些寄存器、數據或者內存地址。
- Execute(執行指令),也就是實際運行對應的R、I、J這些特定的指令,進行算術邏輯操作、數據傳輸或者直接的地址跳轉。
Fetch - Decode - Execute循環稱之為指令周期(Instruction Cycle)。

在取指令的階段,我們的指令是放在存儲器里的,實際上,通過PC寄存器和指令寄存器取出指令的過程,是由控制器(Control Unit)操作的。指令的解碼過程,也是由控制器進行的。一旦到了執行指令階段,無論是進行算術操作、邏輯操作的R型指令,還是進行數據傳輸、條件分支的I型指令,都是由算術邏輯單元(ALU)操作的,也就是由運算器處理的。不過,如果是一個簡單的無條件地址跳轉,那么我們可以直接在控制器里面完成,不需要用到運算器。

時序邏輯電路
有一些電路,只需要給定輸入,就能得到固定的輸出。這樣的電路,我們稱之為組合邏輯電路(Combinational Logic Circuit)。
時序邏輯電路有以下幾個特點:
- 自動運行,時序電路接通之后可以不停地開啟和關閉開關,進入一個自動運行的狀態。
- 存儲。通過時序電路實現的觸發器,能把計算結果存儲在特定的電路里面,而不是像組合邏輯電路那樣,一旦輸入有任何改變,對應的輸出也會改變。
- 時序電路使得不同的事件按照時間順序發生。
最常見的就是D觸發器,電路的輸出信號不單單取決於當前的輸入信號,還要取決於輸出信號之前的狀態。
PC寄存器
PC寄存器就是程序計數器。

加法器的兩個輸入,一個始終設置成1,另外一個來自於一個D型觸發器A。我們把加法器的輸出結果,寫到這個D型觸發器A里面。於是,D型觸發器里面的數據就會在固定的時鍾信號為1的時候更新一次。
這樣,我們就有了一個每過一個時鍾周期,就能固定自增1的自動計數器了。
最簡單的CPU流程


- 首先,有一個自動計數器會隨着時鍾主頻不斷地自增,來作為我們的PC寄存器。
- 在這個自動計數器的后面,我們連上一個譯碼器(用來尋址,將指令內存地址轉換成指令)。譯碼器還要同時連着我們通過大量的D觸發器組成的內存。
- 自動計數器會隨着時鍾主頻不斷自增,從譯碼器當中,找到對應的計數器所表示的內存地址,然后讀取出里面的CPU指令。
- 讀取出來的CPU指令會通過我們的CPU時鍾的控制,寫入到一個由D觸發器組成的寄存器,也就是指令寄存器當中。
- 在指令寄存器后面,我們可以再跟一個譯碼器。這個譯碼器不再是用來尋址的了,而是把我們拿到的指令,解析成opcode和對應的操作數。
- 當我們拿到對應的opcode和操作數,對應的輸出線路就要連接ALU,開始進行各種算術和邏輯運算。對應的計算結果,則會再寫回到D觸發器組成的寄存器或者內存當中。
指令流水線
指令流水線指的是把一個指令拆分成一個一個小步驟,從而來減少單條指令執行的“延時”。通過同時在執行多條指令的不同階段,我們提升了CPU的“吞吐率”。
如果我們把一個指令拆分成“取指令-指令譯碼-執行指令”這樣三個部分,那這就是一個三級的流水線。如果我們進一步把“執行指令”拆分成“ALU計算(指令執行)-內存訪問-數據寫回”,那么它就會變成一個五級的流水線。
五級的流水線,就表示我們在同一個時鍾周期里面,同時運行五條指令的不同階段。
我們可以看這樣一個例子。我們順序執行這樣三條指令。
- 一條整數的加法,需要200ps。
- 一條整數的乘法,需要300ps。
- 一條浮點數的乘法,需要600ps
如果我們是在單指令周期的CPU上運行,最復雜的指令是一條浮點數乘法,那就需要600ps。那這三條指令,都需要600ps。三條指令的執行時間,就需要1800ps。
如果我們采用的是6級流水線CPU,每一個Pipeline的Stage都只需要100ps。那么,在這三個指令的執行過程中,在指令1的第一個100ps的Stage結束之后,第二條指令就開始執行了。在第二條指令的第一個100ps的Stage結束之后,第三條指令就開始執行了。這種情況下,這三條指令順序執行所需要的總時間,就是800ps。那么在1800ps內,使用流水線的CPU比單指令周期的CPU就可以多執行一倍以上的指令數。

流水線設計CPU的風險
- 結構冒險

可以看到,在第1條指令執行到訪存(MEM)階段的時候,流水線里的第4條指令,在執行取指令(Fetch)的操作。訪存和取指令,都要進行內存數據的讀取。但是內存在一個時鍾周期是沒辦法都做的。
解決辦法:在高速緩存層面拆分成指令緩存和數據緩存
在CPU內部的高速緩存部分進行了區分,把高速緩存分成了指令緩存(Instruction Cache)和數據緩存(Data Cache)兩部分。

- 數據冒險
先寫后讀
int main() {
int a = 1;
int b = 2;
a = a + 2;
b = a + 3;
}
這里需要保證a和b的值先賦,然后才能進行准確的運算。這個先寫后讀的依賴關系,我們一般被稱之為數據依賴,也就是Data Dependency。
先讀后寫
int main() {
int a = 1;
int b = 2;
a = b + a;
b = a + b;
}
這里我們先要讀出a = b+a,然后才能正確的寫入b的值。這個先讀后寫的依賴,一般被叫作反依賴,也就是Anti-Dependency。
寫后再寫
int main() {
int a = 1;
a = 2;
}
很明顯,兩個寫入操作不能亂,要不然最終結果就是錯誤的。這個寫后再寫的依賴,一般被叫作輸出依賴,也就是Output Dependency。
解決辦法:流水線停頓(Pipeline Stall)

如果我們發現了后面執行的指令,會對前面執行的指令有數據層面的依賴關系,那最簡單的辦法就是“再等等”。我們在進行指令譯碼的時候,會拿到對應指令所需要訪問的寄存器和內存地址。
在實踐過程中,在執行后面的操作步驟前面,插入一個NOP操作,也就是執行一個其實什么都不干的操作。
- 控制冒險
在執行的代碼中,一旦遇到 if…else 這樣的條件分支,或者 for/while 循環的時候會發生類似cmp比較指令、jmp和jle這樣的條件跳轉指令。
在jmp指令發生的時候,CPU可能會跳轉去執行其他指令。jmp后的那一條指令是否應該順序加載執行,在流水線里面進行取指令的時候,我們沒法知道。要等jmp指令執行完成,去更新了PC寄存器之后,我們才能知道,是否執行下一條指令,還是跳轉到另外一個內存地址,去取別的指令。
解決辦法:
縮短分支延遲
條件跳轉指令其實進行了兩種電路操作。
第一種,是進行條件比較。
第二種,是進行實際的跳轉,也就是把要跳轉的地址信息寫入到PC寄存器。無論是opcode,還是對應的條件碼寄存器,還是我們跳轉的地址,都是在指令譯碼(ID)的階段就能獲得的。而對應的條件碼比較的電路,只要是簡單的邏輯門電路就可以了,並不需要一個完整而復雜的ALU。
所以,我們可以將條件判斷、地址跳轉,都提前到指令譯碼階段進行,而不需要放在指令執行階段。對應的,我們也要在CPU里面設計對應的旁路,在指令譯碼階段,就提供對應的判斷比較的電路。
分支預測
最簡單的分支預測技術,叫作“假裝分支不發生”。顧名思義,自然就是仍然按照順序,把指令往下執行。
如果分支預測失敗了呢?那我們就把后面已經取出指令已經執行的部分,給丟棄掉。這個丟棄的操作,在流水線里面,叫作Zap或者Flush。CPU不僅要執行后面的指令,對於這些已經在流水線里面執行到一半的指令,我們還需要做對應的清除操作。

動態分支預測
就是記錄當前分支的比較情況,直接用當前分支的比較情況,來預測下一次分支時候的比較情況。
例子:
public class BranchPrediction {
public static void main(String args[]) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 10000; k++) {
}
}
}
long end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start));
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
for (int j = 0; j <1000; j ++) {
for (int k = 0; k < 100; k++) {
}
}
}
end = System.currentTimeMillis();
System.out.println("Time spent is " + (end - start) + "ms");
}
}
輸出:
Time spent in first loop is 5ms
Time spent in second loop is 15ms

分支預測策略最簡單的一個方式,自然是“假定分支不發生”。對應到上面的循環代碼,就是循環始終會進行下去。在這樣的情況下,上面的第一段循環,也就是內層 k 循環10000次的代碼。每隔10000次,才會發生一次預測上的錯誤。而這樣的錯誤,在第二層 j 的循環發生的次數,是1000次。
最外層的 i 的循環是100次。每個外層循環一次里面,都會發生1000次最內層 k 的循環的預測錯誤,所以一共會發生 100 × 1000 = 10萬次預測錯誤。
操作數前推
通過流水線停頓可以解決資源競爭產生的問題,但是,插入過多的NOP操作,意味着我們的CPU總是在空轉,干吃飯不干活。所以我們提出了操作數前推這樣的解決方案。
add $t0, $s2,$s1
add $s2, $s1,$t0
第一條指令,把 s1 和 s2 寄存器里面的數據相加,存入到 t0 這個寄存器里面。
第二條指令,把 s1 和 t0 寄存器里面的數據相加,存入到 s2 這個寄存器里面。

我們要在第二條指令的譯碼階段之后,插入對應的NOP指令,直到前一天指令的數據寫回完成之后,才能繼續執行。但是這樣浪費了兩個時鍾周期。
這個時候完全可以在第一條指令的執行階段完成之后,直接將結果數據傳輸給到下一條指令的ALU。然后,下一條指令不需要再插入兩個NOP階段,就可以繼續正常走到執行階段。

這樣的解決方案,我們就叫作操作數前推(Operand Forwarding),或者操作數旁路(Operand Bypassing)。
CPU指令亂序執行

- 在取指令和指令譯碼的時候,亂序執行的CPU和其他使用流水線架構的CPU是一樣的。它會一級一級順序地進行取指令和指令譯碼的工作。
- 在指令譯碼完成之后,CPU不會直接進行指令執行,而是進行一次指令分發,把指令發到一個叫作保留站(Reservation Stations)的地方。
- 這些指令不會立刻執行,而要等待它們所依賴的數據,傳遞給它們之后才會執行。
- 一旦指令依賴的數據來齊了,指令就可以交到后面的功能單元(Function Unit,FU),其實就是ALU,去執行了。我們有很多功能單元可以並行運行,但是不同的功能單元能夠支持執行的指令並不相同。
- 指令執行的階段完成之后,我們並不能立刻把結果寫回到寄存器里面去,而是把結果再存放到一個叫作重排序緩沖區(Re-Order Buffer,ROB)的地方。
- 在重排序緩沖區里,我們的CPU會按照取指令的順序,對指令的計算結果重新排序。只有排在前面的指令都已經完成了,才會提交指令,完成整個指令的運算結果。
- 實際的指令的計算結果數據,並不是直接寫到內存或者高速緩存里,而是先寫入存儲緩沖區(Store Buffer面,最終才會寫入到高速緩存和內存里。
在亂序執行的情況下,只有CPU內部指令的執行層面,可能是“亂序”的。
例子:
a = b + c
d = a * e
x = y * z
里面的 d 依賴於 a 的計算結果,不會在 a 的計算完成之前執行。但是我們的CPU並不會閑着,因為 x = y * z 的指令同樣會被分發到保留站里。因為 x 所依賴的 y 和 z 的數據是准備好的, 這里的乘法運算不會等待計算 d,而會先去計算 x 的值。
如果我們只有一個FU能夠計算乘法,那么這個FU並不會因為 d 要等待 a 的計算結果,而被閑置,而是會先被拿去計算 x。
在 x 計算完成之后,d 也等來了 a 的計算結果。這個時候,我們的FU就會去計算出 d 的結果。然后在重排序緩沖區里,把對應的計算結果的提交順序,仍然設置成 a -> d -> x,而計算完成的順序是 x -> a -> d。
在這整個過程中,整個計算乘法的FU都沒有閑置,這也意味着我們的CPU的吞吐率最大化了。
亂序執行,極大地提高了CPU的運行效率。核心原因是,現代CPU的運行速度比訪問主內存的速度要快很多。如果完全采用順序執行的方式,很多時間都會浪費在前面指令等待獲取內存數據的時間里。CPU不得不加入NOP操作進行空轉。
