Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет

Как «Аллоды Онлайн» с Visual C++ 2010 на 2019 переезжали.

Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет

Привет! Меня зовут Колосов Денис, я являюсь разработчиком клиентской части проекта «Allods Online» в студии IT Territory. Сегодня я расскажу о том, как мы решились обновить среду разработки и заодно компилятор на нашем проекте с Visual C++ 2010 на 2019.

О чем пойдет речь?

  1. Как мы докатились до такой жизни и отважились на этот шаг;
  2. О сборке вендерских библиотек и всего окружения, которое у нас есть;
  3. С какими кастомными проблемами мы столкнулись;
  4. К чему это все привело.

Что касается сборки вендерских библиотек, затрону буквально в нескольких предложениях и больше возвращаться к теме не буду, т.к. в этом плане нам повезло. Во-первых, добрая их половина у нас есть в исходниках и лежит в отдельной папке в репозитории. 90% этих вендоров собираются прямо из solutions — они спокойно конвертируются из 2010 версии в 2019. Во-вторых, все недостающие библиотеки нашлись в vcpkg. Так что всё обновление свелось к монотонной конвертации проектов и выполнению vcpkg search и vcpkg install.

Ну а теперь немного предыстории.

Началось всё случайно. Как в «Магазинчике БО», история пишется дохлыми зайцами. В один прекрасный день пришёл ко мне коллега по проекту и спросил: «Ден, доколе?! Доколе ради простейших операций со строками мне придётся создавать копии, делать бессмысленные аллокации? Ведь давно уже есть string_view…». Так что, считай, благодаря «дохлому» string мы и решили, а не замахнуться ли нам… ну и замахнулись.

В тот момент проект сидел на Visual Studio 2010, и новые версии стандарта C++ с соответствующими печеньками госдепа нам только снились.

Так что мы хотели получить?

  1. Потенциальный буст производительности — без оценок, потому что не знали, к чему бы это привело, особенно в проекте, которому больше 10 лет;
  2. Возможность включать те сторонние библиотеки, на которые мы давно смотрели, но по тем или иным причинам не могли с ними работать (EASTL, Optick Profiler и т. д.);
  3. Сокращение времени разработки;
  4. Прокачать командные скилы. Применить на практике все возможности современного C++, т. к. оставаться на С++ 0x в 20-х годах XXI века — это профессиональная деградация.
Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет

А теперь — с чем столкнулись:

  1. Краши — сразу при запуске клиентского приложения из-под отладчика (delete(void*));
  2. Deadlocks — подтянулись спустя n недель. На наше счастье, он такой был один, и быстро удалось его отловить, а потом нагуглить, в чем причина (Thread-safe Local Static Initialization);
  3. Проблемы с позиционированием объектов в мире: террейн, например, улетел вникуда, а за ним мобы и все остальное (FPU control word);
  4. Магия с шаблонами и макросами;
  5. Исключительно кастомная ошибка: при запуске финальной сборки клиента игры мы проверяем раздел экспорта .exe-файла. Он должен быть пустым. Это требование связано с системой защиты проекта. Но в .export-секции оказалось очень много чего интересного, о чем я и расскажу в заключительной части (_CRT_STDIO_INLINE).

Но обо всем по порядку.

Краши (USER-DEFINED OPERATOR DELETE(VOID *))

  1. Краши происходили из-за нашего кастомного аллокатора памяти на основе dlmalloc: наверное, не секрет ни для кого, что игровые проекты не используют стандартные аллокаторы, которые не заточены под специфику игр.
  2. Стандартный аллокатор не блещет производительностью, что является краеугольным камнем игровых проектов. Кроме того, для игр очень страшна фрагментация памяти. Поскольку они очень “memory-consumption”, если адресное пространство сильно фрагментировано, то память быстро «заканчивается», и происходят креши. Наверное, для 64-битных игр это не критично, но для 32-битных, как у нас — весьма.
  3. В MinGW для проектов, которые собираются на C++ 14 и динамически линкуют стандартную библиотеку libstdc++, не вызывается переопределенный скалярный оператор delete(void *), если не переопределена его sized-версия (GCC Bugzilla – Bug 77726).
  4. Оказалось, что в компиляторе Microsoft Visual C++ (по крайней мере, версии 14.21) есть похожая «особенность». Об этом баге добрые люди даже сообщали в Microsoft (Developer Community), но мы об этом узнали, уже справившись с проблемой. К чести товарищей из «Сиэтловской области», они эту «особенность» пофиксили.

