# 2017-11-17 課堂練習 ## 學習方式 搭配 [15-213/18-213/15-513: Intro to Computer Systems](http://www.cs.cmu.edu/~213/schedule.html) (目前 2017 Fall) 的投影片和錄影 (YouTube 可開字幕) :::info 1. 以下頁面以簡體中文《深入理解計算機系統》為主 2. 下方隨堂練習請在檢討後,於 11 月 25 日前,將完整作答和討論的共筆發信到 `<jserv.tw@gmail.com>`,標題是 `[sysprog] 你的姓名` (中間有空格) ::: ## 數值系統 :::success 隨堂練習 - [x] 2.58: 撰寫函式 `is_little_endian`,在 Little endian 機器上返回 `1`,反之返回 `0`,應能再任意機器上,無論幾位元的架構都適用 (==Page 88==) - [x] 2.66: 撰寫函式 `leftmost_one`,產生得以找出數值 x 最高位 `1` 的位元遮罩 (==Page 90==) - [x] 2.92: 撰寫函式 `float_negate`,給定浮點數 `f`,回傳 `-f`,倘若 `f` 是 $NaN$,直接回傳 `f`。輸入數值範圍為有效的 2^32^ 個數值 (==Page 96==) ::: ### 2.58 * is_little_endian ```clike= #include <stdio.h> #include <stdlib.h> int main() { int char_var = 1; unsigned char *test = (unsigned char*) &char_var; printf("%d, is little endin = %d\n", test[0], test[0] == 1); return 0; } ``` 這主要是利用了 int 的 4bytes 大小以及 char 的 1byte 大小差異來分辨位元之間的排列方法。 根據[參考資料](https://stackoverflow.com/questions/12791864/c-program-to-check-little-vs-big-endian),電腦若是 little_endin,其記憶體配置如下: ``` higher memory -----> +----+----+----+----+ |0x01|0x00|0x00|0x00| +----+----+----+----+ char_var | test ``` 可以發現 test[0] 會是 1。 另一方面,若是 big_endin: ``` higher memory -----> +----+----+----+----+ |0x00|0x00|0x00|0x01| +----+----+----+----+ char_var | test ``` test[0] 就便成了 0。 ### 2.66 * leftmost_one ```clike= int leftmost(unsigned x) { x |= x >> 1; x |= x >> 2; x |= x >> 4; x |= x >> 8; x |= x >> 16; return x ^ (x >> 1); } ``` 後來觀察了一下它的數值變化過程,以 $10_{10} = 1010_2$ 為例: ![](https://i.imgur.com/rJmuYXq.png) 清楚發現到這個 function 的前面右移部份負責將最高為一的位元後的所有位元填滿,接著結果本身與右移一格的結果進行 ```XOR``` 即可獲得最高位元的遮罩。 同樣方法,我們亦可以取出最低位元的遮罩,僅須換個方向: * rightmost_one ```clike= int leftmost(unsigned x) { x |= x << 1; x |= x << 2; x |= x << 4; x |= x << 8; x |= x << 16; return x ^ (x << 1); } ``` ![](https://i.imgur.com/skbherU.png) ### 2.92 * float_negate 根據[教學](https://www3.ntu.edu.sg/home/ehchua/programming/java/datarepresentation.html)所示,我們知道了 float 的單精度二進位表示方式: ![](https://www3.ntu.edu.sg/home/ehchua/programming/java/images/DataRep_Float.gif) 並且同時也知道了 ==$NaN$== 以及 ==$\pm\infty$== 的表示方法: $NaN$: Exponent 段全為一,且 Fractction 段非零。 $\pm\infty$: Exponent 段全為一, Fraction 段也是零,並依照 S 段決定正負。 因此可以寫出以下程式: ```clike= typedef unsigned float_bits; float_bits float_negate(float_bits f) { unsigned sign = f >> 31; unsigned exp = f >> 23 & 0xFF; unsigned frac = f & 0x7FFFFF; // NaN if (exp == 0xFF && frac != 0) { return f } return (sign << 31) | (f & 0x7FFFFFFF); } ``` 但有趣的是,上面的參考資料時發現 float 當中亦存在 $\pm0$,如下圖: ![](https://www3.ntu.edu.sg/home/ehchua/programming/java/images/Representation_FloatingPointNumbers.png) 雖然浮點數的表示方法使用 $(-1)^s \times 1.frac \times 10^{exp}$ 的科學記號表示,對於小數有很高的表示能力,但由於表示用的科學記號預期會帶有 1 在最前面,因此就是沒辦法完全的表現 0 ,這部份只能透過 ==趨近== 的方式由正負兩個方向趨近,因此才會出現 $+0$ 以及 $-0$。 ## 計算機組織與結構 :::success 隨堂練習 - [x] 4.47: 以 C 語言指標改寫原本使用陣列表示法的氣泡排序程式碼 (==Page 327==) / 延伸題目: [最佳化](https://en.wikipedia.org/wiki/Bubble_sort#Optimizing_bubble_sort) / [Analysis on Bubble Sort Algorithm Optimization](http://ieeexplore.ieee.org/stamp/stamp.jsp?arnumber=5635119) ::: ### 4.47 一開始我不太清楚這個題目的意義,但為了符合題意我依然寫了一個程式,開始做點研究。 以下使用指標作為 bubble sort 的 index 作法: ```clike= void bubble(long *data, long count) { long *i, *j; for (i = data; i < data + count - 1; i ++) { for (j = i; i < data + count; j ++) { if (*j > *(j + 1)) { long t = *(i + 1); *(i + 1) = *i; *i = t; } } } } ``` 表面上看似沒什麼差別,這邊我們看看組合語言: ```clike= // void bubble(long *data, long count) { push rbp mov rbp,rsp mov QWORD PTR [rbp-0x28],rdi mov QWORD PTR [rbp-0x30],rsi // for (i = data; i < data + count - 1; i ++) { mov rax,QWORD PTR [rbp-0x28] mov QWORD PTR [rbp-0x8],rax jmp 4005f9 <bubble(long*, long)+0x83> // for (j = i; i < data + count; j ++) { mov rax,QWORD PTR [rbp-0x8] mov QWORD PTR [rbp-0x10],rax jmp 4005db <bubble(long*, long)+0x65> // if (*j > *(j + 1)) { mov rax,QWORD PTR [rbp-0x10] mov rdx,QWORD PTR [rax] mov rax,QWORD PTR [rbp-0x10] add rax,0x8 mov rax,QWORD PTR [rax] cmp rdx,rax jle 4005d6 <bubble(long*, long)+0x60> // swap, t = i, i = j, j = t mov rax,QWORD PTR [rbp-0x8] mov rax,QWORD PTR [rax+0x8] mov QWORD PTR [rbp-0x18],rax mov rax,QWORD PTR [rbp-0x8] lea rdx,[rax+0x8] mov rax,QWORD PTR [rbp-0x8] mov rax,QWORD PTR [rax] mov QWORD PTR [rdx],rax mov rax,QWORD PTR [rbp-0x8] mov rdx,QWORD PTR [rbp-0x18] mov QWORD PTR [rax],rdx // for (j = i; i < data + count; j ++) { add QWORD PTR [rbp-0x10],0x8 mov rax,QWORD PTR [rbp-0x30] lea rdx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x28] add rax,rdx cmp rax,QWORD PTR [rbp-0x8] ja 400596 <bubble(long*, long)+0x20> // for (i = data; i < data + count - 1; i ++) { add QWORD PTR [rbp-0x8],0x8 mov rax,QWORD PTR [rbp-0x30] shl rax,0x3 lea rdx,[rax-0x8] mov rax,QWORD PTR [rbp-0x28] add rax,rdx cmp rax,QWORD PTR [rbp-0x8] ja 40058c <bubble(long*, long)+0x16> // } pop rbp ret ``` 這邊我們對照原版的 bubble sort: ```clike= void bubble(long *data, long count) { int i, j; for (i = 0; i < count - 1; i ++) { for (j = i; j < count - 1; j ++) { if (data[j] > data[j + 1]) { long t = data[j + 1]; data[j + 1] = data[j]; data[j] = t; } } } } ``` 組合語言為: 為方辦觀察這裡只擷取 ```if (data[j] > data[j + 1]) {``` 以及 ```swap``` 的部份 ```clike= push rbp ... // if (data[j] > data[j + 1]) { mov eax,DWORD PTR [rbp-0x8] cdqe lea rdx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rax,rdx mov rdx,QWORD PTR [rax] mov eax,DWORD PTR [rbp-0x8] cdqe add rax,0x1 lea rcx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rax,rcx mov rax,QWORD PTR [rax] cmp rdx,rax jle 40063c <bubble(long*, long)+0xc6> // swap, t = i, i = j, j = t mov eax,DWORD PTR [rbp-0x8] cdqe add rax,0x1 lea rdx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rax,rdx mov rax,QWORD PTR [rax] mov QWORD PTR [rbp-0x10],rax mov eax,DWORD PTR [rbp-0x8] cdqe add rax,0x1 lea rdx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rdx,rax mov eax,DWORD PTR [rbp-0x8] cdqe lea rcx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rax,rcx mov rax,QWORD PTR [rax] mov QWORD PTR [rdx],rax mov eax,DWORD PTR [rbp-0x8] cdqe lea rdx,[rax*8+0x0] mov rax,QWORD PTR [rbp-0x18] add rdx,rax mov rax,QWORD PTR [rbp-0x10] mov QWORD PTR [rdx],rax ... pop rbp ret ``` 可以很明顯的察覺到,注意到,兩邊 swap 的部份指令數差距超過兩倍 (11/28),並且在 if( a > b) 的判斷方面也是有著 7:15 的巨大差距。再更加仔細觀察便會發現原始版本為了實做陣列的 index 運算以及存取,多了許多 add 以及 lea 運算,並且也為了使用這個新計算出來的 index,必須多一道 mov 指令,導致比起原先僅需要兩道指令來載入到暫存器的讀取動作,至少多了兩道(index 的add以及 lea),再計算其他流程上的影響,最終導致指令數多出一倍以上,可見即使是基本的矩陣存取方法上依然有不少陷阱阿。 :::success 隨堂練習 - [x] 6.37: 計算 `N=64` 和 `N=60` 的 cache miss rate (==Page 455==) - [x] 6.45: 撰寫更快的轉置矩陣實作 (==Page 45==) - [x] 6.46: 有向圖轉換為無向圖 (==Page 45==) / 參考: [Graph](http://www.csie.ntnu.edu.tw/~u91029/Graph.html), [相鄰矩陣 (Adjacency Matrix))](https://hellolynn.hpd.io/2017/09/22/%E5%AF%A6%E4%BD%9Cgraph%E8%88%87dfs%E3%80%81bfs%E8%B5%B0%E8%A8%AA/) ::: ### 6.37 題目要求在 4KB cache、16 byte cache block 的 Direct Mapping cache 狀況下計算以下程式碼中的各個 sum 在 N=60, 64 時的 cache miss rate。其中陣列 a 從0x08000000 開始儲存。 ```clike= typedef int array_t[N][N]; int sumA(array_t a) { int i, j; int sum = 0; for (i = 0; i < N; i++) for (j = 0; j < N; j++) { sum += a[i][j]; } return sum; } int sumB(array_t a) { int i, j; int sum = 0; for (j = 0; j < N; j++) for (i = 0; i < N; i++) { sum += a[i][j]; } return sum; } int sumC(array_t a) { int i, j; int sum = 0; for (j = 0; j < N; j += 2) for (i = 0; i < N; i += 2) { sum += a[i][j] + a[i + 1][j] + a[i][j + 1] + a[i + 1][j + 1]; } return sum; } ``` * Direct Mapping Cache 雖說是 Direct Mapping Cache 但其實際上存在兩種定址方式,如[參考網站所述](http://fourier.eng.hmc.edu/e85_old/lectures/memory/node4.html): ![](http://fourier.eng.hmc.edu/e85_old/lectures/figures/DirectMapping.gif) 其存在 Continuous 以及 Interleaved 兩種映射方法,將會把兩者的數值計算出來。 根據[參考共筆](https://hackmd.io/s/H1U6NgK3Z)所整理的 cache address 組成示意圖: ![](https://i.imgur.com/0u9pfIo.png) 我們可以計算出實際上這個 cache 包含多少 set: $S = 4KB / (16byte \times E) = 256, E = 1$ 由於 a 從 0x08000000 開始儲存,這邊根據 address 的表示法判斷總記憶體大小為 $2^{32} byte = 8GB$,這裡根據 address 組成並假設 0x080000000 之後不會存放任何程式執行時會被存取到的資料,開始分項討論: ### Continuous Mapping | set index | tag | block offset | | --------- | -------- | ------------ | | 8 bits | 20 bits | 4 bits | #### N = 60, a size = 14400 bytes($2^{13} < a_{size} < 2^{14}$) 這個大小由於並未大於 $2^{24}$ 因此整個陣列將被對應於單一個 16bytes 的cache。 ##### sumA 程式碼是循序存取,因與陣列在記憶體中的實際配置方法相同順序,不難推測其基本上每四次記憶體存取就會出現一次(第一次存取)失敗,得失敗率為 ==25%==。 ##### sumB 程式碼與 sumA 的差距為,sumB 為跳列存取,每次都為跳下一列的存取方式,並且由於整個 a 陣列都會對應到同一個 cache block,導致次跳列都會造成 cache miss,並且 cache block 遭到清空。 因此可得失敗率為 ==100%==。 ##### sumC 此方法看似複雜,實際上是在外部迴圈部份每次都 +2,並且在內部加法時可以透過多寫些加法項來實做。 若編譯時程式所採用的 evaluation 順序為: ```clike= a[i][j]; a[i + 1][j]; a[i][j + 1]; a[i + 1][j + 1]; ``` 即原始順序,則每次存取將確實跳轉一排,導致失敗率為 ==100%==。 若編譯時其順序有經過微調: ```clike= a[i][j]; a[i][j + 1]; a[i + 1][j]; a[i + 1][j + 1]; ``` 則每兩次存取會出現一次失敗,即失敗率為 ==50%==。 #### N = 64, a size = 16384 bytes($a_{size} = 2^{14}$) 由於在 continuous 的 mapping 方式中,整個 a 陣列依然被對應在一個 cache block 當中,因此其結果也將相同。 ### Interleaved Mapping | tag | set index | block offset | | -------- | --------- | ------------ | | 20 bits | 8 bits | 4 bits | #### N = 60, a size = 14400 bytes($2^{13} < a_{size} < 2^{14}$) 在 Interleaved Mapping 的狀況下 a 陣列可以被切割為 900 個 block,但實際上系統中只有 256 個 block,根據鴿籠原理,前面 132 個 block 將會對應到 4 個 a 陣列值($index = i, i + 1024, i + 2 \times 1024, i + 3 \times 1024$),剩下的 124 個 block 則對應到三次。 ##### sumA 雖然這次的 Direct Mapping 方法不同,但由於其為循序存取,失敗率與 Continuous Mapping 時相同為 ==25%==。 ##### sumB 根據估算,整個 Direct Mapping 4KB Cache 可以塞下 17 行又 4 個 int,其本身雖採取跳行存取,但由於其 cache id 在 j 相同的一列當中並不是完全對齊(多了 4 個 int)的,因此存取後續行時並不會覆蓋到上一次 17 行的 cache 結果,再加上其行數為 60 並未超過 cache block 可用極限,可以完整載入一列。第一次 $[0][0] ~ [59][0]$ 雖然全部失敗但後續的 $[0][1] ~ [59][1], [0][2] ~ [59][2], [0][3] ~ [59][3]$ 皆會成功,以此計算失敗率為 ==25%==。 ##### sumC 這裡使用上面 Interleaved Mapping, N=60, sumB 的相同想法來理解,雖然它一次存取兩行的各兩個元素,但我們可以注意到實際上 a[i][j + 1] 以及 a[i + 1][j + 1] 都是保證會命中的,並且 a[i][j] 以及 a[i + 1][j] 則是皆僅在每次 $j\mod4 = 0$ 時失敗(與 sumB 相同),以此可以計算出失敗率為 2/4(j) + 0/4(j + 2) = 2 / 8 = ==25%==。 #### N = 64, a size = 16384 bytes($a_{size} = 2^{14}$) ##### sumA 由於其為正常順序存取,cache miss = ==25%==。 ##### sumB 雖然存取方法與 N=60 時的 sumB 相同,但十分不幸的當 N = 64 時,其每 16 行剛好對齊整個 cache,也就是每跳 16 行便會開始覆蓋上 16 行的 cache 結果,最後導致每次讀取都會是新的 block,同時 cache 使用率也極低。失敗率為 ==100%==。 ##### sumB 這個方法雖然比起 N=60 時的 sumC 同樣受到了影響,但由於其還是包含循序讀取的部份(a[i][j] -> a[i][j + 1], a[i + 1][j] -> a[i + 1][j + 1]),即使跳行讀取完全失敗,其依然有 2/4 的正確率,因此其失敗率為 ==50%==。 #### 總結 以下總結做表 * Continuous Mapping | type\N | 60 | 64 | | ------ | ---------- | ---------- | | sumA | 25% | 25% | | sumB | 100% | 100% | | sumC | 100% / 50% | 100% / 50% | * Interleaved Mapping | type\N | 60 | 64 | | ------ | --- | ---- | | sumA | 25% | 25% | | sumB | 25% | 100% | | sumC | 25% | 50% | ### 6.45 由於並不是很了解題目的限制條件,這邊我參考了 [github 上的解答](https://github.com/DreamAndDead/CSAPP-3e-Solutions/blob/master/chapter6/6.45.md) 才了解一些此題的目的。 題目透過一次交換兩列的兩個元素來達到至少 50% 的 cache 命中率。其中假設 block 為 8 bytes,可以儲存 2 個 int。 ```clike= for (i = 0; i <= N - 2; i += 2) { for (j = 0; j <= N - 2; j += 2) { dst[j * N + i] = src[i * N + j]; dst[j * N + i + 1] = src[(i + 1) * N + j]; dst[(j + 1) * N + i] = src[i * N + j + 1]; dst[(j + 1) * N + i + 1] = src[(i + 1) * N + j + 1]; } } ``` 因此這邊提出了改良版: ```clike= c = 4; // Cache Block Size(byte) / 4 for (i = 0; i < N - c; i += c) { for (j = 0; j < N - c; j += c) { for (n = 0; n < c; n ++) { for (m = 0; m < c; m ++) { dst[(j + m) * N + (i + n)] = src[(i + n) * N + (j + m)]; } } } } if (i != N) { for (; i < N; i ++) { for (; j < N; j ++) { dst[j * N + i] = src[i * N + j]; } } } ``` 其可以根據 c 所定義的一個 block 能儲存的 int 數量,以此決定要翻轉的子矩陣大小。 另外,若是在於 continuous mapping 的 cache 環境下,則必須慎重決定 n 跟 m 的順序,若是翻轉本身讀取效能較為吃重,優先建議寫成以下: ```clike= for (m = 0; m < c; m ++) { for (n = 0; n < c; n ++) { dst[(i + n) * N + (j + m)] = src[(j + m) * N + (i + n)]; } } ``` 使 src 的橫列部份優先進行迭代。 同樣,若是寫入效能較為吃重,由於 cache 本身 write back 的策略,則建議先將資料完整寫入 dst 的快取後才進行換列: ```clike= for (n = 0; n < c; n ++) { for (m = 0; m < c; m ++) { dst[(i + n) * N + (j + m)] = src[(j + m) * N + (i + n)]; } } ``` ### 6.46 利用與 6.45 相同的想法,依然是在運算時希望可以盡量利用到 cache,並且盡量不讓之前載入至 cache 的資料失效。 由於這次我們必須==讀取兩個元素==運算後寫入至==一個地方==,為了最大化效能表現,我們會希望這些動作都能被 cache 所包含,並且對於寫入是友善的(這邊假設是 write-back)。 首先我參考 6.45 所寫的程式碼,改寫為: ```clike= c = 4; // Cache Block Size(byte) / 4 for (i = 0; i < dim - c; i += c) { for (j = 0; j < dim - c; j += c) { for (n = 0; n < c; n ++) { for (m = 0; m < c; m ++) { G[(j + m) * dim + (i + n)] = G[(i + n) * dim + (j + m)] || G[(j + m) * dim + (i + n)]; } } } } if (i != dim) { for (; i < dim; i ++) { for (; j < dim; j ++) { G[j * dim + i] = G[i * dim + j] || G[j * dim + i]; } } } ``` 其中改寫最為核心的部份是: ```clike= for (n = 0; n < c; n ++) { for (m = 0; m < c; m ++) { G[(j + m) * dim + (i + n)] = G[(i + n) * dim + (j + m)] || G[(j + m) * dim + (i + n)]; } } ``` 然而這段程式碼有個缺點,寫入的過程由於是跳行的操作,根據 6.37 告訴我其有可能每次都觸發 cache-miss,並且直接造成必須將 cache 中的資料寫回至原來目標,造成效能消耗。為此我再修改一下程式碼: ```clike= for (n = 0; n < c; n ++) { for (m = 0; m < c; m ++) { G[(i + n) * dim + (j + m)] = G[(j + m) * dim + (i + n)] || G[(i + n) * dim + (j + m)]; } } ``` 這份程式碼至少可以確保 G[(i + n) * dim + (j + m)] 在於讀取以及寫入上的效能,即使是 continuous mapping 的策略中依然不會變成 100% 的 cache-miss。 ## 參考資料 * [C program to check little vs. big endian](https://stackoverflow.com/questions/12791864/c-program-to-check-little-vs-big-endian) * [A Tutorial on Data Representation](https://www3.ntu.edu.sg/home/ehchua/programming/java/datarepresentation.html) * [CPU Cache 原理探討](https://hackmd.io/s/H1U6NgK3Z) * [Computer Architecture Notes](https://hackmd.io/s/rkloHgHcx) * [計算機組織結構](https://hackmd.io/s/H1sZHv4R) * [CSAPP-3e-Solutions](https://github.com/DreamAndDead/CSAPP-3e-Solutions)