结果:Stockfish 分析流水线从单台 Web 服务器上的每秒 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 包含按局面键去重、前后局面评估、重算工作、重试以及着法评估复用。应把它理解为最终分析的工作量规模,而不是对局数或着数。

我不会把原始实验表作为可下载的文件发布。本文包含论证所需的实测数据;完整的运维账本留在项目笔记里。

基线

基线:每秒 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 局面/秒

生产配置是在保证失败可诊断、清理可靠的前提下并发最高的一套设置。内部的运行账本还记录了批次数量、物化的评估数、回写计时和成本估算。

Stockfish 吞吐提升过程 只有当周边的队列、回写和重试路径能承受生产规模的失败之后,吞吐才真正提升。

批大小

批大小就是重试粒度。更大的批次减少调度和回调开销,但让每个长尾失败的隔离成本更高。

推进过程中直接印证了这一点。一次 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,
)

重要的不是具体的包装代码,而是互不相关的局面需要互不相关的引擎对局身份。

病态局面

推进过程记录了具体的局面,而不只是汇总失败。重要的情况分为两类。

第一类,有些局面对我们实际消费的约定来说过于严格。本节的棋盘图都按走子方视角摆放。

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 并不是普遍意义上的「够用」。它是为这个负载在给成本曲线定价之后选定的默认计算约定。

深度成本曲线 更深的分析可能有用,但它改变的成本量级是倍数,不是百分比。

缓存表现

本地局面键复用很有价值。公共缓存探测还不适合在大规模准备阶段内联使用。

在大规模准备阶段,本地缓存的读穿在云端计算之前就物化了数千条着法评估。这一项应当保持开启。它是确定性的、本地的,并且绑定同一套局面/评估约定。

类 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它来自三种变化之一:

  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. 在更换服务商、深度或工作节点形态之后,用同一份样本重复测试。

经验总结

  • 在扩展计算之前先定义引擎约定。
  • 入队局面键,而不是对局。
  • 把批大小当作重试粒度。
  • 加一个墙上时间上限。深度不是预算。
  • 把引擎生命周期边界当作正确性要求。
  • 把幂等性当成性能工作的一部分。
  • 让缓存来源信息可见。
  • 在生产调优期间优化部署反馈回路。

不要退步:

  • 为无关的局面发送不同的引擎对局身份。
  • 分别存储请求深度和实际达到的深度。
  • 分别存储请求 MultiPV 和实际达到的 MultiPV。
  • position_key 入队和去重。
  • 让批次回写幂等。
  • 让失败的批次能在更小粒度上重试。
  • 把低深度降级当作显式的来源信息,而不是当作已满足深度 18
  • 生产运行结束时应满足 pending=0running=0error=0