- 精华
- 3
- 帖子
- 12890
- 威望
- 7 点
- 积分
- 14460 点
- 种子
- 513 点
- 注册时间
- 2010-5-20
- 最后登录
- 2024-2-6
|
本帖最后由 lucky☆star 于 2021-3-1 21:39 编辑
https://nee.lv/2021/02/28/How-I- ... oading-times-by-70/
作者:T0ST
Rockstar 在 2013 年发售的《侠盗猎车手 V》是史上最畅销的游戏之一,截至 2021 年 2 月它售出了超过 1.4 亿份拷贝。让《侠盗猎车手 V》畅销不衰的原因除了游戏本身出色的主线故事外,更为重要的原因是它的在线模式《GTA Online》,至今仍然在更新的《GTA Online》带来的收入比游戏本身更高,以至于 Rockstar 准备在今年推出独立版本。但许多玩家发现进入游戏界面之后,加载故事模式只要几秒钟,而加载《GTA Online》则需要几分钟。Rockstar 的开发者至今一直没有解决或重视该问题,但这极其影响游戏体验。有开发者对此展开了分析,发现在长达数分钟的加载时间内 CPU 单个核心的负荷几乎跑满,而网络、磁盘和 GPU 的使用率基本上都为零。那么是什么原因导致 CPU 成为瓶颈。进一步分析发现,原因是游戏需要解析一个 10MB 大小的 JSON 文件,而解析器本身有很大的问题。这位开发者对其进行了一番优化,成功将加载时间缩短了 69.4%。
《侠盗猎车手 Online》。以缓慢的加载时间恶名昭彰。在再次拿起游戏完成一些新的抢劫案后,我震惊地发现,它的加载速度仍然和7年前发布的那天一样慢。
是时候了。是时候去解决这个问题了。
#重建
首先我想看看是否有人已经解决了这个问题。我找到的大部分结果都指向了关于游戏如何如此复杂以至于需要加载如此之久的轶事,关于P2P网络架构如何垃圾的故事(并不是说它不是),一些精心设计的加载到故事模式和之后的单人会话的方法,以及一些允许跳过启动R*标志视频的mod。更多的阅读告诉我,我们可以用这些综合起来节省高达10-30秒的时间!
同时在我的电脑上...
#基准
故事模式加载时间:约1分10秒
在线模式加载时间:约6分
启动菜单禁用,从R*标志到游戏内的时间(社交俱乐部登录时间不计算在内)。
老旧但还不错的中|央处理器:AMD|超微半导体 FX-8350
便宜的固态硬盘:金士顿 SA400S37120G
我们必须要有的内存:2x 金士顿 8192 MB (DDR3-1337) 99U5471
不错的显卡:NVIDIA|英伟达 GeForce GTX 1070
我知道我的配置已经过时了,但到底是什么东西能花6倍的时间来加载到在线模式?我没有测量到使用故事到在线加载方式的任何差异,因为其他人在我之前已经发现。即使它确实有效,结果也会在喧嚣中倒下。
#我(不)是个例
如果这个调查可信的话,那么这个足以让80%玩家恼火的问题广泛存在。已经7年了R*!
找了一下谁是幸运的约20%的人,得到3分钟以下的加载时间,我看到了一些高端游戏PC的基准测试,在线模式加载时间约为2分钟。为了2分钟的加载时间,我愿意杀了黑客! 这似乎确实是取决于硬件,但有些东西在这里没有加起来... ...
找了一下谁是幸运的20%的人,得到3分钟以下的加载时间,我看到了一些高端游戏PC的基准测试,在线模式加载时间约为2分钟。为了2分钟的加载时间,我愿意杀了黑客! 这似乎确实是取决于硬件,但这里有些东西不对劲...
他们的故事模式怎么还需要近一分钟的时间来加载?(顺便说一下,M.2那个没算上启动标志。)另外,加载故事到在线只需要他们多花一分钟,而我却多花了5分钟左右。我知道他们的硬件规格要好很多,但肯定不会好5倍。
#高精度测量
有了任务管理器这样强大的工具,我开始研究什么资源可以成为瓶颈。
在花了一分钟的时间来加载故事和在线模式所使用的常用资源后(接近于高端PC的水平),GTA决定在我的机器上最大限度地使用单核四分钟,然后什么也不做。
磁盘使用率?没有! 网络使用量?有一点,但几秒钟后就基本降为零了(除了加载旋转的信息横幅)。GPU使用率?零。内存使用量?完全持平...
什么,它是在挖矿还是什么?我闻到了代码的味道。非常糟糕的代码。
#单线程绑定
虽然我的老AMD CPU有8个核心,而且它的性能确实很强,但它是在旧时代制造的。当AMD的单线程性能远远落后于英特尔的时候。这可能无法解释所有的加载时间差异,但应该可以解释大部分。
奇怪的是,它只用了CPU。我以为会有大量的磁盘读取加载资源,或者大量的网络请求试图在P2P网络中协商一个会话。但就这?这可能是个bug。
#剖析
剖析器是寻找CPU瓶颈的好方法。只有一个问题--它们中的大多数都依赖于对源代码的检测,以获得过程中所发生的完美画面。而我没有源代码。我也不需要微秒级的完美读数--我有4分钟的瓶颈。
进入堆栈采样:对于闭源应用来说,只有一个选择。转储正在运行的进程的堆栈和当前指令指针的位置,以设定的时间间隔建立一个调用树。然后把它们加起来,就可以统计出正在发生的事情。据我所知,只有一个剖析器(可能孤陋寡闻)可以在Windows上做到这一点。而且它已经超过10年没有更新了。它是Luke Stackwalker! 谁能给这个项目一些爱:)
通常Luke会把相同的函数归在一起,但由于我没有调试符号,我只好眼看着附近的地址来猜测是不是同一个地方。然后我们看到了什么?不是一个瓶颈,而是两个!
#跳进兔子洞
在借用了我朋友的完全正版的行业标准反汇编器拷贝后(不,我真的买不起这东西......这些天要去学Ghidra了),我把GTA拆了。
这看起来一点都不对 大多数高知名度的游戏都有内置的逆向工程保护,以防止盗|版、作弊者和修改者。并不是说这能阻止他们。
这里似乎有某种混淆/加密技术在起作用,用胡言乱语代替了大部分说明。不用担心,我们只需要在游戏执行我们想看的部分时转储游戏的内存。指令在运行之前必须先去掉胡言乱语,这样或那样。我身边有Process Dump,所以我用了它,但还有很多其他工具可以做这种事情。
#问题一:它是... strlen?
拆开现在不那么混乱的垃圾堆,发现其中一个地址的标签不知从哪里拉出来了!是strlen?是strlen?沿着调用栈往下走,下一个标签是vscan_fn,之后的标签就结束了,我相当确信是sscanf。
它在解析什么东西 解析什么?反汇编要花很长时间,所以我决定用x64dbg从运行的进程中转储一些样本。一些调试步骤之后,发现是......JSON!他们在解析JSON。他们在解析JSON。一个高达10兆字节的JSON文件,有63000个条目。
- ...,
- {
- "key": "WP_WCT_TINT_21_t2_v9_n2",
- "price": 45000,
- "statName": "CHAR_KIT_FM_PURCHASE20",
- "storageType": "BITFIELD",
- "bitShift": 7,
- "bitSize": 1,
- "category": ["CATEGORY_WEAPON_MOD"]
- },
- ...
复制代码
这是什么?根据一些参考资料,它似乎是一个 "网店目录 "的数据。我猜测它包含了你在《GTA Online》中可以购买的所有可能的物品和升级的列表。
澄清了一些困惑。我相信这些都是游戏中可以购买的物品 而不是直接与微交易挂钩的物品。
但10兆?那不算什么! 而且使用scanf可能不是最理想的,但肯定不是那么糟糕?那...
是的,这要花点时间......公平地说,我不知道大多数scanf实现都叫strlen,所以我不能责怪写这个的开发者。我认为它只是一个字节一个字节地扫描,并且可以在NULL上停止。
#问题二: 让我们用Hash---数组?
结果第二名犯人就在第一名犯人的旁边被叫住了。它们甚至都在同一个if语句中被调用,从这个丑陋的反编译中可以看出。
所有标签都是我加的,不知道函数/参数到底是怎么调用的。
第二个问题?就在解析一个项目之后,它被存储在一个数组中(或者是一个内联的C++列表?不确定)。每个条目看起来像这样。
- struct {
- uint64_t *hash;
- item_t *item;
- } entry;
复制代码
但在存储之前?它对整个数组进行检查,逐一比较项目的哈希值,看它是否在列表中。有约63k个条目,就是(n^2+n)/2=(63000^2+63000)/2=1984531500个检查,如果我的计算正确的话。大部分都是没用的。你有唯一的哈希,为什么不用哈希表。
我在反转的时候给它起了个名字叫hashmap,但它显然不是_hashmap。而且它还会变得更好。在加载JSON之前,hash-array-list-thing是空的。而且JSON中的所有项目都是唯一的! 他们甚至不需要检查它是否在列表中!他们甚至有一个函数来直接插入JSON。他们甚至有一个函数可以直接插入项目! 用这个就可以了! 什么?认真的?
#概念验证
现在,这是很好的,所有的,但没有人会把我当回事,除非我测试这一点,所以我可以写一个点击诱饵标题的文章。
计划是什么?写个.dll,注入GTA中,勾选一些函数,吗,盈利。
JSON的问题是毛毛雨,我不能现实地替换他们的解析器。用一个不依赖strlen的sscanf替换掉会比较现实。不过还有一个更简单的方法。
挂钩strlen
等待长string
"缓存 "它的开始和长度
如果在字符串范围内再次被调用,返回缓存的值
诸如:
- size_t strlen_cacher(char* str)
- {
- static char* start;
- static char* end;
- size_t len;
- const size_t cap = 20000;
- // if we have a "cached" string and current pointer is within it
- if (start && str >= start && str <= end) {
- // calculate the new strlen
- len = end - str;
- // if we're near the end, unload self
- // we don't want to mess something else up
- if (len < cap / 2)
- MH_DisableHook((LPVOID)strlen_addr);
- // super-fast return!
- return len;
- }
- // count the actual length
- // we need at least one measurement of the large JSON
- // or normal strlen for other strings
- len = builtin_strlen(str);
- // if it was the really long string
- // save it's start and end addresses
- if (len > cap) {
- start = str;
- end = str + len;
- }
- // slow, boring return
- return len;
- }
复制代码
而至于哈希数组的问题,就更直接了,只要完全跳过重复检查,直接插入项,因为我们知道值是唯一的。
- char __fastcall netcat_insert_dedupe_hooked(uint64_t catalog, uint64_t* key, uint64_t* item)
- {
- // didn't bother reversing the structure
- uint64_t not_a_hashmap = catalog + 88;
- // no idea what this does, but repeat what the original did
- if (!(*(uint8_t(__fastcall**)(uint64_t*))(*item + 48))(item))
- return 0;
- // insert directly
- netcat_insert_direct(not_a_hashmap, key, &item);
- // remove hooks when the last item's hash is hit
- // and unload the .dll, we are done here :)
- if (*key == 0x7FFFD6BE) {
- MH_DisableHook((LPVOID)netcat_insert_dedupe_addr);
- unload();
- }
- return 1;
- }
复制代码
概念验证代码的完整来源在这里。
#结果
那么,它的效果呢?
原在线模式加载时间:约6分
仅重复检查补丁:4分30秒
仅JSON解析器补丁:2分50秒
同时修复两个问题的补丁:1分50秒
(6*60 - (1*60+50)) / (6*60) = 69.4%的加载时间改进(不错!)。
是的,它做到了!:)
很有可能,这并不能解决每个人的加载时间--在不同的系统上可能会有其他的瓶颈,但这是一个漏洞,我不知道R*这些年是如何错过的。
#太长不看
·启动《GTA Online》时出现单线程CPU瓶颈
·原来,GTA在解析一个10MB的JSON文件时很吃力。
·JSON解析器本身的构造很傻很天真,而且
·在解析之后,有一个缓慢的项目去重复化例程
#R*请修复
如果这件事以某种方式传到Rockstar:这些问题不应该超过一个开发人员一天的时间来解决。请做点什么吧 :<
你可以换成哈希表来进行去重复处理,或者在启动时完全跳过它,这样可以更快地解决这个问题。对于JSON解析器--换一个性能更强的库就可以了。我觉得没有更简单的办法了。
ty <3 |
|