結果:Stockfish 分析流程從單一網頁伺服器上的每秒 4.00 個局面,提升到雲端 CPU 工作節點上穩定的每秒 47-60 個局面。這次部署在 Stockfish 18、深度 18、MultiPV 3 的契約下完成了 251,166 次局面評估。

目前的生產環境設定是批次大小 32max_containers=100、每個局面 300s 上限,以及緩衝寫回。早期的直接寫回執行估算每個局面約 $0.0000200;目前 cpu=0.25 的緩衝路徑估算更接近 $0.0000053

有趣的部分並不是證明 Stockfish 可以平行執行。一個西洋棋局面本來就是一個獨立的引擎工作單位。困難的地方在於找到那個能同時控制成本、讓失敗可復原、並讓深度語意保持準確的運作點。

這裡的實作用 Railway 作為控制平面、用 Modal 執行 Stockfish 工作節點,但可重複利用的部分是工作負載模型、量測方法與失敗限制條件。

可移轉的心得:不要一開始就把工作節點數量最大化。先定義引擎契約、重試單位、寫回契約與量測帳本,然後才提高並行度。

這最適用於高量的離線分析:大量獨立局面、嚴格的品質要求,而且工作量大到讓重試和原始速度一樣重要。對於延遲佔主導地位的單局互動式分析,相關性較低。

實測的生產環境設定:

設定 數值
引擎 Stockfish 18
深度 18
MultiPV 3
工作節點 CPU 0.25
批次大小 32
最大容器數 100
每局面上限 300s
寫回模式 緩衝
大規模執行吞吐量 每秒 47-60 個局面
估算成本 每個完成局面約 $0.0000053

決策紀錄:

決策 目前數值
工作單位 position_key,不是對局 id
品質契約 要求 Stockfish 18、深度 18、MultiPV 3
目前設定 批次 32100 個容器、300s 上限、緩衝寫回
為何選這個點 成本有上限、長尾可重試、清理可靠,且每筆結果都記錄實際達到的深度
何時重新檢視 供應商上限改變、快取命中率上升、寫回排空成為主導因素,或長尾局面主導實際耗時

工作負載定義

面向使用者的物件是一局棋。運算的物件是一個局面。

系統匯入對局、抽出局面、以 position_key 去除重複、評估缺少的局面,等到前後局面都齊備後,再具體化每一著的評估列。

PGN games
  -> positions
  -> unique position keys
  -> Stockfish queue
  -> position eval cache
  -> move-level eval rows
  -> reports, drills, prep, review

生產環境契約是 Stockfish 18、要求深度 18、MultiPV 3,以 position_key 排入佇列,SQLite 作為真實來源,雲端 CPU 容器作為工作節點池。

評估契約是資料模型的一部分。一筆深度 18、MultiPV 3 的列,不能和來自不同引擎、深度或 MultiPV 形狀的公開快取列互換。系統會儲存來源資訊,好讓未來的程式碼能區分自行運算的列、快取命中,以及更深的參考資料。

方法

多數表格使用精簡的執行標籤。25g100g500g 表示由那麼多局對局準備出的工作量。b32 表示批次大小 32c50 表示工作節點上限 50t300 表示每個局面 300s 上限。

量測使用三種標籤:實測用於工作節點或帳本的紀錄,預估用於提交前的成本估算,模型推估用於改變 CPU 配額或寫回形狀之後的運作模型。

吞吐量數字依各次執行所記錄的內容,採用提交的實際耗時或伺服器端批次跨距。這些數字足以比較不同運作點,但不足以宣稱是通用的 Stockfish 基準。

來源工作負載是我自己的歷史西洋棋棋譜庫。生產資料庫記錄了工作、批次、佇列狀態、具體化的評估數量、工作節點實際耗時、寫回時間與成本估算。僅用於分析效能的深度實驗走的是同一條工作節點路徑,但不會把結果寫入生產環境的評估表。

有一個計數上的注意事項:基準狀態列與最終的工作負載計數不是同一個單位。基準那一行是舊的按使用者回填路徑中的階段計數器。最終的 251,166 這個數字包含了 position key 去重、前後局面評估、重新運算的工作、重試,以及著法評估的重複利用。請把它當成最終被分析的工作負載規模,而不是對局數或著法數。

