contributed by < YOYOPIG
>
linux2021
這裡使用二進位的方式來處理大數的運算。定義需要的 macro 及一個大數:
初始化大數及轉換:
這裡需要注意到,由於大數的 array 每一個元素為 uint32_t
,因此需要兩格來儲存 uint64_t
類型的資料。首先將 lower 存到 array[0]
,再透過一次的 right shift 將 upper 存到 array[1]
。如此一來,array 會呈現 little endian 式的排列。在註解中提到,若是使用的機器不為 little endian,是否會造成問題?這裡考慮兩個部份:
首先,根據 stackoverflow 討論,shift 的結果和記憶體存放的 endianess 沒有關係,因此不會出現問題。而第 2 點只需要在後續程式處理的時候多加注意即可。
接著,實作把自定義的 big num 轉成 string 的函式
由於人類閱讀的習慣接近 big-endian,由左至右為 MSB -> LSB 的型式,因此這裡在讀取的時候對 big num 做轉換。
加減法的實作
這裡實作大數的加減法,透過一個區域一個區域由 LSB 開始計算。在進位處理上,add 使用一個變數 carry 儲存,減法則在每次計算完過程後,檢查 res 以及 tmp 的大小關係。若 tmp > res
則代表沒有發生 underflow,可直接結束計算,否則需借位並繼續計算。COND
選 (c) if (!(res > tmp)) break
乘法實作
這裡使用 nested for loop 逐位計算。由於兩個 uint32_t
相乘,有可能會發生 overflow ,因此這裡需要透過一個較大的變數 uint64_t
來暫存結果。因此,III 選擇 (c) UTYPE_TMP intermediate = a->array[i] * (UTYPE_TMP) b->array[j]
。
Karatsuba algorithm 和 Schönhage–Strassen algorithm 這類的快速乘法演算法相繼提出,請引入到上述程式碼
在 sysprog21/bignum 中,已引入 Karatsuba algorithm 處理大數乘法加速。在實作乘法的 mul.c 註解中,可以看到日後打算改寫成更快的 Schönhage–Strassen algorithm
開始研究該演算法及其背後滿滿的數學
這題取自 Leetcode 第 1 題 Two Sum,原題敘述如下:
Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.
You may assume that each input would have exactly one solution, and you may not use the same element twice.
You can return the answer in any order.
當初使用 c++ 刷題時,沒有思考太多就直接用了內建的 std::unordered_map
解題。這裡因為 c 語言並沒有類似的工具,因此需自行實作。首先考慮 hash table 的定義方式:
這裡透過 hlist_node
以及 hlist_head
來定義 hash list,將所有具有相同 hash value 的元素加入同一個 list 中。而整個 map 結構則定義為 hash list array,另外用 bits
紀錄整個 array 的大小。初始化方法如下:
因為使用 list 儲存,這裡在定義 key-value pair 的時候,需要再加上先前定義的 hlist_node
:
接下來就可以開始進行查找與插入了。
大致看一下上面的程式碼可發現,在插入的時候,會將新元素插入至 list 的最前端,再檢查原先 list 中是否有元素,若有的話將其接在新元素的後方。因此,這裡 NNN 以及 PPP 應分別選 (c) n->next = first
, (a) n->pprev = &h->first
在實作 hash function 時,會希望產生的 hash 值越均勻越好,以避免產生過多的 collision。若 hash function 的選擇不良,很可能最後大量的元素都被分配至同一個 list 中,失去了原先向低搜尋時間的效果。可以看到這裡 hash function 的選擇,應為 multiplication hashing 的一種。將數值乘上一個選定的常數後,透過 shift 取其較高的 bits
位做為 hash value。
注意到上面的程式碼中,選定的常數為 0x61C88647
。使用這一特定常數的方法,又被稱為 Fibonacci Hashing。首先,golden ratio 的定義為
將以上式子展開:
將 φ 代入式子中:
用 LaTeX 語法改寫數學式
已更正,謝謝老師提醒
接著,在常數的選擇上,根據我們的 bits 數值 W,選定與 W 互質且最接近 W/φ 的正整數 a 作為最後的常數。根據 bits 的大小,常見的常數為:
W | a |
---|---|
2^16 | 40503 |
2^32 | 2654435769 |
2^64 | 11400714819323198485 |
若是使用了這樣的神奇數字作為最後的常數,會發現在 hashing 時,連續的 key 值會被盡可能的分散,進而達成避免過多 collision 的結果:
然而,若仔細看會發現,這裡程式碼中定義的 GOLDEN_RATIO_32
為 0x61C88647 = 1640531527,和上方表格中的 2654435769 = 0x9E3779B9 並不相同。閱讀 tools/include/linux/hash.h 中的程式碼會發現,在註解中已經給出了解釋。為了增加計算效率,使用 (1-φ) 取代 φ,且最後 hash 出來結果的 distribution 有著和原版一樣的特性,仍能維持低 collision。
Although a random odd number will do, it turns out that the golden ratio phi = (sqrt(5)-1)/2, or its negative, has particularly nice properties. (See Knuth vol 3, section 6.4, exercise 9.)
These are the negative, (1 - phi) = phi**2 = (3 - sqrt(5))/2, which is very slightly easier to multiply by and makes no difference to the hash distribution.
這裡設計一個簡單的實驗,分別將 GOLDEN_RATIO_32
設為原版的 0x9E3779B9 以及 linux 實作版本的 0x61C88647,對 1~10 做 hashing 並以 3 bits 儲存結果:
結果分別為:
orig | 0x9E3779B9 hash | 0x61C88647 hash |
---|---|---|
1 | 4 | 3 |
2 | 1 | 6 |
3 | 6 | 1 |
4 | 3 | 4 |
5 | 0 | 7 |
6 | 5 | 2 |
7 | 2 | 5 |
8 | 7 | 0 |
9 | 4 | 3 |
10 | 1 | 6 |
可以看到,不論是哪個版本,1~8 都被均勻的 hash 到了 0~7 當中,也就是 3 bits 所能表示的所有數值。從 9 開始 hash value 又從頭開始再輪一次。也就是說,之後的每一個數值 n,會和 n % 8 有著相同的 hash value。為了比較兩種方法的快慢,考慮對 1~100000 做 hashing,執行時間分別為:
linux 實作的改進版本,確實比原本快上一些。
這份程式碼實作了 fiber。
In computer science, a fiber is a particularly lightweight thread of execution.
Like threads, fibers share address space. However, fibers use cooperative multitasking while threads use preemptive multitasking. Threads often depend on the kernel's thread scheduler to preempt a busy thread and resume another thread; fibers yield themselves to run another fiber while executing.
可以看到,和一般的 preemptive multitasking 不同,fiber 不仰賴系統的 preemption,而是透過執行單元自行讓出運算資源(當然,需自行在適當時機呼叫 yield()
讓出控制權),以 cooperative multitasking 的方式確保程式的並行運算。
定義 fiber_t 由兩個變數組成,分別負責記錄 child thread 的 pid 以及其 stack pointer。同時,宣告 fiber_list
以儲存日後會產生的 fiber。
實作一些 fiber 的基本功能,包括初始化 fiber、透過 function pointer 給定 fiber 執行的 function等,同時將 sched_yield()
函數 encapsulate 成 fiber_yield()
方便後面呼叫。
透過 clone() 系統呼叫生成新 fiber 並開始執行。若有任何錯誤發生,如 fiber 數量超出給定上限,或在 malloc 途中發生問題等,則將新分配的資源 free 掉並回傳對應的 error message。根據 linux man page,KKK 的位置應該要給 stack pointer,這裡選 (b) (char *) fiber_list[num_fibers].stack + FIBER_STACK
。
由於只有 parent 需要做等待的動作,因此這裡先做了對應的檢查。接著,使用 wait()系統呼叫 等待任一 fiber 執行完畢。接著,在 fiber_list
中找到執行完畢的 fiber,將它的 stack free 掉。最後,為了保持 fiber_list
的整齊,將最後一個 fiber 和當前的空 fiber 對調,避免空間的零碎化。這裡 CCC 的位置應是在尋找執行完成的 fiber,應選 (b) fiber_list[i].pid == pid
。
反覆執行上述程式碼,有時輸出可見 fiber 中的 printf()
結果無法以一整行表示。錯誤結果範例如下:
由於 yield()
的呼叫是在 printf()
之後,因此推測應該不是兩個 thread 同時呼叫 printf()
,比較可能是第一個 thread 呼叫完後,第二個 thread 才呼叫 printf()
,但 I/O buffer 中還有東西未輸出至螢幕上導致。因此,這裡引入 mutex lock 保護每一個 printf()
。
改善後結果如下:
在閱讀這篇 stack overflow 討論串時,發現還有另一種巧思可以解決這個問題。既然不同 thread 呼叫 printf()
時會發生問題,那就改由一個 thread 負責輸出就好,其餘 thread 只負責將欲輸出的結果加入某個被保護住的 thread-safe buffer。為了保持程式的簡單,這裡統一由 main thread 在程式結束前輸出至螢幕。
TODO: 複習生產者-消費者問題及 ring buffer,思考減少 mutex lock 的機制。