返回列表

"Fix the bugs?" Solution Write-up (3rd)

643. FIDE & Google Efficient Chess AI Challenge | fide-google-efficiency-chess-ai-challenge

开始: 2024-11-18 结束: 2025-03-06 游戏AI AI大模型赛
"Fix the bugs?" 解决方案撰写(第 3 名)

"Fix the bugs?" 解决方案撰写(第 3 名)

作者:Andrew Grant (及合作者 Kim Kahre)

发布时间:2025 年 3 月 24 日

竞赛排名:第 3 名

我们的解决方案:https://github.com/AndyGrant/KaggleFish

本文概述了"Fix the bugs?"团队的成员,以及我们提交方案的主要组件的技术细节。在本文中,我将大量参考以下内容:

第 1 节:团队

Andrew Grant;美国;andrew.kaggle@grantnet.us

我是 chess.com 的软件开发人员。我的主要工作是编写 Torch 国际象棋引擎,作为 chess.com 许多产品和工具的替代品。对于这次竞赛,我利用了自己商业引擎 venture Ethereal 的先前经验、开发 OpenBench 国际象棋引擎测试平台的经验,以及多年来为几乎所有顶级引擎(包括 Stockfish、Leela 和 Komodo Dragon)贡献的一般知识。这次竞赛的最初计划是花费大约 20 小时以确保前 4 名的成绩。然而,到最后,当很明显获胜者将由抛硬币决定时,我们产生了争取第一名的兴趣。我只在最后一个月的时间里真正参与了该事件,每周大约花费 25 小时,利用周末和晚上。我与我的好朋友 Kimmy 合作,我曾与他一起开发 Torch。在我已经完成了许多样板工作后,我们组建了团队。我的贡献主要是剥离 CFish 的克隆版,设计和训练驱动引擎评估函数的神经网络,通过 OpenBench 实例管理计算资源,以及通常致力于通过回移植已知想法来增强引擎强度。

Kim Kahre,芬兰;kimkahre@outlook.com

国际象棋引擎开发多年来一直是我的主要爱好之一。我也为 chess.com 开发了 Torch 国际象棋引擎。我对 Koivisto 和 Torch 的所有修补肯定都非常有用。这似乎是一个有趣的挑战,我认为在如此有限的资源下,搜索的行为可能会有所不同(置换表替换方案等可能的差异)。我也非常享受国际象棋引擎开发的一般过程。有机会与 Andrew 组队,我不假思索地答应了。我每周大约花费 20 小时,但仅在竞赛的最后几周。我的贡献主要是尝试尽可能多的搜索想法/调整变体。

第 2 节:竞赛初期的努力

