摘要
战斗记录 App 有两种输入:玩家按截图键(手动),或者 App 自己从录屏流中挑选“关键帧”(值得深读的画面)送去识别(自动)。本实验回答两个实际问题:
- 自动模式为什么漏掉了一闪而过的战斗日志? 战斗日志(Logs)是游戏里逐行记录施法、攻击的文字面板,是认出敌方法术的主要证据。在一局回放中,日志只停留约 3 秒,敌方两次施法都没找到。查明原因是选帧服务“饿死”了:它每处理一帧都开一个占满所有 CPU 核的线程池,系统一忙就落后,App 只好丢帧。改成单线程后每帧从 28.5 毫秒降到 8.9 毫秒,决定逐项不变,同段回放找到 5 个日志关键帧和两个敌方施法。
- 按键到简报要多久,时间花在哪? 在安静机器上按阶段拆开测量,发现主要开销是 App 主线程反复完整校验已保存的读数。加缓存、改为“读数一保存就刷新简报”后,按键到第一次简报的中位数 3.34 → 1.85 秒,p95 仍约 4.8 秒。
另顺带修复了一处“召唤生物不在本地实体表、整帧被拒并重启路由器”的问题,并让攻击预览读取只给用得上的行动卡排名。
要回答的问题
- 自动模式漏掉短暂日志,是选帧规则问题还是实时供帧问题?
- 安静机器上按键到简报的时延是多少?每个阶段各占多少?
数据与来源
- 实时漏帧:用户睡觉时的一次实时回放记录(每个关键帧的去向日志),以及同一段测试录像。
- 时延:13 个测试局截图包,共 182 次按键(用 P127 的离屏真实 App 按键回放)。
- 路由器攻击预览对照:所有截图包中的 80 张攻击/法术预览帧。
方法
- 漏帧:读实时回放的关键帧日志;再把同一段录像按精确 10 帧/秒离线重放给选帧服务,比较两边的关键帧。
- 选帧服务耗时:选帧用的是一个冻结的随机森林类模型(ExtraTrees,300 棵树)。同样的帧分别用“所有核并行”和“单线程”各跑一遍,比较每帧毫秒数,并要求所有决定(帧号、判定、场景)逐项相同。
- 时延:在回放器中记录路由器(router,把截图分给各读取器的程序)自报的读图时间、各任务时间、按键后第一次简报更新时间和全部稳定时间;等待预热完成;限 8 核;之后用 cProfile 剖析主线程。
- 判定原则:改选帧服务只要求决定逐项相同;时延只报实测、按阶段拆开,不与“整帧 p95 ≤3 秒”的项目目标混为一谈。
设置
机器安静(游戏未开),所有任务限 8 核。第一次时延测量混进了两次短基准,已注明。
结果
1. 漏帧原因与修复
那次回放中,App 抓了 8645 帧,只送进选帧器 5659 帧(约 6 帧/秒):服务落后 8 帧时 App 就丢帧。离线按 10 帧/秒重放同一段,选帧器在 975.4、977.1、978.1 秒给出了三个日志关键帧——规则没问题,是实时拿不到帧。原因是模型每帧只预测一行,却每次启动覆盖全部核的线程池(每帧约 19 次线程启动),路由器读图占满 CPU 时最先被挤掉。
| 窗口 | 帧 | 关键帧 | 全核并行 每帧 p50 / p95 | 单线程 每帧 p50 / p95 | 决定逐项相同 |
|---|---|---|---|---|---|
| 测试录像 A,940–1000 秒 | 600 | 28 | 29.1 / 43.4 ms | 9.5 / 20.9 ms | 是 |
| 测试录像 B,300–900 秒 | 6000 | 368 | 28.5 / 40.3 ms | 8.9 / 21.6 ms | 是 |
| 测试录像 C,300–900 秒 | 6000 | 391 | 28.6 / 40.6 ms | 8.8 / 21.0 ms | 是 |
同一段实时回放复测:
| 版本 | 抓帧 | 送进选帧器 | 日志关键帧 | 敌方法术(真值两个) | 英雄 |
|---|---|---|---|---|---|
| 修改前 | 8645 | 5659 | 0 | 无 | 对 |
| 修改后 | 8700 | 6458 | 5 | 两个都找到,0 误报 | 对 |
2. 按键到简报的分阶段时延(修改前,单位秒)
准确性:敌方英雄 13/13、敌方法术 11/11、0 误报。
| 阶段 | p50 | p90 | p95 | 最大 |
|---|---|---|---|---|
| 按键 → 原图保存 | 0.53 | 0.61 | 0.64 | 0.84 |
| 路由器自报读图 | 0.81 | 1.49 | 1.81 | 13.15 |
| 读图之外的读取开销 | 0.37 | 1.46 | 1.73 | 4.21 |
| 按键 → 结果入库 | 1.91 | 3.32 | 4.24 | 15.18 |
| 结果入库 → 第一次简报 | 1.27 | 2.11 | 2.24 | 2.59 |
| 按键 → 第一次简报 | 3.34 | 5.15 | 5.73 | 16.72 |
| 按键 → 全部稳定 | 4.00 | 5.53 | 6.14 | 16.92 |
SE 本身很快:SE(状态估计,把读数累积成对局状态和对手推断)每次刷新 0–25 毫秒,最长 0.5 秒。“入库 → 简报”的 1.3 秒不在 SE。
时间花在主线程反复校验:剖析显示,一局里读取已保存结果的函数被调用 1028 次,每次都从三个文件重建并完整校验回执;视图投影每次刷新又对每条已保存读数做规范化编码(约 2 万次)。改动:按文件大小和修改时间缓存已校验结果;同样字节只完整校验一次;保存原图 PNG 的压缩级别 6 → 3(无损,编码 255 → 128 毫秒)。另外发现简报要等记录页的共享投影保存完(每帧约 1–1.5 秒)才刷新,而简报其实不需要投影,于是改为“读数一保存就刷新”。
| 版本 | 按键 → 入库 | 入库 → 第一次简报 | 按键 → 第一次简报 | 按键 → 全部稳定 | 失败 |
|---|---|---|---|---|---|
| 修改前 | 1.91 / 3.32 / 4.24 / 15.18 | 1.27 / 2.11 / 2.24 / 2.59 | 3.34 / 5.15 / 5.73 / 16.72 | 4.00 / 5.53 / 6.14 / 16.92 | 1 |
| 校验缓存 + PNG 3 级 | 1.44 / 2.12 / 2.27 / 4.51 | 1.22 / 2.21 / 2.49 / 3.21 | 2.77 / 4.19 / 4.77 / 6.74 | 3.40 / 4.55 / 4.98 / 6.95 | 0 |
| 再加读完即刷新 | 1.72 / 3.65 / 4.51 / 6.33 | 0.02 / 0.58 / 0.86 / 3.15 | 1.85 / 3.93 / 4.82 / 7.46 | 3.31 / 5.87 / 7.05 / 8.93 | 0 |
(每格为 p50 / p90 / p95 / 最大,秒;三个版本准确性相同。)
3. 攻击预览只给用得上的行动卡排名
路由器耗时的长尾来自攻击任务:它对每张可见的行动卡(行动条上显示行动顺序的卡片)做身份排名,每张约 0.2 秒,但结果只用到第一张卡和数量与预览下方徽章匹配的卡。其余卡改为只留数量。80 张预览帧新旧各读一次:结果 80/80 相同;攻击任务 p50 1.41 → 0.31 秒,p95 2.74 → 0.72 秒。
失败与负结果
- 一帧整帧被拒并重启路由器:路由器正确认出一个召唤生物(火焰幼虫)的生物面板,但 App 的实体表只收竞技场可选生物,校验报“对象不在本地目录”,整帧作废;更糟的是校验失败会停掉路由器,下一次按键冷启动用了 13 秒。已修:实体表收全部生物(含 7 个召唤单位),内容被拒只拒这一帧、不停进程。
- “读完即刷新”后,“按键 → 入库”反而变慢、“全部稳定”的 p95 变大。原因与测量方式有关:刷新在保存时同步执行,且回放器在简报稳定后就按下一键,落在上一帧还在主线程收尾的时候。真实对局里按键间隔通常更长,但连按仍会排队。
- 视图 Qt 测试有两个旧失败,改动前同样失败。
局限
- p95 仍约 4.8 秒,没有达到项目的 3 秒目标;这些是 13 个测试局的回放数据,不含真实游戏画面抓取。
- 实时模式仍有约 26% 的帧没送进选帧器(6458/8700),瓶颈在 App 一侧,尚未查。
- 主线程上共享视图投影的校验和一个隐藏面板的重算这次没改。
结论与下一步
短暂日志被漏掉的根因是“每帧开全核线程池”这种写法在系统忙时最先被挤掉,单线程预测就解决了,且决定完全不变。按键时延的大头不是识别模型,也不是 SE,而是 App 主线程的重复校验;缓存和提前刷新把中位时延降了近一半。下一步:查 App 一侧剩余约 26% 的丢帧,处理主线程上投影校验和隐藏面板重算,以及路由器约 0.25 秒的固定开销。
Comments