Итого: переопределили sized-версию delete — и краши пропали. Вот так наступила перемога:

void __cdecl operator delete( void* ptr ) noexcept { a1_free( ptr ); } void __cdecl operator delete( void* ptr, size_t /*sz*/ ) noexcept { a1_free( ptr ); } void __cdecl operator delete[](void* ptr) noexcept { a1_free( ptr ); } void __cdecl operator delete[]( void* ptr, size_t /*sz*/ ) noexcept { a1_free( ptr ); }

Здесь a1_free( ptr ) — наша собственная функция освобождения памяти.

Тут же хотелось бы отметить один интересный факт. Давайте посмотрим на разделы импорта одной из библиотек нашего проекта DataProvider.dll, занимающейся подтягиванием ресурсов игры (этот факт справедлив для всех библиотек проекта).

Раздел импорта библиотеки DataProvider.dll (Visual C++ 2010, Debug)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2010, Debug)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2010, Release)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2010, Release)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2019, Debug)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2019, Debug)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2019, Release)
Раздел импорта библиотеки DataProvider.dll (Visual C++ 2019, Release)

Как мы видим, в сборках Visual C++ 2010 и Visual C++2019 раздел импорта отличается. Debug-версии отличаются только наличием sized-версии operator delete в сборке Visual C++ 2019, а вот в Release-сборке версии 2019 вообще отсутствуют operator new/delete. Это отдельный интересный вопрос, который стоит задать компилятору и линковщику.

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

Thread-Safe Local Static Initialization

Существует интересный флажок компилятора /Zc:threadSafeInit, который сообщает ему, что инициализировать локальные статические переменные нужно в thread safe режиме. MSDN говорит, что поведение не определено, если происходит рекурсивная инициализация — то есть, поток выполняет функцию foo, содержащую объявление статической переменной var, и доходит до инициализации этой переменной. Переменная var представляет собой экземпляр очень хитро реализованного класса, конструктор которого может ещё раз вызвать функцию foo. По умолчанию флажок /Zc:threadSafeInit включен в Visual Studio 2015 и выше. Поэтому в нашем случае поведение оказалось вполне себе определено — бесконечный цикл ожидания инициализации переменной.

Вот небольшой кусочек кода, наглядно иллюстрирующий работу флажка /Zc:threadSafeInit. Помимо собственного глобального аллокатора мы используем статические типизированные пулы для часто создаваемых/разрушаемых объектов.

#define DEFINE_STATIC_POOL( ElementType, ElementsCount, PoolVarName ) \ static FORCEINLINE Mem::static_pool<ElementType, ElementsCount> &PoolVarName() \ { \ static Mem::static_pool<ElementType, ElementsCount> __instance; \ return __instance; \ } \ class StaticPoolCreator##PoolVarName \ { \ public: \ StaticPoolCreator##PoolVarName() \ { \ PoolVarName(); \ } \ } static creator##PoolVarName; }

Здесь мы объявляем статическую функцию со статической переменной __instance внутри, стандартный синглтон Майерса. Затем объявляем класс, в конструкторе которого вызывается определенный нами синглтон, и объявляем экземпляр данного класса статическим. Такие телодвижения с известным ударным инструментом необходимы, чтобы ввести в заблуждение вероятного противника (как не в себя оптимизирующий компилятор) и не раздувать секцию .data результирующего .exe-файла клиента игры.

А так выглядит наш поток сознания в assembler:

