摘要

战斗记录 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 秒。

三个原因

  1. “处理时间”里的 App 部分主要是排队:战斗开始后(第 13 次起)有了旁路读取和频繁的 SE。46 次战斗主读取中 45 次发出时识别进程上正有旁路读取,只能排队;识别答复到了以后,UI 线程又几乎总在处理一次 SE 完成,答复要等 UI 空下来才被读出。
  2. 真正随读取次数增长的,是接受之后的 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——每个都是当时整份缓存。

  1. 这段时间识别进程闲着却拿不到活: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 秒分清楚。