Как всего один малютка сократил время загрузки GTA Online на 70%

Для этого ему понадобилось всего лишь…

Это перевод, повествование идет от лица автора.

GTA Online печально известна своей медленной загрузкой.

Снова скачав игру, чтобы поиграть в некоторые из новых ограблений, я был шокирован, обнаружив, что она загружается так же медленно, как и в тот день, когда была выпущена, т.е. 7 лет назад.

Пора разобраться в этом.

Разведка

Сначала я хотел проверить, решил ли уже кто-нибудь эту проблему.

Большинство результатов, которые я нашел, указывают на шутки о том, что игра настолько сложна, что ее нужно загружать столь долго и рассказы о том, что сетевая архитектура p2p попросту некачественная, некоторые сложные способы загрузки в режим истории и в соло-сессию после этого, а также пара модов, позволяющих пропускать видео с логотипом запуска R*.

Это подсказало мне, что с помощью все этого мы можем сэкономить колоссальные 10-30 секунд!

Тем временем на моем ПК…

Бенчмарк

Время загрузки сюжетного режима: ~ 1 м 10 с.
Время загрузки сетевого режима: ~ 6 м.
Меню запуска отключено, время от логотипа R* до начала игры (время входа в Сошиал клаб не учитывается).
Старый, но приличный CPU: AMD FX-8350
Cheap-o SSD: kingston SA400S37120G
RAM: 2x Kingston 8192 MB (DDR3-1337) 99U5471
GPU: nvidia GeForce GTX 1070

Знаю, что моя установка устарела, но что, черт возьми, может быть причиной в 6 раз более долгой загрузки в онлайн-режиме?

Я не мог получить никакой разницы, используя технику загрузки из компании в онлайн, которую другие обнаружили до меня. Даже если бы это сработало, результаты были бы невысокими.

Я не одинок

Если этому опросу можно доверять, то проблема достаточно распространена, чтобы слегка раздражать более 80% игроков. Прошло 7 лет, R*!

Немного осмотревшись, чтобы найти ~ 20% счастливчиков, у которых время загрузки составляет менее 3 минут, я наткнулся на несколько тестов с высокопроизводительными игровыми ПК и временем загрузки в онлайн-режиме около 2 минут.

Я бы убил за возможность загружаться за 2 минуты! Кажется, это зависит от оборудования, но здесь кое-что не сходится…

Почему их режим компании все еще загружается около минуты? (Кстати, в M.2 не считались логотипы запуска.)

Кроме того, загрузка из истории в онлайн занимает у них всего минуту больше, а у меня еще около пяти. Я знаю, что их аппаратные характеристики намного лучше, но не в 5 раз лучше.

Высокоточные измерения

Вооружившись такими мощными инструментами, как, например, диспетчер задач, я начал исследовать, какие ресурсы могут быть узким местом.

Потратив минуту на загрузку общих ресурсов, используемых как для сюжетного, так и для онлайн-режимов (что почти на уровне высокопроизводительных ПК), GTA решает максимально использовать одно ядро на моей машине на четыре минуты и больше ничего не делать.

Использование диска? Нет! Использование сети? Есть немного, но через несколько секунд активность в основном падает до нуля (не считая загрузки вращающихся информационных баннеров). Использование графического процессора? Нуль. Использование памяти? Полностью отсутствует…

Что, это майнинг криптовалюты или что-то в этом роде? Я чувствую запах кода. Действительно плохой код.

Привязка к одному потоку

Хотя мой старый процессор AMD имеет 8 ядер и действительно впечатляет, он был сделан в стародавние времена. Когда-то, когда производительность одного потока была совсем другой, в сравнении с Intel.

Это может и не объяснить всех различий во времени загрузки, но должно пролить свет на большую часть.

Странно то, что используется только ЦП. Я ожидал, что огромное количество операций чтения с диска загрузит различные ресурсы или произойдет множество сетевых запросов, пытающихся согласовать сеанс в сети p2p. Но это? Вероятно, это ошибка.

Профилирование

Профилировщики — отличный способ найти узкие места ЦП. Есть только одна проблема — большинство из них полагаются на инструментальную обработку исходного кода, чтобы получить идеальное представление о том, что происходит в процессе.