7BC04944 call __Init_thread_header (7BBC247Dh) 7BC04949 add esp,4 7BC0494C cmp dword ptr ds:[7BD00944h],0FFFFFFFFh 7BC04953 jne event_sound3d_pool+59h (7BC04979h) 7BC04955 mov ecx,offset __instance (7BCDC4F8h) 7BC0495A call Mem::static_pool<Sound::SoundEvent3D,448>::static_pool<Sound::SoundEvent3D,448> (7BBC1569h) 7BC0495F push offset `event_sound3d_pool'::`2'::`dynamic atexit destructor for '__instance'' (7BC6C130h) 7BC04964 call _atexit (7BBC4DF4h) 7BC04969 add esp,4 7BC0496C push 7BD00944h 7BC04971 call __Init_thread_footer (7BBC3652h)

Init_thread_header — функция Visual C++ Runtime, исходники которой можно посмотреть здесь: vcruntime/thread_safe_statics.cpp. Она начинает потокобезопасный блок инициализации статической переменной. Достигается это путем использования двух вспомогательных счетчиков и флажка, которые управляют этапами инициализации переменной.

Далее мы регистрируем деструктор нашей статической переменной и вызываем функцию Init_thread_footer.

А здесь работает критическая секция, которая управляет доступом к описанным счетчикам и флажкам:

extern "C" void __cdecl _Init_thread_lock(): 7BC63350 push ebp 7BC63351 mov ebp,esp 7BC63353 push offset _Tss_mutex (7C051218h) 7BC63358 call dword ptr [__imp__EnterCriticalSection@4 (7C0521E0h)] 7BC6335E pop ebp 7BC6335F ret

После того, как мы разобрались с зависанием игрового клиента, мы столкнулись с ещё более интересной проблемой: отъехали координаты практически всех объектов в игровом мире.

FPU Control Word

x87 FPU Control Word — это управляющее слово математического сопроцессора, с помощью которого можно управлять режимом округления, точностью, а также маскировать различные исключения. Существует определённый набор API для работы с этим control word — например, функция _controlfp_s. Она позволяет как прочитать, так и записать управляющее слово.

Касаемо последней API — у нее есть сайд-эффекты, выражающиеся в том, что она, в зависимости от аппаратной платформы, затрагивает управляющий регистр SSE2( MXSCR- x86/x64), где также модифицирует флаги округления и не только.

К чему такая долгая предыстория? Дело в том, что, когда мы побороли краши и зависания и смогли на свежесобранной версии попасть в игровой мир, там нас ждал очень неприятный сюрприз. Странным образом отвалился террейн, все мобы и персонажи в мире упали в бездну, кто находился на каких-то объектах, участвовали в каком-то броуновском движении. Здания повисли в воздухе, а часть объектов взлетела в небо. К сожалению, в тот момент я не наделал скриншотов, чтобы поделиться этим глобальным апокалипсисом. Но, в целом, картина выглядела довольно удручающе. Всему виной оказалась одна из вендорских библиотек, которая меняла режим округления вещественных чисел: вместо округления к ближайшему целому отбрасывалась дробную часть.

Вкратце коснемся системы позиционирования «Аллодов». Она базируется на кубах размером 32x32x32 и смещениях внутри них. Игровая позиция задаётся парой векторов Position{ { int, int, int }, { float, float, float } }, и любая позиция приводится к этому виду методом Position::Normalize(). Всё решение проблемы заключалось в модификации этого метода следующим образом:

FORCEINLINE void Position::Normalize() { . . . . . . . . unsigned int prev_ctrl_word = 0; _controlfp_s( &prev_ctrl_word, 0, 0 ); const unsigned int prev_round_mod = prev_ctrl_word & _MCW_RC; _controlfp_s( &prev_ctrl_word, _RC_NEAR, _MCW_RC ); . . . . . . . . _controlfp_s( &prev_ctrl_word, prev_round_mod, _MCW_RC ); . . . . . . . . }

Метод Normalize предназначен для преобразования данных внутри Position в валидную позицию игрового мира. В начале метода мы сохраняем текущее значение управляющего слова и устанавливаем флаг, который меняет режим округления. Затем происходит преобразование позиции, после чего восстанавливается режим округления, который был до вызова нашей функции.

Почему так криво? Дело в том, что в отсутствие времени на выяснение поведения вендорской библиотеки в случае масштабного вмешательства в режим округления было принято решение пойти методом наименьшего сопротивления — «Чтобы ничего не поломать».

Шаблоны и макро-магия

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

