contributed by < KHLee529 >
測試環境
1
移動平均(Moving average),又稱滾動平均值、滑動平均,在統計學中是種藉由建立整個資料集合中不同子集的一系列平均數,來分析資料點的計算方法。
移動平均通常與時間序列資料一起使用,以消除短期波動,突出長期趨勢或周期。短期和長期之間的閾值取決於應用,移動平均的參數將相應地設置。例如,它通常用於對財務數據進行技術分析,如股票價格、收益率或交易量。它也用於經濟學中研究國內生產總值、就業或其他宏觀經濟時間序列。
這個直覺的解法會有 overflow 的問題,若我們已知 a, b 數值的大小,可用下方程式避免 overflow:
接著我們可改寫為以下等價的實作:
其中,這個實作方式是透過將 a, b 先除二後再相加,並考慮若 a, b 同時為奇數時除二的餘數相加會在平均中再加 1。
因此,透過 a & b & 1
得到是否 a, b 兩數最末位同時為 1。若是,則結果為 1 ,若不是則結果為 0。
我們再次改寫為以下等價的實作:
其中,這個實作方式則是需要考慮到二進位加法與位元運算的相關聯。
在二進位的加法當中,對於其中一個位元的運算方式為
由此規則可以發現,在兩數 a
、b
相加時,對應任一位,相加後的結果與 a ^ b
相同,而進位的部分與 a & b
相同。
故 a + b
可以表達成 (a ^ b) + (a & b) << 1
。
而在取平均時,即為將此一結果再右移一個位元,即為 (a & b) + (a ^ b) >> 1
原始程式碼
開啟編譯優化前 (-O0
)
開啟編譯優化後 (-O1
)
註:編譯優化等級
-O1
到-O3
皆呈現相同成果,推論在-O2
與-O3
開啟的優化選項中並無影響這段程式編譯結果的部分。
由反組譯結果可以看出,在未開啟編譯優化時的編譯結果,基本上都是按照原始程式碼的邏輯進行編譯。
意即對於一個 +
運算便會有一個 add
指令,有一個 -
運算便有一個 sub
指另與其對應⋯⋯等等。
而在編譯優化後,可以由 average_v1
的反組譯結果看出,編譯器對於兩個數值相加的運算簡單的以取址的 lea
指令進行,其餘在運算部分並無特別大的差異。
但在另一方面,由反組譯結果可以看到,在編譯優化開啟與否影響到實際指令長度的關鍵差異並非在運算部分的指令,而是未開啟編譯優化時,編譯器會先將所有的引數(arguments) 放入 stack 當中,
再對其進行相關運算。這個變化待閱讀 gcc manual 中有關編譯優化選項的部分後補上。而為何 -O0
的編譯結果會將引數放入 stack 中再進行運算目前沒有頭緒待研究。
linux/average.h
在 linux/average.h
當中 EWMA 的實作以巨集宣告函數的方式實作,其中包含三個函數 ewma_name_init
、ewma_name_read
與 ewma_name_add
。
而這三個函數的意義則從 EWMA 的計算方式出發
由此定義可以理解為何會有 add
相關函數的存在,對於每一個新的值,皆會以 的權重與前一次的總和進行加權平均。故只要針對每一個值呼叫 ewma_name_add
函數即可得到加入新值的結果。
而在 Linux 的實作當中可以將各個函數主要分成兩個部分,參數錯誤預防與運算部分。
錯誤預防部分
針對本巨集的使用,除了精度與權重的數值必須以編譯時期即可得到數值的常數方式宣告以外,亦訂定了對於精確度的規定以及權重需為 使其得以以位元右移方式避免浮點數乘除運算以加速計算。
其中的 BUILD_BUG_ON(condition)
巨集得以在編譯時期針對 condition
為 true
的情況進行編譯錯誤的警告。將其展開後可以得到
在展開為
最後展開為
其中 COUNT
的部分會由編譯器提供的 __COUNT__
巨集得到唯一數值。
透過此一實作,對於編譯時期即知道真偽結果的 condition
,透過編譯器優化決定是否跳過第 5 行的函數呼叫。
當 condition
為真時,會保留第 5 行的函數呼叫,而由於該函數並沒有實際實作,便會引起連結器錯誤,亦或是透過 __attribute__((__error__(msg)))
的功能,於函數呼叫時即會引發編譯錯誤,使得該檢查於編譯時期即有效。
而在這部分當中,由於針對此一巨集當中,只要其中一個編譯錯誤發生即會停止編譯,而為何需要於三個函數皆導入相同的錯誤預防機制則需往後繼續研究。
運算部分
若將巨集的實作當中錯誤預防的部分去除後剩下以下部分。
透過此一簡化過後的結果可以看出,當需要使用 EWMA 的功能時,需要先呼叫 init
函數對資料初始化後透過 add
加入各資料點後以 read
讀取其中資料。
而在主要進行 EWMA 功能運算的 add
當中可以看到其透過大量的運算縮短運算時間。以下為運算主體程式碼。
其中 WRITE_ONCE
巨集為確保資料寫入記憶體的並行相關處理。在此先將其簡化為以下賦值表示式以關注其運算方式。
由以上程式碼可看出,若當 internal
數值為 0 時,即甫初始化結束第一次加入數值時,會將結果設定為 (val << precision)
由此可以看出其 e->internal
的內部儲存結構為
由此簡易的浮點數表示方式,使得加權平均的分數運算得以快速的進行。接著僅需要在加入每一個數值時搭配 的運算即可。
在此部分由於巨集以將 \alpha 限制為 ,因此 的部分僅需要將傳入的數值 先調整成上圖的簡易浮點數表示法 (val << precision
),再將其右移 位即可 ((val << precision) >> weight_rcp
)。 而 的部分亦以相同方式處理。
這部分對於巨集作者在 部分為何選擇先將 internal
左移再右移回來而非直接減去右移的結果有些疑惑仍待研究。
由於此部分若 internal
的整數部份儲存實際使用到的位元數較大,leading zeros 的數量小於 weight_rcp
時,即會在左移時產生 overflow 產生錯誤。而初步推測原因為,若 weight_rcp
數值很大時,每一次 EWMA 幾乎即等於其新加入的值,使得移動平均的意義與效果大幅削弱,因此 weight_rcp
應不會太大,且若每次加入的 數值所佔位元數較多時,使用者應當也會選擇較小的 precision
以避免 overflow。此部分待往後研究其使用案例時進行推測。
而在 Linux 核心當中主要使用到 EWMA 為無線網路相關程式碼。
2
從 Xor 的幾個特性中可以看出端倪,分別為
x ^ 0 = x
x ^ x = 0
x ^ y ^ y = x
故,若欲回傳 a 時,即((EXP4) & -(EXP5))
的部分需為 0
。
而若要回傳 b 時,即((EXP4) & -(EXP5))
的部分需為 a ^ b
。
透過 EXP5
為一個比較操作且前面加上一個負號的提示可知當 EXP5
為真時,((EXP4) & -(EXP5))
的結果為 EXP4
,而當 EXP5
為偽時,((EXP4) & -(EXP5))
的結果為 0
。整理如下,
EXP5 |
-(EXP5) |
(EXP4) & -(EXP5) |
---|---|---|
True (1 ) |
-1 (0xFFFFFFFF ) |
EXP4 |
False (0 ) |
0 (0x00000000 ) |
0 |
在配合回上方推論,即可得知 EXP4
為 a ^ b
,且當 a 較大時 EXP5
需為偽,故推論 EXP5
為 a < b
。
由於運作原理中使用到的特性對於有號與無號數皆適用,故對於有號數亦可直接套用。得到結果如下
透過以下指令搜尋在 git log
當中 commit message 包含 "branchless" 或 "branch-free" 相關內容的。
很不幸的在這當中大部分的程式碼變更相對時間比較久遠導致都已經被修改掉了,因此嘗試以其他關鍵字搜尋相關的 branchless 操作。
而在搜尋關鍵字 "eliminate branch" 時看到如下的結果,幸運的找到了目前還保留的程式碼。
透過這個兩個 patch 紀錄可以清楚的看到它是如何的從 if statement 轉換成 branchless 的程式碼。
3
__builtin_ctz
改寫 GCD,分析對效能的提升;這個實作方式是將公質因數中 2 的部分先行提出,大幅降低數值的大小範圍,使得後面的迭代次數得以降低。
首先這個部分即為將兩者公因數中 2 的次方數部分先提出,並以右移的次數(除以 2 的次數)作為記錄方式。
再來在這個部分,若 u 的質因數分解中仍有 2 的次方部分,該部分必不為兩者的公因數,故可以將其剔除。
最後的部分就如同原本的輾轉相除法,只是由於先前已將質因數中 2 的次方部分提出,所以可以在過程中,再將質因數中 2 的次方部分剔除儘速降低數值大小。
也因此,由於此部分的計算即為輾轉相除法,結束判斷的標準仍為 v 是否等於 0。故 COND
部分為 v
。
如同輾轉相除法的結果,最後 u 即為最大公因數,但由於原先還有 2 的次方部分已經被提出,在回傳時需透過左移位元恢復該部分。故 RET
部分為 u << shift
__builtin_ctz
改寫將以上實作方式以 __builtin_ctz
相關函數進行改寫後,結果如下
將兩函數進行效能測試結果如下
此結果圖有調整過 y 軸範圍,由於原始數據有數筆花費時間高達數萬 nsec 使得圖形被擠壓至下方,故調整 y 軸範圍以呈現主要現象。
從結果可以看出,使用 __builtin_ctz
系列函數計算結尾的 0 的數量較直接右移至最末位不為零的實作方式快。其原因待研究,目前先以編譯結果來做一個初步的假設。
由編譯結果的組合語言可以看出,透過 __builtin_ctz
函數,gcc 得以直接將其編譯成 tzcnt
指令。使得所有計算結尾零數量的部份得以從未知次數的迴圈簡化至接近 constant time 的時間結果。
原先希望透過以搜尋 tzcnt
的 CPI(Cycle per Instruction) 解釋此現象,於網路上搜尋過後發現許多人對於 CPI 的相關資料都是取自Agner Fog. 的 Insturction Table,發現在大多數的處理器當中,tzcnt
的 CPI 大多為 add
, sub
, shr
等基本運算指令的 2 ~ 4 倍。故推論,若迴圈次數大於2次後,使用迴圈的次數應會較慢一些。
為驗證這部份推論,設計簡易實驗,僅測試 __builtin_ctz
功能與迴圈差異。結果如下
由實驗結果可以看出,使用 __builtin_ctz
函數的計算使用時間接近常數,而使用迴圈的計算方式則會根據結尾的零的數量有些許變動,但由於對於一 64 bit 整數而言最多需要計算的迴圈數量就僅有 63 個,所以就使用到這個子函數的程式整體而言使用迴圈仍可算是常數時間的運算。但是用 __builtin_ctz
則可更有效率。
4
本函數的目的為將在 bitmap
當中位元為 1
的位元編號紀錄於 out
並回傳總數量。
其中在 improved
版本的函數中,k
代表一個 64 位元的 bitset 在整個 bitmap 當中的編號,而 r 為在 bitmap 當中末端零的數量。
而 k * 64 + r
即為從 bitmap 最開頭的位元標記為 0 號開始往後數的編號,若該位元為 1
則將其紀錄於 out
陣列當中。
而透過第 12 行的 bitset ^= t
將最末一個為 1 的位元清零後即可在下一次迭代中取得下一個 1
的位置,便可達到本函數目的。
可以使用到本函數的最基本情況為,對於一個佔用容量龐大但位元當中 0, 1 數量差異較大的 bit array 資料,可將其資料壓縮為各個 1 或 0 的位置,進行儲存,可以達到基本的壓縮容量效果。
5
例如,判斷負號只要寫作 bool isNegative = numerator < 0 ^ denominator < 0;
搭配研讀 The simple math behind decimal-binary conversion algorithms
作答
PPP
= pos--
MMM
= list_add_tail
EEE
= &heads[remainder % size]
首先 fractionToDecimal
函數最一開始先進行較簡單的資料處理,當分子或分母為零時回傳值皆是固定所以先行進行處理。接著由於負數的負號必須紀錄於回傳值的最一開頭,故將正負號進行處理,並在往後運算除去正負號影響因素。其中注意由於 -INT_MIN = INT_MIN
的特性,有可能直接進行負號計算會產生 overflow ,故先將其轉為 long long
再進行計算。
而接著便可以藉由整除與否對接下來的結果進行計算,並且先將整數部分紀錄至結果,最後再考慮小數部分結果。
而在小數部分,為處理循環小數部分,必須有方法紀錄循環小數的開頭。為處理這個部分,可以透過循環小數的特性–由相同的餘數開始除–來進行計算。
透過此一特性,基於長除法的計算方式,紀錄每一次除法運算後的餘數以及其相應的小數位置,以便往後尋找。
為此,建立一個雜湊表 (hash map),以每次的餘數為 hash key (暫時找不到一個合適的中文翻譯,先以這個方式紀錄),紀錄其相對應的小數位數。對於每一個往後出現的餘數,皆可以先搜尋其是否出現過。以便得到小數的循環部分。
以上程式碼未被註解掉的部分為較複雜的小數部分,其中 decimal
紀錄小數點後的數字。14至17行的迴圈進行雜湊表的初始化。19行的迴圈開始進行長除法計算,由 37, 38 行可以看到對於每一個剩下的餘數皆進行乘以 10 再除一次取餘數的長除法,而 20 ~ 35 行間即是對於餘數的紀錄。
而在 20 ~ 30 行之間為進行當餘數已開始重複時的判斷,由 decimal
開始第 pos
位為循環小數的開始,便使用 22, 23 行進行循環小數前的部分複製,加上題目需求的括號後再複製剩餘的循環小數部分,便可得到回傳的結果。而 31 ~ 35 行間為將餘數紀錄至雜湊表的過程。若除法結果並非一循環小數,則會在最後脫離迴圈,使用最後 41 行的方式複製至回傳結果,變達成此函數的需求。
以上程式碼有些許部分有些不足,以列點方式於底下呈現。
strcpy
函數由於缺乏邊界確認,故被視為很可能會產生 buffer overflow 的不安全函數,應避免使用。應將其更改為逐一字元拷貝迴圈式複製或者使用 strncpy
將其替換掉。size
皆取代為 RESULT_LEN
,後半部分計算小數部分中的 size
皆取代為 HASH_LEN
以減少模糊的變數意義。MMM
部分巨集,可以從其上下文得知需求為將新製作出之 rem_node
加入雜湊表當中,而由於本實作方式使用 Linux 風格的 doubly-linked-list,對於增加於頭尾兩端花費皆為常數時間。而應將其加在頭或尾端則是直得探討的部分。若是對於大部分的循環小數部分,循環部分的長度會大於未循環的部分,則應將新產生的節點加入雜湊表各鍊的尾端,若小於則應加於尾端。但巨觀來看,雜湊表的性質來說,一個理想的雜湊表應可以達到 的搜尋時間,故在這個部分亦可先行忽略。6
__alignof__
的使用案例 2 則,並針對其場景進行解說ALIGN
, ALIGN_DOWN
, ALIGN_UP
等巨集,探討其實作機制和用途,並舉例探討 (可和上述第二點的案例重複)。思索能否對 Linux 核心提交貢獻,儘量共用相同的巨集本巨集目的在求得資料型態 t
於記憶體中會需要對齊幾個位元。透過 struct
安排的結果來進行計算。
先將巨集大致拆解後可以看出其結構為
故為透過兩個指標相差的距離以計算其對齊方式的技巧。
接著在細看 Y
的部分,可以看到其透過一個 struct
排列兩個元素,由此結構可以推論欲使用的策略如下圖所示
對於任意一個資料類型與 char c
放入一個結構中時,記憶體會呈現上圖的排列模式,若 struct
開頭位於 addr 位址時且資料類型 t
需對其特定數值時,在元素 c
與 _h
當中會加入填充用的記憶體空間 padding 以達到對齊效果。透過這個方式,當 addr 被設定為 0
時,元素 _h
的記憶體位址即為其對齊所需的位元數。
因此,在上方 Y
的部分取得 _h
元素在 struct
中的相對位址,再減去其開頭 (也就是 0) 即可得到其對齊位元數。
7
過程中,你可能會發現可貢獻到 Linux 核心的空間,請充分討論