扩展 Stockfish 并行分析
结果:Stockfish 分析流水线从单台 Web 服务器上的每秒 4.00 个局面,提升到云端 CPU 工作节点上稳定的每秒 47-60 个局面。整个推进过程在 Stockfish 18、深度 18、MultiPV 3 的约定下完成了 251,166 次局面评估。
当前生产配置为批大小 32、max_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 |
| 当前配置 | 批 32、100 个容器、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 形态的公共缓存记录互换使用。系统会保存来源信息,让后续代码能区分自行计算的行、缓存命中的行以及更深的参考结果。
方法说明
多数表格使用简短的运行标签。25g、100g、500g 表示由这么多局棋准备出的工作量。b32 表示批大小 32。c50 表示工作节点上限 50。t300 表示每个局面 300s 的上限。
测量结果使用三类标签:实测指工作节点或账本记录的数据,估计指提交前的成本估算,测算指改变 CPU 份额或回写形态后得到的运行模型。
吞吐数字根据每次运行记录的内容,分别取提交端的墙上时间或服务端批次跨度。它们足以比较不同工作点,但不能当作通用的 Stockfish 基准。
数据来源是我自己的历史棋谱库。生产数据库记录了作业、批次、队列状态、物化的评估数量、工作节点耗时、回写时间和成本估算。仅用于分析的深度实验走同一条工作节点路径,但结果不写入生产评估表。
关于计数有一点要说明:基线状态行与最终的工作量计数不是同一个单位。基线那一行来自旧的按用户回填路径中的阶段计数器。最终的 251,166 包含按局面键去重、前后局面评估、重算工作、重试以及着法评估复用。应把它理解为最终分析的工作量规模,而不是对局数或着数。
我不会把原始实验表作为可下载的文件发布。本文包含论证所需的实测数据;完整的运维账本留在项目笔记里。
基线
基线:每秒 4.00 个局面,运行中途仍显示 9.1h 的预计剩余时间。
最初的路径是在 Railway 的 Web 运行时里跑 Stockfish。它简单,对小规模导入也够正确,但对大型棋谱库来说形态不对。
实时状态行是这样的:
stockfish phase: 6350/137250 (cache=6043, errors=0) 4.00/s ETA 9.1h
那一行是准确的。它也暴露了天花板。更大的 Web 机器会线性提升吞吐,但工作仍然绑在一个长时间运行的进程、一套部署生命周期和一台机器上。
批量负载需要的是受工作节点容量限制的并行、按批次或局面隔离的失败、按作业设定的成本上限,以及持久化的队列状态。第一个扩展目标是可测量的吞吐,同时保留足够的状态以便恢复。
控制面
工作节点变多就需要账本。账本才是主要的基础设施。
控制面用了五张主要表:
analysis_jobs:父级请求、提交者、用途、引擎约定、成本上限、汇总状态。analysis_job_batches:工作节点分片状态、受理数量、物化数量、工作节点耗时、回写计时。analysis_queue:局面键状态和归一化后的评估结果。analysis_requests:谁请求了哪个局面以及原因。evals:面向报告和训练界面的着法级物化行。
工作节点不自行发现工作。控制面认领局面键,创建作业行和批次行,然后把这些已认领的键发给工作节点池。数据库始终是归属、状态、上限、重试和物化的事实来源。
父作业的计数最终由规范的批次行推导得出,因为回写实际上是至少一次语义:客户端重试可能再次提交同一个已完成批次的数据。
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 局面/秒 |
生产配置是在保证失败可诊断、清理可靠的前提下并发最高的一套设置。内部的运行账本还记录了批次数量、物化的评估数、回写计时和成本估算。
只有当周边的队列、回写和重试路径能承受生产规模的失败之后,吞吐才真正提升。
批大小
批大小就是重试粒度。更大的批次减少调度和回调开销,但让每个长尾失败的隔离成本更高。
推进过程中直接印证了这一点。一次 25g b64 c25 的运行完成了 44/45 个批次、受理了 2,767 个局面,随后有一个批次没通过深度校验。后续的长尾排查把失败范围从 32 个局面缩到 8 个,再缩到单个局面。
最终选定批大小 32 是一种折中。批大小 1 最利于诊断,但对大范围编排来说代价过高。批大小 8-16 是不错的重试形态。批大小 64+ 减少批次数量,但长尾隔离更糟。重试工具可以把失败的工作一直拆到单个局面,这比省下一点调度开销更重要。
经验法则:选你能从容重试的最大批大小。
工作节点数量
只有控制面能吸收回调速率之后,增加工作节点才有帮助。
从 25 个容器提到 50 个的提升很大:一次干净的 50 容器验证达到每秒 34.78 个局面。带 300s 上限的 100 容器运行在 100 局分块上达到每秒 47.50 个局面,第一个 500 局分块达到每秒 60.39 个局面。
目前实测的工作点受限于服务商工作区容量和单一的回写目标。当数百个工作节点几乎同时结束时,工作节点直连 Web 的回调不是一条好的长期写入路径。缓冲分片把这一突发挪到 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= 标记的情况下,让同一个 Stockfish 进程跨处理互不相关的 FEN,会让 python-chess 把整个分片当成一局棋。它不会在无关局面之间发送 ucinewgame。有一个工作分片在 1800s 撞上了平台超时;而本地重放全部 64 个局面只用了约 91.8s。切换 game= 标记并发送 ucinewgame 后,那个有问题的局面在一秒内就达到了 [18,18,18]。
边界修复很小:
info = engine.analyse(
board,
chess.engine.Limit(depth=18, time=300),
multipv=3,
game=position_key,
)
重要的不是具体的包装代码,而是互不相关的局面需要互不相关的引擎对局身份。
病态局面
推进过程记录了具体的局面,而不只是汇总失败。重要的情况分为两类。
第一类,有些局面对我们实际消费的约定来说过于严格。本节的棋盘图都按走子方视角摆放。
18;备选变例停在 17.
[15,15,14];按低深度降级方案受理。
[17,17,17];存为 result_depth=17.
[17];存为 result_multipv=0/3.- MultiPV 长尾。
以
32 -> 8 -> 1重试失败批次,隔离出这个六子的后兵残局。Stockfish 返回深度[18, 18, 17]:最佳变例达到深度18,而第三条 PV 浅了一着。受理的记录写下最佳着法f3d5、评估值-8115cp、requested_multipv=3和result_multipv=2。 FEN:8/5p2/3Kp1p1/8/8/5q2/5k2/8 b - -。 - Magnus 真正的病态长尾。
在
120s和180s的严格重试下仍只返回深度[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=17、result_multipv=2/3、最佳着法h3g4、评估值-847cp,以及90,863,715个节点。 FEN:b2r4/2QP2k1/p6p/1p2P1p1/2pP1P2/7K/PPB3P1/4q3 w - -。 - 低深度降级 2。
严格运行在
60.010s后停止,深度为[17]。降级记录写下result_depth=17、result_multipv=0/3、最佳着法g7h8、评估值+761cp,以及89,579,084个节点。 FEN:r4r2/pb2bpk1/1p1qpn2/6Q1/1n1P4/2NB1N1P/PP3PP1/R3R1K1 b - -。
第二类,至少有一个局面并不糟糕,只是在最初的时间上限下偏慢:
[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_depth 和 result_multipv 来源时才受理 |
| 深度成本长尾 | 在样本上,深度 36/MultiPV 1 比深度 18/MultiPV 3 慢 41.44x |
不要把深度重算当成每次缓存未命中的默认路径 |
正确性规则
最终的正确性规则:
- 每个无关局面都发送一次新对局边界;
- 使用每个局面的时间上限;
- 校验实际消费的最佳变例是否达到请求深度;
- 存储
requested_multipv和result_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 并不是普遍意义上的「够用」。它是为这个负载在给成本曲线定价之后选定的默认计算约定。
更深的分析可能有用,但它改变的成本量级是倍数,不是百分比。
缓存表现
本地局面键复用很有价值。公共缓存探测还不适合在大规模准备阶段内联使用。
在大规模准备阶段,本地缓存的读穿在云端计算之前就物化了数千条着法评估。这一项应当保持开启。它是确定性的、本地的,并且绑定同一套局面/评估约定。
类 Lichess/Stockpile 的公共缓存探测在初始测试中有两个问题:最初取样的 50 个局面命中数为 0,后续的校验又遇到限流。这意味着公共 API 探测不能阻塞大规模的生产准备流程。
当前策略:
- 使用本地/全局局面缓存的读穿;
- 大规模运行时把公共缓存探测限量或关闭;
- 在再次尝试之前,先加入增量、持久化、带退避的探测;
- 存储来源、引擎版本、深度、MultiPV、抓取时间状态和数据集元数据;
- 不要把私有的深度 18 记录当成可与更深的公共记录互换。
缓存在其约定匹配时才有用。它不能替代一条针对缓存未命中的可控路径。
产品规则:缓存命中就是带来源信息的计算结果。如果来源、引擎、深度、MultiPV 或延迟不符合产品约定,就把缓存当作可选的加速手段。
本基准的局限
这些数字是有用的运行数据,不是通用的 Stockfish 基准。
负载来自一份个人棋谱库。引擎约定固定为 Stockfish 18、深度 18、MultiPV 3。实现使用 Railway 加 SQLite 作为控制面,并在本次推进期间可用的账户限额下使用 Modal 的 CPU 容器。不同的深度、MultiPV 数量、硬件、局面分布、缓存命中率和服务商限额都会改变结果。
可迁移的是测量方法:定义评估约定、记录作业与批次状态、保留来源信息、在提交前设定支出上限,并且每次只改动一个瓶颈。
发现的瓶颈
随着规模上升,瓶颈会转移。
| 瓶颈 | 症状 | 处理办法 |
|---|---|---|
| 单个 Web CPU | 约 4 局面/秒,预计剩余时间达数小时 |
把互相独立的局面键扇出 |
| 批次长尾 | 一个慢局面拖住整个分片 | 批大小 32、重试拆分、每局面上限 |
| 引擎生命周期 | 本地重放很快,却撞上平台超时 | 每个局面加 ucinewgame 边界 |
| MultiPV 深度校验 | 有效的最佳变例被拒绝 | 校验实际消费的变例,存储结果 MultiPV |
| SQLite 键集合大小 | 500 局准备撞上 SQL 变量数量上限 | 分块查询并使用临时表 |
| 重复回调 | 父作业计数偏高 | 从规范的批次行推导计数 |
| 迟到的错误回调 | 已完成的批次被标记为错误 | 已完成的行即为终态 |
| 公共缓存延迟 | 准备阶段卡住或被限流 | 让公共探测限量且增量化 |
| 部署反馈回路 | 镜像推送耗时 163s |
把沉重的 ML 依赖从 Web 镜像中移出 |
| 工作区上限 | 100 容器的运行成为实际天花板 | 接受该上限,或寻求更高的计算容量 |
镜像体积的修复不属于 Stockfish 计算,但对迭代速度很重要。Web 镜像从 2.85 GB 降到 189.5 MB,镜像推送从 163s 降到 8.1s。
反复出现的规律:一个瓶颈被解决之后,下一个通常都在 Stockfish 之外。
实测的工作点
当前生产配置的预期表现:
| 指标 | 预期范围 |
|---|---|
| 大规模运行吞吐 | 47-60 局面/秒 |
| 完成后的队列清理 | 不留任何 pending、running 或 error 行 |
| 较大分块上的回写 p95 | 记录的运行中均为亚秒级 |
| 每个完成局面的成本 | 在 cpu=0.25 模型下约 $0.000005 |
这是一个稳定的工作点,不是永久最优。当长尾局面主导整体耗时、排空耗时变得可观、服务商账单与模型出现偏离、队列行变得陈旧、缓存命中率变得高且可靠,或批量服务商提高账户限额时,就该调整它。
下一个 10 倍的跃升大概不会来自在同一限额内调 max_containers。它来自三种变化之一:
- 批量服务商提高工作区/容器限额。
- 把负载拆分到相互独立的计算容量上。
- 更多局面能由来源信息兼容的缓存满足。
在那之前,100 容器的缓冲路径就是生产默认值。
应当迁移的部分
经得起时间的不是具体的服务商、价格或批大小。
| 原则 | 实测实例 |
|---|---|
| 先定义评估约定 | Stockfish 18、深度 18、MultiPV 3 |
| 把不可变的工作单元入队 | position_key,而不是对局 id |
| 把批大小当作重试粒度 | 批大小 32 成为默认值 |
| 区分请求质量与实际达到的质量 | 请求深度 vs 实际深度,请求 MultiPV vs 结果 MultiPV |
| 让回写幂等 | 父级计数由批次行推导 |
| 让缓存来源信息可见 | 来源、引擎、深度、MultiPV、抓取时间元数据 |
| 提交前设定支出上限 | 每个作业上都记录成本上限 |
如果另一个系统改变了引擎版本、目标深度、硬件、服务商或局面分布,实测数值就应当变化。原则不该变。
重跑这个基准
以下任一项变化时都要重跑:
- 引擎版本或 UCI 选项;
- 请求深度或 MultiPV 数量;
- 每局面时间上限;
- CPU 形态、工作节点数量或服务商限额;
- 批大小;
- 回写路径;
- 缓存来源和命中率;
- 局面分布。
对超快棋棋谱库最快的设置,未必对开局库、残局研究集或引擎对战语料最快。最小可用的基准循环:
- 从真实负载中取样局面,不要只用初始局面或测试 FEN。
- 剖析目标引擎约定,以及至少一个更深的约定。
- 跑一条小的批大小阶梯:
1、8、16、32、64。 - 在重试和回写可靠之后,再跑工作节点数量的阶梯。
- 分别测量工作节点耗时、提交跨度、回写时间和残留的队列行。
- 跟踪提交前的估计成本和事后服务商的实际支出。
- 校验实际达到的深度和持久化的来源信息,不只看吞吐。
- 在更换服务商、深度或工作节点形态之后,用同一份样本重复测试。
经验总结
- 在扩展计算之前先定义引擎约定。
- 入队局面键,而不是对局。
- 把批大小当作重试粒度。
- 加一个墙上时间上限。深度不是预算。
- 把引擎生命周期边界当作正确性要求。
- 把幂等性当成性能工作的一部分。
- 让缓存来源信息可见。
- 在生产调优期间优化部署反馈回路。
不要退步:
- 为无关的局面发送不同的引擎对局身份。
- 分别存储请求深度和实际达到的深度。
- 分别存储请求 MultiPV 和实际达到的 MultiPV。
- 按
position_key入队和去重。 - 让批次回写幂等。
- 让失败的批次能在更小粒度上重试。
- 把低深度降级当作显式的来源信息,而不是当作已满足深度
18。 - 生产运行结束时应满足
pending=0、running=0、error=0。