一行被重算 63000 次的字符串长度,是怎么让 GTA Online 读盘 6 分钟的

2021 年 2 月,t0st 剖析 GTA Online PC 版读盘,发现一份 10MB JSON 商品目录在解析时反复重算字符串长度,又做了近 20 亿次多余哈希去重检查;二进制层概念验证证明两处小改动可把读盘从约 6 分钟压到 1 分 50 秒。Rockstar 确认了发现,以例外方式支付 10000 美元 bug bounty,并在 1.53 补丁中实装改进。

一行被重算 63000 次的字符串长度,是怎么让 GTA Online 读盘 6 分钟的
本篇导航9 个章节

进一次 GTA Online 要 6 分钟,进故事模式只要 1 分 10 秒。同一台机器、同一块硬盘、同一个游戏。2021 年 2 月,网名 t0st 的程序员决定把这个差距查出个确切原因,而不是停留在抱怨。他先看任务管理器,再用栈采样工具,最后动用反汇编,找到了答案:一份约 10MB、约 63000 件商品的 JSON 目录,解析时为每个物品反复重算字符串长度,存入前又要做根本不可能命中的去重检查。两处小改动之后,他的读盘时间变成 1 分 50 秒。Rockstar 确认了这个发现,以例外方式支付 10000 美元 bug bounty,并在 2021 年 3 月 16 日的 1.53 补丁中实装了读盘改进。

一切的起点:一个核心跑满 100%,其他全部空闲。图片:t0st,nee.lv。

一眼看懂

  • 现象:t0st 的机器上 GTA Online PC 版读盘约 6 分钟,故事模式约 1 分 10 秒

  • 作者自测机器:AMD FX-8350 处理器、NVIDIA GeForce GTX 1070 显卡、金士顿 SA400S37120G 固态硬盘、16GB DDR3 内存

  • 瓶颈:单个 CPU 核心跑满约 4 分钟,硬盘、网络、显卡几乎全程空闲

  • 根因一:解析约 10MB、约 63000 件商品的 JSON 时,sscanf 路径反复调用 strlen,为每个物品重新计算字符串长度

  • 根因二:每解析一件物品,就把它的哈希值与已存全部物品逐一比对,(63000 的平方加

    63000) 除以 2,总计 1984531500 次比较,而这批数据本身已经保证唯一

  • 解法:缓存字符串长度并直接返回缓存值;跳过去重检查,直接插入

  • 效果(作者自测):只修去重检查,4 分 30 秒;只修 JSON 解析,2 分 50 秒;两处都修,1 分 50 秒,降幅 69.4%

  • 官方结果:2021 年 3 月 16 日 1.53 补丁说明写明 PC 读盘改进并致谢 t0st;t0st 称获得 10000 美元 in-game bounty,属于例外

  • 警告:概念验证靠注入 DLL、hook 游戏函数实现,其 README 明确写着不适合普通玩家使用,且在线模式下改动游戏可能导致账号被封,不要在正式账号上运行

症状:硬件解释不了的 6 分钟

t0st 的测量口径很干净:从 Rockstar 标志出现到进入游戏,不计 Social Club 登录时间。故事模式约 1 分 10 秒,在线模式整整约 6 分钟。其他玩家的报告也指向同一方向:高端 PC 进在线模式仍要约 2 分钟,而故事模式接近 1 分钟。换更快的硬盘弥合不了这个差距,这是第一个线索:瓶颈根本不在硬盘。

任务管理器给了第二个线索。第一分钟加载的是故事与在线模式共用的资源,之后 GTA Online 让单个核心跑满约 4 分钟,其他什么都不做。硬盘占用为零,网络几秒后基本归零(除了轮换的信息横幅),显卡占用为零,内存占用完全平坦。游戏既不读盘、不下载、不渲染、也不分配内存,却让一个核心烧满,这只能是代码在循环里空转。

单线程瓶颈也解释了为什么不同机器的痛苦程度差这么多。单线程性能更强的 CPU 能更快地跑完同样的无用功,而更多的核心、更快的硬盘、更好的显卡全都帮不上忙,因为这份工作从来不是并行的、不是硬盘 bound、也不是显卡 bound。如果你的游戏读盘慢、升级硬件又没用,先看是不是一个核心在独自扛下所有活,再去怪硬盘。

t0st 引用的一份玩家投票显示,超过 80% 的玩家对读盘时间感到困扰。图片:t0st,nee.lv。

没有源码怎么做剖析

