摘要
战斗记录 App(Battle Notes)的实时模式在玩家打游戏时不停截屏读取:
- 主读取:定期截一整张图交给识别进程(router,把截图分给各个读取器的程序)完整读一遍;
- 旁路读取:在主读取之间,针对行动条、攻击预览、信息页等做的小读取;
- 每次读到新信息,App 还会启动一次状态估计(SE,把观察累积成对局状态和对手构筑推断)。
用户实玩一局后发现,主读取越到后面越慢。本实验只做测量和修改建议:用这局会话的保存数据还原时间线,再离线重放同一段代码计时。结论是:开销的大头不在识别本身,而在每次主读取之后 UI 线程上随读取次数增长的工作,而且这段时间识别进程空闲、却被 App 挡着拿不到任务。
报告完成后,两项最小修改(P2 编码快速路径、P1 旁路读取放行)已在独立工作区实施并经过三位审查;真实会话数据逐字节相同,但在实时游戏或回放中的实际效果还没测。
要回答的问题
每次主读取在 App 端的处理时间为什么随读取次数增长?哪些修改最小、最安全,又能让识别进程少等?
数据与来源
- 用户用安装版 App 实玩的一局(10 月 14 日 23:58):58 次主读取、249 次旁路读取、316 次 SE 快照。只读,拷贝后分析。
- 测量的是工作区源码;测量路径上的关键文件与安装版逐字节相同(除行尾)。
- 离线运行限制在 4 个逻辑处理器、低优先级;测量时用户正在玩,实时 App 也在跑,处理器有重叠,同一步骤重复测量差约 20%。没有 OCR、视频解码或 GPU。
方法
- 时间线还原:按文件时间和日志,还原每次主读取的截图时刻、请求发出、旁路答复、接受(accepted)、投影提交、发布、回看快照写盘和下一次截图。
- 旁路等待:统计旁路读取从“待发”到“发出”等了多久,多少落在主读取的 App 端空档里。
- 离线重放:把拷贝裁回第 N 次读取,按 App 的顺序真实接受第 N+1 次读取,逐步计时,并用 cProfile 找热点。
- SE 一侧:离屏建出真实主窗口,计时一次 SE 完成在 UI 线程上的工作。
- 速度校准:用一个与读取次数无关的步骤(保存一张关键帧 PNG:实时 0.64–0.68 秒,离线 0.14 秒)估计实时环境比离线慢约 3–4.7 倍。
设置
在 Qt 主线程上的工作(信号、定时器)与子进程(识别、关键帧、SE 常驻进程、投影)分开统计。
结果
实时时间线(中位数,秒)
| 主读取 | 处理时间 | 识别自报 | 等前面的旁路 | 其余 | 接受→投影提交 | 提交→发布 | 发布→快照写完 | 快照→下一次截图 |
|---|---|---|---|---|---|---|---|---|
| 1–12 | 1.73 | 0.68 | 0 | 0.62 | 1.17 | 0.25 | 1.22 | 2.14 |
| 13–30 | 3.64 | 1.05 | 0.34 | 1.97 | 2.94 | 0.44 | 2.06 | 3.01 |
| 31–45 | 4.04 | 1.05 | 0.18 | 2.59 | 3.84 | 0.69 | 2.74 | 3.94 |
| 46–58 | 4.67 | 0.93 | 0.26 | 2.86 | 4.78 | 1.02 | 2.71 | 3.97 |
第 46–58 次,一个主读取周期约 17 秒,其中识别进程做主读取只用约 1 秒。主读取画面相对实际游戏的落后时间也从约 9.5 秒涨到约 35.5 秒。
三个原因
- “处理时间”里的 App 部分主要是排队:战斗开始后(第 13 次起)有了旁路读取和频繁的 SE。46 次战斗主读取中 45 次发出时识别进程上正有旁路读取,只能排队;识别答复到了以后,UI 线程又几乎总在处理一次 SE 完成,答复要等 UI 空下来才被读出。
- 真正随读取次数增长的,是接受之后的 UI 线程工作:每次主读取都把本局全部读取重新组装、编码、校验、写盘好几遍。离线重放:
| 已有读取 → 新读取 | 合计 | 其中 UI 线程 |
|---|---|---|
| 10 → 11 | 0.73 秒 | 0.62 秒 |
| 30 → 31 | 1.44 秒 | 1.26 秒 |
| 57 → 58 | 2.53 秒 | 2.24 秒 |
cProfile(第 57→58 次)里最大的几项:纯 Python 的 JSON 编码器约 47%(同一份大请求被整份编码了好几遍),深拷贝约 25%,投影结果校验约 21%。投影缓存从 0.32 MB 涨到 2.06 MB,58 个回看快照共 64 MB——每个都是当时整份缓存。
- 这段时间识别进程闲着却拿不到活:App 只有在“完全安静”(没有投影任务、没有待接受的读取)时才发主读取,也才发旁路读取。主读取期间 43% 的时间(335 / 775 秒)识别进程因此空着。旁路等待:
| 种类 | 次数 | 等待中位 | 落在主读取 App 端空档的比例 |
|---|---|---|---|
| 行动条 | 18 | 8.1 秒 | 68% |
| 预览 | 18 | 5.9 秒 | 59% |
| 信息页 | 11 | 90 秒 | 45% |
日志里 237 条“旁路被挡”记录全是“已有旁路在飞”;被“安静”条件挡住的等待不记日志,所以以前看不出来。
修改建议
- P1 旁路读取不再等主读取的 App 端工作:只要识别进程上没有主读取(只剩投影/接受在进行),就允许发旁路读取;主读取仍按原条件。旁路读取不碰视图存储,不改变保存格式。
- P2 紧凑 JSON 走 C 编码器的快速路径:对符合原编码器类型规则的值直接用标准库的紧凑编码,输出字节与原编码器完全一致;不合格的值(超长整数、NaN、非字符串键、元组、深度超过 32 等)仍走原编码器,错误原因不变。
- S1(可选):发布时复用刚校验过的请求,不再整局重新组装一次。
离线重放第 58 次主读取的 UI 工作:2.24 秒 → P2 后 1.57 秒 → P2+S1 后 1.35 秒;每次 SE 完成的 UI 工作 0.42 → 0.25 秒。按实时约 3–4 倍的减速推算,每个约 17 秒的周期可少 3–4 秒——这是估计,没有在实时游戏里验证。
实施后(更新)
P2 与 P1 已在独立工作区实施,经过三位审查(字节一致、旁路行为、回归)。审查发现两个错误路径问题——超过容量上限的值会先整体编码再拒绝、调用栈很深时会漏出递归错误——都先写失败测试再修好。真实会话的 58 个回看快照等全部逐字节相同。实施版本在 6 个真实大文件上的编码耗时对比:
| 值 | 大小 | 原编码器 | 新路径 | 加速 |
|---|---|---|---|---|
| 第 58 次后的投影缓存 | 2.33 MB | 0.2245 秒 | 0.0432 秒 | 5.2× |
| 回看快照文件 | 2.33 MB | 0.207 秒 | 0.0431 秒 | 4.8× |
| SE 请求 | 1.40 MB | 0.3421 秒 | 0.0543 秒 | 6.3× |
| 投影输入 | 0.79 MB | 0.1554 秒 | 0.0497 秒 | 3.1× |
| 投影输出 | 1.99 MB | 0.295 秒 | 0.0565 秒 | 5.2× |
| 投影缓存文件 | 2.46 MB | 0.3108 秒 | 0.0525 秒 | 5.9× |
六项都与原编码器逐字节相同,保存的回看快照重新编码也与原文件相同。旁路读取在主读取的投影/接受阶段照常发出。S1 没有做。
失败与负结果
- 处理时间里“其余”的 2–3 秒没能完全拆开:识别进程自报的耗时不含排队和读图等墙钟时间,UI 线程在那几秒具体忙什么只能从文件时间推断。
- 用工作区代码重算第 30、58 次的投影结果,与保存的投影不完全相同(第 10 次相同),原因没查;只用于计时,不影响结论。
局限
- 没有在实时游戏或回放中测 P1/P2 的实际效果;预期节省是按离线测量和约 3–4 倍减速推算的。
- Qt 重画、面板基准子进程、实时预览帧显示等耗时没有测。
- 离线测量时与实时 App 共享处理器,有噪声。
结论与下一步
实时模式变慢的主因是 App 自己:每次主读取后在 UI 线程上反复整局编码、拷贝、校验,并在这段时间里挡住了本可以并行的旁路读取。两项最小修改已实施并保证保存格式逐字节不变。还值得考虑但不属于最小修改的方向:SE 构建请求只做一遍而不是两遍;识别进程有主读取时推迟下一次 SE(会影响简报及时性,需要用户决定);主读取流水线化。下一次实玩建议在收到识别答复时记录一条时间,以便把剩下的 2–3 秒分清楚。
Comments