Дело вот в чем. Мы объявляем массив структур с помощью запутанных макросов. Затем один из макросов генерит в каждой структуре указатель на функцию с типом lua_CFunction. Внутри каждой функции будет вызываться соответствующий объявлению метод класса Sound::ISound2D. Например, первый элемент массива генерирует структуру с функцией, которая будет вызывать метод bool Sound::ISound2D::Play( void ) и т.д.

DEFINE_LUA_STRONG_METHODS_START( Sound::ISound2D ) { "Play", OBJECT_LUA_FUNCTION( bool, Sound::ISound2D, Play, void ) }, { "Stop", OBJECT_LUA_FUNCTION( bool, Sound::ISound2D, Stop, bool ) },

Error C2440 'initializing': cannot convert from 'int (__cdecl *)(lua_State *)' to 'lua_CFunction'

Эту ошибку изначально мы временно решили путём комментирования кода. Но, как известно, в России нет ничего постояннее временного. Так комментарий и прожил до завершающего этапа обновления компилятора, пока не напомнил о себе.

Интересна ошибка тем, что проблемный тип функции определён где-то в недрах LUA-библиотеки следующим образом:

typedef int (*lua_CFunction) (lua_State *L);

Как мы видим, компилятор не может привести друг к другу два идентичных типа указателей на функции. __cdecl в объявлении типа lua_CFunction ни на что не влияет, т.к. дефолтным соглашением о вызовах в C++ как раз и является __cdecl. Так что типы совершенно идентичны.

Путем очень въедливого чтения сообщений компилятора стало понятно, что дело только в объявлениях вида:

{ "Play", OBJECT_LUA_FUNCTION( bool, Sound::ISound2D, Play, void ) },

В то время как объявление:

{ "Stop", OBJECT_LUA_FUNCTION( bool, Sound::ISound2D, Stop, bool ) },

— недовольств компилятора не вызывает. Разницы между строчками практически никакой, кроме типа последнего параметра метода класса. Однако, первый вариант упорно не нравится компилятору, а вот со вторым всё в порядке. Поэтому проблема решилась простой заменой во всех проблемных случаях void на bool.

Из-за большого количества макросной магии и её не принципиальной важности разбирательства с ней сошли на нет. Но осталась задача на будущее — разобрать данное отличие поведения компиляторов MS VC 2010 и MS VC 2019.

_CRT_STDIO_INLINE

Как и любой проект, «Аллоды» имеют несколько конфигураций сборки, и вот на этапе сборки «финальной» конфигурации, которая и распространяется через Игровой центр MY.GAMES (назовём её FINALRELEASE), мы столкнулись с заключительной на сегодня проблемой.

Ниже приведен скриншот, функции на котором наверняка многим знакомы:

Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет

Как я уже говорил, при запуске финальной версии игры происходит проверка раздела экспорта на предмет его «девственной» чистоты. И скриншот показывает, что наша проверка не проходит. Но интересен не сам факт провала проверки, а функции, попавшие в .export-секцию, которые и фейлят проверку. Как нетрудно заметить, это API библиотеки stdio.

Давайте взглянем на то, как определяются данные API:

ucrt\corecrt_stdio_config.h:

#if defined _NO_CRT_STDIO_INLINE #undef _CRT_STDIO_INLINE #define _CRT_STDIO_INLINE #elif !defined _CRT_STDIO_INLINE #define _CRT_STDIO_INLINE __inline #endif

ucrt\stdio.h:

_Check_return_opt_ _CRT_STDIO_INLINE int __CRTDECL _fprintf_l( _Inout_ FILE* const _Stream, _In_z_ _Printf_format_string_params_(0) char const* const _Format, _In_opt_ _locale_t const _Locale, ...) #if defined _NO_CRT_STDIO_INLINE ; #else { int _Result; va_list _ArgList; __crt_va_start(_ArgList, _Locale); _Result = _vfprintf_l(_Stream, _Format, _Locale, _ArgList); __crt_va_end(_ArgList); return _Result; } #endif

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

Причина проблемы оказалась в библиотеке LuaJIT(a Just-In-Time Compiler for Lua). А вернее — в ее сборке. В проекте она собирается с помощью .bat-файла, в котором оказались интересные строки:

