摘要

  • 批量 Stockfish 流水线从单台网页服务器每秒 4 个局面提升到云端 CPU 工作节点每秒 47 到 60 个,共完成 251,166 次深度 18、MultiPV 3 的评估。
  • 收益来自工作节点周边的账本机制:以局面键作为队列、以批大小作为重试单元、幂等写回,以及每个作业提交前的成本上限。
  • 深度不是时间预算。有一个批次一直撞上 1,800 秒超时,直到每个互不相关的局面各自拥有独立的引擎对局标识;此后那个慢局面不到一秒就完成了。
  • 更廉价的工作节点配合缓冲式写回,把模型成本压到每个局面约 $0.0000053。

结果: Stockfish 分析流水线从单台网页服务器上的 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 的网页运行时里跑 Stockfish。它足够简单,对小规模导入也足够正确,但对大型存档来说形态不对。

实时状态行是这样的:

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

那一行是准确的,同时也暴露了上限。更大的网页实例能线性提升吞吐,但工作依然绑在一个长时间运行的进程、一个部署生命周期和一台机器上。

这类批量负载需要的是:并发由工作节点容量界定、故障范围限定在一个批次或一个局面、每个作业有成本上限、队列状态可持久化。第一个扩展目标是可测量的吞吐,并保有足以恢复的状态。

控制平面

更多工作节点需要一个账本。 账本就是主要的基础设施。

控制平面使用了五张主表:

  • 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 局面/秒。

目前实测的工作点受限于服务商工作区容量和单一的写回目标。当几百个工作节点几乎同时完成时,工作节点直连网页服务的回调不是一条好的长期写入路径。缓冲分片把这个突发挪到 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 调用可能一直跑到被外层平台超时杀掉。生产工作节点既需要请求深度,也需要单局面时间上限。

工作节点还需要正确的引擎生命周期边界。在互不相关的 FEN 之间复用同一个 Stockfish 进程、又不更换 game= 令牌,会让 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、评估值 -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 - -。

第二类是至少有一个局面本身没问题,只是在最初的上限下跑得慢:

真正的 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_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 数量、硬件、局面分布、缓存命中率和服务商限制都会改变结果。

可迁移的部分是测量方法:定义评估约定、记录作业和批次状态、保留来源信息、在提交前设定支出上限,以及一次只改一个瓶颈。

发现的瓶颈

随着规模上升,瓶颈也在转移。

瓶颈 症状 处理方式
单个网页 CPU 约 4 局面/秒,预计剩余时间以小时计 把互不相关的局面键扇出
批次长尾 一个慢局面拖住整个分片 批大小 32、重试拆分、单局面上限
引擎生命周期 本地重放很快,却仍撞上平台超时 每个局面一个 ucinewgame 边界
MultiPV 深度校验 有效的最佳变例被拒绝 校验实际消费的变例,存储结果 MultiPV
SQLite 键集大小 500 局的准备撞上 SQL 变量数上限 分块查询并使用临时表
重复回调 父级作业计数偏高 由权威批次记录推导计数器
迟到的错误回调 已完成的批次被标记为错误 已完成的记录即为最终状态
公共缓存延迟 准备阶段卡住或被限流 让公共探测限量且增量执行
部署反馈循环 镜像推送花了 163s 把笨重的 ML 依赖栈移出网页镜像
工作区上限 100 容器运行成了实际上限 接受上限,或去争取更高的计算配额

镜像体积的修复不属于 Stockfish 计算本身,但对迭代速度很重要。网页镜像从 2.85 GB 降到 189.5 MB,镜像推送从 163s 降到 8.1s。

反复出现的规律: 一个瓶颈被搬走之后,下一个通常都在 Stockfish 之外。

实测工作点

当前生产配置下的预期表现:

指标 预期区间
大规模运行吞吐 47-60 局面/秒
完成后的队列清理 没有残留的 pending、running 或 error 记录
较大分块的写回 p95 在记录的运行中低于一秒
每个完成局面的成本 在 cpu=0.25 模型下约 $0.000005

这是一个稳定的工作点,不是永久最优。出现以下情况时就该调整:长尾局面主导实际耗时、排空耗时变得可观、服务商账单偏离模型、队列记录变得陈旧、缓存命中率变高且可靠,或者批量服务商提高了账户额度。

下一个 10x 的跃升,大概不会来自在同一上限内调 max_containers。 它来自三种变化之一:

  1. 批量服务商提高工作区/容器上限。
  2. 把负载拆到互相独立的计算容量上。
  3. 更多局面能由来源兼容的缓存满足。

在那之前,100 容器的缓冲路径就是生产默认配置。

哪些应当可迁移

经久有效的部分不是具体的服务商、价格或批大小。

原则 实测实例
先定义评估约定 Stockfish 18、深度 18、MultiPV 3
把不可变的工作单元入队 position_key,而非对局 id
把批大小当作重试粒度 批大小 32 成为默认值
把请求质量与实际达到的质量分开 请求深度与实际深度,请求 MultiPV 与结果 MultiPV
让写回幂等 父级计数器由批次记录推导
让缓存来源信息可见 来源、引擎、深度、MultiPV、抓取时间元信息
提交前设定支出上限 每个作业都记录成本上限

如果另一个系统更换了引擎版本、目标深度、硬件、服务商或局面分布,实测数值应当变化。原则不该变。

何时重跑这套基准

以下任一项变化时就重跑:

  • 引擎版本或 UCI 选项;
  • 请求深度或 MultiPV 数量;
  • 单局面时间上限;
  • CPU 形态、工作节点数量或服务商限制;
  • 批大小;
  • 写回路径;
  • 缓存来源和命中率;
  • 局面分布。

对快棋存档最快的设置,未必对开局库、残局研究集或引擎对引擎的语料库最快。最小可用的基准循环:

  1. 从真实负载中抽样局面,而不只是起始局面或测试 FEN。
  2. 对目标引擎约定以及至少一种更深的约定做性能剖析。
  3. 跑一个小型批大小阶梯:1、8、16、32、64。
  4. 在重试和写回可靠之后,再跑工作节点数量阶梯。
  5. 分别测量工作节点实际耗时、提交跨度、写回时间和残留队列记录。
  6. 跟踪提交前的估算成本以及事后服务商的实际支出。
  7. 校验实际达到的深度和持久化的来源信息,而不只是吞吐。
  8. 在更换服务商、深度或工作节点形态之后,用同一份样本重跑。

经验总结

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

不要退步:

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