<!--  -->
# 2025q1 Homework 3 (kxo)
contributed by <[LambertWSJ](https://github.com/LambertWSJ)>
# 準備工作
## 合併 compilation database
[clangd](https://clangd.llvm.org/) 搭配 compilation database (`compile_commands.json`) 方便開發者追蹤原始碼,為方便書寫,本文 compilation database 改為 compilation DB。
通常 Kernel API 的說明在實作的註解比較容易找到,標頭檔比較少會寫,如果希望可以跳到 Kernel API 的實作而不是跳到標頭檔的宣告,可以 clone 和開發核心模組相同 Kernel 版本的原始碼,然後再編譯出 Kernel 的 compilation DB。
資列夾結構如下,不必將 linux 原始碼 clone 在 kxo 裡面,在其它路徑也可以,依個人程式碼管理習慣調整即可。
```
/home/lambert-wu/repos
├── kxo
│ └── compile_commands.json
└── linux
└── compile_commands.json
```
將 linux 的 compilation DB 複製到 kxo,再用 [jq](https://jqlang.org/manual/) 合併 linux 和 kxo 的 compilation DB
```shell
kxo$ cp compile_commands.json kxo_db.json
kxo$ cp ~/repos/linux/compile_commands.json linux_db.json
kxo$ jq -s 'add' ./linux_db.json kxo_db.json > compile_commands.json
```
最好要備份合併完的 compile_commands.json,否則 make clean 會把它刪除。
接著 restart clangd 應該會看到 VSCode 底下的 status bar 在建立索引(indexing)中

如果你的 linux 資料夾沒有 `.cache` 的話,才會看到有幾千個檔案在建立索引,否則 clangd 只會幫 kxo 建立索引,如果跳轉有問題的話,可以把 `.cache` 刪掉再 restart 一次
### issue
原本在 kxo 用 .clangd 設定兩個 fragments(`---` 區隔),但這樣會導致 User space 的程式也使用 linux 的 compilation DB,User space API 和 headers 會出現 not found 的錯誤,過去有參考 clangd 的 [issue](https://github.com/clangd/clangd/issues/1092) 設定 PathMatch 也沒試成功,暫時沒有設定 .clangd 的解法,剩下繞點彎路的用 jq 合併 compilation DB 的辦法。
這樣的 .clangd 會用 linux kernel 的 compilation DB 而不是當前 project 的。
```yaml
CompileFlags:
Add: [-xc++, -Wall]
---
CompileFlags:
CompilationDatabase: /home/lambert-wu/repos/linux
```
# 縮減核心模組和使用者的通訊成本
>[commit: 84ca1db](https://github.com/LambertWSJ/kxo/commit/84ca1dbec5c33c7132da5d8ea540096c721d3dae)
kxo 在模組處理好棋盤畫面 `draw_buffer`,再等待使用著讀取,但是這樣的通訊成本太大了,一次需要讀取 66 位元組,應改為讀取棋盤的狀態,再交由使用者繪製棋盤,為此要設計編碼來描述棋盤的狀態。
井字遊戲的棋盤的每一格 3 種狀態 $\to$ `O`, `X`, ` `(空格),可以用 2 個位元來描述這 3 種狀態,kxo 棋盤有 16 格,剛好可以用 32 位元的數值來儲存。
| 格子狀態 | 編碼 |
| -------- | ------ |
| 空格 | $00_2$ |
| `O` | $01_2$ |
| `X` | $10_2$ |
空格除了用 0 很直觀外,AI 演算法會計算對手勝率時,會用 xor 做切換棋手的操作,而任一數值對 0 做 xor 還是保持原值,這樣的 AI 不會考慮別人,只會想到自己。
原本想用 32 位元的整數拆成 2 個 16 位元來描述執 `O` 和 `X` 玩家的棋盤,1 表示自己下的棋子,0 表示沒有棋子,但是 0 不代表對手沒有落子在那一格上面,要額外用對手的棋盤狀態來判斷,才知道 0 是不是真的沒有棋子在上面,這樣的編碼會需要更多的 bitops,所以改用 2 位元描述每一格的狀態,更能夠直觀的得知棋局全貌。
編碼決定好之後,需要考慮下面的問題
- 存取棋盤 $\to$ 讀取/寫入格子
- 如何判斷勝利和對弈中
- xo-user 如何依據這套編碼繪製棋盤
- 如何讓 AI 玩家使用這套編碼
## 存取棋盤
```c
#define CELL_EMPTY 0u
#define CELL_O 1u
#define CELL_X 2u
#define CELL_D 3u
#define GEN_O_WINMASK(a, b, c) \
((CELL_O << (a * 2)) | (CELL_O << (b * 2)) | (CELL_O << (c * 2)))
#define TABLE_SET_CELL(table, pos, cell) (table |= (cell) << ((pos) * 2))
#define TABLE_GET_CELL(table, pos) \
({ \
__typeof__(pos) _pos = (pos) * 2; \
((table & (0b11 << _pos)) >> _pos); \
})
```
<!--
說明為什麼這樣設計 GET/SET,why __typeof__ instead of typeof
[Alternate Keywords (Using the GNU Compiler Collection (GCC))](https://gcc.gnu.org/onlinedocs/gcc/Alternate-Keywords.html#Alternate-Keywords)
[Clang Compiler User’s Manual — Clang 22.0.0git documentation](https://clang.llvm.org/docs/UsersManual.html#c-language-features)
-->
#### 繪製棋盤
將繪製棋盤的任務從 kernel module 交遊 user space 負責,kernel module 專注在對弈。`draw_board` 依據 table 上每一格的編碼用查表法轉換成對應的字元,寫入到 display_buf 裡面。
```c=
static int draw_board(unsigned int table)
{
char cell_tlb[] = {' ', 'O', 'X'};
unsigned int i = 0, k = 0;
display_buf[i++] = '\n';
display_buf[i++] = '\n';
while (i < DRAWBUFFER_SIZE) {
for (int j = 0; j < (BOARD_SIZE << 1) - 1 && k < N_GRIDS; j++) {
display_buf[i++] =
j & 1 ? '|' : cell_tlb[TABLE_GET_CELL(table, k++)];
}
display_buf[i++] = '\n';
for (int j = 0; j < (BOARD_SIZE << 1) - 1; j++) {
display_buf[i++] = '-';
}
display_buf[i++] = '\n';
}
return 0;
}
```
`while` 裡面將棋盤上的每一列寫入到 buffer 裡面。第 8 行在一開始先寫入格子的狀態,彼此用 `|` 區隔 (e.g. `O| | |X`),接著加入換行符號 `\n`,14 行的迴圈繪製底線 `-`,畫完之後再換行。
因此進入下一次 while 的判斷式之前,會寫入好一列的棋盤到 buffer,如下所示
```
O| | |X
--------
```
第 10 行,`TABLE_GET_CELL` 的 MACRO 傳入 k++,一般來說不建議傳入遞增/遞減運算子到巨集,因為很容易造成 double evaluation 的問題,針對這個問題,可以應用 [Linux 核心原始程式碼巨集: max, min](https://hackmd.io/@sysprog/linux-macro-minmax) 提到的 typeof 技巧避免這個問題。
```c
#define TABLE_GET_CELL(table, pos) \
({ \
__typeof__(pos) _pos = (pos) * 2; \
((table & (0b11 << _pos)) >> _pos); \
})
```
## 判斷勝利
這套編碼中,`O` 和 `X` 只差一個 bit,只要位移一個 bit 就會換成對手的旗子,換句話說,勝利棋局只要存 `O` 或 `X` 其中一組就能夠檢查是那個玩家勝利或是還在對弈中。
將所有 `O` 勝利的遮罩存入 `win_patterns`,檢查勝利時再用 `table` 和每一個勝利的 pattern 做 AND 操作,如果等於勝利的棋局,就表示贏了,只要將 pattern 左移 1 bit 就能檢查 `X` 是不是贏了,所有 pattern 都檢查完,沒有勝利的話表示還在對弈中,按原本的實作用 CELL_EMPTY 表示 `' '`。
```c
static uint32_t win_patterns[] = {
/* ROW */
GEN_O_WINMASK(0, 1, 2),
GEN_O_WINMASK(1, 2, 3),
GEN_O_WINMASK(4, 5, 6),
/* ... */
};
char check_win(unsigned int table)
{
int len = ARRAY_SIZE(win_patterns);
for (int i = 0; i < len; i++) {
unsigned int patt = win_patterns[i];
/* check O is win */
if ((table & patt) == patt)
return CELL_O;
/* check X is win */
patt <<= 1;
if ((table & patt) == patt)
return CELL_X;
}
for_each_empty_grid(i, table)
return CELL_EMPTY;
return CELL_D;
}
```
原本的實作沒有用到 `for_each_empty_grid`,於是改寫 MACRO 後,取代原本 for 迴圈的寫法。
```c
#define for_each_empty_grid(i, table) \
for (int i = 0; i < N_GRIDS; i++) \
if (TABLE_GET_CELL(table, i) == CELL_EMPTY)
```
### 改進
>Commit: [0596c8e](https://github.com/LambertWSJ/kxo/commit/0596c8e1aced514f8c4f488adf15219029cb5137)
#### 預先計算表格
<!-- TODO -->
將 `win_patterns` 寫死在表格是不良的慣例(bad practice),如果表格從 4x4 擴展到 10x10,程式碼會有更大一片區域都是勝利條件的遮罩,顯然該預先計算好所有 NxN 的 win patterns 再遊戲開始前計算好。
#### 簡化判斷勝利
table 改成數值後,如果是 0 就可以直接回傳 `CELL_EMPTY`,不用進到迴圈裡面判斷勝利條件,如果進到迴圈檢查出棋局還沒分出勝負的話,再用 `detect_empty_cell` 檢查棋盤上是否還有可以落子的空格。
```c
char check_win(unsigned int table) {
if (!table)
return CELL_EMPTY;
int len = ARRAY_SIZE(win_patterns);
for (int i = 0; i < len; i++) {
unsigned int patt = win_patterns[i];
/* check O is win */
if ((table & patt) == patt)
return CELL_O;
/* check X is win */
patt <<= 1;
if ((table & patt) == patt)
return CELL_X;
}
return detect_empty_cell(table) ? CELL_EMPTY : CELL_D;
}
```
`detect_empty_cell` 的實做如下,`lo` 抓出低位 `0b01`,`hi` 抓出高位 `0b10`,如果高位 `hi` 右移 1 個位元和 `lo` 做 or 運算不等於 `0x55555555` 表示棋盤還有空格。
```c
static int detect_empty_cell(unsigned int table) {
unsigned int lo = table & 0x55555555;
unsigned int hi = table & 0xAAAAAAAA;
return (lo | (hi >> 1)) != 0x55555555;
}
```
<!-- TODO: -->
<!-- 以 `table = 0x` 為例, -->
## 應用編碼到 AI 演算法實作
kxo 運用 [MCTS](https://en.wikipedia.org/wiki/Monte_Carlo_tree_search) 和 [Negamax](https://en.wikipedia.org/wiki/Negamax) 兩種 AI 演算法進行對奕,彼此共用一個 16 位元組的棋盤 `table`,改寫為 4 byte 的整數,要小心翼翼的把原本用字元 `'O'`, `'X'` 表示的棋子,還有存取陣列的操作都轉換為 bitops,一個不小心會變成一個 AI 在落子,另一個 AI 不下棋的情況。
改為用 4 位元組整數 table 傳進演算法裡面做運算,紀錄棋盤後,帶來下面的好處
- 優化複製和初始化的速度
MCTS 使用的 `simulate` 和 `mcts` 兩個函式會複製 table,原本的實作要呼叫 `memcpy` 複製整個棋盤,編碼因為是整數,只要指派就完成複製。
```diff
-static fixed_point_t simulate(const char *table, char player)
+static fixed_point_t simulate(uint32_t table, char player)
{
char current_player = player;
- char temp_table[N_GRIDS];
- memcpy(temp_table, table, N_GRIDS);
+ uint32_t temp_table = table;
/* ... */
}
```
- 降低函式呼叫成本
原本的 `table` 以指標傳遞,實際參數大小依架構而異,在 32 位元系統上為 4 byte,在 64 位元系統上為 8 byte。改用整數型別後,雖然在 32 位元架構下與指標大小相同,但在 64 位元架構下,可節省 4 byte 的參數傳遞空間,進一步提升呼叫效率。
### 優化 `get_score`
>Commit [8b5b16f](https://github.com/LambertWSJ/kxo/commit/8b5b16f780810c231e31955fb5d04e32b3c7eaf8)
<!-- TODO: -->
## 效能分析
這裡分兩部分測量,測量核心模組和使用者程式 `xo-user`,前者用 ftrace 測量,後者用 perf,各測量 60 秒
<!-- TODO explain -->
### 使用者程式
#### 編碼前
```
Performance counter stats for './xo-user -t 60' (3 runs):
279 context-switches ( +- 0.90% )
43,417,295 cpu_atom/cycles/ ( +- 0.96% ) (80.72%)
47,978,573 cpu_core/cycles/ ( +- 1.59% ) (19.28%)
15,568,811 cpu_atom/instructions/ # 0.36 insn per cycle ( +- 1.41% ) (80.72%)
20,383,916 cpu_core/instructions/ # 0.42 insn per cycle ( +- 0.94% ) (19.28%)
798,963 cpu_atom/cache-misses/ ( +- 2.54% ) (80.72%)
851,619 cpu_core/cache-misses/ ( +- 5.52% ) (19.28%)
64 page-faults ( +- 1.38% )
378 syscalls:sys_enter_read ( +- 0.54% )
60.002514 +- 0.000404 seconds time elapsed ( +- 0.00% )
```
#### 編碼後
```
Performance counter stats for './xo-user -t 60' (3 runs):
602 context-switches ( +- 0.35% )
80,425,744 cpu_atom/cycles/ ( +- 0.94% ) (94.11%)
109,147,313 cpu_core/cycles/ ( +- 2.67% ) (5.89%)
27,768,690 cpu_atom/instructions/ # 0.35 insn per cycle ( +- 0.28% ) (94.11%)
48,707,777 cpu_core/instructions/ # 0.45 insn per cycle ( +- 3.10% ) (5.89%)
1,392,524 cpu_atom/cache-misses/ ( +- 0.67% ) (94.11%)
1,509,690 cpu_core/cache-misses/ ( +- 3.28% ) (5.89%)
64 page-faults ( +- 1.04% )
581 syscalls:sys_enter_read
60.0996 +- 0.0768 seconds time elapsed ( +- 0.13% )
```
編碼後只需要 4 byte,核心複製資料到使用者空間更快,1 分鐘內呼叫的次數比編碼前高了 53%,但是也因為每次只讀了 4 byte,因此會更頻繁的像核心模組要資料,導致更多的 context switches,
### 核心模組
核心模組會測量編碼相關的函式,觀察改寫成棋盤編碼後能為效能帶來多少的改進。執行 `xo-user` 60 秒,在這期間用 ftrace 一次測量一個函式。
為了避免表格出現橫向卷軸,下表函式名稱做些簡化
ai_one_work_func $\to$ ai_one
ai_two_work_func $\to$ ai_two
drawboard_work_func $\to$ drawboard
| 函式名稱 | 版本 | 執行次數 | 平均執行時間 | 標準差 | 總耗時(us) |
| ------------------- | ------ | -------- | ------------ | --------- | ----------- |
| check_win | base | 208113 | 0.17 | 0.18 | 34422.99 |
| | encode | 226929 | 0.12 | 0.10 | 26836.84 |
| mcts | base | 110 | 365283.94 | 220413.31 | 40181233.66 |
| | encode | 131 | 268343.12 | 179813.17 | 35152948.75 |
| negamax | base | 135 | 1296.84 | 2222.73 | 175073.91 |
| | encode | 143 | 1036.69 | 1349.74 | 148246.36 |
| timer_handler | base | 576 | 28.00 | 27.08 | 16127.07 |
| | encode | 576 | 33.23 | 28.00 | 19140.98 |
| produce_board | base | 379 | 1.63 | 1.66 | 619.66 |
| | encode | 576 | 3.47 | 2.00 | 1995.95 |
| kxo_read | base | 369 | 162555.13 | 228511.56 | 59982843.62 |
| | encode | 577 | 103904.06 | 549.67 | 59952645.07 |
| ai_one | base | 112 | 359648.34 | 218211.86 | 40280613.83 |
| | encode | 132 | 264752.14 | 177650.80 | 34947282.25 |
| ai_two | base | 113 | 3262.96 | 3129.28 | 368714.91 |
| | encode | 129 | 2487.72 | 1735.59 | 320916.36 |
| drawboard | base | 338 | 111851.44 | 189695.43 | 37805788.28 |
| | encode | 537 | 20.52 | 15.24 | 11021.58 |
除了 `timer_handler` 這類定時觸發的函式之外,編碼版的執行次數都比原版的多,其它如平均執行時間幾乎都比原版好,只有 `timer_handler` 和 `produce_board` 的執行時間比原版久,這部份還在做實驗找原因中。
`drawboard_work_func` 編碼板和原版的執行時間差很多是因為編碼版不需要在裡面等待 mutex lock 釋放,用 ftrace 追蹤發現是在等待 `ai_one_work_func` 做完 `mcts` 並釋放 mutex lock, `drawboard_work_func` 才可以繼續執行 `draw_board`。
```diff
static void drawboard_work_func(struct work_struct *w) {
/* ... */
- mutex_lock(&producer_lock);
- draw_board(table);
- mutex_unlock(&producer_lock);
/* Store data to the kfifo buffer */
mutex_lock(&consumer_lock);
produce_board();
mutex_unlock(&consumer_lock);
wake_up_interruptible(&rx_wait);
}
```
可以用 grep 將 ftrace 的 log 提取出標記為 `@` 的執行時間,就能觀察出 `mcts` 是 `drawboard_work_func` 的瓶頸,原版要等到 `mcts` 執行完畢並釋放鎖才能讓其它函式有所進展,因為編碼版把繪製棋盤的責任交由使用者之後,`drawboard_work_func` 才不會因為等待 `producer_lock` 解鎖而卡住。
```shellscript
$ grep -n "@" ./trace
129: 7) @ 852999.3 us | } /* mcts [kxo] */
130: 7) @ 853036.5 us | } /* ai_one_work_func [kxo] */
135: 9) @ 852993.6 us | } /* mutex_lock */
136: 9) @ 853019.5 us | } /* drawboard_work_func [kxo] */
204: 9) @ 615072.3 us | } /* mcts [kxo] */
205: 9) @ 615102.0 us | } /* ai_one_work_func [kxo] */
206: 2) @ 615154.8 us | } /* mutex_lock */
207: 2) @ 615185.8 us | } /* drawboard_work_func [kxo] */
272: 2) @ 154949.5 us | } /* mcts [kxo] */
273: 2) @ 154975.9 us | } /* ai_one_work_func [kxo] */
278: 0) @ 154873.8 us | } /* mutex_lock */
279: 0) @ 154899.7 us | } /* drawboard_work_func [kxo] */
```
有了數據之後,接著計算改進幅度,平均執行時間, 標準差, 總耗時改善幅度計算方式
$$
improve = 100 \times \frac{base - encode}{base}
$$
執行次數則改為
$$
improve = 100 \times \frac{encode - base}{base}
$$
| 函式 | 執行次數 | 平均執行時間 | 標準差 | 總耗時 |
| -------------| -------- | ------------ | ------ | ------ |
| check_win | +9.04 | +29.41 | +44.44 | +22.04 |
| mcts | +19.09 | -26.54 | -18.42 | -12.51 |
| negamax | +5.93 | +20.06 | +39.28 | +15.32 |
| timer_handler | 0 | -18.68 | -3.4 | -18.69 |
| produce_board | +51.98 | -112.88 | -20.48 | -222.1 |
| kxo_read | +56.37 | +36.08 | +99.76 | +0.05 |
| ai_one | +17.86 | +26.39 | +18.59 | +13.24 |
| ai_two | +14.16 | +23.76 | +44.54 | +12.96 |
| drawboard | +58.88 | +100 | +100 | +100 |
# 多個 AI 對弈
>[commit: e65ab69](https://github.com/LambertWSJ/kxo/commit/e65ab699a4600eb0f072bfaa242aaad761a2fd5a)
參考 [BigMickey](https://hackmd.io/@BigMickey/linux2025-homework3) 的實作,將原版單一棋局對弈的實作,擴展到多個 AI 對弈。
為了後續實作不同功能,先定義好每場 AI 棋局所需的結構。
- `game.h` 中的 `xo_table` 結構用於存放棋盤資料與對應的棋局 ID,核心模組與使用者空間(xo-user)都會使用它,方便雙方都能清楚知道對應的是哪一局遊戲的棋盤。
- `ai_game.h` 中的 `ai_game` 結構則只在核心模組使用,將原本單局棋局的控制變數集中管理,包含輪到哪方、遊戲是否結束、記錄數據等,並整合與 AI 運算相關的 work_struct 與 mutex。
- 這樣的設計使得未來擴充到多場 AI 對奕時,程式碼更容易維護與改寫。
```c
/* game.h */
struct xo_table {
int id;
unsigned int table;
};
/* ai_game.h */
struct ai_game {
struct xo_table xo_tlb;
char turn;
u8 finish;
struct mutex lock;
struct work_struct ai_one_work;
struct work_struct ai_two_work;
struct work_struct drawboard_work;
};
```
## 標記未完成棋局並安排 tasklet 進行 AI 對弈
原版的 timer handler 會判斷棋局分出勝負的話,就將 `table` 存入 `kfifo` 等待 `xo-user` 取出來繪製棋局,否則會呼叫 `ai_game` 透過 `tasklet` 在對弈一次,玩家落子後就把 table 存入 `kfifo`。
同樣的設計擴展到多場井字遊戲的棋局的話,變成收集還沒分出勝負的棋局交由 tasklet 安排對弈,而分出勝負的棋局就存入 xo_table 到 kfifo 待取。
透過 bitops 能夠將上述的點子有效率的實作出來,檢查每場遊戲是否分出勝負,還沒結束的遊戲用 OR 運算紀錄第 `i` 場到變數 `unfini` 裡面,分出勝負的遊戲就把 `xo_tlb` 存入到 `kfifo`。
```c
static void timer_handler(struct timer_list *__timer)
{
char cell_tlb[] = {'O', 'X'};
unsigned int unfini = 0;
for (int i = 0; i < N_GAMES; i++) {
struct ai_game *game = &games[i];
struct xo_table *xo_tlb = &game->xo_tlb;
uint8_t win = check_win(xo_tlb->table);
if (win == CELL_EMPTY)
unfini |= (1u << i);
else {
pr_info("kxo: game-%d %c win!!!\n", xo_tlb->id, cell_tlb[win - 1]);
read_lock(&attr_obj.lock);
if (attr_obj.display == '1') {
int cpu = get_cpu();
pr_info("kxo: [CPU#%d] Drawing final board\n", cpu);
put_cpu();
/* Store data to the kfifo buffer */
mutex_lock(&game->lock);
produce_board(&game->xo_tlb);
mutex_unlock(&game->lock);
wake_up_interruptible(&rx_wait);
}
if (attr_obj.end == '0')
game->xo_tlb.table = 0;
read_unlock(&attr_obj.lock);
}
}
/* ... */
}
```
上述迴圈做完後,將還沒結束的遊戲 `unfini` 指派到 `game_tasklet.data` 作為 `game_tasklet_func` 的參數,接著呼叫 `ai_game` 對 `game_tasklet` 排程,觸發 softirq 時,會把要對奕的棋局推進 work queue 等待執行。
要判斷哪些遊戲已經結束,其實不需要額外宣告一個 fini 變數,然後在迴圈中像這樣 `fini |= (1u << i)` 逐一紀錄。
由於已經用 `unfini` 這個變數紀錄了「尚未結束」的遊戲,因此只要對 `unfini` 取反(使用 `~unfini`),就能得到「已經結束」的遊戲。
接著,再與 `GENMASK(N_GAMES - 1, 0)` 做 AND 運算,確保只保留第 0 到第 N_GAMES-1 場的有效位元,這樣就能正確取得所有已分出勝負的遊戲。
如果 kxo 還沒結束,而且有些遊戲已經分出勝負了,就安排下次 timer handler 觸發時間。
```c
static void timer_handler(struct timer_list *__timer) {
for (int i = 0; i < N_GAMES; i++) {
/* .... */
}
const unsigned int fini = ~unfini & GENMASK(N_GAMES - 1, 0);
if (unfini) {
WRITE_ONCE(game_tasklet.data, unfini);
ai_game();
mod_timer(&timer, jiffies + msecs_to_jiffies(delay));
} else if (attr_obj.end == '0' && fini)
mod_timer(&timer, jiffies + msecs_to_jiffies(delay));
}
```
## Game tasklet 驅動 AI 對弈
當 tasklet 被觸發就表示還有沒結束的遊戲,那些沒結束的遊戲已經在 timer handler 找出來存入到變數 `unfini`,並傳入到 tasklet 當作參數,可以用 32 位元的漢明權重 `hweight32` 得出沒結束的遊戲有幾個,再逐一對每個遊戲做下面的動作
1. 從 unfini 的低位開始,用 ffs 逐一取出遊戲的 ID
2. 從 unfini 移除這場遊戲,這場遊戲的 AI 要準備下棋了
3. 依據是否下完棋和現在換誰下,決定推入 $AI_1$ 或 $AI_2$ 到 work queue
4. 推入落子完的棋盤工作到 work queue,棋盤會推入到 kfifo 等待 xo-user 繪製
```c
static void game_tasklet_func(unsigned long unfini) {
/* ... */
int n = hweight32(unfini);
for (int i = 0; i < n; i++) {
int id = ffs(unfini) - 1;
unfini &= ~(1u << id);
struct ai_game *game = &games[id];
READ_ONCE(game->finish);
READ_ONCE(game->turn);
smp_rmb();
if (game->finish && game->turn == 'O') {
WRITE_ONCE(game->finish, 0);
smp_wmb();
queue_work(kxo_workqueue, &game->ai_one_work);
} else if (game->finish && game->turn == 'X') {
WRITE_ONCE(game->finish, 0);
smp_wmb();
queue_work(kxo_workqueue, &game->ai_two_work);
}
queue_work(kxo_workqueue, &game->drawboard_work);
}
/* ... */
}
```
TODO: 解釋上述用到 memory barrier API 的用途,為什麼這樣用?
## AI 落子
AI 落子的函式為 `ai_one_work_func` 和 `ai_two_work_func`,兩個函式實作的程式碼很相像,差別在於使用的演算法和棋子,這裡以 `ai_two_work_func` 舉例。
<!--
TODO: explain ai_two_work_func
get_cpu # preempt_disable
put_cpu # preempt_enable
-->
```c
static void ai_two_work_func(struct work_struct *w) {
int cpu;
struct ai_game *game = container_of(w, struct ai_game, ai_two_work);
struct xo_table *xo_tlb = &game->xo_tlb;
unsigned int table = xo_tlb->table;
WARN_ON_ONCE(in_softirq());
WARN_ON_ONCE(in_interrupt());
cpu = get_cpu();
mutex_lock(&game->lock);
int move;
WRITE_ONCE(move, negamax_predict(table, CELL_X).move);
smp_mb();
if (move != -1)
xo_tlb->table = VAL_SET_CELL(table, move, CELL_X);
WRITE_ONCE(game->turn, 'O');
WRITE_ONCE(game->finish, 1);
smp_wmb();
mutex_unlock(&game->lock);
put_cpu();
}
```
## module 沒寫好不只核心會 crash,重開機也會有問題
程式碼修到 `ai_one/two_work_func` 後,嘗試編譯並載入到核心執行,開始用 xo-user 觀看結果時,跳出一大堆 kernel panic 的錯誤訊息,看一下 kxo 安息的地方是執行 `__slab_free` 發生的
```shell
$ make
$ sudo rmmod kxo
$ sudo insmod kxo.ko
$ sudo ./xo-user
```
用另一個終端機下 `sudo dmesg -w` 觀察 kxo 的錯誤訊息
```log
[193899.779069] kxo: [CPU#1] game-3 ai_two_work_func completed in 1056 usec
[193899.779071] Workqueue: kxod ai_two_work_func [kxo]
[193899.779081] RIP: 0010:__slab_free+0x152/0x280
...
[193899.779113] Call Trace:
[193899.779115] <TASK>
[193899.779119] ? zobrist_put+0x7c/0xd0 [kxo]
[193899.779124] ? zobrist_clear+0x53/0x8d [kxo]
[193899.779128] kfree+0x2c6/0x360
[193899.779132] zobrist_clear+0x53/0x8d [kxo]
[193899.779136] negamax_predict+0x65/0x90 [kxo]
[193899.779140] ai_two_work_func+0x81/0x160 [kxo]
[193899.779179] </TASK>
```
此時 xo-user 是卡住的狀態,即便我按下數次 Ctrl+C 也關不了,只好用 `sudo kill -9 359{6,8,9}` 關掉 3 個 xo-user 行程(為什麼執行一次 xo-user 會有 3 個行程?),打開 htop 再檢查一次,還是沒有把 xo-user 完全關掉,程式的狀態為 D(uninterruptible sleep)。
```c
PID USER PRI NI VIRT RES SHR S CPU% MEM TIME Command(merged)
3596 root 20 0 0 0 0 D 0.0 0.30 0:00:00 xo-user
```
先不管 xo-user,嘗試移除 kxo 又卡住了,用 lsmod 發現 kxo 的 Used by 為 -1,這好像沒得挽救了,只剩下重開機的選擇,重開機又發生一件神奇的事情。
>我把 Ubuntu 24 裝在迷你電腦,kxo 和其它程式都在上面開發,要寫程式再透過 VSCode 的 SSH 連線過去。
>
靜待一段時間後,用 SSH 連不上 Ubuntu,再等一下好了,SSD 開機很快的,再連過去 N 次還是一樣的訊息
```
Connection closed by xxx.xxx.x.x port 22
```
把螢幕線接到迷你電腦來看怎麼一回事,原來卡在 reboot,因為還在 `kill xo-user`,慢慢等還是可以重開機的,但不知道會等多久,我選擇強制關機再重開,又可以順利連過去。
```log
systemd-shutdown[1]: Syncing filesystems and block devices.
systemd-shutdown[1]: Sending SIGTERM to remaining processes...
systemd-shutdown[1]: Received SIGTERM from PID 1 (systemd-shutdown).
workqueue: console: callbc hogged CPU for >1000us 5 times, consider switching to TQ_UNBOUND
systemd-shutdown[1]: Waiting for process: 3596 (xo-user)
systemd-shutdown[1]: Sending SIGKILL to PID 3596 (xo-user)
systemd-shutdown[1]: Process 4000 (plymouthd-fd-es) has been marked to be excluded from killing.
systemd-shutdown[1]: Waiting for process: 3596 (xo-user)
```
kernel module 寫不好,不只核心會 crash,連帶 user space 程式也會出問題,user space 程式關不掉又導致重開機有問題,每個錯誤環環相扣,最後變成整個系統不穩定。這就是為什麼寫 kernel module 要格外謹慎,錯一步就可能把整個系統拖下水。
## 修復 kernel panic 的問題
# 設計 TUI 顯示多場棋局
>繪製多個棋局和 logo $\to$ >Commit [235ff35](https://github.com/LambertWSJ/kxo/commit/235ff351e2e6c8c34efbdd75b81de3f03965888a)
核心模組已實作完多個 AI 對弈的畫面,為此設計了 [TUI](https://gist.github.com/LambertWSJ/3055b0cdc3469d45723b65932ebe5c3e) 讓使用者程式連續顯示多場遊戲,並研究 [auto-tetris](https://github.com/jserv/auto-tetris) 怎麼繪製 TUI 畫面,從中引入基本 TUI 繪圖用的函式,再加入一些 escape 控制字元來實作。
為了提升整體 TUI(文字使用者介面)的可讀性與視覺一致性,這份設計圖中使用 [Box-drawing characters](https://en.wikipedia.org/wiki/Box-drawing_characters) 來繪製 tic-tac-toe 棋盤與介面邊框,取代傳統的 +, -, | 等 ASCII 字元。
傳統字元在顯示多個井字遊戲的棋盤時容易產生大量空隙,看起來雜亂、不對齊。而 box-drawing characters(如 `─`, `│`, `┼` 等)則能提供更緊密、對齊清晰的表格線條,使畫面結構更集中、不鬆散,也更貼近圖形化的佈局效果。
## 從 auto-tetris 移植 TUI 函式
從 [auto-tetris](https://github.com/jserv/auto-tetris) 移植基礎的 TUI 繪製函式和 escape 控制字元到 kxo 來實作設計圖。
outbuf 為 4096 byte 大小的記憶體區塊,內部紀錄的是 `outbuf_printf`, `outbuf_write` 寫入的資料,當超過 `FLUSH_THRESHOLD` 的時候,會把寫入到 stdout 顯示 outbuf 的內容,如果需要即時顯示的話,可呼叫 `outbuf_flush` 將當前的 buffer 寫入到 stdout。
```c
#define ESC "\033"
#define CLEAR_SCREEN ESC "[2J" ESC "[1;1H"
#define HIDE_CURSOR ESC "[?25l"
#define SHOW_CURSOR ESC "[?25h"
#define RESET_COLOR ESC "[0m"
#define OUTBUF_SIZE 4096
#define FLUSH_THRESHOLD 2048 /* Flush when half-full for optimal latency */
#define RESET "\033[0m"
#define RED "\033[31m"
#define GREEN "\033[32m"
#define o_ch GREEN "O" RESET
#define x_ch RED "X" RESET
static struct {
char buf[OUTBUF_SIZE];
size_t len;
bool disabled; /* Emergency fallback when buffer management fails */
} outbuf = {0};
void outbuf_flush();
void outbuf_printf(const char *format, ...);
void outbuf_write(const char *data, size_t data_len);
void safe_write(int fd, const void *buf, size_t count);
void gotoxy(int x, int y)
```
為了方便繪製特定遊戲的棋局,新增儲存和恢復座標的控制字元,後續再繪製第 N 個 table 的時候,可以先跳過去把棋局繪製完成後再返回,避免繪製的位置跑掉
```c
#define SAVE_XY ESC "[s"
#define RESTORE_XY ESC "[u"
void save_xy() {
outbuf_write(SAVE_XY, strlen(SAVE_XY));
}
void restore_xy() {
outbuf_write(RESTORE_XY, strlen(RESTORE_XY));
}
```
在 TUI(文字使用者介面)中進行繪製的方式,某種程度上類似於 Web 前端使用 Canvas API:需要手動控制繪製起始座標、處理相對與絕對位置的移動,並管理座標的儲存與恢復。但與 Canvas 相比,TUI 顯得陽春許多。
TUI 並不處理複雜的動畫或幾何圖形,而是著重於透過文字、控制字元、Unicode(如 box-drawing characters)等方式,來結構化地呈現資訊。這樣的設計更適合用於資源有限的環境,或需要在純文字介面中提供直覺化操作與清晰視覺效果的應用。
## 繪製 logo
設計圖規劃的 KXO logo 可以上網搜尋 `text to ascii art` 找喜歡的字體輸出成 logo,我選用 Larry 3D 作為範例,把複製好的文字存成文字檔,再藉由 `lolcat -f` 把鮮豔的 color code 一同存到檔案裡面。
```shell
$ cat logo.txt | lolcat -f > logof.txt && cat ./logof.txt
```
後面補上的 `&& cat ./logof.txt` 是為了預覽 logo,接著執行 xo-user 時把 logo 載入後繪製出來
```c
int main(int argc, char *argv[]) {
/* ... */
char *logo = load_logo("logof.txt");
/* ... */
render_logo(logo);
/* ... */
}
```
因為要一行行的把 logo 印出來,strchr 用換行(`\n`)做字串分割,又因為 strchr 一定要先執行過才知道能不能找到 `\n`,所以用 do-while 迴圈而不是 for 迴圈。
`x = 38` 則是自己依照設計圖中 logo 的起始位置設定的,k 是換行用的變數,當繪製完就換行,避免所有的字元都擠在同一行。
```c
void render_logo(char *logo) {
char *beg = logo;
const char *pos;
int ln = '\n';
int k = 0;
int x = 38;
do {
pos = strchr(beg, ln);
k++;
gotoxy(x, k);
size_t len = pos - beg;
outbuf_write(beg, len);
beg += len + 1;
} while (pos);
gotoxy(x, k);
outbuf_flush();
}
```
當找到換行的位置 pos 後,將位置移動到 `gotoxy(x, k)`,x 是固定的,所以遞增 k 就好,這段要給 `outbuf_wrtie` 的字串長度為 `pos - beg`,寫入完後要更新開頭 `beg` 到換行的下個字元 `len + 1` 並檢查 `pos`,只要不是 NULL 就繼續從 beg 開始尋找換行並繪製 logo,重複這個過程直到 logo 繪製完成。
:::info
小訣竅: VSCode 反白一段文字後,視窗的右下角會顯示反白選中多少個字元。
以下圖為例,反白到繪製 logo 的起始位置,右下角顯示`(38 selected)`,Ln 367 則是當前行數,這樣就可以知道要繪製的 X 和 Y 的位置,**千萬不要用方向鍵慢慢默數到你要的位置**。

:::
## 繪製多場遊戲
因為有多個棋盤要繪製,暫時先用不精準的詞彙來區分 `BOARD` 和 `TABLE`,兩者以下圖來呈現
`BOARD` $\to$ 用來區分每一個棋盤的分界線,顯示遊戲的 ID、棋局、用什麼演算法對弈。
`TABLE` $\to$ 4x4 的棋盤,顯示當前的棋局,下圖中 `+2` 表示 row 落子要遞增的長度,反之亦然,`+4` 表示 col
下圖標註了邊長和棋盤從左上角為起點,每一格 x, y 遞增的量
fig.`BT_design`
```
(BOARD)
├─────────────── 35 ──────────────┤
┌─────────────────────────────────┐ ─┬─ (TABLE)
│ 15 Game-1 13 │ ─┬─ │ 17
│ ┌───┬───┬───┬───┐ │ │ │ ┌───┬───┬───┬───┐
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ ├───┼───┼───┼───┤ │ │ │ ├───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ 9 ├───┼───┼───┼───┤ 8 │ 11 13 +2 ├───┼───┼───┼───┤ 9
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ ├───┼───┼───┼───┤ │ │ │ ├───┼───┼───┼───┤
│ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └───┴───┴───┴───┘ │ │ │ └───┴───┴───┴───┘
│ 12 mcts vs nega 10 │ ─┴─ │ +4
└─────────────────────────────────┘ ─┴─
```
TODO: 解釋很多行的 `render_boards_temp` 實作
使用 `render_boards_temp` 繪製出來,因為是固定的,所以只繪製一次,不會進到 while 迴圈裡重複繪製。
```c
int main(int argc, char *argv[]) {
/* ... */
render_logo(logo);
render_boards_temp(N_GAMES);
tui_init();
while (!end_attr) {
/* ... */
}
}
```
分隔線繪製完成後,接著要繪製棋盤,要先從核心模組取得棋局,才能夠在使用者空間繪製出來,當讀到棋局 `xo_tlb`,則需要依據 ID 將開始繪製的位置 gotoxy 過去,當繪製完成後再恢復到原本的開始繪製位置,避免之後都是從 gotoxy 的位置開始繪製,導致 TUI 畫出一堆亂碼
```c
int main(int argc, char *argv[]) {
/* ... */
while (!end_attr) {
/* ... */
if (FD_ISSET(STDIN_FILENO, &readset)) {
/* ... */
} else if (read_attr && FD_ISSET(device_fd, &readset)) {
FD_CLR(device_fd, &readset);
read(device_fd, &xo_tlb, sizeof(struct xo_table));
save_xy();
update_table(&xo_tlb);
restore_xy();
}
/* ... */
}
}
```
繪製棋局的第一步要把 ID 轉換到 BOARD 開頭的位置,才能對照 fig. `BT_design` 標註的邊長和位移量開始繪圖。
```c
#define UI_COLS 3
#define BOARD_W 35
#define BOARD_H 13
#define BOARD_BASEY 10
void update_table(const struct xo_table *xo_tlb) {
const char *cell_tlb[] = {" ", o_ch, x_ch};
int id = xo_tlb->id;
unsigned int table = xo_tlb->table;
int y = BOARD_BASEY + (id / UI_COLS) * (BOARD_H - 1);
int x = (id % UI_COLS) * BOARD_W + 1;
gotoxy(x + 15, y + 2);
outbuf_printf("Geme-%d\n", id);
}
```
第 n 個棋局起始 x, y 位置
x $\to$ 第 n 個 col 乘上 BOARD 寬度
y $\to$ BOARD_BASEY(logo 的下一行) + 第 n 個 row 乘上 BOARD 高度
有了 Game ID 的起始位置,只要對照上圖 fig. `BT_design` 把 x, y 加上位移就能輕鬆繪製期局和資訊了
### 顯示遊戲 ID
```c
void update_table(const struct xo_table *xo_tlb) {
/* ... */
gotoxy(x + 15, y + 2);
outbuf_printf("Geme-%d\n", id);
/* ... */
}
```
- 繪製 AI 玩家用的演算法
目前還沒實作動態切換演算法,所以都先固定用原本 player1 和 player2 用的演算法
```c
void update_table(const struct xo_table *xo_tlb) {
/* ... */
gotoxy(x + 12, y + 12);
outbuf_printf("MCTS vs NEGA\n");
/* ... */
}
```
### 繪製棋盤
```c
void update_table(const struct xo_table *xo_tlb) {
/* ... */
int tlb_x = x + 9;
int tlb_y = y + 3;
const int tlb_w = 17;
const int tlb_h = 9;
for (int i = 0; i < tlb_h; i++) {
gotoxy(tlb_x, tlb_y + i);
bool last_h = i + 1 == tlb_h;
for (int j = 0; j < tlb_w; j++) {
bool last_w = j + 1 == tlb_w;
if (i & 1) {
/* odd row */
if (DIVBY(j, 4))
outbuf_write("│", BOXCH_LEN);
else
outbuf_write(" ", 1);
} else {
/* even row */
if (j == 0) {
if (i == 0)
outbuf_write("┌", BOXCH_LEN);
else if (last_h)
outbuf_write("└", BOXCH_LEN);
else
outbuf_write("├", BOXCH_LEN);
} else if (i == 0 && last_w) {
outbuf_write("┐", BOXCH_LEN);
} else if (DIVBY(j, 4)) {
if (i == 0)
outbuf_write("┬", BOXCH_LEN);
else if (i != tlb_h - 1 && !last_w)
outbuf_write("┼", BOXCH_LEN);
else if (last_h && !last_w)
outbuf_write("┴", BOXCH_LEN);
else if (last_h && last_w)
outbuf_write("┘", BOXCH_LEN);
else
outbuf_write("┤", BOXCH_LEN);
} else
outbuf_write("─", BOXCH_LEN);
}
}
}
/* ... */
}
```
### 繪製棋局
棋盤繪製完成後,再用 bitops 把 table 上的每一個棋子依序繪製到棋盤上。
按照上圖 `BT_design` 的標註,x, y 的位移量分別為 2 和 4,只要將
```c
void update_table(const struct xo_table *xo_tlb) {
/* ... */
const char *cell_tlb[] = {" ", o_ch, x_ch};
int tlb_x = x + 9;
int tlb_y = y + 3;
const int cell_x = tlb_x + 2;
const int cell_y = tlb_y + 1;
const int stepx = 4;
const int stepy = 2;
for (int i = 0; i < N_GRIDS; i++) {
const int pos_x = cell_x + (i & (BOARD_SIZE - 1)) * stepx;
const int pos_y = cell_y + (i / BOARD_SIZE) * stepy;
gotoxy(pos_x, pos_y);
outbuf_printf("%s", cell_tlb[TABLE_GET_CELL(table, i)]);
}
/* ... */
}
```
初步把設計圖棋盤繪製的部分刻出來了,展示效果如下

### 顯示下棋紀錄
>Commit [7ab469a](https://github.com/LambertWSJ/kxo/commit/7ab469a430eccb136e12cf6d0057d822c0136b9d)
為了要充分利用已有的程式碼,而且要盡量降低核心模組和使用者的通訊成本,可以將 xo_table 的結構做些調整。
- 把 `id` 改成 attr 可以在多存一些資訊在變數裡面,未來可以依據需求擴充。這裡我將 `[8:4]` 定義為遊戲的總下期數 `step`
- 新增無號 64 位元整數 `moves`,從低位元開始,每 4 個位元用來紀錄旗子在棋盤上的位置
```diff
struct xo_table {
- int id;
+ int attr;
unsigned int table;
+ unsigned long moves;
};
```
### 繪製分頁
>Commit: [10aef25](https://github.com/LambertWSJ/kxo/commit/10aef2516a97931decfd9e605f293b787f45ef88)
# Load average
# 動態切換 AI 演算法
> commit [c1c3f32](https://github.com/LambertWSJ/kxo/commit/c1c3f32c37d411f88d7cbd273f1e7bc063488851)
<!-- # 從 ttt 移植 Reinforcement Learning -->
<!-- # 引入若干 Coroutine 顯示多場遊戲 -->