А у меня нет исходного кода. Мне также не нужны показания с точностью до микросекунд, ведь у меня есть узкое место на 4 минуты.

Для приложений с закрытым исходным кодом есть только один вариант — выгрузить стек запущенного процесса и текущее местоположение указателя инструкций для построения дерева вызовов через заданные интервалы.

Затем сложить их, чтобы получить статистику происходящего. Есть только один профилировщик, о котором я знаю, который может делать это в Windows.

И он не обновлялся более 10 лет. Это Люк Стакуокер! Кто-нибудь, пожалуйста, подарите этому проекту немного любви.

Обычно Люк группирует одни и те же функции вместе, но, поскольку у меня нет отладочных символов, мне приходилось смотреть на ближайшие адреса, чтобы угадать, что же это за место. А что мы видим? Не одно узкое место, а два!

Вниз по кроличьей норе

Позаимствовав у моего друга полностью законную копию стандартного дизассемблера (нет, я действительно не могу себе это позволить… собираюсь научиться гидре на днях), я пошел разбирать GTA.

Большинство крупных игр имеют встроенную защиту от реверс-инжиниринга, чтобы отпугнуть пиратов, читеров и моддеров. Не то чтобы это их когда-либо останавливало.

Похоже, здесь действует какая-то обфускация/шифрование, которая заменила большинство инструкций тарабарщиной.

Не волнуйтесь, нам просто нужно выгрузить память игры, пока она выполняет ту часть, которую мы хотим рассмотреть.

Инструкции должны быть деобфусцированы, прежде чем запускаться тем или иным способом. У меня завалялся Process Dump, и я его использовал, но для этого есть много других инструментов.

Проблема первая: это… Стрлен?!

Разборка менее запутанного дампа показывает, что у одного из адресов откуда-то извлечена этикетка! Это strlen? Спускаясь вниз по стеку вызовов, помечается следующий, vscan_fn и после этого ярлыки заканчиваются, хотя я вполне уверен, что это sscanf.

Он что-то разбирает. Разбор чего? Распутывание разборки заняло бы вечность, поэтому я решил сбросить некоторые образцы из запущенного процесса с помощью x64dbg.

После некоторого отладочного шага выясняется, что это… JSON! Они разбирают JSON. Колоссальные 10 мегабайт JSON с примерно 63 КБ записей.

…, { «ключ»: «WP_WCT_TINT_21_t2_v9_n2», «цена»: 45000, «statName»: «CHAR_KIT_FM_purchase20», «storageType»: «bitfield», «bitShift»: 7, «bitSize»: 1, «категория»: [ «category_weapon_MOD» ] }, …

Что это такое? Судя по некоторым ссылкам, это данные для «каталога интернет-магазинов». Я предполагаю, что он содержит список всех возможных предметов и улучшений, которые вы можете купить в GTA Online.

Устранение некоторой путаницы: я полагаю, что это предметы, покупаемые за игровые деньги, которые напрямую не связаны с микротранзакциями.

Но 10 мегабайт? Ого! И использование sscanf может быть хоть и не оптимальным, но уж точно не таким ужасным? Что ж…

Да, это займет некоторое время… Честно говоря, я понятия не имел, что sscanf называется большинство реализаций strlen, поэтому, я не могу винить разработчика, написавшего это. Я бы предположил, что он просто просканировал побайтно и мог остановиться на NULL.

Проблема вторая: давайте использовать массив Hash…?

Оказывается, второго нарушителя вызывают рядом с первым. Они оба вызываются в том же if-операторе, что и в этой уродливой декомпиляции:

Все ярлыки мои, понятия не имею, какие функции/параметры на самом деле вызываются.

Вторая проблема? Сразу после анализа элемента он сохраняется в массиве (или во встроенном списке C++? Не уверен). Каждая запись выглядит примерно так:

struct { uint64_t * хэш; item_t * item; } Вход;

Но что происходит перед сохранением? Он проверяет весь массив один за другим, сравнивая хэш элемента, чтобы узнать, есть он в списке или нет.

С ~63k записями, это (n^2+n)/2 = (63000^2+63000)/2 = 1984531500. Это моя математика. Большинство из них бесполезны. У вас есть уникальные хэши, почему бы не использовать хеш-карту.