. . . . . . . @setlocal @set LJCOMPILE=cl /MD /nologo /c /O2 /W3 /D_CRT_SECURE_NO_DEPRECATE /D_CRT_STDIO_INLINE=__declspec(dllexport)__inline /I"include" /I"src" /Fo"obj/" @set LJLINK=link /nologo @set LJMT=mt /nologo @set LJLIB=lib /nologo /nodefaultlib @set DASMDIR=dynasm @set DASM=%DASMDIR%\dynasm.lua . . . . . . .

Макрос D_CRT_STDIO_INLINE определяется образом, указанным выше, и затем передается компилятору. Таким образом, D_CRT_STDIO_INLINE определяется как __declspec(dllexport)__inline. С точки зрения компилятора, а заодно и здравого смысла, определение функции с префиксом __declspec(dllexport)__inline вполне законно, и на практике приводит к вполне логичным и ожидаемым результатам. Грубо говоря, компилятор заменяет вызовы функции внутри библиотеки, где она реализована, и где он может это сделать, на её тело. Но также он помещает её в раздел экспорта данной библиотеки.

Проблема в том, что LuaJIT используется в проекте в виде статической библиотеки, и таким образом весь её этот экспорт переходит в наш результирующий exe-файл. Как зачастую случается, решение самой проблемы выглядит тривиальным на фоне выяснения причин этой проблемы.

Итоги

  • Первое и самое главное — мы апдейтили проект до стандарта ISO C++ 17. И, наконец, получили возможность применять современные стандарты в разработке.
  • Количество аллокаций в кадре снизилось в разы. В определённых моментах, например, с 600-700 до 200-250. Да, это всё-равно громадное значение, и далеко ещё не самое высокое. Но прогресс заметен.
  • FPS увеличился в среднем на ~10-15% в «гладком» кадре.

Что такое «гладкий» кадр? Будем считать, что это кадр, не нагруженный большим количеством интерфейсных событий. Например, даже этот относительно лёгкий замес порождает огромное число сообщений для апдейта интерфейса:

Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет

А вот так выглядит «гладкий» кадр:

Как протащить верблюда сквозь игольное ушко, или обновление компилятора С++ на игровом проекте старше 10 лет
  • Получили возможность дальнейшей оптимизации кодовой базы проекта на основании последних нововведений стандартов C++11/14/17.
  • Время сборки проекта и размер результирующего exe-файла клиента сколько либо заметно не изменились, что также является плюсом.

Эпилог

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

Я решил посмотреть asm-код одного из тестов SSE-функций. Результат Visual C++ 2019 превзошел старую версию, как гроссмейстер школьника. При максимально идентичных настройках там, где Visual C++ 2010 просто заменил вызов inline-функции на соответствующую SSE-инструкцию, 2019-я версия увеличила шаг цикла и применила аналогичные требуемой AVX-инструкции. С учётом кода теста оптимизация выглядела вполне закономерной, но только старая версия не делала даже намёков на неё.

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

Денис Колосов
Клиентский разработчик IT Territory (MY.GAMES)
199199
100 комментариев

Комментарий недоступен

61
Ответить

Вот такие статьи и нужны, сам готовлю к публикации дневник разработки своей игры с примерами кода на Java

1
Ответить

Так что мы хотели получить?Да скучно вам просто стало, захотелось приключений)

13
Ответить

Блин, так быстро раскрыли лавочку... Обидно. )))

6
Ответить

Многоядерность появилась? А то, если правильно помню, всё на одном ядре работало.
Обновление на стим версию распространяется?

5
Ответить

Да, вы всё правильно помните. Основная нагрузка на одно ядро. Есть фоновые потоки, но реально разгружающих основной поток всего 2 (чуть больше). На данный момент работаем над этим, но работы очень много, так что многопоточный pipeline это наше светлое будущее.

15
Ответить

Мы объявляем массив структур с помощью запутанных макросов.Зачем это делается так? В других языках даже макросов нет, чтоб так не делали, но плюсовики продолжают так делать. Неужели нет лучших вариантов?

4
Ответить