tl;dr
我使用一台阿里云抢占式实例运行 Stockfish 国际象棋引擎,并在本地GUI中实时查看结果。这一实例可以与任何允许自由配置国际象棋引擎的GUI(例如en-croissant
或者Chessbase
)交互。在测试使用的实例规格中,性能可达8千万节点每秒(80MNodes/s),成本在截稿时约为3.2元/小时。
背景
我希望使用国际象棋引擎实时旁观 2024 FIDE World Chess Championship 的比赛,并在赛后立刻生成一份引擎分析报告。
一般情况下,如果想要使用一个国际象棋引擎,我会在本地运行一个 Stockfish 实例。然而,我很快发现本地 Stockfish 会占用大量的CPU计算资源和内存资源,导致系统卡顿(这一现象在尝试使用Chessbase
开启支招器时,快速移动棋子时尤为明显)。
另外,本地运行的 Stockfish 实例的搜索速度也很慢,导致引擎并不能确切地评估当前的局面。
在 2024 FIDE World Chess Championship 的一个月比赛内,我最多只有15天,每天最多5小时左右的时间需要使用这台服务器,另外,由于 Stockfish 的计算是无状态的(除了哈希表,但这无关紧要),我也能接受由于价格波动导致的计算资源突然中断。
所以,我选择使用阿里云的抢占式实例运行我的 Stockfish 实例。
启动云服务器实例
登录云服务器管理控制台,并创建一个实例。
对于我的测试,我选择华北5区(呼和浩特)可用区A的 密集计算型 ic5.16xlarge 实例,CPU / 内存比为 1:1,拥有 64个 Intel Skylake Xeon Platinum 8163 2.5GHz 处理器核心。
需要注意的是阿里云在实例指标中并不建议使用 密集计算型 ic5,但由于相比其他 HPC 应用场景,Stockfish 对内存的需求非常小,Stockfish 17 最多支持1024个线程,但是哈希表一般不会超过8G,而 ic5 是唯一一个CPU / 内存比为1:1的实例,其余的实例都或多或少有更多的内存被浪费了。
另外,使用抢占式实例要求阿里云账户中至少拥有100元的余额,并且在创建抢占式实例后会冻结账户的100元余额,这一冻结会在最后一个抢占式实例释放后的24小时解冻。
Stockfish 对存储性能要求很低,所以硬盘选择高效云盘 20GB。
除了下载 Stockfish 和在本地和云端之间传递UCI指令和引擎结论以外,这台实例几乎不会使用别的流量,所以我使用按量付费(CDT),峰值带宽100Mbps。
系统选择 Ubuntu 24.04,并使用密钥对登录。使用密钥对登录对后续的操作非常重要,确保你配置使用了密钥对登录。
创建实例后,复制对应的公网IP地址,然后在终端中执行:
ssh -i "私钥文件路径" root@公网IP地址
首次执行这一命令时,你可能会被提示确认服务器的指纹,此时,输入yes
并回车即可继续连接。
如果出现了类似root@Stockfish:~#
的提示符,说明你已经成功访问了你的云服务器。
准备环境
在root账户下执行:
wget -O stockfish.tar.gz https://github.com/official-stockfish/Stockfish/releases/latest/download/stockfish-ubuntu-x86-64-avx2.tar && tar -xzf stockfish.tar.gz -C /root/ && rm stockfish.tar.gz && mv /root/stockfish-ubuntu-x86-64-avx2 /root/stockfish 2>/dev/null
如果wget拉取速度过慢,你可以自行配置Github镜像或者手动下载到本地然后上传到服务器以继续操作
你应该能在/root/
下发现一个stockfish
文件夹:
root@Stockfish:~# ls
stockfish
root@Stockfish:~# ls stockfish/
AUTHORS CITATION.cff CONTRIBUTING.md Copying.txt README.md src stockfish 'Top CPU Contributors.txt' wiki
云服务器本地测试
执行:
/root/stockfish/stockfish
如果一切顺利,你能看到:
root@Stockfish:~# /root/stockfish/stockfish
Stockfish 17 by the Stockfish developers (see AUTHORS file)
然后,使用UCI命令配置引擎执行线程数,这个数最好是3倍你的CPU核心数:
setoption name Threads value 192
应该能看到返回:
info string Using 192 threads
确认引擎已经准备好:
uci
如果看到uciok
,就表明引擎已经准备好运行了:
id name Stockfish 17
id author the Stockfish developers (see AUTHORS file)
option name Debug Log File type string default <empty>
option name NumaPolicy type string default auto
option name Threads type spin default 1 min 1 max 1024
option name Hash type spin default 16 min 1 max 33554432
option name Clear Hash type button
option name Ponder type check default false
option name MultiPV type spin default 1 min 1 max 256
option name Skill Level type spin default 20 min 0 max 20
option name Move Overhead type spin default 10 min 0 max 5000
option name nodestime type spin default 0 min 0 max 10000
option name UCI_Chess960 type check default false
option name UCI_LimitStrength type check default false
option name UCI_Elo type spin default 1320 min 1320 max 3190
option name UCI_ShowWDL type check default false
option name SyzygyPath type string default <empty>
option name SyzygyProbeDepth type spin default 1 min 1 max 100
option name Syzygy50MoveRule type check default true
option name SyzygyProbeLimit type spin default 7 min 0 max 7
option name EvalFile type string default nn-1111cefa1111.nnue
option name EvalFileSmall type string default nn-37f18f62d772.nnue
uciok
开始计算:
go infinite
你会看到引擎的计算结论。
停止计算:
stop
引擎会停止计算并给出当前的最佳结论 (1. e4):
bestmove e2e4 ponder e7e5
使用Ctrl+Z结束Stockfish运行进程:
^Z
[1]+ Stopped stockfish/stockfish
root@Stockfish:~#
远程通信软件制作原理
原理上,只要执行以下指令,就能获得一个远程的 Stockfish UCI 界面:
ssh -i "私钥文件路径" root@公网IP地址 /root/stockfish/stockfish
但是,这会有以下问题:
- 大部分的国际象棋GUI都只允许指定一个不传参的可执行文件(.exe)作为引擎执行文件。
- 很多国际象棋GUI无法指定太多线程数(例如对于
Chessbase
,无法指定超过16的线程数)。 - 对于
Chessbase 17
及以上版本,它的Buddy Engine必须同样使用默认支招器,在这时,会有两个引擎实例同时执行,我们不希望将云服务器的资源均分在这两个实例上,我们希望能为主引擎提供尽可能多的算力。
所以我们需要一些方法来额外配置。
编写远程通信软件
可以使用Python建立一个对指定SSH指令的双向通信,然后打包成一个可执行文件。
首先,指定SSH命令:
SSH_COMMAND = ["ssh", "-i 私钥文件路径", "root@公网IP地址", "/root/stockfish/stockfish"]
创建一个SSH子进程:
try:
# 启动SSH进程
process = subprocess.Popen(
SSH_COMMAND,
stdin=subprocess.PIPE, # 标准输入
stdout=subprocess.PIPE, # 标准输出
stderr=subprocess.PIPE, # 错误输出
text=True, # 文本模式
bufsize=1 # 行缓冲
)
except Exception as e:
print(f"启动Stockfish进程失败: {e}", file=sys.stderr)
sys.exit(1)
现在,我们从STDIN向引擎写入命令:
def write_to_engine(process, command):
"""向引擎写入命令"""
try:
process.stdin.write(f"{command}\n")
process.stdin.flush()
except Exception as e:
print(f"写入引擎命令失败: {e}", file=sys.stderr)
我们从STDOUT读取引擎的输出,并打印出来:
def read_from_engine(process):
"""读取引擎输出"""
try:
for line in process.stdout:
line = line.strip()
if line:
print(line, file=sys.stderr)
except Exception as e:
print(f"读取引擎输出错误: {e}", file=sys.stderr)
我们创建一个单独的线程来将引擎输出打印出来:
reader_thread = threading.Thread(target=read_from_engine, args=(process,), daemon=True)
reader_thread.start()
在主循环中,我们不断读取用户的输入,并发送给引擎,引擎的输出将会实时打印出来:
while True:
command = input()
if command.lower() == "quit":
write_to_engine(process, "quit")
break
write_to_engine(process, command)
完整的代码如下:
import subprocess
import sys
import threading
# SSH连接配置
SSH_COMMAND = ["ssh", "-i 私钥文件路径", "root@公网IP地址", "/root/stockfish/stockfish"]
def write_to_engine(process, command):
"""向引擎写入命令"""
try:
process.stdin.write(f"{command}\n")
process.stdin.flush()
except Exception as e:
print(f"写入引擎命令失败: {e}", file=sys.stderr)
def read_from_engine(process):
"""读取引擎输出"""
try:
for line in process.stdout:
line = line.strip()
if line:
print(line, file=sys.stderr)
except Exception as e:
print(f"读取引擎输出错误: {e}", file=sys.stderr)
def main():
try:
# 启动SSH进程
process = subprocess.Popen(
SSH_COMMAND,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
except Exception as e:
print(f"启动Stockfish进程失败: {e}", file=sys.stderr)
sys.exit(1)
# 创建读取线程
reader_thread = threading.Thread(target=read_from_engine, args=(process,), daemon=True)
reader_thread.start()
# 主命令处理循环
try:
while True:
command = input()
if command.lower() == "quit":
write_to_engine(process, "quit")
break
write_to_engine(process, command)
except EOFError:
write_to_engine(process, "quit")
except Exception as e:
print(f"处理命令时发生错误: {e}", file=sys.stderr)
write_to_engine(process, "quit")
# 主循环结束,尝试关闭进程
try:
process.terminate()
except Exception:
pass
# 等待读取线程结束
reader_thread.join(timeout=1)
if __name__ == "__main__":
main()
将其保存为app.py
,然后准备将其打包成一个可执行文件。
打包为可执行文件
编写一个执行器,在脚本目录下新建一个run.py文件:
import subprocess
def main():
subprocess.run("python app.py 2>&1", shell=True)
if __name__ == "__main__":
main()
然后,在脚本目录下执行:
pip install pyinstaller
pyinstaller --onefile --clean --noconfirm --distpath ./ --name Stockfish run.py
del *.spec
rmdir /s /q build
你会在脚本目录下发现一个Stockfish.exe,在国际象棋GUI中指定这个文件作为国际象棋引擎,即可开始使用远程服务器运行 Stockfish。
进阶-充分利用服务器资源
当前虽然已经能够作为一个合格的国际象棋远程引擎使用,然而,这仍未解决问题2和3。
国际象棋GUI一般不允许配置超过16个线程,即使可以,也有可能很快会被调回来。
然而,我们可以通过修改实际向引擎发送的线程数,以达到”虽然GUI发送的线程数很小,但实际上执行的线程数很多“的效果。
修改app.py
的主循环部分:
command = input()
if command.lower() == "quit":
write_to_engine(process, "quit")
break
write_to_engine(process, command)
国际象棋GUI调整引擎线程数的指令是:
setoption name Threads value 线程数
我们识别这个指令特征,如果国际象棋GUI尝试调整引擎线程数,我们向引擎实际发送一个更大的值(例如64的3倍,192):
if command.lower() == "quit":
write_to_engine(process, "quit")
break
elif command.startswith("setoption name Threads value "):
write_to_engine(process, "setoption name Threads value 192")
else:
write_to_engine(process, command)
这会导致在Chessbase 17
中,Buddy Engine跟主引擎占据同样的资源,这并不理想,我们希望主引擎占用尽可能多的资源。
我们可以进一步修改代码,识别国际象棋GUI尝试调整的线程数,如果这个线程数大于10(Buddy Engine占用的线程数一般低于这个值,在1-7左右),我们才向引擎发送更大的值,否则,将调整指令原样发送:
if command.lower() == "quit":
write_to_engine(process, "quit")
break
elif command.startswith("setoption name Threads value "):
threads_value = int(command.split(" ")[-1])
if threads_value > 10:
write_to_engine(process, "setoption name Threads value 192")
else:
write_to_engine(process, command)
else:
write_to_engine(process, command)
完整代码如下:
import subprocess
import sys
import threading
# SSH连接配置
SSH_COMMAND = ["ssh", "-i 私钥文件路径", "root@公网IP地址", "/root/stockfish/stockfish"]
def write_to_engine(process, command):
"""向引擎写入命令"""
try:
process.stdin.write(f"{command}\n")
process.stdin.flush()
except Exception as e:
print(f"写入引擎命令失败: {e}", file=sys.stderr)
def read_from_engine(process):
"""读取引擎输出"""
try:
for line in process.stdout:
line = line.strip()
if line:
print(line, file=sys.stderr)
except Exception as e:
print(f"读取引擎输出错误: {e}", file=sys.stderr)
def main():
try:
# 启动SSH进程
process = subprocess.Popen(
SSH_COMMAND,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1
)
except Exception as e:
print(f"启动Stockfish进程失败: {e}", file=sys.stderr)
sys.exit(1)
# 创建读取线程
reader_thread = threading.Thread(target=read_from_engine, args=(process,), daemon=True)
reader_thread.start()
# 主命令处理循环
try:
while True:
command = input()
if command.lower() == "quit":
write_to_engine(process, "quit")
break
elif command.startswith("setoption name Threads value "):
threads_value = int(command.split(" ")[-1])
if threads_value > 10:
write_to_engine(process, "setoption name Threads value 192")
else:
write_to_engine(process, command)
else:
write_to_engine(process, command)
except EOFError:
write_to_engine(process, "quit")
except Exception as e:
print(f"处理命令时发生错误: {e}", file=sys.stderr)
write_to_engine(process, "quit")
# 尝试关闭进程
try:
process.terminate()
except Exception:
pass
# 等待读取线程结束
reader_thread.join(timeout=1)
if __name__ == "__main__":
main()
由于我们打包的可执行程序实际上是一个执行app.py的执行器,任何对app.py
的更改都不需要重新编译这个执行器。