当这次竞赛宣布时,我迅速提交了我自己的商业引擎 Ethereal (https://github.com/AndyGrant/Ethereal) 的剪枝版本,以便建立一个基线指标来比较其他提交。最终,许多用户提交了 Ethereal 的版本。我拿走了 Ethereal,删除了神经网络,删除了对多线程、表基库和其他非必要组件的所有支持。我还缩小了各种预计算表和哈希表的大小。通过这些步骤,我能够提交一个使用手工 crafted 评估 (HCE) 的 Ethereal 版本,这要归功于 Alexander Tian 的这个包装脚本 (https://www.kaggle.com/competitions/fide-google-efficiency-chess-ai-challenge/discussion/548061),他是 Altair 国际象棋引擎的作者,也是 chess.com 的 Torch 引擎的贡献者。该脚本通过在 Kaggle 的环境和我们习惯的典型引擎通信协议之间建立包装器,使引擎开发者能够轻松访问竞赛。

这个提交能够迅速达到事件的顶部,并在那里停留了相当长的时间。作为一次性的开发工作,我添加了支持 Pondering(预思考)。对于非引擎开发者来说,Pondering 可能很明显,但它是在对手的回合上进行思考的行为。这在引擎开发中不再真正完成,因为它引发了对硬件资源分配和公平性的担忧。典型的 Pondering 机制是当你走棋时,猜测对手的走棋。然后在等待时,你就像你的猜测是正确的那样进行搜索。如果你是对的,那么你就会得到"ponderhit",并且你节省了很多时间。如果你错了,那么至少你已经探索了一些可能通过转置出现的位置。引擎通常通过在引擎中拥有一个 dedicated 线程来管理这一点,该线程轮询 stdin 以等待"ponderhit"或"stop"信号。由于此事件的限制,现在通过在搜索期间偶尔处理来自 stdin 的行来实现。

在此之后,没有再花费精力去处理 Ethereal 提交。它位于排行榜的顶部,直到各种版本的 Stockfish 分支开始接管。那时很明显——对我来说其实更早——获胜的解决方案都将是对 CFish 的分支,具有不同程度的开发努力,以及不同程度的精简。

第 3 节:最小化 CFish 内存和尺寸的步骤

本次竞赛的主要挑战之一是减小二进制文件的大小。将二进制文件的大小再减小 1kb,将允许神经网络中再多 1kb 多的权重。空间如此关键,以至于我断言,如果前三名竞争者中的任何一个被允许使用 128kb,他们将以巨大的优势赢得比赛,远远超过我们在这里看到的深刻统计上不显著的结果。

大多数最初的删除不值得详细讨论。但这里有一些:删除表基库,删除 polyglot 开局库,删除 NNUE 实现,删除 NUMA 支持,删除线程支持,删除大页,删除 multipv 搜索,删除 UCI 接口的大部分组件,删除技能等级,删除多余的引擎输出,删除布谷鸟表。

为了保持我们的提交可用于 OpenBench 框架,我们不得不开始进行预处理器定义,以仅排除 Kaggle 构建的代码。使用这种机制,我们能够完全最小化所有引擎输入和输出。在正式的提交中,引擎接受的唯一输入是"position fen … moves …..\n go wtime … btime …",产生的唯一输出是"bestmove … ponder …"。

我们的解决方案是在 WSL 上的 Ubuntu 20.04.6 LTS 上开发的。最初的步骤之一是构建所有版本的 clang,看看哪个产生的二进制文件尺寸最小,而没有 meaningful 地改变引擎的速度。我们得出 clang-10 是最优的。正如许多其他竞争对手发现的那样,我们使用了以下额外的 clang 标志来减小二进制文件的大小:-fno-unwind-tables -fno-asynchronous-unwind-tables -fvisibility=hidden -fexperimental-new-pass-manager

尽管尽了最大努力,仍然需要做更多的工作。尺寸减小的下一个飞跃来自用 __attribute__((minsize, cold)) 标记非关键函数,以提示编译器生成这些函数的显著较小版本。这些基本上应用于所有不在引擎搜索热循环中的函数。一些内联的函数被复制了,制作了一个热内联版本和一个冷 noinline 版本,以进一步减小二进制文件的大小。

Python 包装器本身的编写方式相当模糊,并在构建我们的提交档案时使用 python-minifier 实用程序进一步减小。我们的实际引擎二进制文件使用 xz (lzma) 压缩,并在 python 脚本内部解压缩,因为 kaggle 环境中缺少 lzma 工具。我们更愿意使用 7zip,它产生的二进制文件更小,但尽管允许上传 7zip 文件,kaggle 环境内部对其解压缩的支持不起作用。

还有许多其他努力专门针对二进制文件减小,因为它涉及神经网络权重。这些将在最后一节中解决,一旦提供了关于权重的背景信息。

关于内存使用,降低 RAM 使用量对我们的提交来说并不是什么障碍。我们采取了明显的初步步骤来减小置换表。以及在从 HCE 切换到 NN 后,删除了现在过时的表,如 Pawn Hash 表和 Material Hash 表。主要的节省来自:使用 avx2-bitboards 进行走法生成,这种方法允许您在预计算表中节省约 200kb,代价是更大的二进制文件大小;删除 Continuation History 表的维度,最初占用了 8MB+;删除链接 libmath 的需要;减小最大内部搜索深度以大大减小表大小;删除链接 libpthread 的需要。

我们的最终提交使用了 768kb 的置换表。根据我们使用 pmap 和其他工具的测量,我们本可以再多使用 1024kb 并且仍然在 5MB 限制内,至少在本地是这样。我们测量到将哈希表加倍价值 14 elo,但决定最好远低于限制,以防 Kaggle 环境发生变化,确实发生了变化。

第 4 节:改进底层引擎

尽管 CFish 很强,但它缺乏 2021 年至今的大量发现。我们使用 OpenBench 框架 exhaustive 地测试了许多这些更改,该框架由我的机器(~144 线程)、Kimmy 的机器(~32 线程)、Styx 慷慨捐赠的机器(~384 线程)以及最后几天的 Google Cloud Compute(~256 线程)提供支持。快速查看我们的测试日志,以下是我们添加到引擎的一些内容,附有简短说明。

  • +11 elo. Pawn Correction History(兵校正历史)。这是由 Caissa 国际象棋引擎的作者 Michal Witanowski 发现的。它的作用是维护一个哈希表,由底层兵 + 王结构的哈希索引,跟踪位置的静态评估和搜索评估之间差异的运行近似值。这随着时间的推移提高了类似位置的静态评估质量。https://www.chessprogramming.org/Static_Evaluation_Correction_History
  • +11 elo. Non-Pawn Correction History(非兵校正历史)。与上面相同,但由单个玩家的棋子及其位置的哈希索引,排除他们的兵。
  • +6 elo. Minor/Major Correction History(轻子/重子校正历史)。与上面类似,创建两个新的哈希表。一个由轻子(马 + 象 + 王)的棋子和位置的哈希索引,另一个由重子(车 + 后 + 王)索引。
  • +6 elo. Counter Correction History( counter 校正历史)。与上面类似,除了由先前走的棋索引。
  • +2 elo. Counter Move Pruning using a Depth Based Margin(使用基于深度的边界的 Counter 走法剪枝)。Counter 走法剪枝是引擎中的一种机制,它查看走法的历史分数,如果搜索深度低且历史表明走法不好,则决定不搜索它。我们像通常一样改进了这一点,将“不好”的定义缩放为深度的函数。
  • +6 elo. Various mechanisms that mix the Fail-Soft and Fail-Hard framework(混合 Fail-Soft 和 Fail-Hard 框架的各种机制)。学术上,AlphaBeta 剪枝有两个分支,fail-soft 和 fail-hard。引擎倾向于使用 fail-soft,因为它允许额外的信息。但已经发现混合两者,以缓和原始搜索边界之外的估计,是一种改进。
  • +7 elo. Late Move Reductions Deeper(更深的后期走法缩减)。这个概念在典型的 LMR 循环内应用扩展或缩减,基于从 fail-soft derived 的信息,关于走法分数与我们目前最佳走法分数之间的距离。
  • +9 elo. Prior Counter Move Bonus( prior Counter 走法bonus)。当走法好到我们的对手没有后续时,对走法的历史分数应用非常复杂的 bonus。
  • +1 elo. Altair Fail-Soft Multicut(Altair Fail-Soft Multicut)。Multicut 说如果一个位置中的两个走法看起来相当好,那么我们可以假设其中一个确实很好,并停止搜索。这种机制通过使用 fail-soft 结果而不是 fail-hard 结果得到改进,这是来自 Altair 国际象棋引擎的想法。

除了这些补丁和许多更多之外,使用 SPSA 调优获得了大量的 elo。引擎中的 essentially 每个常数值——可以视为搜索的超参数——都被扔进 OpenBench 上的 SPSA 优化器中,我们将玩数千万局游戏,对值进行小调整,并 honing 到更接近最优值的值。就收敛性而言,它并不好,但可靠地以最小的开发者努力提高了强度。

我们调优了每一个适用的搜索参数和时间管理参数。我们也以同样的方式调优了神经网络的权重,针对 L1、L2 和 L3 权重。这可以视为一种后训练、现实世界的调优。训练神经网络最小化损失函数,我们希望这个损失函数是 elo 的良好代理,但显然存在一些脱节。

第 5 节:神经网络

由于本次竞赛的性质,“模型”只是最终解决方案的一小部分。为此事件训练的神经网络是使用名为 Grapheus 的开源工具完成的,由 Torch 国际象棋引擎的另一位开发者编写。该工具实现了训练简单前馈神经网络的典型工具套件,但专门针对基于国际象棋的输入。该框架使用我个人的数据集合进行喂养,这些数据是通过让 Ethereal 国际象棋引擎与自身和对手对弈生成的,并根据这些游戏结果训练模型。该数据不公开,但如有必要可以发布以满足竞赛条件。

现代国际象棋引擎通常使用高度过参数化的输入集,但由于本次竞赛的空间限制,我们选择了简单的方案。我们从神经网络的 768 个输入开始。768 是有 2 个玩家、6 种棋子类型和 64 个格子的结果。2x6x64=768。然而,我们像许多其他团队一样减少了这一点,删除了兵不能占据的 32 个格子(第一和第八 rank)。我们还通过始终确保我们正在评估的玩家的王被镜像到 E-H 文件上来删除另外 32 个格子。如果王在 A-D 文件上,我们可以翻转整个棋盘布局。我们认为王和兵是最重要的特征,所以我们复制它们以提取额外的价值,而不必复制其他参数,从而节省空间。选择这些特征是很自然的,因为大多数国际象棋引擎开发者都从这种 exact 架构开始,由于其体积小且易于训练而不会过拟合。

网络使用简单的 batch gradient descent 与 ADAM 优化器进行训练,拟合一个损失函数,将神经网络输出的 sigmoid 映射到 [0..1],与游戏结果标签 0.0 表示输,0.5 表示和,1.0 表示赢相匹配。 attempts 被 made 去做更有趣的事情——使用更高质量的数据集或网络进行知识蒸馏;努力分阶段剪枝和重新训练网络;以及使用更广泛使用的数据集,如 Leela Chess Zero 项目提供的数据集——但最终这些方法都没有证明比最初的尝试更好。这可能是由于对现有系统的 bias,以及事实上只有约 48kb 的神经网络权重能做这么多。

模型训练使用两个阶段。两个阶段都是 500 个"super batches"(epoch),每个包含 1024x1024x128 个训练样本。第一阶段从 0.001 的 LR 开始,在第 400 个 epoch 后下降 40 倍。第二阶段以 0.00025 的 LR 继续,并在第 400 个 epoch 后再次进行 40 倍下降。阶段之间的主要区别是第一阶段训练模型以拟合 sigmoid(label_search) + label_result 的平均值,在国际象棋圈中通常称为"50/50 eval/WDL"训练。第二阶段纯粹在 label_result 上训练,称为"Pure WDL"。所有模型都是在我家里的个人 3090RTX 上训练的,每个阶段大约需要 8 小时才能达到所需的训练步骤。模型的推理速率几乎是无关紧要的,因为它推动了每秒数千万次的推理。

模型架构定义可以在这里看到:https://github.com/AndyGrant/ClosedGrapheus/blob/kaggle/src/models/ethereal.h#L90-L136

快速总结,假设对典型国际象棋模型有一些理解:

  • 我们使用 768/PSQBB 设置,带有王镜像和兵格子移除
  • 我们使用"half"范式,其中每一方都有自己的 FT 累加器
  • 每个玩家有两个 FT;一个是通常的 PSQBB,另一个仅是友好的王(镜像)和所有兵
  • 单个 FT 在 clipped ReLU 步骤后使用 pairwise multiplication 激活
  • 所有 FT 然后连接,将 side-to-move 的 FT 放在首位。
  • 结果然后 fed 进入"L1",一个产生 8 个输出的 affine transform。
  • 使用 ReLU 激活
  • 结果然后 fed 进入"L2",一个产生 16 个输出的 affine transform。根据棋盘上的棋子数量,有 8 个 L2 副本。
  • 使用 ReLU 激活
  • 结果然后 fed 进入"L3",一个产生 1 个输出的 affine transform。根据棋盘上的棋子数量,有 8 个 L2 副本。
  • 结果与一个"skip neuron"连接,该神经元求和原始材料值
  • 从这里取 sigmoid 仅用于拟合损失函数

使用"dual"FT,允许关于王和兵结构的额外 specialization,是我们解决方案与其他解决方案之间最有意义的区别。我相信我们也是提交了最大神经网络之一的团队。这与 int8 量化配对,以及压缩技巧如转置权重,或非功能性地分解权重块,帮助提供了这种优势。

我们的另一个优势是我们遵循了严格的、统计上有意义的测试制度。所有更改都使用两轮序列概率比检验进行测试,时间控制与 Kaggle 环境相当。这使我们有高度的信心认为我们的更改是有意义的。一般来说,这是计算机国际象棋引擎开发中非常重要的实践。我们能够做到这一点要归功于 pooled 资源,以及我们在此事件之前数十年的经验。

同比赛其他方案