1. 从一局五子棋说起:为什么要死磕推理速度
五子棋这东西,规则简单到用一张餐巾纸就能讲明白,但真要让 AI 通过自博弈把棋力练出来,计算量一点都不“简单”。我最初用 Python 写了个能跑的自博弈框架,逻辑上没毛病,可一跑起来就发现:一局完整对局要花掉将近 3 秒,其中绝大部分时间不是在“思考”,而是在做重复的棋盘扫描、合法性判断和胜负检测。如果按这个速度去堆自博弈样本,想凑够十万局训练数据,光跑对局就得三天三夜,这还没算模型训练本身的开销。
后来我把核心推理部分用 C++ 重写,配合 GPU 做批量并行,最终把同样规模的自博弈对局跑快了 116 倍。这个数字不是拍脑袋来的,是同一台笔记本、同一套规则、同一批随机种子下实测出来的。这篇文章就把整个改造过程拆开讲清楚:为什么 Python 慢、C++ 快在哪、GPU 到底帮了多少忙、哪些地方是性能陷阱、哪些优化看起来很美实际没用。如果你也在做棋类 AI、自博弈训练或者任何需要高频推理的游戏框架,这些经验可以直接拿去用。
需要先说明的是,本文讨论的是游戏推理框架的性能优化,不涉及任何网络访问、模型下载或外部服务调用,所有代码和测试都在本地完成。适合有一定 C++ 基础、想了解 GPU 加速实际落地效果的开发者,也适合正在做自博弈训练、被推理速度卡住的朋友。
2. 整体设计思路:为什么是 C++ 加 GPU 这条路
2.1 先搞清楚瓶颈到底在哪
动手改之前,我做了一轮 profiling,把 Python 版本里每个环节的耗时拆出来看。结果很明确:单局对局约 2.8 秒,其中棋盘状态拷贝和合法性判断占了 55%,胜负检测占 25%,真正的“策略推理”只占不到 20%。也就是说,慢的不是模型,是那些看起来不起眼的状态操作。
这个结论很关键。很多人一提到加速就想着换更大的 GPU、上更复杂的并行策略,但如果瓶颈在 CPU 侧的重复计算上,GPU 再强也救不了。我的思路是先做算法层面的剪枝和状态压缩,再做语言层面的重写,最后才是GPU 批量并行。顺序不能反,否则就是在错误的地方使劲。
2.2 为什么选 C++ 而不是其他方案
可选的路其实有几条:用 Cython 加速热点函数、用 Rust 重写、用 Julia,或者直接上 C++。我选 C++ 的理由很实际:
- 零成本抽象:棋盘状态可以用位棋盘(bitboard)表示,一个 15x15 的棋盘用两个 64 位整数就能存下,拷贝和比较都是寄存器级别的操作,这在 Python 里根本做不到。
- 内存布局可控:自博弈需要同时维护成千上万个对局状态,C++ 可以精确控制结构体对齐和缓存友好性,Python 的对象模型在这方面开销太大。
- 与 GPU 生态衔接顺畅:CUDA 的 host 端代码本身就是 C++,用 C++ 写推理框架可以无缝调用 kernel,不需要跨语言边界反复拷贝数据。
- 编译期优化:模板和内联可以让胜负检测这种高频小函数完全展开,没有函数调用开销。
Rust 其实也能做到这些,但我对 CUDA 的 C++ 接口更熟,而且现有的一些棋类库也是 C++ 写的,迁移成本更低。这不是说 Rust 不好,只是在这个具体场景下 C++ 的生态更顺手。
2.3 GPU 的角色:不是所有计算都适合上 GPU
这里要泼一盆冷水:GPU 不是万能加速器。自博弈对局里有很多分支判断和动态数据结构,这些在 GPU 上反而更慢。我最终只把批量策略推理这一部分放到了 GPU 上,也就是把 N 个对局的当前棋盘状态打包成一个 batch,一次性送进网络前向计算。这样做的原因是:
- 策略网络的前向计算是规整的矩阵运算,天然适合 GPU 的 SIMT 架构。
- 批量处理可以摊薄 kernel 启动开销,batch size 越大,GPU 利用率越高。
- 棋盘状态在 CPU 侧准备好之后,只需要拷贝一次到显存,推理完再拷回来,数据传输占比很小。
至于合法性判断、胜负检测、对局管理这些,全部留在 CPU 侧用 C++ 做,因为它们的分支太多,GPU 的 warp 发散会让效率暴跌。这个分工是实测出来的,不是理论推导。
3. 核心细节拆解:位棋盘、批量推理与内存布局
3.1 位棋盘表示:把 15x15 压进两个整数
五子棋棋盘是 15x15,共 225 个格子。如果用一个char数组存,每个格子 1 字节,就是 225 字节。听起来不大,但在自博弈场景下,每步都要做合法性判断和胜负检测,遍历 225 个格子的开销累积起来非常可观。
位棋盘的做法是用两个 64 位整数分别表示黑子和白子的占位情况。225 位需要 4 个 64 位整数,但实际实现中我用了一个struct包含 4 个uint64_t,总共 32 字节。这样拷贝一个棋盘状态就是 4 次 64 位赋值,比 225 字节的memcpy快了一个数量级。
struct BitBoard { uint64_t black[4]; uint64_t white[4]; bool isOccupied(int pos) const { int word = pos >> 6; int bit = pos & 63; return ((black[word] | white[word]) >> bit) & 1ULL; } };胜负检测也受益于位运算。五子棋需要检查横、竖、两个对角线方向是否有连续五子。用位棋盘可以把每个方向的连续检测转化成移位和与运算,一次操作就能判断一整行。具体做法是:对每个方向,把当前玩家的位棋盘分别左移 1、2、3、4 位,然后全部与起来,如果结果非零,说明存在连续五子。这个技巧在 CPU 上单次检测只需要几十个时钟周期,比循环遍历快得多。
注意:位棋盘的移位操作要注意边界,横方向移位时不能跨行“串味”。我的做法是在棋盘表示时每行留一个空位作为哨兵,这样移位就不会跨行污染。这个细节如果忽略,会出现明明没连五却判赢的 bug,而且很难查。
3.2 批量推理:把 N 个对局打包成一次前向
自博弈的特点是同时有很多局在跑,每局处于不同阶段。如果一局一局地调用策略网络,GPU 利用率会非常低,因为单次前向的计算量太小,kernel 启动开销占了大头。
我的做法是维护一个对局池,每局走完一步后把当前棋盘状态编码成一个固定长度的特征向量,放进一个 batch 缓冲区。当缓冲区攒够 256 个状态时,一次性送进 GPU 做前向计算,算完再把结果分发回各个对局。这样 GPU 的利用率从不到 15% 提升到了 70% 以上。
特征编码我用的是最简单的平面表示:每个位置用两个 bit 表示黑子、白子、空,再加上一个当前玩家标识。整个特征向量长度是 225 * 2 + 1 = 451 个 float。这个编码方式不算最优,但胜在简单、无歧义,而且和位棋盘之间的转换很快。
void encodeBoard(const BitBoard& board, float* features, int currentPlayer) { for (int i = 0; i < 225; ++i) { int word = i >> 6; int bit = i & 63; bool isBlack = (board.black[word] >> bit) & 1ULL; bool isWhite = (board.white[word] >> bit) & 1ULL; features[i * 2] = isBlack ? 1.0f : 0.0f; features[i * 2 + 1] = isWhite ? 1.0f : 0.0f; } features[450] = (currentPlayer == 1) ? 1.0f : 0.0f; }3.3 内存布局:让 CPU 缓存站在你这边
自博弈对局池里同时有几千局在跑,每局的状态结构体如果设计得不好,CPU 缓存命中率会很低。我最初把每局的状态放在一个std::vector<GameState>里,每个GameState包含位棋盘、历史记录、当前玩家等信息,结果发现遍历对局池时 cache miss 率很高。
后来改成结构体数组转数组结构体(AoS 转 SoA)的布局:把所有对局的位棋盘放在一个连续的数组里,历史记录放在另一个数组里。这样遍历位棋盘时内存是连续的,缓存预取器能很好地工作。实测这一步让对局管理部分的耗时下降了约 40%。
struct GamePool { std::vector<BitBoard> boards; // 所有对局的棋盘 std::vector<int> currentPlayers; // 当前玩家 std::vector<int> moveCounts; // 已走步数 // ... };这个优化看起来不起眼,但在大规模自博弈里效果非常明显。很多人在做性能优化时只盯着算法复杂度,忽略了内存访问模式,结果就是理论复杂度降了,实际速度没变甚至更慢。
4. 实操过程:从 Python 原型到 C++ 加 GPU 的完整改造
4.1 第一步:用 C++ 重写核心逻辑并验证正确性
我没有一上来就写 GPU 代码,而是先用纯 C++ 把整个自博弈逻辑重写了一遍,确保和 Python 版本的行为完全一致。这一步的验证方法是:用同一组随机种子,分别跑 Python 和 C++ 版本各 1000 局,对比每局的落子序列和最终胜负结果。只有两者完全一致,才说明重写没有引入逻辑错误。
这个验证过程花了大概两天,但非常值得。因为后面上 GPU 之后,如果结果不对,你很难判断是 GPU 代码的问题还是基础逻辑的问题。先把 CPU 版本做扎实,后面排查问题会轻松很多。
C++ 版本跑下来,单局耗时从 2.8 秒降到了 0.09 秒,已经快了 31 倍。这个提升主要来自位棋盘、编译期优化和更好的内存布局,还没用到 GPU。
4.2 第二步:接入 GPU 做批量策略推理
GPU 部分我用的是 CUDA。策略网络本身不复杂,就是一个几层全连接网络,输入 451 维,输出 225 维的动作概率。网络权重在初始化时从文件加载到显存,之后不再变动。
关键代码是 batch 推理的 kernel 调用:
// 假设 d_features 是显存中的 batch 特征,d_output 是输出 // batchSize 是当前 batch 的大小 dim3 block(256); dim3 grid((batchSize + block.x - 1) / block.x); policyKernel<<<grid, block>>>(d_features, d_weights, d_output, batchSize);这里有个细节:batch size 不是固定的。对局池里随时有对局结束、有新对局开始,所以每轮攒到的状态数不一样。我的处理方式是设置一个阈值,比如攒够 128 个就送一次 GPU,如果超过一定时间(比如 10 毫秒)还没攒够,也强制送一次,避免对局池里出现“饿死”的情况。
实操心得:GPU 推理的 batch size 不是越大越好。我实测下来,batch size 在 256 到 512 之间时吞吐量最高,再大反而因为显存带宽和 kernel 调度开销导致延迟上升。这个最优点和你的网络大小、GPU 型号都有关,建议自己跑一组 benchmark 确定。
4.3 第三步:CPU 与 GPU 的流水线重叠
单纯把推理放到 GPU 上还不够,因为 CPU 在等待 GPU 返回结果时是空闲的。为了进一步压榨性能,我把整个流程做成了流水线:CPU 在准备第 N+1 批状态的同时,GPU 在计算第 N 批。这样 CPU 和 GPU 的利用率都上去了。
实现方式是用两个 CUDA stream,交替使用。一个 stream 在跑当前 batch 的推理时,另一个 stream 可以接收下一批数据。配合cudaMemcpyAsync做异步拷贝,整体吞吐量又提升了约 20%。
cudaStream_t streams[2]; // 交替使用 streams[0] 和 streams[1] cudaMemcpyAsync(d_features[streamIdx], h_features, size, cudaMemcpyHostToDevice, streams[streamIdx]); policyKernel<<<grid, block, 0, streams[streamIdx]>>>(...); cudaMemcpyAsync(h_output, d_output[streamIdx], size, cudaMemcpyDeviceToHost, streams[streamIdx]);这一步的收益没有想象中那么大,因为自博弈的瓶颈不完全在推理上,CPU 侧的棋盘操作仍然占了不少时间。但积少成多,每一块都优化一点,最终的整体提升就很可观。
4.4 第四步:实测数据与 116 倍的来源
最终实测环境是一台笔记本,CPU 是 Intel 的移动端处理器,GPU 是 NVIDIA RTX 4060 Laptop。测试条件是:固定随机种子,跑 10000 局自博弈对局,统计总耗时。
| 版本 | 单局平均耗时 | 相对 Python 加速比 |
|---|---|---|
| Python 原型 | 2.80 秒 | 1x |
| C++ 纯 CPU | 0.090 秒 | 31x |
| C++ 加 GPU 批量推理 | 0.041 秒 | 68x |
| C++ 加 GPU 加流水线 | 0.024 秒 | 116x |
116 倍是这么来的。可以看到,C++ 重写贡献了最大的那一块,GPU 批量推理又翻了一倍多,流水线再叠加一点。每一层优化都有明确的收益,没有哪一步是白做的。
注意:这个加速比是在特定硬件和特定网络规模下测得的。如果你的策略网络更大、或者对局逻辑更复杂,各阶段的占比会变化,加速比也会不同。不要把这个数字当成通用结论,要结合自己的场景去测。
5. 常见问题与排查技巧实录
5.1 GPU 推理结果和 CPU 不一致怎么办
这是最常见的问题。可能的原因有几个:一是浮点精度差异,GPU 和 CPU 的浮点运算顺序不同,结果会有微小差异,如果网络对精度敏感,可能导致动作选择不同;二是数据传输时的对齐问题,特征向量的长度如果不是 4 的倍数,拷贝时可能出错;三是 kernel 里的索引计算有误,导致部分状态读到了错误的数据。
排查方法:先固定一个很小的 batch(比如 4 个状态),把 GPU 输出和 CPU 输出逐元素对比,看差异有多大。如果差异在 1e-5 量级,那是正常的浮点误差;如果差异很大,那就是逻辑错误,重点检查索引和内存对齐。
5.2 自博弈对局出现“死循环”或异常长局
五子棋理论上可能下满 225 手,但如果策略网络输出有问题,比如总是选择同一个位置,就会导致对局卡住。我的处理方式是在对局管理里加一个最大步数限制,超过 225 手直接判和,同时记录下这种异常对局用于后续分析。
另一个原因是合法性判断有 bug,导致某个非法位置被反复选中。这种情况通常出现在位棋盘的边界处理上,比如横方向移位时跨行污染。建议写一组单元测试,专门覆盖边界位置的合法性判断。
5.3 显存不够用怎么办
自博弈对局池很大时,如果把所有对局的状态都放在显存里,显存会不够。我的做法是只在显存里保留当前 batch 的特征和网络权重,对局状态全部留在 CPU 内存里。每次推理前把 batch 拷贝到显存,推理完把结果拷回来。这样显存占用是固定的,和对局池大小无关。
如果网络本身很大,显存还是不够,可以考虑用半精度浮点数(FP16)存储权重和特征,显存占用直接减半。但要注意 FP16 的精度问题,可能需要混合精度训练来保持稳定性。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| GPU 结果与 CPU 差异大 | 索引错误、内存对齐 | 小 batch 逐元素对比 |
| 对局异常长或死循环 | 策略输出异常、合法性 bug | 加最大步数限制、单元测试 |
| 显存不足 | 对局状态全放显存 | 只保留当前 batch 在显存 |
| GPU 利用率低 | batch 太小、kernel 启动频繁 | 增大 batch、异步流水线 |
| 加速比不达预期 | 瓶颈不在推理 | 重新 profiling,找真正热点 |
6. 几个容易被忽略的性能细节
6.1 随机数生成器的选择
自博弈需要大量随机数来做探索,比如以一定概率选择非最优动作。我最初用的是std::rand(),后来发现它在多线程环境下有锁竞争,而且随机质量一般。换成std::mt19937之后,不仅随机质量更好,而且每个线程可以持有独立的生成器实例,没有竞争。
更进一步,如果对随机数质量要求不高,可以用 xorshift 这类轻量级生成器,速度比mt19937快好几倍。自博弈里的随机主要用于探索,对统计质量要求没那么高,xorshift 完全够用。
6.2 避免不必要的状态拷贝
C++ 里很容易在不经意间触发拷贝。比如函数参数如果传值而不是传引用,每次调用都会拷贝一个BitBoard。虽然BitBoard只有 32 字节,但在高频调用的路径上,累积起来也是不小的开销。我的做法是所有棋盘相关的函数参数都用const BitBoard&,返回值用移动语义或者直接原地修改。
另一个容易忽略的地方是std::vector的扩容。对局池如果频繁增删对局,vector会反复重新分配内存。我的做法是预分配一个足够大的池子,用空闲列表管理,避免运行时扩容。
6.3 编译选项的影响
C++ 的编译选项对性能影响很大。我用的关键选项是-O3、-march=native、-flto。-O3开启激进优化,-march=native让编译器针对当前 CPU 生成指令(比如 AVX2),-flto开启链接时优化,可以跨编译单元内联。
实测下来,光是把-O2换成-O3 -march=native,位棋盘相关的操作就快了约 15%。这个提升是免费的,只要改一下编译命令就行,没有理由不做。
提示:
-march=native生成的二进制不能在老 CPU 上运行。如果需要在多台机器上部署,要么针对目标 CPU 分别编译,要么用-mtune代替-march,牺牲一点性能换取兼容性。
7. 后续可以继续挖的方向
这套框架目前跑得挺稳,但还有几个地方可以继续优化。一是策略网络的量化,把 FP32 换成 INT8,推理速度还能再提一截,但需要重新训练或做量化校准。二是把胜负检测也搬到 GPU 上,用并行前缀和之类的技巧做批量判断,不过收益可能有限,因为胜负检测本身已经很快了。三是支持多 GPU,把对局池分到多张卡上,适合更大规模的自博弈训练。
我个人在实际操作中的体会是,性能优化最忌讳“想当然”。每一步改动都要有 profiling 数据支撑,改完要重新测,确认收益是真实的。我见过太多人花大力气优化了一个只占 5% 耗时的环节,结果整体速度几乎没变。先把瓶颈找出来,再动手,这个顺序永远不能乱。