我不會把原始實驗表格作為可下載的檔案公開。這篇文章包含了支撐論點所需的實測資料列;完整的操作帳本留在專案筆記裡。

基準

基準:每秒 4.00 個局面,執行途中仍可看到 9.1h 的預估剩餘時間。

原本的路徑是在 Railway 的網頁執行環境裡跑 Stockfish。這對小量匯入來說簡單且足夠正確,但對大型棋譜庫來說形狀不對。

即時狀態列看起來像這樣:

stockfish phase: 6350/137250 (cache=6043, errors=0) 4.00/s ETA 9.1h

那一行是準確的。它也顯示了天花板。更大的網頁機器會線性提升吞吐量,但工作仍然綁在一個長時間執行的程序、一個部署生命週期與一台機器上。

這個批次工作負載需要由工作節點容量限制的平行度、範圍限定在單一批次或局面的失敗、每個工作的成本上限,以及持久化的佇列狀態。第一個擴展目標是有足夠狀態可供復原的實測吞吐量。

控制平面

更多工作節點需要一本帳本。帳本就是主要的基礎設施。

控制平面使用五張主要的表:

  • analysis_jobs:父層請求、提交者、目的、引擎契約、成本上限、彙總狀態。
  • analysis_job_batches:工作節點分片狀態、接受數、具體化數、工作節點實際耗時、寫回時間。
  • analysis_queue:position key 狀態與正規化後的評估結果。
  • analysis_requests:誰為了什麼原因要求哪個局面。
  • evals:供報表與訓練介面使用的著法層級具體化列。

工作節點不會自行去找工作。控制平面認領 position key、建立工作與批次列,然後把這些已認領的 key 送給工作節點池。資料庫仍是擁有權、狀態、上限、重試與具體化的真實來源。

父層工作的計數器最終改由標準批次列推導,因為寫回實際上是至少一次語意:客戶端重試可能再次提交同一個已完成批次的內容。

SELECT
  SUM(accepted) AS accepted,
  SUM(materialized) AS materialized,
  SUM(CASE WHEN status != 'completed' THEN 1 ELSE 0 END) AS open_batches
FROM analysis_job_batches
WHERE job_id = ?

如果重複的回呼需要人工稽核,那個吞吐量數字就不可信。

吞吐量結果

穩定的躍進大約是單機基準的 12-15x

可靠的吞吐量提升來自同時改變好幾個變數:批次大小、容器上限、每個局面的時間上限、資料庫查詢形狀,以及回呼的冪等性。

請把這當成一道調校階梯,而不是單純的工作節點數量基準測試。

執行形狀 接受數 批次數 實際耗時/跨距 吞吐量 預估成本
25g b32 c25 3,006 94 206.344s 每秒 14.57 個局面 $0.06012
100g b32 c50 12,701 397 365.205s 每秒 34.78 個局面 $0.25402
100g b32 c100 t300 10,282 322 216.439s 每秒 47.50 個局面 $0.20564
500g b32 c100 t300 45,429 1,420 752.324s 每秒 60.39 個局面 $0.90858

最後幾個大規模區塊都落在同一個區間:

工作 接受數 跨距 吞吐量
30 45,429 752.324s 每秒 60.39 個局面
31 38,248 ~663.0s 每秒 57.69 個局面
32 32,857 ~599.0s 每秒 54.85 個局面
33 27,650 ~562.0s 每秒 49.20 個局面
34 22,371 ~444.0s 每秒 50.39 個局面
35 15,951 ~326.0s 每秒 48.93 個局面
36 3,403 ~72.0s 每秒 47.26 個局面

生產環境設定是能讓失敗仍可診斷、清理仍然可靠的最高並行度配置。內部執行帳本另外記錄了批次數、具體化的評估數、寫回時間與成本估算。

Stockfish 吞吐量演進 吞吐量只有在周邊的佇列、寫回與重試路徑能承受生產規模的失敗之後才改善。

批次大小

批次大小就是重試的粒度。較大的批次減少排程與回呼開銷,但會讓每一次長尾失敗更難也更貴地隔離出來。

這次部署直接看到了這一點。一次 25g b64 c25 的執行完成了 44/45 個批次、接受了 2,767 個局面,之後有一個批次未通過深度檢查。後來的長尾調查把失敗從 32 個局面縮到 8 個,再縮到單一局面。

