使用云端服务器运行 Stockfish
本文最后更新于 101 天前,其中的信息可能已经有所发展或是发生改变。

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

但是,这会有以下问题:

  1. 大部分的国际象棋GUI都只允许指定一个不传参的可执行文件(.exe)作为引擎执行文件。
  2. 很多国际象棋GUI无法指定太多线程数(例如对于Chessbase,无法指定超过16的线程数)。
  3. 对于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的更改都不需要重新编译这个执行器。

暂无评论

发送评论 编辑评论


|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
下一篇