讀書不用多,作詩不須工。海邊無事日日醉,夢魂不到蓬萊宮。
-- <送劉攽倅海陵>,蘇軾詩。
昨天看過整個概念之後,再來是如何導入的問題。我認為最大的障礙,也是我事後回想起來最有陰影的經驗,就是前提假設不同。這些研究先驅們,一開始就設定了是要訓練出能打的代理人為目標,但這個專案卻是從一個遊戲系統轉化而來。它們有什麼不同呢?且讓我引用參考書中的幾個範例片段,讓各位讀者有個感覺。
首先是節點的宣告,書中使用
def __init__(self, state, p):
self.state = state
self.p = p # 這是 PUCT 會用到的係數
self.w = 0
self.n = 0 # 昨日提過,w 和 n 是最主要要維護的值
self.child_nodes = None
這沒什麼特異之處。但是這個 self.state
,在使用上可是非常的活躍。如
self.state.next(action)
:只要帶入 action
,就能夠使用 next
方法取得下一個遊戲盤面。這和我以遊戲系統為主的架構大不相同!我必須要從客戶端傳送行動給予伺服器,交由它判定之後,才能夠得知下一個遊戲盤面是什麼。換言之,意義非常不同,next
是代理人自己推算後續盤面,但是我這裡,在代理人沒有概念遊戲會怎麼變化的情況,它相當於是只能和遊戲系統互動,然後真實地造成了遊戲狀態的改變,而沒有純粹自己推算這種功能。self.state.legal_actions()
:回傳這個盤面下的可行著手。這個,雖然我本來沒有規劃,但後來也在第八日當中描述了 QUERY 的做法,所以算是有替代方案,也不像 next
那樣困難。self.state.is_done()
和 is_lose()
:判斷局面是否已經結束、是否輸了。這個,在遊戲系統的架構下,你代理人就儘管下棋,你輸了我會告訴你。所以這個用法,相當於蒙地卡羅三條件之一的判斷,實作起來也不太直接。整個專案可以分成三個階段:遊戲系統實作(Rust)、代理人 AlphaZero 演算法實作(Python)、以及最後的訓練和迭代。其中每個階段都有噩夢。第二個階段的噩夢,就是拆解無狀態蒙地卡羅,融入我已經有的遊戲系統架構的過程。
最棘手的當然就是書中的 next
的功能。我的代理人的任何選擇必然會有附加效應加諸於環境,這是沒有辦法的。也許這就是一個無模型代理人的矛盾?它甚至沒有能力自己計算棋局的變化。然而說好的白板一塊(AlphaZero 的 tabula rasa 的宣稱),難道不是這樣嗎?如果一個代理人,可以實作一整套規則拿來當 library 呼叫,這難道算是白板一塊嗎?但其實在這裡仰天長嘯沒有用,我們寫程式的人必須解決問題。
由於實在不願意將這種遊戲本身的知識強行加諸代理人之上,所以我就必須創造一些新的機制讓這些成為可能。所以首先,我做的是 savepoint
與 undo
這兩個小小的組成元件。
savepoint
說起來也蠻無恥的,同上連結,直接在遊戲系統裡面安插一個真偽值,用來讓伺服器判斷,現在是不是有儲存某個狀態。這個狀態,不是只某個特定的標準回合,而是某個標準回合內的特定 Action
物件的狀態。對應到昨日提到的蒙地卡羅樹搜尋法,就是那個根節點的定錨。在圍棋或是其他經典棋類當中,你一手我一手是很均衡的配置。但是現代桌遊有 combo 的爽度需求,很少有一個行動就結束自己回合的,而本作也是這樣:一個標準回合有羅盤階段、棋盤階段與標記階段等最多可達 13 次子行動的決策,這每一個子行動都可以擔任根節點的角色。從該狀態,往後推算遊戲狀況,收集之後依序更新展開的遊戲樹當中的節點價值。這個 savepoint
值可說是**「我正在被模擬嗎?」**的別名。
不只系統核心,還有每一個標準回合的節點本身,也有一個對應的真偽值,如
pub fn save(&mut self) {
self.history.borrow_mut().savepoint = true;
self.savepoint = true;
}
從 self.history
這個整個遊戲物件對應到的最新遊戲紀錄節點,將之標記起來,這樣之後的 undo
行動才有一個可以找尋的標的。先就概念上描述,如果我們正在蒙地卡羅模擬中,碰觸到任何一個應該返回的條件(如終局或是遭遇到尚未展開子節點的節點),那就該能夠返回根節點去。這裡的這兩個真偽值標記,可以在標準回合的尺度下,確定該節點就是應當返還的地方。但是更細部的,也就是目前進行到哪一個子行動的階段?這就需要在當初呼叫這個 save
左右的時機點一併儲存下來。這發生在伺服器當中,
if !get_action(stream, input) {
return false;
}
if input[0] >= MIN_SPECIAL_CODE {
let possible_change = match input[0] {
QUERY_CODE => {
// nothing special to be done because the `return_query` is
// shared by all special codes now.
false
}
SAVE_CODE => {
g.save();
*saved = a.clone();
false
}
// Both reset functions can potentially alter the action phase.
// Keep looping inside this one doesn't make sense, so we have to return
// true, to re-route to an appropriate action.
RETURN_CODE => {
g.reset(false);
*a = saved.clone();
true
}
CLEAR_CODE => {
g.reset(true);
*a = saved.clone();
true
}
_ => false,
};
顧名思義即可,在 get_action
之後如果取得的不是一般行動,而是特殊行動碼(input[0] >= MIN_SPECIAL_CODE
),則分別做這些處理。其中在需要設定存檔點( SAVE_CODE
)的狀況,就會呼叫到那個 save
函數,而且也會將此時的 Action
物件存下來(*saved = a.clone();
)。在後續的兩種 reset
中,也都會有復原這個行動的操作(*a = saved.clone();
)。
讀者諸君不知有無聽過數年前的動畫「重啟咲良田」否?這個
save/reset
,概念上就是一樣東西。事實上這種複雜時間軸的玩意,也是比較戲劇化的蒙地卡羅模擬吧...
undo
undo
就是前述 reset
內的基本功能。reset
就是多個 undo
直到抵達存檔點的狀態。而 RETURN
和 CLEAR
的不同之處在於,回到根節點之後,是否清除掉存檔的狀態。跑這個模擬都會設定模擬次數,在達到那個次數之前都是 RETURN
,最後一次 CLEAR
之後,模擬狀態解消,代理人做出的選擇即是真正的選擇,然後遊戲進入新的狀態,代理人便可設之為新的根節點,再次啟動一輪新的模擬。
程式碼在遊戲系統中,
pub fn reset(&mut self, clear: bool) {
loop {
if self.history.borrow().savepoint {
break;
}
self.undo();
if self.phase == Phase::Main(1) {
// Otherwise, why are we reseting history???
// If in the future we can undo setup, it would be useful, but...
assert_eq!(self.history.borrow().savepoint, true);
break;
}
}
if clear {
self.history.borrow_mut().savepoint = false;
self.savepoint = false;
}
let _ = self.history.borrow_mut().children.pop();
}
}
說到 undo
函數本身,那可就非常複雜了(Wow,有 150 行,中間還有極富 Rust 氣魄的連續詠唱),因為逆著操作標準回合的內容,儘管影響範圍只有單一 Action
物件,還是需要參照對應的遊戲狀態才行。這裡就不深入了。
現在還在蒐集資料。第一次開始可以享有紀錄族譜的榮耀感。
現在的始祖是第十六世代的第二批訓練,使用純粹的殘局訓練出來;
再來是第十八世代的第一批訓練,都是從始祖的遊玩紀錄訓練出來,目前也都正以它來蒐集資料,預計用來訓練第十九世代。
說來有趣,昨天仔細想想,其實這整個強化迴圈的瓶頸在蒐集資料。縱使完全隨機對局可以在記憶體夠用的情況下使用斷線大法來加速數百倍,但一般遊戲對局總之還是得經過 pytorch 的模型推論,那些矩陣計算無論如何不太可能快成這樣。但有沒有可能 python 為基礎的代理人框架,太耗時了呢?
於是稍微做了一點量測,選擇的工具就是剛好昨天雷N大大的「D15 淺談 Go Tool Trace - 1」提到的火焰圖(flame graph)。
我使用的是,
$ perf record -F max -g python main.py -t Plague ...
$ perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
Well,其實看不出什麼東西。轉成 png 之後沒辦法看,但總之右邊那塊很深的,應該就是 torch 的計算 stack,最後呼叫到 mkl sgemm 之類,還算合理的東西。但最大塊的,不確定怎麼會牽扯到 libgomp
,這不是 GNU OpenMP 的函式庫嗎?整張圖丟進去,凡事問 ChatGPT,有哪裡還可以優化。
哇賽,這真的有夠瞎,翻找「對話紀錄」,發現我根本就忘了附圖給它,結果它還是給了一票答案...整個太雜了。就不貼出來了。
大致上是,如果 CPU 太吃重,應該考慮用 GPU 算更有效率;可以調整哪些環境變數來控制 OpenMP 和 MKL 的並行程度等等。但最後一點看到之後大吃一驚,它建議
Avoid Unnecessary Gradients: Ensure you’re not computing gradients during inference, which could accidentally trigger more intensive operations. Use:
with torch.no_grad():
outputs = model(inputs)
純推論不需要計算梯度,這常識我是知道,但就是沒想到應該要這樣操作。改了之後,
其實這個分佈乍看之下也沒差多少,但是時間少了 20%,實在是不無小補。已經改善於這個 patch 當中。
接下來呢?老樣子用遊戲紀錄訓練下個世代是可以的嗎?還是需要一定程度的經驗回放再拿一些殘局來用?需要調整網路架構嗎?會不會這個架構的天花板已經到了、註定就卡在 40% 上下的對隨機勝率?需要再調整什麼超參數嗎?
孟德爾種豆子的時候應該不需要這麼煩惱吧...