最終的批次大小 32 是折衷。批次大小 1 對診斷最好,但對大規模調度來說太貴。批次大小 8-16 是不錯的重試形狀。批次大小 64+ 減少批次數量,但讓長尾隔離更糟。重試工具可以把失敗的工作切到單一局面,這比擠出一點排程開銷更重要。

經驗法則:選擇你能輕鬆重試的最大批次大小。

工作節點數量

工作節點數量是在控制平面能吸收回呼速率之後才有幫助。

25 個容器躍升到 50 個的效果很大:一次乾淨的 50 容器驗證達到每秒 34.78 個局面。100 容器搭配 300s 上限的執行,在 100 局的區塊上達到每秒 47.50 個局面,而第一個 500 局區塊達到每秒 60.39 個局面。

實測的運作點目前受供應商工作區容量與單一寫回目標所限制。當數百個工作節點幾乎同時完成時,工作節點直接回呼網頁端並不是好的長期寫入路徑。緩衝分片把突發流量移到 Modal Volume,然後由伺服器在一個受控階段排空結果。

這表示 max_containers 並不是唯一的天花板。超過某個點之後,下一個瓶頸會變成排程、分片處理、排空速率,或資料庫具體化。

cpu=0.25 這個設定之所以可行,是因為工作負載有大量獨立局面,而正確性由達到深度的驗證來保證。在長尾局面或供應商限制成為瓶頸之前,較便宜的工作節點加上足夠的平行度,是比少量大型工作節點更好的預設選擇。

成本

成本上的勝利來自工作節點形狀與寫回形狀,而不是降低分析標準。直接寫回的執行落在每個提交局面約 $0.0000200。較新的排程執行器使用 cpu=0.25 的成本比例與緩衝寫回,約為舊估算的四分之一。

階段 範例 局面數 預估成本 每局面成本
大規模直接寫回部署 工作 30 45,429 $0.90858 $0.0000200
緩衝 cpu=0.25 路徑 工作 63 235 $0.001175 $0.0000050

這段實驗期間的 Modal 儀表板顯示總資源支出 $17.43:CPU $16.69、記憶體 $0.74。這包含失敗的試探、效能分析、重試、深度實驗,以及生產部署的工作。它適合回答「這次探索花了多少錢」,而不是最終運作點的邊際成本。

紀律在於提交給工作節點之前就強制套用成本上限,並把估算值寫進工作帳本。

要把三個數字分開看:提交前的預估成本、執行後供應商的實際帳單,以及在重試與快取重用之後每個被接受局面的成本。它們回答的是不同的問題。

正確性限制

引擎契約需要的不只是 depth=18

深度不是實際耗時的預算。只指定深度的 Stockfish 呼叫可能一直跑到外層平台逾時把它殺掉。生產環境的工作節點同時需要一個要求深度與一個每局面的時間上限。

工作節點也需要正確的引擎生命週期邊界。在不改變 game= token 的情況下,讓同一個 Stockfish 程序跨越彼此無關的 FEN 重複使用,會讓 python-chess 把整個分片視為一局棋。它不會在無關局面之間送出 ucinewgame。有一個工作節點分片在 1800s 撞上平台逾時;本機重放全部 64 個局面約 91.8s 就完成。改成變動的 game= token 並送出 ucinewgame 後,那個有問題的局面在一秒內就達到 [18,18,18]

這個邊界的修正很小:

info = engine.analyse(
    board,
    chess.engine.Limit(depth=18, time=300),
    multipv=3,
    game=position_key,
)

重點不是那個包裝函式的具體寫法,而是彼此無關的局面需要彼此無關的引擎對局身分。

病態局面

這次部署記錄了實際的局面,而不只是彙總的失敗數。重要的案例分成兩類。

第一,有些局面對我們實際消費的契約來說過於嚴格。本節的棋盤都以行棋方視角呈現。