t0st 没有 Rockstar 的源码,所以常规插桩式剖析工具用不上。他用的是栈采样:按固定间隔抓取运行中进程的栈与指令指针,再汇总成调用树。当时他用的 Luke Stackwalker 是一款 Windows 栈采样工具,写文章时已有 10 多年没更新。没有调试符号,他只能靠肉眼把相近地址归为同一处。结果不是一个热点,而是两个。

要看真正的代码,他在游戏执行到目标部分时把内存 dump 出来(因为发行版指令经过混淆),再对解混淆后的 dump 做反汇编。一个采样地址带有一个标签:strlen。再往上一帧标着 vscan_fn,t0st 明确表示他有把握、但不是百分之百确定调用者是 sscanf。这种诚实很重要:反汇编过程中的标签是他自己起的,原文也写明了这一点。他能通过采样运行中的进程确认的是代码在解析什么:JSON,约 10MB,约 63000 个物品条目,看起来是 GTA Online 全部可购物品与升级的网店目录。

无源码、无符号下的栈采样:两个热点,靠肉眼把相近地址归组。图片:t0st,nee.lv。
发行版代码经过混淆,t0st 在运行时 dump 内存后才做反汇编。图片:t0st,nee.lv。

问题一:strlen 被多算了 63000 次

大多数 sscanf 实现内部会调用 strlen,t0st 写得很公道:调查之前他也不知道这一点,所以他不怪写解析代码的开发者以为 sscanf 会逐字节扫描并在空字符处停下。但后果很残酷:解析器每处理一个新物品,就要从头重算一次字符串长度。约 63000 件物品,就是约 63000 次本可只算一次的长度计算,每次都要走一遍长字符串。

解法简单到近乎难堪,而这正是重点。Hook 住 strlen,等一个长字符串出现,把它的起点与长度缓存下来;之后 strlen 再以该范围内的指针被调用时,直接返回 end 减 str 的缓存值。剩余长度变小时还有一道护栏:自动卸载 hook,避免干扰后续无关代码。这个教训远不止这一个游戏:任何在循环里反复向不变缓冲区索要长度的解析循环,都是在线性流程里再乘一次线性代价,账单按物品数量放大。

解析开销的可视化:每个新物品都会触发一次针对长字符串的全新长度计算。图片:t0st,nee.lv。
为什么一份 10MB 文件会花掉几分钟:解析器反复重测字符串,而不是复用一次测量结果。图片:t0st,nee.lv。

t0st 公开的 strlen hook 核心逻辑引自 nee.lv(2021 年 2 月 28 日)与公开概念验证仓库 tostercx/GTAO_Booster_PoC:hook 住 strlen;当出现长度超过 20000 上限的字符串时保存其起点与终点;之后以该范围内的指针再次调用时返回 end 减 str;剩余长度低于上限一半时卸载 hook。这是二进制层 hook,不是 Rockstar 源码。

问题二:为不存在的重复做的近 20 亿次检查

第二个热点就在第一个旁边,同一个 if 语句里调用。解析完一件物品后,游戏把它存入一个类似数组的列表,每个条目包含一个哈希值与一个物品指针。但存入之前,代码要把新物品的哈希值与已存的每一个逐一比对。约 63000 个条目时,(n 平方加 n) 除以 2,t0st 算出是 1984531500 次检查。他把这个结构命名为 not_a_hashmap:它存着哈希值,却根本不是哈希表,真正的哈希表应该以接近常数的时间查到键,而不是每次全量扫描。

接下来是让性能 bug 变成笑话的细节:JSON 加载前这个列表是空的,而 JSON 里的全部物品本来就是唯一的。这道去重检查防范的是输入数据已经排除掉的情况。更糟的是,代码里本来就有一个直接插入物品的函数,解法就是跳过检查、直接调用它。这里有两个教训。第一,平方级算法会在小功能里舒适地藏身,直到数据量变大:63000 听起来不大,平方之后就吓人了。第二,一道正确性检查只有在它防范的不变量真的可能被破坏时才值得付出代价;输入本身已保证唯一时,这道检查就是纯开销,而且按平方增长。诚实的修法是删掉它,或者换成真正的哈希表,t0st 给 Rockstar 的建议正是这两个选项。

两个热点出现在同一个 if 语句中;t0st 注明此处的标签均为他自己所起。图片:t0st,nee.lv。
存哈希值的列表不是哈希表:每次插入都要扫描此前已存的全部内容。图片:t0st,nee.lv。

效果:单修一项,再两项全修

这个案例能成为证据而不只是轶事,关键在于 t0st 把两处修复分开测了。他的机器上,原始在线读盘约 6 分钟;只修去重检查,4 分 30 秒;只修 JSON 解析,2 分 50 秒;两处都修,1 分 50 秒。合计降幅 (360 减

110) 除以 360,即 69.4%。