Я назвал его hashmap, двигаясь задним ходом, но это точно not_a_hashmap. И становится еще лучше. Перед загрузкой JSON объект hash-array-list пуст. И все элементы в JSON уникальны!

Им даже не нужно проверять, есть он в списке или нет! У них даже есть функция для непосредственной вставки предметов! Просто используйте это! Серьезно, чт? !

PoC

Это хорошо и все такое, но никто не будет воспринимать меня всерьез, если я не протестирую это.

План? Напишите .dll, залейте в GTA, подключите некоторые функции — профит.

Есть проблема с JSON, я не могу реально заменить их парсер. Замена sscanfс той, которая не зависит от strlen будет более реалистичной идеей. Но есть еще более простой способ.

  • hook strlen
  • ждать длинной строки
  • «Кешировать» начало и длину
  • если он вызывается снова в диапазоне строки, вернуть кешированное значение

Что-то вроде:
size_t strlen_cacher ( char * str)
{ статический символ * начало; статический символ * конец; size_t len; const size_t cap = 20000;
// если у нас есть «кешированная» строка и текущий указатель находится внутри нее if (start && str> = start && str <= end) { // вычисляем новый strlen len = end — str;
// если мы приближаемся к концу, выгружаем себя // мы не хотим испортить что-то еще if (len lpvoid) strlen_addr);
// сверхбыстрый возврат! return len; }
// подсчитываем фактическую длину // нам нужно хотя бы одно измерение большого JSON // или обычного strlen для других строк len = builtin_strlen (str);
// если это была действительно длинная строка // сохраняем начальный и конечный адреса if (len> cap) { start = str; конец = str + len; }
// медленный, скучный return return len; }

А что касается проблемы с хеш-массивом, это проще — просто полностью пропустите повторяющиеся проверки и вставьте элементы напрямую, поскольку мы знаем, что значения уникальны.

char __fastcall netcat_insert_dedupe_hooked ( каталог uint64_t, uint64_t * ключ, uint64_t * элемент)
{ // не стал менять структуру uint64_t not_a_hashmap = catalog + 88;
// не знаю, что это делает, но повторить то, что сделал оригинал if (! (* ( uint8_t (__fastcall **) ( uint64_t *)) (* item + 48) ) (item)) return 0;
// вставляем напрямую
netcat_insert_direct (not_a_hashmap, key, & item);
// удаляем хуки при попадании в хэш последнего элемента // и выгружаем .dll, на этом мы закончили 🙂 if (* key == 0x7FFFD6BE ) { MH_DisableHook ((lpvoid) netcat_insert_dedupe_addr); выгрузить (); }
возврат 1;
}

Полный исходный код PoC здесь.

Полученные результаты

Похоже сработало.

Исходное время загрузки онлайн-режима: ~ 6 мин.

Время с исправлением только проверки дублирования: 4 мин. 30 с.

Время с использованием только патча парсера JSON: 2 мин. 50 с.

Время с исправлением обеих проблем: 1 мин. 50 с.

(6 * 60 — (1 * 60 + 50)) / (6 * 60) = улучшение времени загрузки на 69,4% (приятно!)

Черт, да!

Скорее всего, это не решит проблему времени загрузки для всех — в разных системах могут быть и другие узкие места, но это настолько зияющая дыра, что я понятия не имею, как R* упускала ее все эти годы.

tl; dr

  • При запуске GTA Online возникает узкое место в одном потоке процессора.
  • Оказывается, GTA изо всех сил пытается разобрать файл JSON размером 10 МБ.
  • Сам парсер JSON плохо построен/наивен
  • После синтаксического анализа происходит медленная процедура дедупликации элементов

R* исправьте пожалуйста

Если это каким-то образом доходит до Rockstar: на решение проблемы у одного разработчика не должно уйти больше суток. Пожалуйста, сделайте что-нибудь с этим.

Вы можете либо переключиться на хэш-карту для дедупликации, либо полностью пропустить все при запуске в качестве более быстрого исправления.

Для парсера JSON — просто замените библиотеку на более производительную. Не думаю, что есть более простой выход.

 

Источник

Читайте также