MultiPV 長尾局面的棋盤
MultiPV 長尾。 黑方行棋,黑方在下。最佳路線達到深度 18;替代路線停在 17.
Magnus 真正病態長尾局面的棋盤
Magnus 長尾。 黑方行棋,黑方在下。嚴格重試只達到 [15,15,14];以低深度後備方式接受。
低深度後備案例一的棋盤
後備 1。 白方行棋,白方在下。停在 [17,17,17];已儲存 result_depth=17.
低深度後備案例二的棋盤
後備 2。 黑方行棋,黑方在下。停在 [17];已儲存 result_multipv=0/3.
  • MultiPV 長尾。32 -> 8 -> 1 重試失敗批次,隔離出這個六子的后兵殘局。Stockfish 回傳深度 [18, 18, 17]:最佳路線達到深度 18,而第三條 PV 仍淺一著。被接受的那一列記錄最佳著法 f3d5、評估值 -8115cprequested_multipv=3result_multipv=2
    FEN:8/5p2/3Kp1p1/8/8/5q2/5k2/8 b - -
  • Magnus 真正病態長尾。120s180s 的嚴格重試仍只回傳深度 [15, 15, 14]。最後的非嚴格後備儲存了 result_depth=15、最佳著法 b7a7、評估值 -933cp,以及明確的低深度來源記錄。
    FEN:1r4k1/1q3pb1/3p2p1/2p1p1Pp/2P1N3/1p2B2P/1P1QBP2/K5RR b - -
  • 低深度後備 1。 嚴格執行在 60.013s 後停止,深度為 [17, 17, 17]。後備列記錄 result_depth=17result_multipv=2/3、最佳著法 h3g4、評估值 -847cp,以及 90,863,715 個節點。
    FEN:b2r4/2QP2k1/p6p/1p2P1p1/2pP1P2/7K/PPB3P1/4q3 w - -
  • 低深度後備 2。 嚴格執行在 60.010s 後停止,深度為 [17]。後備列記錄 result_depth=17result_multipv=0/3、最佳著法 g7h8、評估值 +761cp,以及 89,579,084 個節點。
    FEN:r4r2/pb2bpk1/1p1qpn2/6Q1/1n1P4/2NB1N1P/PP3PP1/R3R1K1 b - -

第二,至少有一個局面本身不壞,只是在最初的上限下太慢:

真正需要 120 秒的長尾局面棋盤
慢但合理的長尾。 黑方行棋,黑方在下。最初的上限太低;同一個局面達到 [18,18,18] ,在 300s.
  • 真正的 120s 長尾。120s 下,一次單局面重試回傳最佳路線深度 [17, 17, 18],而相鄰的 31/32 次重試都很快完成。改用 300s 上限後,完全相同的局面在 140.171s 達到 [18, 18, 18]
    FEN:r5k1/1r6/p1p5/3pB1Q1/P1nPp2p/2P1P3/5PPP/6K1 b - -

這些例子說明了為什麼系統要儲存實際的結果深度與實際的 MultiPV 數量,而不是假裝每一筆被接受的列都滿足深度 18 與 MultiPV 3

這次部署也記錄了幾種失敗形態:

案例 徵兆 解法
引擎生命週期 一個分片撞上 1800s 平台逾時;本機重放 64 個局面只花 91.8s 讓無關的 FEN 擁有不同的 game= 身分,好讓 ucinewgame 被送出
MultiPV 深度檢查 32 -> 8 -> 1 的重試切分隔離出一個合法局面,其替代 PV 路線較淺 驗證實際被消費的最佳路線,並儲存 result_multipv
真正的慢速長尾 31/32 次單局面重試很快完成;一個超過 120s,但在 300s 上限下以 140.171s 完成 為大規模契約保留較高的每局面上限
低深度後備 被隔離出的合法局面在後備上限下仍無法滿足全部深度/MultiPV 目標 只在具備明確 result_depthresult_multipv 來源記錄時才接受
深度成本長尾 在樣本上,深度 36/MultiPV 1 比深度 18/MultiPV 341.44x 不要把深度重算當成每次快取未命中的預設路徑

正確性規則

最終的正確性規則:

  • 每個無關局面都送出一個新對局邊界;
  • 使用每局面的時間上限;
  • 驗證被消費的最佳路線已達到要求深度;
  • 儲存 requested_multipvresult_multipv
  • 從批次列推導父層計數器;
  • 把已完成的批次列視為最終結果。

最主要的非顯而易見心得:當引擎跑在批次工作節點裡時,引擎協定的語意就變成了生產環境的正確性問題。

