tsaiiuo
在減少 lock 依賴那邊,可以量化新增過後的效能影響嗎?
原本的標題寫
kxo 如何減少 lock 依賴並支援多使用者
,但實際內文主要是探討如何調整並行機制以支援多使用者,標題可能導致誤解,已修改。至於效能影響,由於這邊探討的是「如何支援多使用者」這個原本不存在的功能要如何實現,追求的並非效能提昇,直接將其與原本的單使用者實作比較並不合理,因此這邊無法提供量化的效能影響。 weiso131
HeLunWu0317
請問關於update_state_value()與原公式 TD-learning的不同,會不會可能是這個 state 更新是需要轉移視角的,和 negamax 原理類似,減號是要作為視點轉換, next 會變成對弈中的另一方視角。
你說的沒錯,稍早發的 issue 也收到類似回覆,確實是我少考慮了這個部份。 weiso131
Hande1004
關於通訊方法討論這部分想請問雖然把棋盤所需的 byte 數降低至 4 bytes,但大多情況 kernel 端傳給 user 端是用一個 page 為單位,也就是就算降低棋盤的 byte 數,傳輸的大小成本依然一樣,那請問實際上有提高甚麼樣的效率或真正節省到哪部分嗎?
我嘗試利用 bootlin 追蹤
linux v6.15.4
中 x86 (大部分人電腦的處理器應該都是 x86 架構)的copy_to_user
實作,發現實際上進行複製的函式為copy_user_generic
,其中,若存在 FSRM feature ,會使用rep_movs
這個 x86 指令,根據felixcloutier.com
的描述,它會一次複製一個 byte 。關於「 kernel 端傳給 user 端是用一個 page 為單位」這件事情,我沒有找到相關資料佐證,嘗試追蹤copy_to_user
的實作也發現它在 x86 架構上並非一次複製一個 page ,希望同學能提出相關的參考資料來說明。
至於降低棋盤 bytes 數是否有效率提昇或真正節省到哪部分?效率提昇的部份目前缺乏量化分析,然而,儘管經過測試後發現它真的能帶來效能提昇,對使用者而言可能不明顯,因為目前最大的效能瓶頸是 MCTS 演算法。對於我所使用的「用 1 bytes 傳遞步數的方法」,除了能減少傳遞的 bytes ,這個方法也直接的提供使用者下棋的先後順序,不用做額外的處理,這讓使用者維護棋盤歷史資料的成本降低。weiso131
以第三份作業為基礎、搭配課堂討論和觀摩學員的成果,提出 kxo 的改進方案並落實,至少該涵蓋以下:
核心通訊界面的 GET_USER_ID
Userspace
結構原本 xo-user.c
與核心模組互動的變數被四散在各處,使用一個結構體將其整合,為之後要支援的參數調整演算法
與使用者端的多使用者功能
做準備,具體功能如下
mod_control
與 user_control
兩個函式,讓核心模組的 read, write 操作更直觀,同時切換 userspace 中 turn 的狀態get_permission
取得當前使用者Todo:
update_state_value
實作如下展開會變成
然而,在 xo 屬於 minimax 遊戲,應該盡可能降低對手的得分,一開始沒考慮到這件事情,誤以為它是 bug 。
移除多餘函式
game.h
中的 check_win
不再需要drawboard
已由 xo-user 的函式維護,因此可以移除userspace.h
已經提供 random_get_move
供 get_action_epsilon_greedy
使用,這也導致 get_available_moves
不再需要將 char table[16]
改成使用 uint32
的 board
Userspace
中的 board
使用 user_control
實現與核心的 write 互動
最後訓練工作在 xo-train.c
進行, make
之後執行 ./xo-train
即可開始訓練
某次開兩個終端機與 kxo 模組互動,兩邊都用到 NEGAMAX
,結果核心就發生了 Segment fault , 觀察 negamax 有大量全域變數使用,推測可能是因為錯誤的寫入發生問題
history_score_sum
與 history_count
MAX_SEARCH_DEPTCH
之前都是用來更新 history_score_sum 與 history_count)zobrist_get
搜尋這個盤面是否已經被計算過,用來節省計算available_move
進行排序 (history_score_sum
和 history_count
用在這裡)available_move
搜尋negamax
遞迴, negamax
需要最小化對方得分並最大化我方得分,因此加上負號history_score_sum
, history_count
, best_move
zobrist_clear()
釋放 hlist_node
觀察上述過程可以發現,實際上這個演算法用到的所有全域變數(hash_value
, history_score_sum
, history_count
, zobrist_table
, hash_table
)都只是拿來暫存資料,結束之後就會還原成最初的樣子,可以將其移動至 stack
或是,放在 heap
,結束時再釋放
消除全域變數依賴,使 negamax 支援多使用者同時執行。
引入 context 結構體 negamax_context_t 封裝所有需要的狀態。
使用者自行管理 context 生命周期,記憶體由使用者分配與釋放。
確保遞迴過程內部使用的狀態皆封裝在 stack 或 heap 中,保證 thread-safe。
核心函數說明
negamax_context_t *ctx
傳遞
所有函數(如 negamax
, negamax_sort
, zobrist_get
等)都改為顯式接受 context
指標 ctx
,完全移除任何全域狀態存取。
negamax_sort
使用歷史評分來排序 move,以加快剪枝效率。評分資料從 ctx->history_score_sum
中取得,避免干涉他人執行。
negamax
主遞迴搜尋函式,採用 alpha-beta
剪枝 + zobrist
快取。所有表格與歷史資訊皆由 ctx
提供與還原。
有兩種方法可以有效的傳遞棋盤資訊
'o', 'x', ' '
),有 16 個位子就會有 種組合,需要使用 4 bytes 來傳遞一步棋的資料顯然可以用更少的 bytes 做資料傳遞,但是需要使用者維護過去的棋盤資訊。不過,如果能假設使用者會自己維護歷史資料,在實現 ctrl + q
結束並印出歷史資料的功能就會變得非常簡單,核心模組的 namespace 不需要額外維護歷史資料,也不再需要使用 sysfs (這導致沒辦法將模組變成全域可讀可寫),因此最後選擇傳遞一步棋的資訊
考慮多使用者的情形,雖然可藉由 task_id 區分不同 task 的要求
但考慮一個 task 多次開啟 kxo ,仍需要傳遞使用者 id 來區分身份
此時
read 所需的 buffer 大小就是 max(id 所需 bit, 64)
write (使用者端下棋) 所需的 buffer 大小就是 id 所需 bit + 64
將原本以 printf("%s", display_buf)
輸出整段字串的作法,改為使用 display_board()
函數解析單一位元編碼的棋步資訊並以棋盤形式顯示。此改動的主要目的是為了減少使用者空間與核心空間之間的通訊成本,僅傳遞一個位元編碼即可描述一次落子動作,並在使用者空間進行畫面重建,同時結合歷史記錄模組進行棋局狀態更新。
display_board(char table) 的輸入參數 table 為一個 8-bit 資料,透過位元操作取得如下資訊:
(table & 0xF)
表示落子位置(0~15)
(table >> 4)
& 1 表示玩家(1 為 X,0 為 O)
(table >> 5)
若為 1 表示重設棋盤(新一局開始)
棋盤狀態由 board 這個 32-bit 變數管理,其中高 16 位記錄格子是否有棋子,低 16 位記錄該棋子為 X 或 O。顯示邏輯使用雙層迴圈輸出 4x4 棋盤,每一格以 ASCII 字元顯示對應棋子,並加上適當分隔符號與橫線來模擬棋盤外觀。
在傳入帶有重設旗標的落子資料時,會清空棋盤並透過 history_new_table() 建立新的歷史節點。此方式能夠減少核心回傳資料的長度,也將顯示 邏輯移至使用者空間。
history 模組用來記錄使用者的操作過程,例如下棋或輸入指令。實作上,使用一個 linked list(鏈結串列)來串接多個 history 節點,每個節點負責記錄一段操作序列。
一開始會建立一個初始節點,作為整段歷史的開頭與目前記錄的對象。每次操作都會被壓縮成 4 bits,依序儲存在節點的 value 欄位中,並用 count 記錄目前已存幾筆。
當一個節點記錄的數量達上限(因為 value 是 64 位元,一次最多存 16 筆),或者邏輯上需要分段時,系統會建立新的節點接到串列後面,並移動 current 指標指向它。若整體節點數超過最大限制(128),會釋放最舊的節點以控制記憶體使用。
列印歷史時,會從最早的節點開始,逐筆解碼其中的操作並輸出。釋放歷史時,則會一路釋放整條 linked list,避免記憶體洩漏。
由於棋盤歷史紀錄已交由使用者端儲存,只要持續執行 read
操作, ctrl+P
暫停的功能可以透過控制是否繼續繪製新的棋盤來實現,不需要 attr_obj.display
來決定是否傳送新的棋盤。而 ctrl+Q
的功能其實可以單純的結束迴圈並關閉 kxo ,核心不需要針對 ctrl+Q
進行額外的處理,因此 attr_obj.end
也沒有保留的必要。至於 attr_obj.resume
原本在 main.c
就沒有被使用。
此外,在 [PATCH] sysfs: tightened sysfs permission checks 中, sysfs 預設不開放 other 的寫入權限,這沒辦法單純透過調整核心模組程式碼來更改,這對於核心模組而言可能是不必要的限制。
考慮上述的情況,應該將 kxo_state 移除。
Todo
用一個 byte 儲存權限資料
前 4 個 bit 代表 player2 要使用的權限
前 4 個 bit 代表 player1 要使用的權限
若權限設為 USER_CTL
,輪到對應的玩家時,就能使用 write 操作,但不能使用 read 操作。 設成其他權限時,會使用對應的演算法,此時不能使用 write 操作但可以使用 read 操作。 相關的定義在 kxo_ioctl.h
。
EFAULT
USER_CTL
權限的玩家,會回傳 -EPERM
縮減使用者和核心空間通訊的成本
EFAULT
-EPERM
-EPERM
read
操作相同在我原本的 kxo 專案,為了讓不同的使用者觀察到同一個盤面,且在任一使用者按下 Ctrl + Q 時同步輸出紀錄,移動紀錄是被存放在核心的。
然而,現在的情況是考慮多個使用者獨立的狀態,而移動紀錄也只會被存取一次,那將移動紀錄存放在核心空間就顯得不必要,而且移動到使用者端會有以下優點:
誰下了哪一步棋
此時核心模組的主要功能就是裁判,詳細功能如下:
第一個功能考慮到之後需要做到 人工智慧程式可指定運作於使用者/核心層級
從使用者傳過來的移動不能保證是合法的,因此有驗證的必要
第二個功能是原本就存在的功能
第三、四個功能會獨立出來討論
最直觀的想法
ioctl
讓使用者決定一些資訊,同時回傳 user_id
給使用者如果有攻擊者反覆呼叫 ioctl ,這個系統會很容易被灌爆記憶體?
跟 rota1001
討論
我提出可能可以藉由得知 task id 來作為 ban 使用者的依據
他也認為這個解法可能可行,只要能夠得知哪個 process 建立了一堆 child process
但他也提出這個問題應該是可以不要考慮的,因為 kxo 的使用者想搞事,消耗的也是自己的資源
後來想想,若在這個前提下能拿到 task id 等等跟 task struct
綁在一起的參數,就不用另外生成核心模組的 uid
然而,在 5/20 與老師下課的討論時,問到作業要求引入 corutine 的意義,他希望我們可以利用 corutine 做到在同個畫面中顯示多個棋局,在此情況下,一個 thread 就可能有多個 user ,然而一個 thread 就只有一個 task id ,在此情況下仍要引入其他機制來辨識使用者身份。
原先的 work_function, tasklet_function, time_handler 的設計並沒有考慮不同使用者的問題,要讓它們能夠正確執行,必須進行修改。
原先的 work_function
分成 ai1
, ai2
, drawboard
並且在全域存取 table
與 turn
要處理多使用者必須將棋盤相關資訊放在該使用者對應的結構
然而, work_func 沒辦法藉由 queue_work 的時候取得參數
觀察 work_func 的格式
它會傳遞 work_struct *
,也就是 work item 作為參數
因此只要把 work_struct
放進 user_data
,就能在 work_func
內利用 container_of
取得 user_data
的位址
user_data
內的 work_struct
可利用 INIT_WORK
進行初始化
觀察原本的 ai_one_work_func
與 ai_two_work_func
的程式可發現,它們的差異只有使用的 AI 算法和棋子的圖案相反,剩下的實作邏輯完全一樣
考慮之後需要動態切換 ai 演算法,實作一個通用的 ai_work_func 然後存取 user_data
當下的資訊決定輪到誰比較合理
至於 drawboard
這個函式,其原本的功能是繪製出用來顯示的棋盤,並放入 kfifo ,然而,在往後的修改中,需要放入 kfifo 的資訊只有下了哪一步棋 ,如果能跟 ai_work_func
放在一起就能直接取得結果,不用額外的 buffer 存放。
drawboard
還有另外一個重要的功能,就是 wake_up_interruptible(&rx_wait);
使用者在 read
的時候,若 kfifo 內沒有東西,會進入睡眠狀態,直到 wake_up_interruptible(&rx_wait);
被呼叫,此時再嘗試取得 kfifo 的內容。
在多個使用者的情況下,若共用一個 rx_wait
會導致多個 read
被頻繁喚醒,但只有部份的 read
能真正結束等待,比較合理的作法是讓 user_data
也保有一個 rx_wait
原本的 time_handler 負責的是確認棋盤時否分出勝負
來決定要重置棋盤,還是呼叫 tasklet_function 將 work_item 放進 workqueue
然而,判斷是否分出勝負以及重製棋盤這兩個功能,其實可以在 work_func
完成,若保留在 time_handler 還會有兩個缺點
在有多個 cpu 的情況下, time_interrupt 與 work 可能發生在不同 cpu ,導致 racing 嗎?
因此,原本的 time_handler 的功能只有呼叫 tasklet_function 會被保留
原本的 tasklet 會需要一個 finish ,確保下棋完成
否則可能會出現連下兩步的情況
然而,只有一種 work_item
則沒有這個問題
由於 tasklet 物件不會被重複排列的特性,可以確保系統不需要反覆的存取所有 work 並且嘗試放進 workqueue
為了方便存取,可以利用 hlist
將所有 user_data
串起來
注意用語: access 是「存取」,而非「訪問」(visit)
若現在輪到使用者下棋,則不排入 work_queue
除此之外,在多使用者的情況下,它還需要做到回收使用者資料的功能
在原本的程式中,只有單個使用者的情況下 release ,可以無腦的把 work_queue 清空
然而,在多使用者的情況下不能這麼做,而且需要釋放不再使用的使用者
若需要釋放的使用者已經不在 work_queue 中,可以直接將它釋放
反之,則等待它完成工作,此時就與上述情況相同
這會需要一個變數紀錄它是否已經不再使用
然而,在實際考慮到 work_queue API
之後,發現直接在 tasklet_function
內直接進行 release
是不可行的。
work_queue
提供 cancel_work_sync
,可確保在這個函式回傳之後,可以安全的釋放 work_item
,然而,這個函式可能會進入睡眠, tasklet function 是不允許睡眠的。
因此,應該要將 release 的工作放在其他地方,可藉由將需要 release 的 user_data 放入一個 trash_list ,再藉由其他方法釋放記憶體
由於 UserData
會被 TidData
管理,在使用 delete_tid_data
時很有可能有些 UserData
的 work
剛好還在 worl_pool 中執行,即使使用 cancel_work_sync
仍然需要等待它執行完畢,若很不幸的遇到執行很久的 MCTS
,會導致 delete_tid_data
很久才能完成,因此先將其標記成 unuse
,之後搭配其他功能延後釋放是更好的選擇
doc
workqueue.h
workqueue_type.h
由 struct work_struct work;
是 workqueue
安排任務的結構體,可藉由 INIT_WORK(&user_data->work, work_func);
初始化,並指定要執行的任務
work
藉由 queue_work
來將任務安排進 workqueue
, 而 queue_work
能確保同個 work
不會在所有 workqueue
中出現兩次,為了能充分使用 workqueue
的能力,每個 user 有自己的 work_item 才能確保 workqueue
可以盡力處理多個 user 的要求。
此外,由於 work function 被定義成 void (*work_func_t)(struct work_struct *work);
,只有 work
作為參數傳遞
為了更新 user 的棋盤狀態與玩家切換,將 work_struct
定義在 UserData
中可以利用 contain_of
取得結構體的地址,這就能取得與 user
相關的所有資訊並進行更新。
藉由 DECLARE_KFIFO_PTR
定義, API 使用參考 FIFO Buffer
DECLARE_KFIFO_PTR(fifo)
會定義一個 kfifo 指標 fifo
kfifo_alloc
, kfifo_free
, kfifo_in
, kfifo_to_user
原始碼註釋與文件上都表示要傳送 fifo 的指標
然而原本的 kxo 實際使用時卻加上取址操作(&
),而這樣會正確執行,有可能是文件有問題?
關於為什麼會選擇讓每個使用者擁有自己的 kfifo 。一個很明顯的好處是可以減少 lock 的使用, kfifo
雖然提供 kfifo_out_peek
可以先判斷 kfifo 的第一項是不是當前使用者在等待的資料,再決定要不要拿出來。 但 kfifo
只保證 SPSC 的情況下是 lock free 的,這種情況得加上 lock。
前面提到 unuse
的功能,那要如何在 TidData
消失後仍可以存取到 UserData
?
此時可以利用 include/linux/list.h
中的 hlist
來維護鍊結串列,利用 tasklet function 定期檢查
根據前面的討論, namespace 需要區分不同的 tid ,同個 tid 也會有多個不同的使用者,某個 tid 使用 close 時,需要將對應 tid 之下的所有 user_data 釋放。
若將 tid 與 user_id 以某種方式相加並取得一個 id ,沒辦法直接反應 tid 與 user_id 的親子關係,在新增或釋放 user_data 時,需要額外的資料結構維護,因此存在一個結構,紀錄某個 tid 之下有哪些 user 會是比較好的選擇。
為了 O(1) 取得 tid 對應的結構,使用陣列是個好選擇,但是直接開一個 pid_max
大小的陣列顯得很浪費,使用 hash table 能更有效的節省空間。 當 hash table 發生碰撞時,可以使用 hlist 處理
namespace 使用 hlist 需要讀寫鎖, add, del 需要 write_lock ,存取只需要 read_lock ,為了避免一次阻塞大量的 thread ,可以每一個 hash_table 的 hlist_head 搭配一個 rw_lock
完全使用 tasklet 來管理 userdata 該在 user_list 還是 trash_list
建立與刪除使用者
使用爆搜取得使用者 id ,可以用 CAS 來避免 lock
使用 queue 維護 user_id ,會需要一個 lock 來避免衝突
kxo_namespace
定義在 kxo_namespace.c
內,main.c
透過函式與 struct kxo_namespace kxo_namespace[NAMESPACE_MAX];
互動write_lock
鎖住對應的 kxo_namespace
vmalloc
失敗回傳 -1
,否則回傳 0
TidData
之下的所有 UserData
使用 atomic_set
設成 unuse
,需要使用 atomic
操作是因為 unuse
可能被 user_list_queue_work
存取kxo_namespace
的讀寫鎖用 read_lock
鎖住USER_MAX
目前為 32
,逐個檢查是否未使用並不會花太久cmpxchg
) 嘗試檢查與交換,若到最後都失敗則回傳 -1user_data->hlist
加入 user_list_head
時使用 spin_lock
保護,避免 user_list_queue_work
同時存取user_id
回傳對應 UserData
user_id
是否超過 USER_MAX
queuework
user
是 unuse
時,不會立刻刪除,會將它放到 trash_list_head
,之後透過回收機制再真正回收,原因參考核心模組並行討論這只能在 rmmod
時被呼叫,目的是真正清空 user_list 與 trash_list 上所有 uesr_data 動態配置的記憶體,在未來可以考慮加入定期清理的函式,但這個函式能確保記憶體被徹底清空,始終有存在的必要
在維護 kxo_namespace
與 user_list_head
時,我使用到了 lock ,即使 lock 住的內容都是鍊結串列操作,由於相關函式主要都被使用在核心模組通訊界面,這是會被排程的(可以從能夠使用 current 這個 task_struct 得知),雖然現代排程器不太可能讓一個任務飢餓太久,但探討 lock-free 我們不能考慮排程器的優化,這個前提下,如果一個執行緒搶到 lock 之後馬上被排程,然後陷入無盡的等待,整個系統將完全不會有進展(kxo_namespace
要剛好所有 user 都發生碰撞,假設一個 user 沒發生碰撞,那它會有進展)
至於這件事情是否會對效能造成很大的影響?
kxo_namespace
由於不會一次鎖住全部,降低了因為其中的 rwlock
鎖住整個 kxo_namespace
的風險
而 user_list_head
critical section 的操作甚至只包含了單純的 hlist_add_head
與 hlist_entry_safe
,不會讓 critical section 持續太久
因此,即時它沒作到 lock free ,考慮現代排程器會維護公平性,實際上目前架構不會對系統造成非常大的影響,但仍有改進的空間
可以發現,這邊用到 lock 的原因都跟 hlist 脫不了關係,由於 hlist 要同時維護 next
跟 prev
,很難讓刪改操作是 atomic 的
考量這邊的使用情境,是否可以自訂 linklist 結構,使其操作 lock free?
參考 lock free programing 案例分析
目前沒想到
參考: 並行程式設計: Lock-Free Programming
hlist 會維護一個 prev 來方便刪除操作,然而,為了達到 lock-free ,沒辦法保留 prev ,因為 next, prev 的插入與移除皆需要 CAS
來維護,以確保左節點與右節點相鄰,維護兩個指標可能會導致 next 成功,但在 prev 未成功時被存取導致錯誤,無法確保 atomic 。
文中維護的是一個有大小順序的鍊結串列,使用 insert
需要先 search
,考慮 kxo_namespace 使用情境, add_head
就夠用了,讓操作能夠 O(1)
考慮 kxo_namespace 使用情境, remove 時能夠保證能先知道目標的位址
利用記憶體對齊的特性,可以確保不會發生位址的最後一個 bit 被使用到的狀況 (因為都是 4 的倍數 + 必須對齊),因此可以將最後 1 bit 用來標示是否被 remove
需要利用 CAS 確保 mark 的指標正確
這個 remove 必須在遍歷時使用,為了避免要刪除的節點仍被存取時釋放,造成 segment fault ,因此先使用 mark_remove
標記,在這個節點下次被存取時,才真的改變 last->next
達成 remove 。
為了確保刪除時左節點沒有被刪除,需要用 CAS 檢查它是否還存在。因為這個原因, remove 時沒辦法馬上將其釋放,然而除了系統結束的時候,沒有其他時間點能保證此時釋放不會出問題(釋放的節點不會是其他要刪除節點的左節點)。
為了達成 remove
,當 last
為 head
時需要做額外檢查,需要利用 CAS
保證 head
真正指向要刪除的節點,才能將其刪除,否則放棄這次的刪除操作。
考慮目前使用 lock 的情況
user_list_head
此處只有 add_head
會發生競爭, remove
的部份只有 user_list_queue_work
會用到, remove
時能夠保證不會有其他執行緒要存取目標節點,之後可以搭配回收機制在系統尚未結束時釋放記憶體,因此這個情況適合使用 lock-free 鍊結串列。
回收機制
目前還未真正實現,但情況跟 user_list_head 差不多,執行回收時只要先用 CAS 檢查並把 head 的 next 設成 NULL , 再把後面的鍊結串列整個釋放掉就結束了
kxo_namespace
由於可能有多個執行緒進行 remove
,沒辦法在系統結束前釋放記憶體,若系統長時間運行會累積大量無用的記憶體。原本的作法由於只會某個 index,只要確保 hash_function 分佈足夠分散,實務上很難真的阻塞整個系統,已經達到接近 lock-free 的效能,因此這個結構不使用 lock-free 鍊結串列維護
WQ_CPU_INTENSIVE
的影響經過實測,不會影響排程策略跟 nice 值,但跑 mcts 的時候電腦變得不卡了,根據 doc 的描述,它會讓這個 workqueue 的 work 不會阻礙同個 work pool 其他 work 的執行,這讓那些需要快速回應的任務(例如:滑鼠事件)反應變快,使用者就比較不會感受到電腦變卡的問題
runnable CPU intensive work items will not prevent other work items in the same worker-pool from starting execution
mcts
,一個使用者兩方都是 negamax
chmod +x trace_ai_func.sh
user_list_queue_work
的功能放進 work_struct
,內部反覆執行 user_list_queue_work
與睡眠一段時間
待驗證:
worker pool
會傾向讓 cpu 有事情做就好,放在 work 比較不會發生 ai_work_func
的搶佔user_list_queue_work
work_struct
放進有 WQ_MEM_RECLAIM
的 flag 的 workqueue