contributed by < freshLiver
>
linux2022
取平均值最直覺的方法當然是相加後除以二:
但上面的會有 Integer Overflow 的問題,因此可以用以下方式改寫來避免該問題:
由於兩數(假設為 low
以及 high
)的平均值就是在兩數間找到另一數 mid
,使得 low
以及 high
與 mid
的差值相等(若 high
、low
兩數和為奇數的話,mid
會離 low
比較近一點)。
而找中點 mid
的操作可以從 high
、low
兩數的差除 2,然後再加到 low
上,這樣就可以避開將 high
、low
相加而造成 Integer Overflow 的問題。
問題 1. 需要注意參數的大小順序
如上面的測試結果,由於 low
跟 high
是無號整數,所以在 (high - low)
的部份如果 low
的值比 high
大的話會造成結果與預期不符。
問題 2. 相較於加法以及乘法,除法需要的 cycle 數較多
這種寫法與 (a + b) / 2
比較相近,是先將 a
、b
兩正整數右移 1 位元來達成除 2 的效果。但直接右移會損失掉最後一位元,而最後一位元的和卻可能會造成進位,因此需要另外計算是否進位。
而最低位元的進位會發生在 c 與 d 皆為 1 的時候,因此可以用 AND 檢查兩位元是否皆為 1,但是需要檢查的位元只有最低位元,因此可以利用 1 除了最低位元以外都是 0 的特性,與 a & b
的結果進行 AND 捨去其他位元的資訊:
綜合以上可以知道 EXP1
是要處理進位的部份,可以透過 a & b & 1
完成
上面的方法用了多個 +
、>>
以及 &
,但還能再簡化:
與前一個方法不同,這個寫法不只是把最後一位元的進位另外計算,而是直接使用 AND 以及 XOR 找出需要進位以及不須進位的位元,並透過位移同時完成除二以及進位的效果。
透過對 a
、b
進行 XOR 操作能夠找出 0
與 1
各有一個的位元,而這些位元就是相加後不須進位的位元,像是上方的第 4, 3, 2, 1 個位元。
而如同上一個寫法,透過 a
與 b
進行 AND 則可以找出會造成進位的位元,像是上方的第 7, 6, 0 個位元。
而有了這兩個資訊的話,只要將 AND 的結果左移就能夠知道 a
b
兩數相加後的值了:
但很明顯的這會造成最高位元的進位消失,因此在這個解法中反而是將 XOR 的結果右移,然後再加上 AND 的結果:
這樣雖然會造成 XOR 結果的最低位元消失,但其實最後一位元本來就只需要是否進位這個資訊而已,因此這個作法不僅能夠正確的保留資訊,還能完成兩數取平均(除 2)的要求。
以下是以上三個方式使用 GCC 開啟 -O3
最佳化時的組合語言輸出 (程式碼以及原始輸出):
low
的暫存器 esi
的值給 eax
暫存器eax
與存有 high
的暫存器 edi
相加並存到 eax
eax
右移一位來達成 eax / 2
eax
與存有 low
的暫存器 esi
相加得到最終值a
跟 b
的暫存器 edi
、esi
存進 eax
、edx
edi
、esi
進行 AND 運算,運算結果會存在 edi
eax
、edx
進行 shr
右移來達到 / 2
的效果1
進行 AND 運算,得到最低位元是否須進位eax
,得到除二後的相加結果eax
、edi
相加可得到結果這個寫法若是使用 -O1
參數進行最佳化,會得到下方的組合語言:
可以發現和 -O3
最佳化輸出的組合語言相比,執行的指令都沒變,只差在執行順序不同, -O1
輸出的組合語言是依照程式碼由左至右執行,而 -O3
則是將相同的指令放在一起,
a
的 edi
複製到 eax
並與存有 b
的 esi
進行 XOR 運算來取得不須進位的位元shr
右移來除二a
與 b
的 edi
esi
進行 AND 運算找出需要進位的位元,並存在 edi
中edi
與第二步的結果 eax
相加獲得結果若是將這種寫法用 -O1
進行最佳化,也會像前一種寫法一樣,出現指令順序被重排的情況:
max(a,b)
從題目可知 EXP5 是 a
、b
進行大小比較,因此 -(EXP5)
的值不是 0
就是 -1
。而一數值與 0
進行 AND 運算的結果必為 0
、與 -1
進行 AND 運算的結果則會與原數值相同。
到這邊為止,可以知道這個函式的結果必為 a ^ (EXP4)
或 a ^ 0
,所以可以依照 a
、b
的大小關係推測 EXP4, EXP5 的最簡表示應分別為:
-EXP5
或 EXP4
為 0
才能使結果為 a
-EXP5
或 EXP4
為 0
才能使結果為 a
EXP4
應為 a ^ b
且 -EXP5
不可為 0
才能使結果為 b
由於 EXP4 應為 a ^ b
,所以 -EXP5
在 時會是 0
,而 時則為 -1
,因此 EXP5 應為 a < b
或 a <= b
。
這個函式應該也適用於有號數,不太確定要求是什麼
在 include/linux/bitops.h 有中包含了幾個 rotate 的操作函式,像是 rol32
、ror64
以及其他名稱相近的函式:
以 rol8
為例,這個函式可以把 word
最高位的 shift
個位元移動到最低 shift
個位元,就像是 word
的最高位與最低位相連一樣,所以若是 word
為 0b11110000
的話,當 shift
為 5 時,結果就會是 0b00011110
。
實作方式是將 word
左移 shift
位元後,再與 word
右移 8 - shift
位元的結果進行 OR 組合。
比較特別的是右移 8 - shift
位元的部份是透過二補數的特性來進行:
因此若 shift
不大於 8,(shift - 1)
也不會大於 7,則 (shift - 1)
只會影響 2^n - 1
的最低三位元,所以 ((-shift) & 7)
相當於 7 - (shift - 1)
也就是 8 - shift
。
在程式碼中可以看到 (shift & 7)
與 ((-shift) & 7)
,我想是因為 shift
的最大值超過 word
的總位元數,因此需要與 7
進行 AND 來避免 shift
超過 7
的情況,但不太明白為什麼要選擇 32 位元的 unsigned int
而不是用 8 位元的 __u8
來儲存 shift
的值。
求兩整數的最大公因數 (GDB) 可以透過實作輾轉相除法來完成。
上面的程式碼使用了迭代方式實作輾轉相除法,但 12 行的 %
本質上是除法,所以可以嘗試將 %
改用其他位元操作來加速運算:
不使用 %
的輾轉相除法實作概念與使用 %
的寫法相似,都是兩數輪流對另一數取餘數,但不使用 %
的實作方式是透過連續的減法來取餘數,可以從第 12 行的 do...while
區塊看出:
u < v
時,就不斷將 v
減去 u
,直到 u > v
時就代表原本的 v % u
完成v % u
完成時,會有兩種狀況:
v % u == 0
:此時代表已經找到最大公因數了,所以迴圈應該要結束,因此 COND 應為 v
v % u != 0
:此時因取餘數的操作都是以 v -= u
進行,所以第 17 行的 else
部份負責將 v
與 u
對調,而且不只是單純對調,而是將新的 v
換成 u - v
來減少一輪 v -= u
的操作。但單純將 %
改用減法取代不一定能夠減少計算時間,所以上面的實作還利用了數次除二的計算來減少運算:
u
與 v
可以分別表示成 以及 ,因此第 7 行的迴圈移去 u
、v
最低位元的 個,並用 shift
紀錄。u
與 v
就不會有 2 的公因數,因此若是 u
或 v
是二的倍數的話,可以直接移除尾端連續的 0:
while
會先將 u
尾端連續的 0 移除,讓 while
在第一次進入時會將 v
尾端連續的 0 移除,讓 。而之後由於 u
、v
可能會互換,所以第二次後進入此迴圈的意義則是將被取餘數的 v
尾端連續的 0 移除(u
尾端的 0 在互換前就被移除掉了)。最後由於一開始就先找到 shift
並位移掉了,所以需要將 v % u == 0
時的 u
左移 shift
位元才會是正確的最大公因數,因此 RET 應為 u << shift
。
__builtin_ctz
改寫並分析對效能的提升由於 u
、v
的型別是 uint64_t
,而 __builtin_ctz
的參數型別是 unsigned int
,所以要改用 __builtin_ctzl
或 __builtin_ctzll
。
嘗試使用迴圈對 [S,E)
區間的數兩兩呼叫 GCD 函式進行測試,並使用 perf
對結果進行簡單的分析:
while
檢查 ctz(無最佳化)while
檢查 ctz(開啟 -O3
最佳化)__builtin_ctzll
改寫(無最佳化)__builtin_ctzll
改寫(開啟 -O3
最佳化)上面是在開啟與不開啟編譯器最佳化的情況下,分別對兩種實作方式進行 10 次 1 億次( 區間內,兩兩取最大公因數)呼叫後的平均結果,可以看到使用 __builtin_ctzll
改寫後:
項目 | 無最佳化 | -O3 |
附註 |
---|---|---|---|
cycle | |||
instruction | |||
IPC | |||
branch | while 版開啟最佳化後 branch 數增加 |
||
branch-miss | 兩種實作方式開啟最佳化後 branch-miss 皆增加 | ||
執行時間 |
在 lib/math/gcd.c 使用了兩種方式實作 GCD 函式,這兩個函式大致上與這題不使用 %
實作的概念相似,都是用迴圈進行連續減法來完成取餘數的操作,但一個使用了 __ffs
、另一個則是使用位元操作尋找最低位的 1(Find First Set):
而註解則說明了使用兩種方式實作 GCD 的原因是 __ffs
會比第二種方式快,但不是所有硬體都有支援 __ffs
的指令,因此這邊用 CONFIG_CPU_NO_EFFICIENT_FFS
來檢查要使用哪種實作方式。
__ffs
實作與上面使用 __builtin_ctz
改寫的部份相似,這邊的實作的概念也是先移除 a
、b
尾端連續的 0(),最後(第 17 行)再透過左移補回去。但不同的是這個實作方式是先將 a
、b
進行 OR 操作,所以 r
尾端連續 0 的數量就等同於 ,不需要額外進行像前面實作中的 min(utz,vtz)
。
另一個不同點則是當 與 的最大公因數為 0 時的處理方式,在使用 __builtin_ctz
的實作中可以直接用統一的形式 u << min(utz, ctz)
回傳答案,但這邊的實作則是用 r & -r
回傳 。
r & -r
的技巧假設有一數 r
可以用 0b10010100
表示的話,r & -r
的意義相當於將最低位的 1 以外的位元都設為 0,即 r & -r
的結果為 0b00000100
。
而這個操作的原理是使用二補數的特性:
10010100 (r)
01101011 (~r, r 的一補數)
01101100 (-r, r 的二補數 = ~r + 1)
由於 r
的二補數相當於 r
的一補數再加 1
,而當 + 1
這個會造成進位並導致:
因此可以將 r
與 -r
拆成三個部份來看:
-r
這部份相當於 ~r
,所以與 r
AND 後皆為 0r
與 -r
的第 k 個位元都是 1,AND 後也是 1r
的這部份與 -r
都是 0,AND 後也是 0因此 r & -r
就相當於將第 k 個位元(最低位的 1)以外的位元設為 0,即相當於 1 << ctz(r)
。
另一個實作方式也有用到前一個實作方式中的 r & -r
的技巧來取得 1 << ctz(r)
,但是並不會將 a
()、b
()尾端的 個 0 移除,反而是只留下尾端的 個 0,第 11 及 17 行的 while
迴圈即是用來將多餘的 0 移除。
因此當 時,回傳 r
就代表 ,而 時則可以直接回傳 a
(或 b
),而不須進行額外的左移。
另一個特別之處則是在第 27 ~ 30 行的部份。在這個實作中只有第 12 行以及第 25 行兩處會修改到 b
的值:
b >> ctz(r)
必為奇數swap
則因為 a
在第 18 行時就會移除尾端多餘的 0,因此即使 a
、b
互換 b >> ctz(r)
也必為奇數因此 b >> ctz(r)
必為奇數,而相似的,a
在經過第 18 行與第 25 行後也會保證 a >> ctz(r)
為奇數,因此第 26 行的 a -= b
後 a
尾端連續 0 的數量必大於 ctz(r)
,因此第 27 行可以右移移除一個多餘的 0。
但在移除一個 0 後,a >> ctz(r)
則不能保證必為奇數或是偶數,因此第 28 及 29 行則在 a >> ctz(r)
為奇數時用另一個奇數 b
補回,讓 a >> ctz(r)
變成偶數,因此第 30 行可以保證 a >> ctz(r)
必為偶數、可以進行右移。
第 27 ~ 30 行如果只是要移除 a
尾端多餘的 0 的話大可交給下一輪的第 18 行處理,不太明白這四行的用途,但透過 blame 找到這個函式的相關 commit 訊息,似乎是有進行實驗,待讀。
似乎與 FLL 相關,待補。
這個函式可以用來找到由 bitmapsize
個 64 位元無號整數構成的陣列 bitmap
中所有的 1,並將這些 1 「相較於 bitmap
開頭的位置」依序(從低位址開始)寫到 out
陣列中(第 k
個 1 的位置會寫到 out[k]
中,即第 9 行的作用)。
而為了依序找到 bitmap
中的 1,這邊是用迴圈重複 64 次右移以及 AND 1 檢查是否為 1,若 AND 結果為 1 就紀錄該位元的位置(第 p
個數的第 i
個位元)。
由於每個數都要進行 64 次右移來檢查,所以當該數 1 的位元的數量少時會很沒效率,因此可以改用 ctz
直接找到 1 的位置:
這個實作方式則是用 ctz
找到最低位的 1 的位置,接著再透過前一題提到的 r & -r
的技巧(EXP6)將最低位的 1 以外的位元設成 0,再與原數 XOR 就能將最低位的 1 設為 0 而其他位元保持不便,因此下一個迭代就能再用 ctz
找到新的最低位的 1。
Linux Kernel 的 drivers/md/ 目錄中包含許多 Multiple Device driver 的操作程式碼,而其中有不少操作使用了 bitmap 進行處理。
在這個實作中先依序檢查了分母以及分子是否為 0,接著再紀錄商的正負號並將分母以及分子轉成正數(在 32 位元有號整數中,-2147483648
沒有對應的正數,所以改用 64 位元的有號整數來避免轉成正數時出現 Integer Overflow)。
前置處理完成後就進入計算答案的部份,這邊將除法的結果分成四個部份來看:
其中除了「整數部份的商」之外都可能會不存在,因此在第 35 行的部份若發現能夠整除就直接回傳整數部份的商;而若是無法整除則須補上小數點。
接著是小數部份的商,但由於小數部份可能會出現循環小數,而循環部份要寫在 ()
內,因此需要另外使用 decimal
紀錄小數部份的商,並使用了 Linux Kernel 的 list API 來實作雜湊表,共有 1333 個欄位,並以餘數為 key
、key % size
為雜湊函式紀錄此餘數對應的商在 decimal
中的第幾個字元:
decimal
尾端,並將這個餘數以及對應的商在 decimal
的位置(假設為 pos
)加到雜湊表中(第 61 ~ 65 行),因此 MMM 及 EEE 應分別為 list_add
以及 &heads[remainder % size]
。i
),可分成兩個部份:
decimal[0:k]
加入到儲存答案的陣列 p
,因此 PPP 應為 pos--
才能使迴圈結束時的 decimal
指標指向第 pos
個字元。(
、循環部份 decimal[k:i]
、)
加入到儲存答案的字元陣列 p
,最後再直接回傳答案。decimal
複製到小數點後(無循環小數)。原題目中有提到:
It is guaranteed that the length of the answer string is less than 104 for all the given inputs.
但不知道為什麼這個實作中 result
這個儲存答案的字元陣列的大小為 1024。
也不明白為什麼雜湊表的大小為 1333。
pos
會是 -1
,可用一補數檢查是否為 0
strcpy
strncpy
decimal
大小應考慮整數部份以及小數點
decimal
大小應小於 result
大小以免在 strcpy
時超出範圍decimal
以及 heads
應該要在程式結束前釋放記憶體
if
內不可以直接對 fraction
操作LeetCode 的 runtime 測量有點神奇,相同的程式碼第一次提交跑 9ms,第二次提交卻 0ms。
__alignof__
在這個巨集中,為了找到型別 t
對齊時需要的位元組大小,使用了與 offsetof
相似的技巧,並將 0 看作是一個有兩個成員的結構體的開頭地址:
t
對齊需要的大小不會小於第一個成員對齊需要的大小t
,若此型別對齊需要的大小比第一個成員對齊需要的大小還大的話,這個結構體實際佔用的空間就會是型別 t
對齊所需的大小的兩倍接著由於這個結構體物件的起始地址是 0,所以第二個成員的地址(即 _h
相對於此結構體開頭的 offset)就是這個結構體佔用空間的一半,也就是型別 t
對齊所需的大小,因此 M 應為 _h
TODO : struct padding, null pointer dereference
到目前為止,感覺上 _h
的 offset 感覺就是 __align__
要找的數值了,為什麼還需要轉型成 char *
再減去另一個指標?
首先可以先從規格書 n1570 中的 6.5.6 Additive operators 找到關於減法運算的情境:
- For subtraction, one of the following shall hold:
— both operands have arithmetic type;
— both operands are pointers to qualified or unqualified versions of compatible complete object types; or
— the left operand is a pointer to a complete object type and the right operand has integer type.
可以看到減法運算要求:
而在這個巨集的實作是兩個運算元都是指標,所以應該屬於第二種情境。為了確定它是屬於第二種情境,可以先從規格書中的 6.2.5 Types 看到關於 qualified 及 unqualified 型別的說明:
- Any type so far mentioned is an unqualified type . Each unqualified type has several qualified versions of its type, corresponding to the combinations of one, two, or all three of the const, volatile, and restrict qualifiers. The qualified or unqualified versions of a type are distinct types that belong to the same type category and have the same representation and alignment requirements. A derived type is not qualified by the qualifiers (if any) of the type from which it is derived.
這段說明了在這(第 26 項)以前提到的型別都是屬於 unqualified type,而在這之前提到了 char, integer types, floating types 等 basic types 以及 function, pointer 等 derived types,可以看到 char 是屬於 unqualified type。
接著則是 compatible type 的說明,可以在規格書的 6.2.7 Compatible type and composite type 看到相關說明:
- Two types have compatible type if their types are the same. (後略)
在第一項的一開始就說明了,若兩個型別相同的話,他們就是 compatible type,因此可以確定這個巨集的減法運算確實是屬於第二種情境(兩個運算元是 compatible unqualified type 的指標)。
接著再回到 6.5.6 Additive operators 找到關於兩指標相減的說明:
- For the purposes of these operators, a pointer to an object that is not an element of an array behaves the same as a pointer to the first element of an array of length one with the type of the object as its element type.
首先,在這邊的說明可以知道一個指向非陣列元素的指標等同於指向一個大小為 1 的陣列的首位元素,接著再看兩個指向相同陣列中的元素的指標減法運算的說明:
- When two pointers are subtracted, both shall point to elements of the same array object, or one past the last element of the array object; the result is the difference of the subscripts of the two array elements. The size of the result is implementation-defined, and its type (a signed integer type) is ptrdiff_t defined in the <stddef.h> header. If the result is not representable in an object of that type, the behavior is undefined. In other words, if the expressions P and Q point to, respectively, the i-th and j-th elements of an array object, the expression (P)-(Q) has the value i−j provided the value fits in an
從這邊可以看到當兩個指向相同陣列中的元素的指標進行減法運算時,結果會是一個有號整數,而其數值則等同於兩指標指向的 index 的差值。
在看完指標間減法運算的特性後,推測在找到 _h
的 offset 後還另外進行減法運算的用意是為了將 &((struct { char c; t _h; } *)0)->_h
這個結構體中 _h
地址 透過有定義的方式轉型成一個數值,因此 X 應為 0 才不會影響到正確地址 。
為什麼是轉型成 char *
進行減法運算?
轉形成 long *
再進行減法運算會出現不符合預期的結果?
第 9 點有說到兩指標應該要指向「相同陣列物件」,但實作中卻只是將 0 與 _h
的地址轉型成 char *
而已
__alignof__
的 2 個使用情境https://github.com/torvalds/linux/blob/master/arch/s390/mm/vmem.c
ALIGN
- include/linux/align.hALIGN_DOWN
- include/linux/align.hALIGN_UP
- tools/testing/selftests/net/tcp_mmap.cTODO
上面的程式碼是連續的 if
而不是 if...else...
,所以在 15 的倍數時前三個 if
都會成立,並印出 FizzBuzzFizzBuzz
,不確定是題目刻意設計的還是寫錯。
從迴圈最後看回去會比較好理解。
首先從最後的 strncpy
可以看出是要透過 "FizzBuzz%u"
這個字串來輸出,而根據題目可知:
div5 && div3
時應該要印出 FizzBuzz
div3 == true
時應該要印出 Fizz
div5 == true
時應該要印出 Buzz
所以在上述四種情況時應該分別從 FizzBuzz%u
字串的特定位置開始印出特定長度的字串內容:
第 17 行應該是 8 >> div5
,不然非 3, 5 倍數時只會印出 u
。
接著就可以透過這個條件知道 KK1, KK2, KK3 應該填入什麼:
KK3 應為 div3 << 2
才能讓 div3 == true
時從 &"FizzBuzz"[0]
開始印出。
KK1, KK2 要讓 length 在:
div3 == true
時為 4 (2 << 1)
div5 == true
時為 4 (2 << 1)
div3 && div5
時為 8 (2 << 2) == (2 << 1) << 1
!div3 && !div5
時為 2 (2 << 0)
因此 KK1, KK2 應分別為 div3
以及 div5
。
naive.c
和 bitwise.c
效能落差TODO
https://github.com/lemire/fastmod
大致上跟 quiz 的解法差不多,但是由於題目要求要用 malloc
回傳字串陣列,所以用了 sprintf
輸出格式化後的結果到回傳陣列。