如果一個基準測試只量測最終吞吐量、忽略實際達到的深度,它可能看起來很成功,卻悄悄讓分析品質下降。

深度與品質的取捨

更高的深度是成本乘數,不是免費的品質旋鈕。

一份僅用於效能分析的 10 個局面樣本比較了幾種契約:

引擎契約 工作節點實際耗時 最慢的局面 相對於深度 18/MultiPV 3
深度 18、MultiPV 1 5.301s 0.571s 0.51x
深度 18、MultiPV 3 10.329s 2.442s 1.00x
深度 24、MultiPV 1 24.861s 5.728s 2.41x
深度 24、MultiPV 3 56.252s 13.428s 5.45x
深度 30、MultiPV 1 87.375s 16.263s 8.46x
深度 36、MultiPV 1 428.071s 106.782s 41.44x

這讓生產環境的選擇很清楚:快取未命中時維持 Stockfish 18、深度 18、MultiPV 3;當來源資訊符合產品需求時,採用更深的公開快取命中;不要把深度 30+36 當成每個個人對局長尾局面的預設重算目標。

深度 18 並非普遍地「足夠」。它是在為成本曲線定價之後,為這個工作負載選定的預設運算契約。

深度成本曲線 更深的分析可能有用,但它改變成本形狀的幅度是倍數,不是百分比。

快取行為

本機的 position key 重用很有價值。公開快取試探還沒準備好內嵌在大規模準備流程中。

在大規模準備期間,本機快取的讀取穿透在雲端運算之前就具體化了數千筆著法評估。這應該保持啟用。它是決定性的、本機的,而且綁定同一份局面/評估契約。

公開的 Lichess/Stockpile 類快取試探在初期測試中有兩個問題:前 50 個抽樣局面命中數為 0,而後來的合理性檢查遇到速率限制行為。這意味著公開 API 試探不能阻塞大規模生產準備流程。

目前的政策:

  • 使用本機/全域局面快取的讀取穿透;
  • 對大規模執行,讓公開快取試探受限或關閉;
  • 在再次嘗試之前,加入漸進式、持久化、具備退避機制的試探;
  • 儲存來源、引擎版本、深度、MultiPV、取得時間狀態與資料集中介資料;
  • 不要把私有的深度 18 列視為可與更深的公開列互換。

快取在其契約相符時才有用。它不能取代一條為快取未命中準備的受限路徑。

產品規則:快取命中是帶有來源資訊的運算結果。如果來源、引擎、深度、MultiPV 或延遲不符合產品契約,就把快取當成可選的加速手段。

這份基準測試的限制

這些數字是有用的運作資料,不是通用的 Stockfish 基準。

工作負載是一份個人西洋棋棋譜庫。引擎契約固定在 Stockfish 18、深度 18、MultiPV 3。實作使用 Railway 加 SQLite 作為控制平面,以及在這次部署期間帳號可用額度下的 Modal CPU 容器。不同的深度、MultiPV 數量、硬體、局面分佈、快取命中率與供應商限制都會改變結果。

可移轉的部分是量測方法:定義評估契約、記錄工作與批次狀態、保留來源資訊、在提交前設定支出上限,並一次只改變一個瓶頸。

找到的瓶頸

隨著規模擴大,瓶頸會移動。

瓶頸 症狀 解法
單一網頁 CPU 約每秒 4 個局面,預估剩餘時間長達數小時 把獨立的 position key 展開分散出去
批次長尾 一個慢速局面卡住整個分片 批次大小 32、重試切分、每局面上限
引擎生命週期 本機重放很快,平台卻逾時 每個局面都設 ucinewgame 邊界
MultiPV 深度檢查 有效的最佳路線被拒絕 驗證被消費的路線,儲存結果 MultiPV
SQLite key 集合大小 500 局的準備流程撞上 SQL 變數上限 分塊查詢並使用暫存表
重複的回呼 父層工作計數過高 從標準批次列推導計數器
延遲抵達的錯誤回呼 已完成的批次被標記為錯誤 已完成的列即為最終結果
公開快取延遲 準備流程停滯或被速率限制 讓公開試探受限且漸進
部署回饋循環 映像檔推送花了 163s 把笨重的 ML 套件堆疊移出網頁映像檔
工作區上限 100 容器的執行成為實際上的天花板 接受這個上限,或尋求更高的運算容量