这些数字要仔细读,因为它们纠正了一个常见误读:单修任何一处都解释不了全部故事,而且在这台机器上 JSON 解析修复的贡献大于去重修复。只修那个叫得最响的毛病,仍会给玩家留下近 3 分钟的等待。两个热点都剖析、每处修复都单独测量,才让结论站得住。所有这些时间都是 t0st 在一台老机器上的自测,不是跨硬件的受控基准。PC Gamer 在官方补丁后的复测是另一个独立数据点:Wes Fenlon 从 3 分 15 秒降到 2 分钟,Phil Savage 测到 1 分多钟但没有计时过的基线。它们印证的是方向,不是 69.4% 这个精确数字。Rockstar 本人没有公布百分比,也没有披露官方修复的实现细节,所以本文不声称官方补丁与概念验证完全相同。

官方修复:确认、赏金与 1.53 补丁

时间线很短,这也是本案至今仍被引用的原因之一。t0st 于 2021 年 2 月 28 日发文;3 月 15 日更新:Rockstar 已确认修复即将推出,他通过 HackerOne in-game bounty 获得 10000 美元,属于例外(该计划通常只覆盖安全问题);3 月 16 日,Rockstar 发布 GTAV 1.53 补丁,PC 部分写明读盘改进并致谢 t0st。t0st 用同一台硬件重测官方补丁,报告从 Rockstar 标志到完全在线不到 2 分钟。

一个流传中容易失真的数字需要纠正:个别二手报道夸大了赏金金额或张冠李戴到其他补丁。第一手来源 t0st 自己的更新写的是 10000 美元、例外性质的 in-game bounty,补丁说明把致谢挂在 1.53 补丁名下,本文采用这两个数字。

t0st 用同一台硬件重测官方补丁:修复生效。图片:t0st,nee.lv。

带回你自己项目的清单

  • 先剖析启动过程,再谈任何优化。单个核心跑满 100%,而硬盘、网络、显卡全都空闲,这是代码异味,不是硬件不足

  • 每个嫌疑点单独测量。t0st 的分项计时表明解析修复的贡献大于去重修复,只修叫得最响的那一处会留下大部分等待

  • 绝不在循环里反复计算不变缓冲区的长度。缓存一次,或者把长度随指针一起传下去,并留意那些在内部偷偷算长度的标准库调用

  • 质疑每一道输入数据已经保证其不变量的检查。输入本身已保证唯一时,去重扫描不是安全措施,而是按平方增长的税

  • 如果扫描必须保留,就用真正的哈希表。存哈希值的列表不是哈希表,名字起得像也不会让查找变便宜

  • 重写之前先用最小干预证明诊断。缓存一个值、跳过一道检查的 hook,把理论变成了 69.4% 的实测改进,这正是 Rockstar 在约两周内采取行动的原因

  • 不要把二进制 hook 当作面向玩家的修复发布。t0st 的概念验证是向开发者证明 bug 的演示,其 README 警告不适合普通使用、在线改动有封号风险;这类工作的正确归宿是开发者自己的补丁,而本案的结局正是如此

来源与证据边界

来源:t0st《How I cut GTA Online loading times by 70%》,nee.lv,2021 年 2 月 28 日,更新于 2021 年 3 月 15 日与 16 日;tostercx/GTAO_Booster_PoC(GitHub 公开已归档仓库,创建于 2021 年 2 月 28 日);Rockstar Games GTAV 1.53 补丁说明,2021 年 3 月 16 日,PC 读盘改进并致谢 t0st;PC Gamer 2021 年 3 月报道,Rockstar 的确认、10000 美元赏金为例外性质、复测时间(Fenlon 3 分 15 秒到 2 分钟;Savage 1 分多钟、无计时基线);Eurogamer 与 Digital Foundry 2021 年 3 月报道,仅作佐证。证据边界:全部读盘时间均为单机自测或媒体复测,不是受控基准;Rockstar 没有披露实现细节,因此官方修复只表述为读盘改进,不写成 t0st 的代码;概念验证是二进制层 hook,不是官方源码;t0st 的反汇编标签是他自己起的,原文中已有标注。

  • 联机不卡却总被踢出重下地图?Factorio 掉同步真正的原因

  • 为什么我的 Balatro 存档损坏后还能加载,里面的数据却错了?

本文不含任何联盟链接。正文中的全部读盘时间均已注明来源;本文没有编造任何新的基准、帧数或实测结果。

收藏这篇攻略

登录后可收藏攻略,并在不同设备同步。

登录 / 注册