[]# Linux 核心專題: 並行程式設計相關測驗題
執行人: TonyLinX
eleanorLYJ
不知道為什麼使用了 lock free 的方式,卻效果更差了
可能是 false sharing 的問題? 你是否使用過 padding 或 alignment 等方式解決?
fcu-D0812998
排成演算法 : 要把哪些任務分配給哪個 ring buffer 很重要。
HeLunWu0317
TODO: 重作第 12 週測驗題
enqueue()
好像沒有對 queue full 的情況下做預防?
包含延伸問題
著重 lock-based vs. lock-free + thread pool
包含延伸問題
期末專題解說影片
Github
unoptimized.c
)以下程式是實作一個 64×64 方塊 (tile) 為單位的矩陣乘法 (GEMM): (unoptimized.c)
以 的矩陣為例,若我們設定 tile(也是 micro tile)大小為 ,則矩陣會被劃分成 的 tile,如下所示:
其中, 表示矩陣 中第 行第 欄的 tile,tile 的尺寸為 。
mm_tile
vs mm_edge
在實際程式運行中,對於每個 tile 區塊,會先檢查該區塊是否為完整 tile:
mm_tile
進行矩陣運算。mm_edge
進行矩陣運算。舉例來說,若欲計算 的矩陣乘法,在最後一行與最後一列上便無法構成 的完整 tile,因此這些區域將交由 mm_edge
處理。
ALIGN_UP
在 main.c 中,將矩陣的尺寸(如 m
, n
, p
)補齊成 TILE_SIZE = 64
的倍數,以避免邊界不完整的 tile 出現,進而提升記憶體對齊與計算效率。程式中使用了以下巨集:
技巧 :
((x) + TILE_SIZE - 1)
:確保只要 x 不是 64 的倍數,就會進位到下一個倍數 & ~(TILE_SIZE - 1)
:把最低 6 bits 清 0,讓結果剛好是 64 的倍數目標 :
pad_mat
與 pad_t_mat
將原始矩陣複製到新的、經過 zero-padding 的記憶體區塊中,大小為 TILE_SIZE
(64)的倍數。
pad_mat()
:針對 A 矩陣的補齊
使用 aligned_alloc(MEM_ALIGNMENT, ...)
分配對齊的記憶體(如 64-byte),透過 memset
全部填成 0,由於 A 矩陣 是 row-major 所以不需要轉置,直接透過 memcpy
把原本第 i 行的 c 個 float 複製到新 buffer 中第 i 行開頭,右邊剩下的自動保留為 0(前面用 memset)。
pad_t_mat()
:針對 B 矩陣的轉置與補齊
使用雙層迴圈進行轉置與寫入,把原本的 B[i][j]
被寫到 B_T[j][i]
,
init_thread_pool
建立指定數量的 worker threads,並設定 CPU affinity:
透過 pthread_create
建立工作執行緒,並將每個 thread 綁定到指定的 CPU core 上(pthread_setaffinity_np
)。
mm
把整個矩陣乘法任務,以 tile 為單位拆分成小任務(task_t
),並丟給 thread pool 執行。這是一種 Non-blocking Dispatch 的方法,也就是把工作派出去的時候,主程式不會停下來等這個工作做完,而是馬上繼續下一件事。當所有工作丟完後,主線程呼叫 wait_for_completion()
等待所有 tile 被算完。
enqueue
任務佇列實際上是一個由 queue_node_t
組成的單向 linked list,當有新的任務進來會加入尾端並以 pthread_cond_signal()
將 not_empty
條件變數的 thread 喚醒,通知有新任務可取。
worker_thread
當 thread 被建立後,會執行 worker_thread
函示,若 thread pool 沒有要被 shutdown
, thread 會先取得鎖,原因是要它可能要讀取和修改 queue 裡面的內容,這些動作必須保證只有一個 thread 在操作。當拿到鎖後,會檢查有沒有任務或thread pool 是否要關閉,如果沒有任務的話就釋放鎖等待別人喚醒(pthread_cond_wait(&pool->not_empty, &pool->lock)
),如果有任務就取得任務,釋放鎖給其他 thread 使用。任務內容就是調用 mm_tile
計算一個 tile,完成後減少tasks_remaining
;若所有任務還未完成,先完成的 thread,會回到 thread pool 繼續等待,直到有新工作被喚醒。當所有任務皆完成,此 thread 會調用 pthread_cond_broadcast(&pool->all_done)
讓主執行緒能被喚醒。
wait_for_completion
主函式在 mm
裡 enqueue
完全部工作後,呼叫 wait_for_completion
。
destroy_thread_pool
使用 atomic_store(&pool->shutdown, true)
將 shutdown 旗標設為 true。由於這個變數是被多個 thread 共用的,因此使用 atomic 操作可以避免資料競爭,並確保每個 worker thread 都能正確且同步地感知「要結束了」的訊號。
有些 worker 可能正在 pthread_cond_wait(¬_empty)
上等待(因為 queue 暫時是空的),這時呼叫 pthread_cond_broadcast(¬_empty)
將它們全部喚醒。這樣它們會醒來後發現 shutdown == true
,並跳出工作迴圈結束自己。
使用 pthread_join
等待所有 thread 都完成並返回。這一步確保 worker threads 不會在後面我們開始釋放記憶體的時候還在執行,避免 use-after-free。最後釋放掉 pool->threads
所使用的記憶體,並呼叫 pthread_mutex_destroy
、pthread_cond_destroy
釋放互斥鎖與條件變數。
在原本的 main.c 中,thread pool 的實作是 lock-based:使用 pthread_mutex_t
搭配 pthread_cond_t
來保護與同步任務佇列(linked list 實作),此設計雖然正確進行矩陣運算,但是在每次工作執行緒在取任務(worker_thread
)與主執行緒在新增任務(enqueue
)都需進入相同的臨界區。大量執行緒同時競爭此鎖會造成等待,導致效能缺失。以下程式為 main.c
中, enqueue
與 worker_thread
的設計,使用同一把鎖 pool->lock
保護佇列的增減操作::
參考 並行程式設計: Lock-Free Programming 這篇文章,可以使用 ring buffer 來避免 main thread 放入工作和 worker thread 取出任務的競爭問題,再透過 semaphore 取代 condition variable。
以下的程式碼使用一個 ring buffer 以及 semaphore 的方式實作出 thread pool:
下圖是比較 lock-based 以及 lock free 的 throughput,可以觀察到目前只使用一個 ring buffer 的方式,效能會比使用 lock-based 還差。
可以發現 lock-free 的效果更差了,雖然本實作採用 lock-free ring buffer,理論上減少了鎖競爭,但所有 worker threads 仍需原子性競爭同一個 head 變數,這會導致在多執行緒下產生嚴重的 cache line ping-pong 現象。一次只能有一個 worker 成功取得任務,其餘則進入休眠,造成 thread utilization 下降,反而讓 lock-free 版本效能不如 lock-based。
Cache line ping-pong 指多核心同時頻繁寫入同一塊 cache line(如 atomic 操作共享變數),導致該 cache line 在各核心間不停搬移,嚴重拖慢效能。這種現象屬於 cache false sharing 的極端情形。
為了解決這個問題,我實作了一個 worker 對應一個 queue 的改版設計,每個 thread 有自己獨立的 ring buffer 和 semaphore,任務透過 round-robin 分發,這樣 worker 只需操作自己的 queue,不會再有一堆 worker 搶一個共同變數的問題。
lockfree_rr.c
由下面的實驗發現 lockfree_rr
完成整個矩陣運算的任務所需的時間,比 lockfree
還高。
問題出在,任務是以 round-robin 方式均勻分派,導致即使某些 worker 剛進入休眠狀態(因為 queue 空了),下一筆任務卻很可能馬上又被分配給它,造成頻繁的睡眠 → 喚醒循環。
所以我加入一段 spin-before-sleep 的設計,先嘗試「忙等」(spinning)一段時間,以避免 thread 一有工作就睡、馬上又被喚醒。
由下面的實驗可以觀察到矩陣運所需所需時間從原本 0.5 掉到 0.4。但是跟 lockfree
沒有明顯優勢,還不是很好。
在前面的設計中,我們會讓沒有任務的 worker 進入睡眠,但這樣的做法有一個問題:有些任務的執行時間比較長,如果某些 worker 分到這些重任務,就會忙得要命;而其他沒有任務的 worker 卻早早進入睡眠,這樣就會造成負載不平衡,導致整體效率下降。為了解決這個問題,引入 work stealing 策略。以下的設計讓每個 worker 優先處理自己 queue 的任務,若無任務可取,則進入一段 spin + work stealing 階段,主動嘗試從其他 worker 的 queue 中竊取任務。只有當多次嘗試皆失敗後,才會進入 sleep 狀態等待喚醒,避免頻繁的休眠與喚醒造成效能瓶頸。
由下面實驗,可以觀察到有了 work stealing 原本矩陣運算的時間可以下降 0.02 sec,從這個設計開始 lockfree_rr 明顯優於 lockfree 的版本,但是還是輸 lock-based。
這問題出在,原本的 work stealing 設計中,worker 每次僅從其他 queue 中竊取一個任務,當任務不平均時,容易導致某些 thread 不斷嘗試偷任務、造成高頻率的 stealing 行為。為了減少竊取次數,我設計了 batch stealing 機制 steal_batch
,讓一個 thread 一次能偷取 STEAL_CHUNK
(這裡根據實驗設為 3)個任務,並暫存在steal_buf[]
中。worker thread 會依序執行 steal_buf
中的任務,直到消化完,再重新檢查自己 queue 或再次發動 stealing。
steal_batch
的具體設計是先讀取 head
與 tail
來計算剩餘任務數量,如果剩下的任務數量不夠 STEAL_CHUNK
,就直接返回 false,不執行偷取。如果有足夠的任務,會先透過 CAS 操作(atomic_compare_exchange_strong_explicit
)檢查是否有另外一個 worker 同時在偷同一個人的任務,如果成功,代表這段區域我搶到了,就可以安全地從 tasks[(head + k) % capacity]
中讀取任務,並且在複製的過程中要減少被偷的 worker 擁有的任務量 (sem_trywait
)。若 CAS 失敗,表示 head
已被其他 thread 偷走(或自己偷了),此時不 retry(因為無法保證是否還有足夠任務),而是直接去嘗試偷其他 worker 的任務。
根據下圖可以發現,當一次竊取數量為 3 時,throughput 最高。
由此實驗可以看到,經歷上述的這些調整,使得 lock-free-rr
在完成整個矩陣運算過程,可以比 lock-based 還更加有效率。
我用 perf 去觀察整個程式,耗費時間最多的地方在哪裡?可以看到在計算 mm_tile
是最花費時間。
回顧 lockfree_rr.c 的 mm_tile
可以發現,目前的 mm_tile
並不支援 SIMD 架構,還是取單一個值進行計算。
參考 0xff07 HackMD 筆記,我們可以將計算核心改寫為支援 SIMD 的架構。由於 MICRO_TILE = 8
,意味著我們的 micro-tile 為 8×8。這使我們可以利用 AVX 的 _m256 指令集,一次處理 8 個 float。結合手動 loop unrolling(展開 8 個 row),即可實作出一次更新 64 個浮點數的方式。
我們做的是:
計算公式為:
每一次 8*8 micro-tile 矩陣乘法的視覺化如下:
下面的計算方式可以觀察到,若要計算出 C 矩陣的第一列 (c00 ~ c07), a00 ~ a0k 會被重複使用 8 次,所以我們可以用 __m256 a0 = _mm256_set1_ps()
存 8 個一樣的 a 值,然後分別與 b00 ~ b70 去計算 k = 0 下 c00 ~ c07 的值,然後透過 for loop 將所有 k 計算的結果相加就是 c00 ~ c07 的值。 那其他列計算的方式也跟第一列一模一樣,所以我們可以透過手動 loop unrooling 一次計算 8 列,這樣的方式可以從過去計算單 1 個數值優化成一次計算 64 個數值,大幅增加效能。
看實驗成果,可以發現改成支援 SIMD 架構後, throughput 大副度地提昇。
main.c pthread_cond_broadcast(&pool->all_done)
的部分應該在呼叫之前要先取得對應 mutex lock,避免 lost wake-up 的問題。 chage.diff
Calling pthread_cond_signal() or pthread_cond_broadcast() when the thread does not hold the mutex lock associated with the condition can lead to lost wake-up bugs. Oracle Solaris Multithreaded Programming Guide. “Lost Wake-Up Problem”
The pthread_cond_broadcast() or pthread_cond_signal() functions may be called by a thread whether or not it currently owns the mutex that threads calling pthread_cond_wait() or pthread_cond_timedwait() have associated with the condition variable during their waits; however, if predictable scheduling behavior is required, then that mutex shall be locked by the thread calling pthread_cond_broadcast() or pthread_cond_signal(). — POSIX.1-2017, The Open Group Base Specification, Issue 7 (POSIX.1-2017).
包含延伸問題