AlphaZero 五子棋实战(番外一):性能优化 2.5x 的实践
AlphaZero 五子棋实战(番外一):性能优化 2.5x 的实践 —— 每代 33.7 分钟压到 13 分钟,算法一行没改前五篇讲的是怎么训对:配置、参数、机制、踩坑、评估。这一篇番外讲一件完全不同的事:怎么训快。一次纯工程优化——不改网络、不改超参、不改 MCTS、不改训练目标,只把每个工兵各自调一次 GPU改成所有工兵的推理请求攒成一批统一调 GPU。结果:每代 33.7 分钟 → 稳定 13~15 分钟(最快 11.7 分钟),自对弈吞吐 2.2~2.8 倍,而且优化前后的对局数据逐条等价(E1 数值最大差异 8.6e-06,E2 逐局对照 0.000e00)。全文约 6200 字,含9 个真实踩坑。所有数字都来自训练日志、nvidia-smi采样和等价性测试脚本,没有估算。适合人群在跑自对弈强化学习,GPU 利用率却一直在 10~20% 晃的人想不加机器但想让训练快一倍的人(这篇的答案:先看时间花在哪,别急着换卡)用多进程/多工兵架构跑自对弈,每个进程各自调一次 GPU 的人(这正是最大的浪费点)想知道性能优化怎么证明自己没改坏结果的人你将收获① 一个先定位瓶颈再动手的时间拆分方法:拆完才发现,每代 33 分钟里训练只占 25 秒② 一套跨进程攒批推理的落地架构(独立推理进程 Unix socket 三处最小侵入钩子)③两步等价性验证法(数值对照 逐局数据逐字节对照),以及为什么攒批理论上不会改变结果的机制解释④9 个真实踩坑,包括一个一半产出白费的配置 bug 和一个监控悄悄失真的观测 bug目录1. 先修表,再修车:每代的 33 分钟到底花在哪2. MFU 7%:算力被浪费在了哪里3. 方案:把 20 个工兵的推理请求攒成一批4. 敢上生产的前提:等价性验证5. 实测:33.7 分钟 → 13~15 分钟6. 踩坑实录:9 个坑7. 还没榨干:剩下的空间在哪8. 速查表(建议收藏)1. 先修表,再修车:每代的 33 分钟到底花在哪很多人优化训练的第一步是换个更快的卡或者把 batch 调大。我们这次的第一步是先量一遍:从训练日志里抓每代的四个关键时间戳,拆成两段——# 每个训练代都会打印这四行,时间戳直接抓出来做减法就能拆段2026-09-13 07:33:20 INFO9个工兵(v36)已重启# ← 本代自对弈开始2026-09-13 07:58:16 INFO 收集工兵:90局, 总计100局# ← 自对弈结束(产局阶段)2026-09-13 07:58:42 INFO 训练307: 学习完成2026-09-13 07:58:42 INFO 检查点已保存(iteration307)# ← 本代结束拆出来的结果把我们都吓了一跳:代产局阶段训练阶段代总耗时30624.9 min25 秒25.4 min30736.3 min25 秒36.7 min30834.2 min25 秒34.7 min30931.1 min25 秒31.5 min训练只占 25 秒,占一代总时间的 1% 出头。剩下 99% 全在自对弈产数据。这一张表直接决定了后面所有工作的方向:调学习率、换优化器、加正则 →没用,那 1% 里能省出多少?换更强的显卡跑训练 →也没用,训练才 25 秒唯一的杠杆:让自对弈产局变快顺带说一句,这个产局时间 一代时间的结构在自对弈 RL 里是常态,不是我们项目的特例。AlphaZero 类方法的瓶颈从来在数据生成侧——推理次数比训练次数高两三个数量级(每代 1.6M 次前向推理 vs 16384 个训练样本)。所以后面讲的所有手段,都是在给推理提速。另外注意产局时间的波动:24.9 / 36.3 / 34.2 / 31.1——最大值比最小值大 46%。原因是木桶效应:一代的产局时间等于最慢那个工兵的时间,某个工兵抽到一局 200 手的缠斗局,整代都得等它。这个认识后面会用到两次(一次用于减少波动,一次用于发现 bug)。2. MFU 7%:算力被浪费在了哪里定位到瓶颈是推理,下一个问题是:推理的算力利用率是多少?算一遍 MFU(模型浮点利用率):每样本前向 ≈ 1.1 GFLOPs (15×15 棋盘 / 128 通道 / 8 个残差块 ≈ 16 个 3×3 conv 4 通道输入) 自对弈: 100 局 × ~20 步 × 800 次模拟 ≈ 1.6M 次前向 → ≈ 1760 TFLOPs/代 训练 : 16384 样本 × 3(前向反向) × 1.1G ≈ 54 TFLOPs/代 每代总计算 ≈ 1.8 PFLOPs ÷ 1800 秒 ≈ 1.0 TFLOPs/s 2080 Ti FP32 峰值 ≈ 13.4 TFLOPs/s → MFU ≈ 7%7%。一张 13.4 TFLOPs/s 的卡,实际只跑出 1 TFLOPs/s。剩下的 93% 在干什么?在等。看自对弈的一次 MCTS 模拟在干什么:选点(UCB) → 落子 → 翻转视角 → 叶子节点 → 【网络前向:1 个局面】 → 反向回溯 ↑ 每 800 次模拟,这种1 个局面的前向要发生 800 次每个 ep 的推理请求只带一个局面(实际每个局面还要做 8 个对称增强 8 个样本),batch 最大 8,GPU 一次算 4 毫秒,然后等下一个请求。三个工兵、九个工兵,都是各自排队各自算——GPU 大部分时间在等队列,不在算。顺便说个数字上的巧合:一张 2080 Ti 跑 4ms 的小 batch 卷积,和跑 4ms 的大 batch 卷积,耗时几乎一样(卷积核启动 显存访问是大头,算力不是)。也就是说:同样的 4 毫秒,算 8 个局面和算 120 个局面,成本差不多。这就是全部机会所在。3. 方案:把 20 个工兵的推理请求攒成一批思路一句话:把每个工兵各自调 GPU改成集中到一台服务器上,谁来了先排队,攒够一批一起算。架构长这样:┌────────────┐ ┌────────────┐ ┌────────────┐ │ 工兵 0 │ │ 工兵 1 │ ... │ 工兵 19 │ 20 个自对弈进程 │ (MCTS 树) │ │ (MCTS 树) │ │ (MCTS 树) │ 只管搜索, 不算网络 └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ Unix socket 请求: 棋盘 最后一手 └────────────┬───┴───────────────────────┘ ▼ ┌──────────────────────┐ │ infer_server (独立进程) │ ← 独占 GPU, 只干一件事: 攒批 forward │ 收请求 → 攒 20 个/等 3ms → 一次 forward │ └──────────┬───────────┘ ▼ GPU (2080 Ti)核心是服务端那个攒批循环——注意它是攒够 20 个 或 等满 3 毫秒就发车,不傻等:defbatch_loop(self):whileself.running:batch[]deadlinetime.time()self.max_wait# 默认 0.003 秒whilelen(batch)self.max_batch:# 默认 20remainingdeadline-time.time()ifremaining0:breaktry:batch.append(self.req_q.get(timeoutremaining))exceptqueue.Empty:breakifnotbatch:time.sleep(0.0005)continue# ← 到这里, batch 里是来自不同工兵、不同对局的局面conns,boards,lmszip(*batch)withself.net_lock:ps,vsself._forward(np.stack(boards),list(lms))forconn,p,vinzip(conns,ps,vs):conn.sendall(...)# 结果按连接原路返回3 毫秒这个等待窗口是关键:它决定了为了攒批,单个请求最多延迟多久。3ms 相对一次 MCTS 模拟的总开销(~百微秒到毫秒级)几乎可以忽略,但足以让 20 个工兵的请求汇到一起。客户端侧(工兵 主进程)的改动极小,一共三处钩子——这是能不能上生产的关键:# 钩子 1: __init__ —— 环境变量存在才挂载远程推理, 不设就完全不变self._remoteNone_sockos.environ.get(GOMOKU_INFER_SOCK)if_sock:try:self._remoteRemotePredictor(_sock)logging.info(✅ 已启用远程攒批推理: %s,_sock)exceptExceptionase:logging.error(❌ 远程推理初始化失败, 回退本地推理: %s,e)# 失败自动降级self._remoteNone# 钩子 2: predict_inference —— 有 remote 走 socket, 没有走原来的本地前向defpredict_inference(self,board_batch,last_movesNone,playersNone):ifself._remoteisnotNone:returnself._remote.predict(board_batch,last_moves,players)...# 原路径一行未动# 钩子 3: save_checkpoint —— 写 metadata(工兵发现新代的信号)之前, 先让 server 热加载新权重ifself._remoteisnotNone:agent._remote.update_weights(history_path)logging.info(已通知 inference server 热加载: %s,...)# 然后才写 metadata.json —— 顺序反了会导致工兵看到新代、却用旧权重产局三处加起来不到 30 行,MCTS、温度调度、随机开局、Q 记录一行未改;不设环境变量时行为与优化前 100% 一致。顺带一提:这个方案不是我们发明的。项目更早的 v12 时代就做过一模一样的独立推理服务 Unix socket 攒批,这次是把它移植到 v36。老方案在新架构上复用,比从零设计便宜得多——如果你手上的项目已经有过类似尝试,先去翻翻历史代码。4. 敢上生产的前提:等价性验证性能优化最怕的事:速度快了,但学出来的东西不一样了。而且这种偏差不会立刻炸,是几个版本之后你才发现这批权重怎么这么菜。所以上线前做了两道验证:E1:数值对照(同一批局面,本地 batch 算 vs 服务端攒批算)# ① 本地: net(batch N) 一次 forwardwithtorch.no_grad():p_local,v_localnet(torch.as_tensor(inp))# ② 远程: 逐条 predict (N1) → 服务端攒批后返回rpRemotePredictor(SOCK)foriinrange(n):p,vrp.predict(boards[i][None],[lms[i]],None)# ③ 比较 policy log 概率 value 的最大绝对差异 (容差 1e-5)dpfloat(np.abs(p_local-p_remote).max())dvfloat(np.abs(v_local-v_remote).max())结果:项实测容差policy log-p 最大差异8.6e-06 1e-5 ✅value 最大差异8.6e-06 1e-5 ✅E2:逐局数据对照(4 个种子各跑 1 局自对弈,对比落盘数据)比数值更硬的一层:让本地推理版和攒批推理版各跑 4 局自对弈,把落盘的.pkl逐项对比:对比项结果每步 policy 概率 / Q 值 / 棋盘 / 胜者 / 手数全部 0.000e00(完全一致)为什么攒批理论上不该改变结果?这里有个容易被忽略的机制点,也是我们敢批量上线的底气:推理阶段 BatchNorm 用的是 running stats,不是 batch 内的统计量。训练时 BN 依赖同 batch 内其他样本的均值方差,所以 batch 大小会实实在在影响输出;而推理(eval 模式)下 BN 用的是训练时累计的 running_mean/running_var,每个样本的输出去其他样本完全无关。卷积、全连接同样逐样本独立。结论:在 eval 模式下,把 N 个局面拼成一个 batch,和逐条算,数学上是同一件事——差别只来自浮点运算的累加顺序(不同 batch size 走不同 kernel),量级在 1e-6,也就是 E1 测到的那 8.6e-06。反过来说,如果哪天你要攒批的是训练而不是推理,这条就不成立了,必须重新验证。这也是我们只在推理侧攒批的原因之一。5. 实测:33.7 分钟 → 13~15 分钟同一台机器(RTX 2080 Ti,96 核),同一套配置(100 局/代、sim 800、batches 32 × batch_size 512),唯一区别是推理路径:① 代内拆分对比阶段改前(各工兵本地推理,9 工兵×10 局 主进程 10 局)改后(攒批推理,20 工兵,主进程只等 100 局)产局31.6 min(4 代均值)14.1 min(5 代均值)训练25 秒25 秒(没动过)代总耗时32.1 min(抽样 4 代)14.5 min(抽样 5 代)② 多代连续跟踪(凌晨到深夜,优化后已稳定)代代耗时备注33714.0 min优化后常态区间33815.6 min抽到长局33913.4 min34014.5 min35213.3 min35312.3 min35419.3 min当天最慢的一代(长局拖尾)35515.5 min35612.1 min最快③ 提速汇总指标改前改后倍数代耗时(均值)33.7 min(项目长窗口记录 25.4~41.0)13~15 min2.3~2.5×代耗时(最快一代)25.4 min11.7 min2.2×自对弈吞吐3.1 局/min6.9 局/min2.2×MFU(实算)7%~18%2.4×最后两行值得单独说一下:吞吐提速倍数(2.2×)和 MFU 提升倍数(2.4×)基本吻合——这正好说明提速不是靠少算点东西换来的(每代仍是 100 局训练数据、sim 仍是 800),而是同样的计算量被 GPU 更快地吃下去了。如果哪天你优化完发现速度快了但 MFU 没变,那大概率是哪里偷偷少算了,值得回头查。④ 服务端自己的仪表盘(每 30 秒打一条)统计: 请求 57716 | forward 4142 | 平均batch 13.93 | 1924 req/s | forward均耗时 3.9ms平均 batch 10.5~16.4(上限设 20):远远高于优化前的 8,说明 20 个工兵确实把队列喂起来了QPS 1400~2200(优化过程中的 9 工兵版本是 1190)单次 forward 3.8~4.1 ms:和只算 8 个局面时几乎一样 → 印证了第 2 节那句4 毫秒算 8 个和算 120 个成本差不多⑤ 数据量没变改前改后每代训练用局数100 局(工兵 90 主进程 10)100 局(全由工兵产)每代训练样本1638416384每代训练时间25 秒25 秒同样的数据、同样的算法、同样的卡,只是换了个调用 GPU 的方式。这也是我觉得这件事最值得写的地方:大部分时候,你不缺算力,你缺的是让算力别闲着。6. 踩坑实录:9 个坑6.1 一代卡死:主进程在等一个永远不会来的局症状部署完攒批推理,主进程启动后日志停在等待工兵: 0/100局已到,再也没动过。根因主进程里重启工兵的代码在主循环的末尾——也就是说它是跑完一代才重启工兵。而第一代还没有任何工兵跑起来,主进程就在等工兵的产出,工兵却要等主进程重启才启动。死锁。修复冷启动必须手动起工兵(启动脚本里显式for wid in 0..19拉起),不能指望主进程自己拉。顺便说,我们的启动脚本后来加了工兵是否各就位的逐个校验——这里踩过一次,以后每次启动都验:forwidin$(seq0$((WORKERS-1)));don$(pgrep-fc[G]omoku-worker_v36.py$wid\$)if[$n!1];thenecho⚠️ wid$wid进程数$n(应为 1);BAD1;fidone6.2 一半产出白费:改了配置,工兵没跟着改症状把每工兵局数从 10 调到 5(想让 20 个工兵凑 100 局),但看落盘数据发现每代产了 200 局,而主进程收到 100 局就开训了——另外 100 局纯白算。根因主进程的期望局数是从metadata.config读的(worker_count × worker_games_per_worker),而工兵脚本里的局数是一个硬编码常量:GAMES_PER_ITER10# ❌ 不随 config 变化配置一改,主进程口径变了,工兵口径没变 → 主进程等 100 局,工兵产 200 局。修复让工兵也从 metadata 读,与主进程口径严格对齐:def_games_per_iter():从 metadata.json 的 config 读工兵每代局数 —— 与主进程 expected 口径严格对齐try:metajson.load(open(METADATA_PATH))nint(meta.get(config,{}).get(worker_games_per_worker,GAMES_PER_ITER))returnnifn0elseGAMES_PER_ITERexceptException:returnGAMES_PER_ITER教训:凡是多个进程共享一个配置的地方,硬编码常量就是地雷——它不会报错,只会让你白烧算力。这类 bug 的特征是能跑、结果也对,只是成本翻倍,不做数据审计根本发现不了。6.3 缺 1 局死等:一条长局把整代拖死症状某代进度卡在等待工兵: 99/100局已到,再也不涨。根因主进程等的是精确 100 局。只要有一个工兵的长局跑崩了、或者被系统 OOM 杀掉,它就永远交不出那 1 局 → 主进程while True死等。修复两层:①超额产局缓冲——让工兵每代产 6 局(20×6120),主进程只等 100,谁掉链子都有余量;②停滞超时保护——N 秒没有新增局就打印告警并用现有局开训:logging.warning(⚠️ 等待工兵停滞超时({}s 无新增, 仍 {}/{}局) → 用现有局开训.format(stall_timeout,len(done),expected))这条和 6.4 是同一类问题的两个面:等一个精确数字在分布式里迟早会卡死,要么给缓冲,要么给超时。6.4 木桶效应:一代的时间等于最慢那个工兵症状产局时间在 25~41 分钟之间大幅跳动。根因每代都要等所有工兵交齐。9 个工兵每人 10 局,只要有一个抽到超长对局(实测最长 27 分钟),整代就被它拖住。修复把每人 10 局 × 9 人改成每人 6 局 × 20 人 超额缓冲:单个工兵的任务量减半,最慢的那根木板就不再是瓶颈;同时 20 个工兵让攒批队列更饱满(batch 从 8 涨到 13~16)。这两个效果是叠在一起的——工兵越多 → 队列越满 → 批越大 → 越快 → 木桶效应越弱。6.5 工兵看到新代,却用旧权重产局症状权重热加载偶发慢一代:日志显示工兵已经在产第 N1 代,但 server 还是第 N 代的权重。根因主进程保存检查点后,要经过通知 server 换权重 → 写 metadata → 工兵读到 metadata 开始新代这条链。如果先写 metadata 再通知 server,工兵可能在毫秒级的时间窗里读到新代,却用旧权重算完第一批推理。修复把通知放在写 metadata 之前(见第 3 节钩子 3)。顺序 语义,这类 bug 竞争窗口极小、复现极难,但会让训练数据里混入少量上一个网络的标签——最脏的那种污染。6.6 静默降级:服务没起来,性能悄悄退回优化前症状一切正常,就是慢。根因客户端钩子设计成连不上 server 就回退本地推理(这是对的,不能让训练挂),但没有任何提示。于是server 忘启动 / socket 没清干净 / server 崩了都表现为训练正常但慢一倍。修复① 启动脚本在起主进程之前就检查 socket 就绪(60 秒轮询),没就绪直接unset GOMOKU_INFER_SOCK并明确打印回退本地推理;② 服务端每 30 秒打一条统计日志,监控侧据此判断存活——“日志超过 180 秒没更新 服务死了”,这是最可靠的存活判据(比pgrep可靠:pgrep -f会匹配到自己的命令行)。6.7 监控失真:训练换了架构,监控没跟着改(观测类,最阴)症状训练监控面板还在,但当前代 / 进度 / ETA全部为空,或者停在几小时前。根因一次优化改了三件事,监控侧一个没跟上:变化监控侧后果主进程不再自对弈(train_episodes_per_iteration: 10 → 0)日志里再也没有代N 局M/10,而监控正是靠它取进度的主日志从/tmp/train_v36.log挪到项目目录监控还在读旧文件(已停更 4 小时)→ 静默显示旧数据工兵 9 → 20面板还是 9 个工兵格,后 11 个的产出看不见修复监控口径全部改成从新日志格式取数:进度改读等待工兵: N/100局已到;当前代以metadata.iteration为准(它就是正在产局的那一代);工兵数量从metadata.config.worker_count读,不写死。教训:优化训练架构时,监控是必须同步修改的下游消费者。这属于最阴的一类 bug——训练本身完全正常,只是你看不见它。我们后来专门做了一张服务器日志输出 ↔ 监控字段的对照表,改日志文案前先对照一遍。6.8 ETA 虚高一倍:口径没跟着改症状监控显示预计还要 30 分钟,实际 14 分钟就完了。根因老的 ETA 是剩余局数 × 平均速率,而剩余局数取的是max(主进程剩余, 最慢工兵剩余)。这套逻辑在主进程自己产 100 局的时代是对的;换成20 个工兵均摊 5 局之后,单个工兵的分母仍是 10 局,最慢工兵天然显示还差 8 局 → ETA 直接翻倍。修复口径改成**“剩余 本代目标 - 主进程已收到”**:优化后主进程的唯一进度指标就是收到了多少局,用它算 ETA 才对。6.9 统计取到了三个小时前的老数据(字典序)症状新加的每代耗时面板,显示的是第 92~99 代的数据(三小时前),不是最近的代。根因统计每代耗时是拿权重文件的 mtime 相邻做差,取最近 12 个:ls-lweights_it*.pt|tail-12# ❌ 字典序: it100 it2, it92 排在 it339 后面字典序下weights_it92排在weights_it339之后,tail取到的全是老代。修复按 mtime 数值排序再取尾:ls-l--time-style%s weights_it*.pt|sort-k6-n|tail-12# ✅顺带一提:这个文件名字典序 ≠ 数值序的坑,我们在版本号的显示排序上踩过不止一次(it9 it90 it91的经典排序错误)。凡是按迭代号排序的地方,一律用数值序。7. 还没榨干:剩下的空间在哪优化完的当下实测:指标实测值说明GPU 利用率17~18%nvidia-smi采样 8 次,功耗 110W/250W,显存 13.2GB/22.5GB工兵 CPU 合计5 核(共 96 核)20 个工兵各自约 25% CPU,大部分时间在等 socket平均 batch10.5~16.4(上限 20)队列还没喂满单次 forward3.8~4.1 ms已接近小 batch 的耗时下限三条观察(都是现象,不是结论):GPU 才 18%,批上限还没打满(平均 13 而上限 20)→ 理论上还能再塞工兵,或者把GOMOKU_MAX_BATCH/ 等待窗口调大试试。但要小心:等待窗口调大 单请求延迟上升,可能反过来拖慢每个工兵的游戏速度,得实测,别拍脑袋。CPU 只用了 5/96 核——说明现在既不是 GPU 瓶颈也不是 CPU 瓶颈,更像是延迟瓶颈(每个 MCTS 模拟要等一次 socket 往返 攒批窗口),这也是工兵大部分时间在等的原因。工兵数量本身也是一个可调参数,但受木桶效应约束:加人 → 单人工时减少 → 但队列更长、单次攒批等待可能增加。存在一个最优点,我们目前只是20 是比 9 好,没找过极值。如果你在类似架构上做优化,这一节的方法比结论重要:每次改完,先看 GPU 利用率、批大小、队列延迟这三个数,再决定下一步动哪里。顺便列一下我们主动排除的方案(免得你重复试一遍):方案为什么排除依据(实测)换更强的显卡训练只占 25 秒/代,瓶颈在产局;而且现在既不吃满 GPU 也不吃满 CPU,像是延迟受限,换卡收益不明第 1 节代内拆分把攒批上限继续调大(20)平均 batch 只有 13,上限根本没用满——先让队列吃饱,再谈加大服务端统计混合精度 / 梯度累积 / 优化器调参那 25 秒里还能省出多少?理论上限 1% 出头第 1 节用 gRPC / 共享内存替掉 Unix socket20 个工兵 CPU 合计只用 5/96 核 → 通信开销不是当前瓶颈psnvidia-smi采样降低 MCTS 的 sim 数会改变棋力与数据分布,这属于算法变更,不是纯提速,会直接破坏等价性这个前提第 4 节而应该做的下一件事,是把省下来的时间换成数据:现在每代还是 100 局,但一代的时间只剩下原来的 43%——富余出来的算力目前是空闲的。这也是我们下一步最想做的事(每代 100 → 200-300 局),而不是继续抠那 18% 的 GPU 利用率。8. 速查表(建议收藏)#坑一句话修复1主进程死等工兵(冷启动)启动脚本显式拉起工兵 逐个校验 wid 就位2配置改了工兵没改,一半产出白费多进程共享的参数从metadata.config读,禁止硬编码3缺 1 局整代死等超额产局缓冲(产 6 等 5) 停滞超时告警开训4木桶效应:一代 最慢工兵增加工兵数、减少单人工时(10×9 → 6×20)5工兵用旧权重产新代先通知 server 热加载权重,再写 metadata6服务没起来,静默降级启动前检查 socket 就绪;服务端定时打统计日志,超时未更新已死7监控口径错位(训练换了架构)列日志输出 ↔ 监控字段对照表,改日志前先对照8ETA 虚高一倍剩余局数改用主进程已收到,不要用最慢工兵剩余9耗时统计取到老数据按 mtime 数值排序(sort -k6 -n),文件名一律数值序优化流程本身也总结成四步(比具体手段更通用):① 拆时间 → 先量每代时间花在哪一段(我们: 训练 25s vs 产局 30min) ② 算 MFU → 用计算量 ÷ 墙钟 ÷ 峰值定位空闲(我们: 7%) ③ 改路径 → 优先合并调用而不是加大算力(我们: 攒批推理) ④ 验等价 → 数值对照 逐局数据对照, 两道都过才上生产8.1 可复现清单(照抄能跑)# ① 先起推理服务(独立进程, 独占 GPU) —— 必须在主进程之前起来GOMOKU_MAX_BATCH20GOMOKU_INFER_SOCK/tmp/gomoku_infer.sock\nohuppython3 scripts/infer_server_v36.pyinfer_server.log21for_in$(seq160);do[-S/tmp/gomoku_infer.sock]break;sleep1;done# 等 socket 就绪(最多 60s)# ② 主进程: 带上 socket 环境变量 → 自动挂载远程推理; 不带 → 行为与优化前 100% 一致exportGOMOKU_INFER_SOCK/tmp/gomoku_infer.socknohuppython3 Gomoku-v0_pt_v36.pytrain_v36.log21# ③ 20 个工兵必须【显式】拉起 —— 主进程的重启工兵在主循环末尾, 冷启动不会自己拉forwidin$(seq019);donohuppython3 Gomoku-worker_v36.py$widworker_v36_${wid}.log21done# ④ 验证:每个工兵日志里都应出现这一行(没有静默回退本地推理, 性能会悄悄掉回去)grep-l已启用远程攒批推理worker_v36_*.log|wc-l# 期望 20# ⑤ 看服务端吞吐(每 30 秒一条: 请求 / forward / 平均batch / QPS / forward耗时)tail-finfer_server.log|grep统计提示:第 ③ 步如果漏了,训练不会报错——只是慢。所以每次启动都跑一遍第 ④ 步,这是最省事的性能自检。写在最后这篇番外想说的其实是一句很反直觉的话:训练慢,通常不是卡不行,是你把卡喂错了。我们这个项目里,把每代从 33.7 分钟压到 13 分钟,做的事情里没有一行算法——没动网络结构、没动学习率、没动 MCTS 参数、没动训练目标。只是把20 个进程分别喊 GPU 来算 8 个局面改成20 个进程把请求交到一个服务里,让 GPU 一次算 120 个局面。而支撑这次优化能落地的,是两件看起来很笨的事:先量再改(第 1 节的拆时间)——如果当时直接去调学习率,我们能省下的是那 25 秒里的一点点先证等价再上线(第 4 节的两道验证)——性能优化最容易出的事故不是没变快,而是变快了但学的东西悄悄变了最后留一句给同样在跑自对弈的人:先去泡一杯茶,看 20 分钟日志的时间戳,把每代的时间拆成段。大概率你会发现,真正该优化的地方,和你原本以为的不一样。如果这篇帮你找到了 GPU 闲置的那 80%,给个赞⭐9 个坑 四步流程,建议收藏评论区聊聊:你的 GPU 利用率是多少?瓶颈最后卡在哪儿?代码 全部对局记录:Giteekid141212/alpha-zero-gomoku番外一 · 完。正篇五篇讲的是怎么把棋训强,这篇番外讲的是怎么把训练跑快。如果后面还写,大概会往这两个方向:一是把上面第 7 节那 18% 的 GPU 利用率继续榨(加宽攒批窗口 / 工兵数量扫参),二是回到主线——跟自己对打看不出进步的更多解法。如果你遇到白棋无法健康训练的问题请参考番外二白棋并非不可训练一种可行的五子棋白棋训练方案共勉。