# 2020q3 Homework3 (quiz3) contributed by < `Tim096` > > [2020q3 第 3 週測驗題](https://hackmd.io/@sysprog/2020-quiz3#2020q3-%E7%AC%AC-3-%E9%80%B1%E6%B8%AC%E9%A9%97%E9%A1%8C) > [Github](https://github.com/Tim096/dict) ## 測驗 1 ### 事前準備 何謂 const ? 答 : 不詳細但是精簡的說法會是 "只讀"。 以下舉例解釋: ```cpp= const int a; int const a; const int *a; int * const a; int const * a const; ``` 前兩行的作用是一樣,a是一個常數型整數。 何謂常數型整數 ? 答 : 常數就是恆常不變的數值,一但經初始化就無法再改變其值而且必須在宣告的時候便初始化。 第三行意味著a是一個指向常數型整數的指標 (也就是,整型數是不可修改的,但指標可以)。 第四行意思a是一個指向整數的常數型指標 (也就是說,指標指向的整數是可以修改的,但指標是不可修改的)。 最後一行意味著a是一個指向常數型整數的常數型指標 (也就是說,指標指向的整數是不可修改的,同時指標也是不可修改的)。 ### 題目: **(做一個「算數右移」出來)** $-5≫^{arith}1=-3$ 上述 $-3$ 正是 $\dfrac{-5}{2}$ 的輸出,並向 $−∞$ 進位(rounding)。 `m` = -5 `n` = 1 針對有號整數的跨平台 ASR 實作如下: ```cpp= int asr_i(signed int m, unsigned int n){ const int logical = (((int) -1) OP1) > 0; unsigned int fixu = -(logical & (OP2)); int fix = *(int *) &fixu; return (m >> n) | (fix ^ (fix >> n)); } ``` 請補完程式碼。 OP1 = `>> 1` OP2 = `m < 0` <font color="red">Q : 第二行 `((int) -1)` 為何要特地這樣寫 ? 有甚麼特別的意義嗎 ? </font> A : 為了題目方便 (選擇題的不便性) ### 原理: * 根據 C 語言規格書所述,對有號型態的物件進行算術右移歸屬於 [unspecified behavior](https://hackmd.io/@sysprog/c-undefined-behavior) ,程式行為仰==賴編輯器實作和平台特性==而定 >The result of E1 >> E2 is E1 right-shifted E2 bit positions. … If E1 has a signed type and a negative value, the resulting value is implementation-defined. * 第二行 : 因此 logical 所做的事,就是確認目前的執行環境,是「邏輯右移」還是「算數右移」 * 透過 `(int) -1` 確認執行環境狀態,將 `-1` 右移一個 bit ,因此 OP1 為 `>>1` * 邏輯右移:將最左的 bit (Most Significant Bit) 補 0 ,結果 > 0 ,`logical = 1` * 算數右移:將最左的 bit (Most Significant Bit) 補上原本失去的符號,在此是補上 1 ,結果 < 0 ,`logical = 0` * 第三行 : 若執行環境為「邏輯右移」 ( `logical = 1` ),且` m < 0` ,就需要對最左 bit 補上有號狀態,因此可推測出 OP2 為 `m < 0` :::info * 算數右移: * 若 m > 0 補 0 `fixu = 0` * 若 m < 0 補 1 `fixu = 0` * 邏輯右移: * 若 m > 0 補 0 `fixu = 0` * 若 m < 0 補 0 `fixu = -1` 題目的目標是實作出來一個「算數右移」,因此若系統本身就是「算數右移」則不用理他,此時觀察「邏輯右移」在`m < 0`時做的事情和「算數右移」是不一樣的,所以我們需要使用`第三行`把這一種情況挑出來 ::: * 第四行 : `*(int *) &fixu` 用意在於將 `fixu` 強制轉型為 `int` 型態 <font color="red">Q : 為何不一開始就把它定義成`int` ? </font> A : 可避免編譯器進行過度 (aggressive) 最佳化 <font color="red">Q : 還是不懂請老師說得更佳詳細一點 ? 何謂"過度的最佳化" ? </font> A : 老師的用意會要使用組合語言來輸出比較,是否會有差別 * 若環境為「邏輯右移」且 `m < 0` , `fix` 經強制轉型後為 `-1`,透過 `(fix ^ (fix >> n))` 保留下需補上的有號 bit ,確保與 `m >> n` 後的結果做 `or` 運算不會失真 * 簡單一點來說 : `fix`把原本經過「邏輯右移」少掉的 1 補回去了。 ## 測驗 2 ### 題目: **找出輸入是不是$4^n$次方** >Built-in Function: `int __builtin_ctz (unsigned int x)` >* Returns the number of trailing 0-bits in x, starting at the least significant bit position. If x is 0, the result is undefined.(返回"右"起第一個1之後的0的個數) >Built-in Function: `int __builtin_clz (unsigned int x)` >* Returns the number of leading 0-bits in x, starting at the most significant bit position. If x is 0, the result is undefined.(返回"左"起第一個1之前0的個數) 我們可用 `__builtin_ctz` 來實作 LeetCode [342. Power of Four](https://leetcode.com/problems/power-of-four/),假設 int 為 32 位元寬度。實作程式碼如下: ```cpp bool isPowerOfFour(int num) { return num > 0 && (num & (num - 1))==0 && !(__builtin_ctz(num) OPQ); } ``` 請補完程式碼。 OPQ = `& 0x1` ### 原理: * `num > 0` : $4^n$肯定 >0 * `(num & (num - 1))==0` : 假設 n 為 2 的次方,且 n > 0 ,從二進制角度觀察, n 的最右邊的 bit 1 ,其他值為 0 ,則 n & n-1 必為 0 ,如果 `num` 不是 $2^n$ 就必不為 $4^n$ ::: info **x & (x - 1) == 0** 判斷x是不是$2^N$次方(無號數) 如果一個數字是$2^N$次方,那麼其二進位表達的最高位為1,其餘位為0。 換個角度思考: 讓某個值少 1,就是讓那個值對最右邊的1產生借位。x & (x - 1)實際上是在把二進位表示中最靠右邊位的1變成0,而$2^N$次方最右邊的1 e.g. : 8 = (1000) , 7 = (0111) , 8 & 7= 0 ::: * 在滿足前兩個條件的前提下,從右往左數到第 1 個 1 之前的 0 總數(也就是ctz)為偶數個,`__builtin_ctz`肯定是一個偶數,而偶數和`0x1`做 AND 運算肯定是 `0` :::danger ### 延伸問題: 1. 改寫上述程式碼,提供等價功能的不同實作,儘量降低 branch 的數量 * 若 n 為 2 的倍數,以二進制表示下,只有其中一值為 1 ,其他皆全為 0 * 若 n 為 4 的倍數,在二進制表示下,一次會向右位移 2 格,因此透過 `__builtin_ctz(num)` 計算的結果,會皆為偶數 * 透過 `__builtin_popcount(num)` ,可知 `1-bits` 的個數 >Built-in Function: int __builtin_popcount (unsigned int x) >* Returns the number of 1-bits in x. ```cpp= bool isPowerOfFour(int num) { return __builtin_popcount(num) == 1 && ( __builtin_ctz(num)%2) == 0 ; } ``` 2. LeetCode [1009. Complement of Base 10 Integer](https://leetcode.com/problems/complement-of-base-10-integer/) * 透過 xor 運算,做 bit 翻轉 * 利用 `__builtin_clz(N)` ,計算需運算 xor 的有效長度 * 根據 `__builtin_clz(N)` 說明, `N=0 is undefined` ,因此需額外考慮 N=0 的狀況 ```cpp= int bitwiseComplement(int N) { if(N==0) return 1 ; return N^(0xffffffff >> __builtin_clz(N)) ; } ``` ![](https://i.imgur.com/rz39jFm.png) 3. LeedCode [41. First Missing Positive](https://leetcode.com/problems/first-missing-positive/) * 因為此題是為了找出陣列中,未出現的最小正整數,因此可將數值安插在所屬的陣列位置,例如 `1` 放在 `nums[0]` , 3 放在 `nums[2]` 這樣的手法,再透過對陣列內容尋訪,若該位置 `nums[i]` 沒有放入數值,那麼最小值就是 `i+1` ,若陣列中每個位置都有對應的值,則最小值為 `NumsSize+1` * 排序過程所需時間複雜度為 $O(n)$ ,而在尋訪陣列尋找最小值時, `wosrt case` 為 $O(n)$ ,即陣列中每個位置都有數值,因此在 `worst case` 下,整體時間複雜度為 $O(2n)$ * 排序方法說明:若 `nums[i]` 不為正整數,就不在排列目標名單中,直接將其值設為 0 ;如果 `nums[i]` 未超出 numsSize (可被安排於陣列內),且目標位置內尚未存放目標值,則做交換的動作,交換過程需考慮到同值交換 ( 程式碼10~16行 ) 而導致的無窮迴圈 * 以陣列 `[4,1,3,5,6]` ,最後排列的結果會為 `[1,0,3,4,5]` ,因此輸出結果為 `2` ```cpp= int firstMissingPositive(int* nums, int numsSize) { for(int i=0 ; i<numsSize ; i++) { if(nums[i] > 0) { if (nums[i] < (numsSize+1) && (i+1) != nums[i]) { int tmp = nums[i] ; if(nums[i] == nums[tmp-1]) nums[i] = 0 ; else { nums[i] = nums[tmp-1] ; nums[tmp-1] = tmp ; i-- ; } } else if (nums[i] > numsSize) nums[i] = 0 ; } else nums[i] = 0 ; } for(int i=0 ; i<numsSize ; i++) { if(nums[i] == 0) return i+1 ; } return numsSize+1 ; } ``` ![](https://i.imgur.com/bHi5qwj.png) ::: ## 測驗 3 ### 事前準備 - `__builtin_popcount()` : 用於計算一個 32 位無號整數有多少個為1 - `__builtin_clz(num)` : 返回"左"起第一個1之前0的個數 - `population count`簡稱 `popcount` 或叫 `sideways sum`,是計算數值的二進位表示中,有多少位元是 `1` ### 題目: 針對 [LeetCode 1342. Number of Steps to Reduce a Number to Zero](https://leetcode.com/problems/number-of-steps-to-reduce-a-number-to-zero/),我們可運用 gcc 內建函式實作,假設 int 寬度為 32 位元,程式碼列表如下: ```cpp= int numberOfSteps (int num){ return num ? AAA + 31 - BBB : 0; } ``` 請補完程式碼。 AAA = `__builtin_popcount(num)` BBB = `__builtin_clz(num)` ### 原理: * 在二進制表示法中,每個 1 最後都要進行 -1 的動作,每向右挪 1 bit 都要進行除 2 的動作,直到最後最後結果剩下 1 ,再做最後的 -1 ,過程有點類似透過「短除法」將十進制轉為二進制 * 透過 `31 - __builtin_clz(num)` 計算需要往右挪多少次 * 再透過 `__builtin_popcount(num)` 計算需要 `-1` 多少次 ### 舉例幫助理解 Input: num = 14 Output: 6 Explanation: Step 1) 14 is even; divide by 2 and obtain 7. Step 2) 7 is odd; subtract 1 and obtain 6. Step 3) 6 is even; divide by 2 and obtain 3. Step 4) 3 is odd; subtract 1 and obtain 2. Step 5) 2 is even; divide by 2 and obtain 1. Step 6) 1 is odd; subtract 1 and obtain 0. `__builtin_popcount(num)`當中有多少個 `1` 就需要被扣除多少次數(14當中有3個`1`) `31 - __builtin_clz(num)`二進制當中最高位元的 `1` 代表者要被除多少次數(14最高位元中的 `1` 在第3個) :::danger ### 延伸問題: 1. 改寫上述程式碼,提供等價功能的不同實作並解說 * 透過 `(num|0x1)` 的動作,避免 `__builtin_clz()` 發生輸入為 0 的狀況,且不影響整體執行結果 ```cpp= int numberOfSteps(int num){ return 31 - __builtin_clz(num|0x1) + __builtin_popcount(num) ; } ``` ::: ## 測驗 4 ### 題目: 考慮以下 64-bit GCD (greatest common divisor, 最大公因數) 求值函式: ```cpp= #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; for (shift = 0; !((u | v) & 1); shift++) { u /= 2, v /= 2; } while (!(u & 1)) u /= 2; do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (XXX); return YYY; } ``` 請補完程式碼。 XXX = `v` YYY = `u << shift` ### 原理: ```cpp= #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; while (v) { uint64_t t = v; v = u % v; u = t; } return u; } ``` * 透過上述的程式碼,了解輾轉相除法的整個過程: * 根據輾轉相除法的概念,當過程執行到 u 、 v 其中一值為 0 ,則最大公因數為不為 0 的那個數,因此可以得知 $gcd(a,0)=a$ 的特性, `if (!u || !v) return u | v` 此部分就是在確認 u 、 v 是有 0 存在 * 再來輾轉相除法是依循著 $gcd(a,b)=gcd(b,a$ $mod$ $b)$ 這個概念在進行,整個 `while` 迴圈就是在做這樣的重複計算,直到 `v = 0` 為止 ```cpp= #include <stdint.h> uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; for (shift = 0; !((u | v) & 1); shift++) { u /= 2, v /= 2; } while (!(u & 1)) u /= 2; do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (XXX); return YYY; } ``` * 回到最大公因數的概念,如果兩數都可被 $2$ 連除,則最大公因數必參雜著 $2^n$ * `!(u | v) & 1`只會有 `1` 或 `0` 的情況 * 若是 `1` 的情況代表 `u` 和 `v` 皆為偶數,因此最大公因數必參雜著$2^n$,所以 `for` 迴圈跑一次去做 $gcd(u, v) = 2 * gcd(u/2, v/2)$ * 若是 `0` 的情況代表 `u` 和 `v` 其中一個是奇數,因此最大公因數必**沒有**參雜著$2^n$,那就不用做 `for` 迴圈了 <font color="red">Q : 是不是我有理解錯誤不然為甚麼不要直接用 `if-else` 就好了 ? </font> A : 程式碼行數可以短一點,迪摩根定律可以用來當延伸教材,希望能自己去改 `if-else` 自己去實驗效能 :::info * 透過 `(u | v) & 1` 這個計算動作,確認 u 、 v 兩者皆為偶數,若其中 1 值為奇數,與 1 做 & 計算後會有不為 0 的結果,並透過 shift 這個變數,紀錄總共右移多少次,來得到兩數皆為 0 或產生至少一個奇數的計算結果 ::: * 第8~11行 : 因為奇數與偶數無法做到整除的動作,因此透過 while 迴圈將判斷 u 、 v 是否為偶數,若是的話就進行除 2 的動作,以方便後面的計算 :::info * 再比較 u 、 v 大小,透過相減的方式,使 v < u ,而 else 的部分,則是 v =< u 的狀況,將 u - v,然後再與 v 對調 * 而輾轉相除法的結束條件就是其中一值被減為 0 ,因此 `XXX = v` * 輾轉相除法的最後結果就如同一開始所述,為 $gcd(a,0)=a$ ,但一開始有做對 2 連除的動作,因此這邊補上連除後的結果,故 `YYY = u << shift` ::: * 其實上述就只是在做一般的 **輾轉相除法(Euclidean algorithm)** 而已,但是最後的 `return u << shift` 要注意一下記得如果上面最一開始的 `for` 迴圈那邊有被 $/2$ 過的話這邊需要乘 ($*2$) 回來 :::danger ### 延伸問題: 1. 在 x86_64 上透過 __builtin_ctz 改寫 GCD,分析對效能的提升 * 藉由 `__builtin_ctz( u|v )` ,可快速計算出shift的值 * 將原本透過 `while` 計算出的 `shift` 改由 `__builtin_ctz( u|v )` 來執行 ( 5~11 行) ```cpp= uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; shift = __builtin_ctz( u|v ) ; if(shift) { u >>= shift ; v >>= shift ; } if (!(u & 1)) u >>= __builtin_ctz(u) ; do { if (!(v & 1)) v >>= __builtin_ctz(v) ; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u<<shift; } ``` 因為 `__builtin_ctz()` 主要是在對一開始判斷是否存在著因數 $2^n$ 進行優化,因此在測試設計上,針對 u 、 v 都為偶數的狀態去做測試,並透過 [perf](http://wiki.csie.ncku.edu.tw/embedded/perf-tutorial) 進行效能分析: ```gcc int main() { for(int i = 0 ; i < 0xffff ; i+=2) { for(int j = 0 ; j < 0xffff ; j+=2) { gcd64(i, j) ; } } return 0 ; } ``` * 以原本的方法進行測試: ``` Performance counter stats for './perf_test.o': 15,0863.80 msec task-clock # 1.000 CPUs utilized 624 context-switches # 0.004 K/sec 23 cpu-migrations # 0.000 K/sec 45 page-faults # 0.000 K/sec 6258,6631,7894 cycles # 4.149 GHz 3804,6978,9838 instructions # 0.61 insn per cycle 976,2472,2490 branches # 647.105 M/sec 141,9692,1133 branch-misses # 14.54% of all branches 150.882867503 seconds time elapsed 150.864725000 seconds user 0.000000000 seconds sys ``` * 透過 `__builtin_ctz()` 取代 while 迴圈的方法測試: ``` Performance counter stats for './perf_test.o': 8,4535.58 msec task-clock # 1.000 CPUs utilized 303 context-switches # 0.004 K/sec 10 cpu-migrations # 0.000 K/sec 45 page-faults # 0.001 K/sec 3499,8308,2866 cycles # 4.140 GHz 2541,5180,7243 instructions # 0.73 insn per cycle 552,3677,8057 branches # 653.415 M/sec 51,5058,8256 branch-misses # 9.32% of all branches 84.536884443 seconds time elapsed 84.536043000 seconds user 0.000000000 seconds sys ``` :::info 可以發現,透過 `while` 的方式計算 shift ,在 `branch` 使用上,比 `__builtin_ctz()` 多了將近一倍的數量,這也是導致效能低落的主要原因。 ::: ## 測驗 5 ### 題目: 在影像處理中,[bit array](https://en.wikipedia.org/wiki/Bit_array) (也稱 `bitset`) 廣泛使用,考慮以下程式碼: ```cpp= #include <stddef.h> size_t naive(uint64_t *bitmap, size_t bitmapsize, uint32_t *out) { size_t pos = 0; for (size_t k = 0; k < bitmapsize; ++k) { uint64_t bitset = bitmap[k]; size_t p = k * 64; for (int i = 0; i < 64; i++) { if ((bitset >> i) & 0x1) out[pos++] = p + i; } } return pos; } ``` 可用 clz 改寫為效率更高且等價的程式碼: ```cpp= #include <stddef.h> size_t improved(uint64_t *bitmap, size_t bitmapsize, uint32_t *out) { size_t pos = 0; uint64_t bitset; for (size_t k = 0; k < bitmapsize; ++k) { bitset = bitmap[k]; while (bitset != 0) { uint64_t t = KKK; //***** int r = __builtin_ctzll(bitset); out[pos++] = k * 64 + r; bitset ^= t; } } return pos; } ``` 請補完程式碼。 KKK = `bitset & -bitset` ### 原理: 先理解這個程式的目標是在實作什麼功能,有一個存放 `64-bits` 的 `array ( bitmap )` ,裡面共可存放 `bitmapsize` 個變數,而 `array` 是透過連續記憶體分配的方法去實作,在 `bitmap` 中,每一個數字占用 `64-bits` 的空間, `bitmap[0]` 區段為 0-63 , `bitmap[1]` 區段為 64-127 以此類推,而整個程式的運作目的是透過 `out` 這個陣列,去依序儲存在這連續的記憶體空間裡面,出現 `bit 1` 的位置,而 `pos` 則是回傳出現 `bit 1` 的數量。 舉個例子來說,輸入 `bitmap = [0,1]` ,會得到 `pos = 1` ,`out[0] = 64` 這樣的結果,因為第一個數字為 0 ,在前 64 個 bit 中,沒有任何 1 出現,而第二個數字為 1 ,因此在第二個數字個第一個 bit 為 1 ,對應的連續位置為 64 。 * `naive` 運作流程解釋: * 透過 `bitset` 來記錄當前所讀到的數值 * 透過 `p = k*64` 來計算目前數值所對應到的連續位置起始值 * 透過迴圈逐一右移再與 `1` 做 `&` 計算,確認是否為 `bit 1` * 如果是的話,則將該位置 `p + i` 儲存到 `out` 陣列中 * `improved` 運作流程解釋: * 透過 `__builtin_ctzll` 來計算當前的 `bitset` 中 `bit 1` 的位置,計算完成後,將該位置的 `bit` 清為 0 ,再繼續判斷 `bitset` 中是否還有 `bit 1` 存在 * 如何將最右邊的 1 設成 0 呢?我們知道如果 $A$ & $\bar{A}$ = 0(每個 bit 都相異),$A$ & $(\bar{A} + 1)$ 則會得到和 $A$ 最右邊的 1 相同位置為 1,其他為 0 的 mask `t`,與此 mask 做 XOR 運算就可以消除最右邊的 1 。 * 因此我們可以透過 `bitset & -bitset` 這樣的方法,得到可以將從右數來的第一個 `bit 1` 設為 `0` 的 `mask`,因此 `KKK = bitset & -bitset` 。 * 最後再透過 `bitset ^= t` 將 `ctz` 指出的` bit` 清為 0 ,得以繼續下一輪執行 bit 檢查 <font color = "red">Q : 在學習的過程當中,中間這一些運算是每一個應該是知道如何應用即可,還是說要知曉如何精準的證明會比較好 ? </font> A : 工程領域其實比較不需要這麼精準的證明,除非特別的領域 先知道證明在哪裡,未來做擴充的時候會更好用 ###### tags: `進階電腦系統應用2020` `quiz3`