# 2020q3 Homework3 (quiz3) contributed by < `JimmyLiu530` > ###### tags: `進階電腦系統理論與實作` > [題目](https://hackmd.io/@sysprog/2020-quiz3) ## 測驗1: Arithmetic right shift for signed integers 在 C 語言中,對有號型態的物件進行算術右移(簡稱 ASR)歸屬於 unspecified behavior。考慮到程式碼的相容性,我們想實作一套可跨越平台的 ASR,假設在二補數系統,無論標的環境的 shift 究竟輸出為何,我們的 ASR 總可做到: $-5>>^{arith}1$ $= -3$ 上述 $-3$ 即為 $\dfrac{-5}{2}$ 向 $-\infty$ 進位的輸出 ```c= 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)); } ``` ### 程式說明 算術右移一個正數其最左邊的 bit 必定是補 0 ,而算術右移一個負數有可能是補 0 或是補 1,而根據題目的意思,顯然是後者,因此需要知道 compiler 是補 0 還是 1。若是補 1,則符合我們的要求不需修補;否則需要修補。而這個修補會在 line 6 的`| (fix ^ (fix >> n))`。 line 3 的用意就是在看此 compiler 對一個負數算術右移後會補 0 還是 1,若補 1,則右移後的結果為負 (<0),因此 logical 為 0;否則 logical 為 1。所以 `OP1 = >> 1`。 接著,考慮算術右移一個負數補 0 的情況,因為 line 6 的修補項是要用來修補的,所以 `(fix ^ (fix >> n))` 的最高的 n 個位數一定要是 1,也就代表 `fix` 會是 `0xffffffff`,最後可推得 `OP2 = m < 0`。 此結果也符合一開始說的條件: 除了原本 compiler 右移負數補 1 不用修補之外,正數也不需要 (即只有`右移補 0` 且 `為負數`才需要修補)。 :::info 已解問題: `int fix = *(int *) &fixu;` 這樣寫的用意? `int fix = (int) fixu;` 以及可以改成這樣嗎? > 雖然此程式看似可互換,但這兩行程式碼的意義不太相同,前者為 `interpret`,後者為 `cast`。看下面的例子: > ```c= > float a = 0.5; > int b = (int)a; > int c = *(int*)&a; > printf("%f 0x%08x 0x%08x\n", a, b, c); > ``` > 會得到: > ```c= > 0.500000 0x00000000 0x3f000000 > ``` > 也就是說前者以 整數 的角度去詮釋(interpret) 浮點數,好讓我們去對其中的某幾個位元做操作。 > > 而後者是直接把 浮點數 強制轉型(cast)成 整數。 ::: ### 延伸問題 #### 1. 數學證明和 Graphviz 製作的圖解 #### 2. 練習實作其他資料寬度的 ASR,可參照 [C 語言:前置處理器應用篇](https://hackmd.io/@sysprog/c-preprocessor) 撰寫通用的巨集以強化程式碼的共用程度; ------------------------------------- ## 測驗2: Leetcode [342. Power of Four](https://leetcode.com/problems/power-of-four/) GCC extension 其中包含兩個函式: ctz 和 clz: > 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. > 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. 我們可用 `__builtin_ctz` 來實作 LeetCode 342. Power of Four,假設 int 為 32 位元寬度。實作程式碼如下: ```c= bool isPowerOfFour(int num) { return num > 0 && (num & (num - 1))==0 && !(__builtin_ctz(num) OPQ); } ``` ### 程式說明 line 3 的第二個條件 `(num & (num - 1))==0` 其實就區分出 2 的冪次方的數了,像是 $2^3 =1000_{(2)}$ 減1後,$7 = 0111_{(2)}$,因此接下來只要排除掉 2 的冪次方($2^n$)中,n 為奇數的數即可。一樣可以透過觀察: | decimal | binary | # of trailing zero | | ------- | ------ | ------------- | | 2 | 10 | 1 | | **4** = $4^1$ | 100 | **2** | | 8 | 1000 | 3 | | **16** = $4^2$ | 10000 | **4** | | 32 | 100000 | 5 | | **64** = $4^3$ | 1000000 | **6** | 發現 n 為奇數的數,其 trailing zero 也會是奇數個,因此 `OPQ = & 0x1`。 ### 延伸問題 #### 1. 改寫上述程式碼,提供等價功能的不同實作,儘量降低 branch 的數量 ```c= bool isPowerOfFour(int num) { return num > 0 && __builtin_popcount(num) == 1 && !(__builtin_ctz(num) & 0x1); } ``` 將原本的 `(num & (num - 1))==0` 改成 `__builtin_popcount(num) == 1`,兩者都可以過濾掉非 2 的冪次方的數。 又或是可以改寫成: ```c= bool isPowerOfFour(int num) { return num > 0 && !(__builtin_popcount(num) ^ 0x1) && __builtin_ffs(num) & 0x1); } ``` `__builtin_popcount(num) == 1` 等價於 `!(__builtin_popcount(num) ^ 0x1)`,且 4 的冪次方的數其 first set bit 的位置會是奇數。 > Built-in Function: int __builtin_ffs (int x) > - Returns one plus the index of the least significant 1-bit of x, or if x is zero, returns zero. #### 2. 練習 LeetCode [1009. Complement of Base 10 Integer](https://leetcode.com/problems/complement-of-base-10-integer/) 和 [41. First Missing Positive](https://leetcode.com/problems/first-missing-positive/),應善用 clz **Complement of Base 10 Integer**: ```c= int bitwiseComplement(int N){ if (N == 0) return 1; uint32_t mask = 0xffffffff >> __builtin_clz(N); return N ^ mask; } ``` ![](https://i.imgur.com/98Sq7j0.png) - 位元反轉 -> 想到 `^ 1111...1111` - 只需要反轉最低 `32 - (N 的 leading zero)` 個位元 **First Missing Positive**: ```c= // TODO ``` ---------------------------------------------- ## 測驗3: LeetCode [1342. Number of Steps to Reduce a Number to Zero](https://leetcode.com/problems/number-of-steps-to-reduce-a-number-to-zero/) 給定一個非負整數 `num`,回傳將此數依照下面的規則變成 0 需要幾步。而規則就是: > 當目前的數為偶數,則將它除以 2;否則將它減 1。 我們可運用 gcc 內建函式實作,假設 int 寬度為 32 位元,程式碼列表如下: ```c= int numberOfSteps (int num) { return num ? AAA + 31 - BBB : 0; } ``` ### 程式說明 因為在正整數中,除以 2 即為右移 1 位元,所以總共要除幾次就等於 MSB 移到最低位需要移幾次,MSB 移至最低位需 `31 - __builtin_clz(num)` 步,因此也需要除那麼多次。 再者,當 `num` 的二進位表示法中有 k 個 1 時,代表總共需要做 k 次的減 1,因為這些 1 總會被移到最低位元的位置,而最低位元為 1 代表是奇數需要減 1 ,所以共需 `__builtin_popcount(num)` 次減 1 因此最後答案就會是 `AAA = __builtin_popcount(num)` 及 `BBB = __builtin_clz(num)` ### 延伸問題 #### 1. 改寫上述程式碼,提供等價功能的不同實作並解說 ```c= // TODO ``` #### 2. 避免用到編譯器的擴充功能,只用 C99 特徵及 bitwise 運算改寫 LeetCode [1342. Number of Steps to Reduce a Number to Zero](https://leetcode.com/problems/number-of-steps-to-reduce-a-number-to-zero/),實作出 branchless 解法,儘量縮減程式碼行數和分支數量 ```c= // TODO ``` --------------------------------------- ## 測驗4: 64-bit GCD 考慮以下 64-bit GCD (greatest common divisor, 最大公因數) 求值函式: ```c= #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; } ``` 改寫為以下等價實作: ```c= #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 因數時,代表 2 就不會是他們的公因數,因此將另一個仍含有 2 因數的數除以 2 直到變成奇數, e.g. gcd(24,9) = gcd(6,9) line 5-6 其實就是在做上述的第一點,總共提出`shift` 個 2。line 8-9 及 11-12就是做上述的第二點。而程式中的 do-while 其實就是輾轉相除法,只是是用減法去實現,因此 `XXX = v`。最後,再將原本的結果 u 左移 shift 個 bit(即 x$2^{shift}$),所以 `BBB = u << shift`。 ### 延伸問題 #### 1. 在 x86_64 上透過 `__builtin_ctz` 改寫 GCD,分析對效能的提升 ```c= uint64_t gcd64(uint64_t u, uint64_t v) { if (!u || !v) return u | v; int shift; int ctz_u = __builtin_ctz(u); int ctz_v = __builtin_ctz(v); if (ctz_u < ctz_v) shift = ctz_u; else shift = ctz_v; u = u >> ctz_u; v = v >> ctz_v; do { while (!(v & 1)) v /= 2; if (u < v) { v -= u; } else { uint64_t t = u - v; u = v; v = t; } } while (v); return u << shift; } ``` // TODO分析 _____________________________________ ## 測驗5: Positions of set bits in bit array 在影像處理中,[bit array](https://en.wikipedia.org/wiki/Bit_array) (也稱 bitset) 廣泛使用,考慮以下程式碼: ```c= #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 改寫為效率更高且等價的程式碼: ```c= #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; } ``` ### 程式說明 第一個程式的作用在於看 `bitmap` 中哪些 bit 為 1,並將其位置記錄在 `out` 中。第一與第二個程式只有 line 8 的那個 while 的不同而已,因此聚焦在此。 透過觀察第一個程式碼,可知其脈絡為一個位元一個位元地去檢查 `bitset` 是否為 1,即 若要檢查第 k 個位元,需將 `bitset` 右移 k 位元,再與 1 比較。 而第二個程式也是 follow 這個脈絡,只是在 line 10 計算了 `bitset` 的 trailing zero,可以減少傻傻地去檢查為 0 的位元的時間,來提升效率。最後,line 12 需將把檢查過的位元變成 0,因此 `KKK = bitset & -bitset`,這是因為 `x & -x = x & (~x + 1) = 保留其 LSB,其餘設為 0`,所以 x ^ (x & -x) 會將原本 x 的 LSB 變成 0。 ### 延伸問題 #### 1. 舉出這樣的程式碼用在哪些真實案例中 - 在 linux kernel 中有使用這種資料結構 - 第 k 位被設成 1 若且唯若 k 在佇列裡時,可提升從硬體中找到「首位 0」操作的效率 - 也會用在記憶體頁、inode、磁碟分割區等的分配上,去看哪些記憶體已被使用 #### 2. 設計實驗,檢驗 clz 改寫的程式碼相較原本的實作有多少改進?應考慮到不同的 [bitmap density](http://www.vki.com/2013/Support/docs/vgltools-3.html) #### 3. 思考進一步的改進空間