映像檔大小的修正不屬於 Stockfish 運算的一部分,但它對迭代速度很重要。網頁映像檔從 2.85 GB 降到 189.5 MB,映像檔推送從 163s 降到 8.1s

反覆出現的模式:一個瓶頸被移開之後,下一個通常在 Stockfish 之外。

實測運作點

目前生產環境設定的預期行為:

指標 預期範圍
大規模執行吞吐量 每秒 47-60 個局面
完成後的佇列清理 沒有殘留的待處理、執行中或錯誤列
較大區塊的寫回 p95 在記錄的執行中低於一秒
每個完成局面的成本 cpu=0.25 模型下約 $0.000005

這是一個穩定的運作點,不是永久的最佳解。當長尾局面主導實際耗時、排空耗時變得顯著、供應商帳單與模型出現分歧、佇列列變得過期、快取命中率變得又高又可靠,或批次供應商提高帳號上限時,就該改變它。

下一個 10 倍躍進大概不會來自在同一個上限內調整 max_containers它會來自三種改變之一:

  1. 批次供應商提高工作區/容器上限。
  2. 工作負載分散到彼此獨立的運算容量上。
  3. 更多局面能由來源資訊相容的快取滿足。

在那之前,100 容器的緩衝路徑就是生產環境的預設值。

哪些部分應該可移轉

能長久留存的部分不是那個特定的供應商、價格或批次大小。

原則 實測案例
先定義評估契約 Stockfish 18、深度 18、MultiPV 3
把不可變的工作單位排入佇列 position_key,不是對局 id
把批次大小視為重試粒度 批次大小 32 成為預設值
把要求品質與達到品質分開 要求深度 vs 達到深度、要求 MultiPV vs 結果 MultiPV
讓寫回具備冪等性 父層計數器由批次列推導
讓快取來源資訊可見 來源、引擎、深度、MultiPV、取得時間中介資料
在提交前設定支出上限 每個工作都記錄成本上限

如果另一個系統改變引擎版本、目標深度、硬體、供應商或局面分佈,實測數值就應該改變。原則不應該改變。

重跑這份基準測試

當下列任一項改變時就重跑:

  • 引擎版本或 UCI 選項;
  • 要求深度或 MultiPV 數量;
  • 每局面的時間上限;
  • CPU 形狀、工作節點數量或供應商限制;
  • 批次大小;
  • 寫回路徑;
  • 快取來源與命中率;
  • 局面分佈。

對快棋棋譜庫最快的設定,不一定是對開局資料庫、殘局研究集或引擎對引擎語料庫最快的設定。最精簡而有用的基準測試循環:

  1. 從真實工作負載中抽樣局面,不要只用起始局面或測試用 FEN。
  2. 對目標引擎契約以及至少一個更深的契約做效能分析。
  3. 跑一道小型批次階梯:18163264
  4. 在重試與寫回都可靠之後,再跑工作節點數量的階梯。
  5. 分別量測工作節點實際耗時、提交跨距、寫回時間與殘留的佇列列。
  6. 追蹤提交前的預估成本,以及事後供應商的實際支出。
  7. 驗證達到的深度與持久化的來源資訊,不只是吞吐量。
  8. 在改變供應商、深度或工作節點形狀之後,用同一份樣本重跑。

心得

  • 在擴展運算之前先定義引擎契約。
  • 把 position key 排入佇列,而不是對局。
  • 把批次大小視為重試粒度。
  • 加上實際耗時上限。深度不是預算。
  • 把引擎生命週期邊界當成正確性要求。
  • 把冪等性納入效能工作的一部分。
  • 讓快取來源資訊保持可見。
  • 在生產環境調校期間,最佳化部署回饋循環。

不要退步:

  • 為無關的局面送出不同的引擎對局身分。
  • 分別儲存要求深度與達到深度。
  • 分別儲存要求的與達到的 MultiPV。
  • position_key 排入佇列並去除重複。
  • 讓批次寫回具備冪等性。
  • 讓失敗的批次能以更小的粒度重試。
  • 把低深度後備視為明確的來源記錄,而不是已滿足深度 18
  • 讓生產執行以 pending=0running=0error=0 結束。