Книга представляет собой сборник размышлений о языке программирования C++, алгоритмах и практиках в контексте разработки игр — о его сильных и слабых сторонах, практических решениях и устоявшихся способах работы. C++ на сегодняшний день остаётся основным языком в индустрии разработки игр благодаря сочетанию высокой производительности, гибкости и широких возможностей низкоуровневого контроля.

Википедия определяет "оптимизацию" как процесс модификации системы для повышения её эффективности или сокращения потребления ресурсов. В контексте разработки игр эти вводные приобретают особый смысл, ведь любая игра существует в условиях жёстко ограниченного бюджета. На одной чаше весов — процессорное время, память и время кадра, на другой — требования игроков и рынка к уровню графики, физике, искусственному интеллекту и пользовательскому опыту, и эти "весы" почти всегда перевешивают не в сторону разработчиков.
Оптимизацию всегда стоит начинать с общей картины. Не имеет смысла тратить время на случайные мелкие правки — важнее найти настоящие узкие места: где именно теряется производительность, и какие подсистемы расходуют ресурсы больше, чем приносят пользы. Для этого существуют профилировщики, счётчики производительности и трассировка. Процесс выглядит так: нашли проблемное место, замерили его влияние, попробовали улучшение, снова измерили результат. Если улучшения нет — откатываемся назад и ищем следующего кандидата.
Порядок действий таков:
Или более запоминающаяся концепция — MEDAL:
Measure > Examine > Develop > Assess (Check results) > Loop.
Опытный разработчик рассматривает оптимизацию не как набор "трюков", а как процесс, основанный на анализе данных. При этом в арсенале полезно иметь проверенные техники для работы с памятью, многопоточностью, рендерингом, математикой и т. п., применяя личный опыт и инструментарий, чтобы получить измеримый результат без бессмысленной траты времени. К сожалению, без личного опыта здесь никуда — без хорошей практики или учителей, научиться видеть проблемные места сложно. Меня всегда удивляло, как разработчики умудряются размещать большой объём вычислений на относительно "слабом" железе, к каким трюкам и решениям прибегают, чтобы приложение работало быстро. Это относится не только к игровым движкам, но и базам данных, системам управления и т. д. Особенно заметна эта разница при портировании относительно свежих игр (поколение PS3+) на разные портативные консоли вроде Nintendo Switch, Steam Deck и мобильные устройства. Nintendo Switch по производительности не сильно далеко ушёл от PlayStation 3, и попытки перенести требовательные игры, рассчитанные как минимум на следующее (PS4) поколение консолей, приводят к значительным проблемам, которые непросто решаются. Приведу сравнение возможностей трёх поколений игровых платформ (табл. 3.1), это важно для уяснения уровня решений, которые можно переносить между платформами без существенных компромиссов, а также для понимания уровней оптимизаций, на которые нужно будет обращать внимание при работе.
|
Компонент |
PlayStation 3 |
Nintendo Switch (портатив) |
Steam Deck |
|---|---|---|---|
|
CPU |
Cellх1 PPE + х6 SPE ~25 GFlops |
4×ARM Cortex-A57 1,02 ГГц ~25 GFlops |
AMD Zen2 2,4–3,5 ГГц (~448 GFlops) |
|
GPU |
NVIDIA (~GeForce 7800) ~192 GFlops |
NVIDIA Maxwell GM20B ~157 GFlops |
AMD RDNA 2 ~1,6 TFlops |
|
RAM |
256 МБ XDR 256 МБ GDDR3 ~25,6 ГБ/с (GPU) |
4 ГБ LPDDR4 ~25,6 ГБ/с |
16 ГБ LPDDR5 ~88 ГБ/с |
|
API |
Проприетарный |
OpenGL ES 3.2 / Vulkan / NX |
OpenGL3+ / Vulkan |
Таблица 3.1. Сравнение параметров игровых платформ
С точки зрения CPU, у PS3 используется архитектура Cell с одним основным PPE-ядром и шестью активными SPE-сопроцессорами. В реальных игровых задачах производительность такой конфигурации оценивается в районе 20–30 GFlops, хотя задействовать весь потенциал Cell крайне сложно.
У Nintendo Switch — 4-ядерный ARM Cortex-A57 с частотой 1,02 ГГц, но фактически 3-ядерный, потому одно ядро всегда отдано системе, выдающий примерно сопоставимые 20–25 GFlops при типичных нагрузках. Несмотря на архитектурные различия, оба процессора работают на сравнимом уровне. С GPU ситуация выглядит чуть иначе. У PS3 установлен RSX — адаптированный вариант GeForce 7800 с пиковой производительностью около 192 GFlops и устаревшей шейдерной моделью. В Switch используется GPU на базе NVIDIA Maxwell (Tegra X1): в портативном режиме он работает на сниженной частоте, выдавая около 157 GFlops, а в стационарном режиме (когда приставка установлена в докстанцию) производительность может достигать 393 GFlops.
Что касается оперативной памяти, Switch оснащён 4 ГБ LPDDR4 с пропускной способностью около 25,6 ГБ/с, тогда как у PS3 — 256 МБ XDR для CPU и 256 МБ GDDR3 для GPU с примерно такой же скоростью. Switch немного выигрывает за счёт объёма, но не по пропускной способности. Steam Deck представлен как пример текущего минимального уровня железа, на которое ориентируются разработчики при создании игр, т. е. перенос на уровень вниз ещё возможен, но с большими оговорками, а вот перепрыгнуть уровень не получится уже чисто по техническим причинам. Сравнение этих трёх платформ условное, с выходом Steam Deck 2 консоль Nintendo Switch превращается в аналог PS3, и поднимается общая планка параметров. Это однако не отменяет тот факт, что уровни оптимизации остаются те же, с теми же проблемами и набором решений, но возросшими размерами ресурсов.
В любом случае игра — это достаточно требовательный софт, зачастую с мягким реалтаймом, не важно, упаковали вы её для PS3 или последнего Steam Deck. Выдавать приемлемую скорость нужно в любом случае, иначе играть будет некомфортно и игру никто не купит. Небольшим подспорьем при переносе на мобильные платформы является их стабильное железо, хотя для смартфонов целый "зоопарк" процессоров, видеокарт и окружения, говорит скорее об обратном. На консолях с этим всё получше, и спецификации "железа" меняются раз в несколько лет.
Когда речь заходит о портировании игры, оптимизации можно разделить на несколько уровней: архитектура, алгоритмы и код.
Архитектурные решения охватывают систему взаимодействия модулей, набор слоёв данных, выбор системы аллокации памяти (custom allocators vs system malloc, object pooling vs free objects, dynamic allocation vs allocation free), модель использования потоков (single-threaded, multi-threaded, shared state, actor-based concurrency). Неоптимальная архитектура проявляется в виде избыточного потребления памяти, медленной работе, простое потоков, блокировке ресурсов или дорогом доступе к ним.
Исправление архитектурных недостатков требует больших временных и человеческих затрат для рефакторинга основных подсистем игры или движка, что часто означает переписывание основных компонентов ядра проекта вроде управления объектами, рендеринга, или системы памяти. Например, переход от наследования и компонентов к архитектуре Entity-Component-System (ECS), замена механизма выделения объектов, или реорганизация системы команд. Такие изменения дают максимальный прирост производительности, т. к. оптимизация доступа к памяти может улучшить производительность в несколько раз, правильная организация работы с потоками может задействовать все ядра CPU и снизить простои.
Однако архитектурные изменения на поздних стадиях разработки создают эффект домино во всей кодовой базе, требуя месяцев исправления и дорогого регрессионного тестирования, что делает такие оптимизации практически невозможными перед выпуском игры или при портировании на новые платформы.
Алгоритмы определяют, насколько эффективно будет работать ваша программа. Правильный выбор алгоритма может буквально спасти fps: замена обычного перебора элементов массива (сложность O(n)) на бинарный поиск в отсортированном массиве (O(log n)) или поиск в хеш-таблице (O(1)) даёт огромный прирост скорости при увеличении объёма данных. В массиве из миллиона элементов линейный поиск может потребовать миллиона операций, бинарный поиск — всего 20, а хеш-таблица найдёт элемент практически мгновенно. Да, у нас почти не бывает таких массивов, но достаточно нескольких мест, которые вызываются часто, и "нужный" миллион обращений будет достигнут, в плохом смысле.
Но теоретическая сложность алгоритмов не всегда совпадает с реальной производительностью из-за особенностей современного железа — процессоры используют кэши (L1, L2, L3), и алгоритмы, которые работают с данными последовательно, получают огромное преимущество благодаря аппаратной предвыборке. В результате простой линейный поиск по компактно упакованному массиву может значительно обогнать поиск в хеш-таблице, если данные помещаются в кэш первого уровня. Связанные списки, теоретически эффективные для вставок, на практике будут медленнее динамических массивов из-за "блуждания по указателям". Процессоры используют предсказание ветвлений, но это хорошо работает, когда условия в вашем коде предсказуемы, простой алгоритм с понятной логикой может работать быстрее сложного с множественными проверками.
Поэтому разработчик часто смотрит не только BigO-нотацию, но и реальные условия применения: размер данных, паттерны доступа к памяти, предсказуемость ветвлений, архитектуру целевого железа.
Разработка включает профилирование кода на реальных данных и создание бенчмарков для сложных участков. Часто оказывается, что "наивная" реализация обгоняет "теорию" в конкретных условиях применения из-за лучшей локальности памяти и отсутствия накладных расходов на поддержание сложной структуры. Для пояснения сказанного приведём характерный пример:
// Пример оптимизации поиска
bool slow_contains(const std::vector<int>& vec, int target) {
// Каждый раз вызываем find
return std::find(vec.begin(), vec.end(), target) != vec.end();
}
bool fast_contains(const std::vector<int>& sorted_vec, int target) {
// Простая оптимизация,
// используем бинарный поиск для отсортированного массива
return std::binary_search(sorted_vec.begin(),
sorted_vec.end(), target);
}
Уровень локальных оптимизаций (в отдельном блоке кода, функции или алгоритме, области применения алгоритма) направлен на детальную настройку решения для увеличения производительности операций и снижения накладных расходов. Речь идёт о мелких оптимизациях, которые чаще всего затрагивают относительно небольшие участки программы и могут быть реализованы в рамках одного код-ревью: до сотни изменений и до десяти файлов за раз.
Основные направления включают программирование без использования кучи (heapless logic) через реализацию алгоритмов и структур на стеке, объектных пулов и специализированных аллокаторов. Разворачивание и оптимизация циклов (loop unrolling) позволяет улучшить параллелизм на уровне команд, когда компилятор не может автоматически выполнить эту оптимизацию из-за сложности кода. Другие техники включают инлайнинг "горячих" функций, внедрение SIMD-решений для векторизации вычислений и заботу о локальности данных и следования соответствующим паттернам использования. Процесс оптимизации часто включает детальное профилирование через инструменты вроде Intel VTune, Pix, Razor, Tracy, или встроенные профилировщики игровых движков. Анализ состоит в изучении неэффективных инструкций, измерении числа промахов кэша, избыточных вызовов виртуальных функций, скрытых копий при передаче параметров, "дорогих" операций с объектами. Исправление этих проблем в большинстве случаев требует компромиссов между читаемостью кода и вносимым решением, поэтому такие оптимизации применяются только к важным участкам кода, которые действительно занимают значительную часть времени кадра.
Одна из самых распространённых задач на этом уровне — оптимизация циклов. В коде очень часто встречаются вложенные циклы, которые компилятор не в силах улучшить, но которые можно упростить или заменить более эффективными алгоритмами. Например, если внутренний цикл выполняет поиск по коллекции, стоит задуматься о предварительной подготовке структуры данных или использовании хэш-таблицы, чтобы избежать избыточных проходов.
Иногда достаточно вынести инвариантные вычисления за пределы цикла или изменить порядок обхода, чтобы снизить нагрузку на процессор:
void calculatePhysicsSlow(vector<float>& pos, vector<float>& vel, int frameStep)
{
for (int i = 0; i < iterations; ++i) {
for (size_t j = 0; j < positions.size(); ++j) {
// Инвариантные вычисления повторяются,
// компилятор может не увидеть их в сложных случаях
float gravity = world->getGravity(); float damping = object->getDamping();
float timeStep = getDeltaTime() / frameStep;
vel[j] += gravity * timeStep; vel[j] *= damping;
pos[j] += velocities[j] * timeStep;
}
}
}
---------------------------------------------------------------------------------------------------------------------
void calculatePhysicsFast(vector<float>& pos, vector<float>& vel, int frameStep)
{
// Инвариантные вычисления вынесены за пределы циклов
float gravity = world->getGravity(); float damping = object->getDamping();
float timeStep = getDeltaTime() / frameStep;
const float gravityTimeStep = gravity * timeStep;
for (int i = 0; i < iterations; ++i) {
for (size_t j = 0; j < positions.size(); ++j) {
velocities[j] += gravityTimeStep;
velocities[j] *= damping;
positions[j] += velocities[j] * timeStep;
}
}
}
Современные компиляторы с включёнными оптимизациями могут автоматически выполнять такие преобразования, но в сложных случаях с указателями, виртуальными функциями или при запутанной логике ручное управление остаётся единственным вариантом для достижения максимальной производительности. Вынесение инвариантных вычислений представляет собой фундаментальную оптимизацию, основанную на принципе loop-invariant code motion, где выражения, результат которых не изменяется на протяжении всех итераций цикла, выносятся за его пределы для однократного вычисления. Инвариантными считаются операции с константами, обращения к неизменяемым переменным, математические выражения с фиксированными значениями и вызовы функций с детерминированным результатом.
Например, вычисление deltaTime / iterations, sin(angle) для фиксированного угла или sqrt(maxDistance) внутри цикла приводит к избыточным операциям на каждом проходе, тогда как вынесение этих операций в предварительно вычисленные переменные сокращает количество операций с O(n) до O(1).
Следующий важный момент — поиск и устранение линейного поиска в коде, особенно внутри циклов. Линейный поиск (например, std::find или перебор массива вручную) может быть вполне приемлемым для небольших коллекций, но в "горячих" участках кода или при большом объёме данных он становится серьёзным узким местом. Замена линейного поиска на бинарный, использование ассоциативных контейнеров или предварительная сортировка данных позволяют значительно ускорить выполнение:
struct GameObject {
int id; string name; float x, y, z; bool isActive;
GameObject(int _id, const string& _name,
float _x, float _y, float _z, bool _active)
: id(_id), name(_name),
x(_x), y(_y), z(_z), isActive(_active) {}
};
// Линейный поиск по имени - O(n)
GameObject* findObjectByName(const string& name) {
auto it = find_if(objects.begin(), objects.end(),
[&name](const GameObject& obj)
{
return obj.name == name;
});
return (it != objects.end()) ? &(*it) : nullptr;
}
Основные техники оптимизации включают три подхода: хеш-таблицы, бинарный поиск и кэширование. Хеш-таблицы (unordered_map) обеспечивают среднюю сложность поиска O(1), они особенно эффективны при частом доступе по уникальным ключам. Хорошей практикой является предварительное создание индексов, например, отображение ID в позицию в массиве или построение индекса по строковому имени — это значительно ускоряет доступ к данным. Кэширование позволяет сохранять результаты дорогостоящих операций и возвращать их быстро при повторных запросах. Главное — следить за актуальностью данных и корректно инвалидировать кэш.
Одноразовые вложения в индексацию и сортировку быстро окупаются при множественном поиске, поэтому важно правильно выбирать структуру данных под конкретный паттерн использования и быть готовым к компромиссам: ускорение доступа часто требует дополнительной памяти.
Хеш-таблицы хороши для частых поисков по ключам, бинарный поиск — для стабильных, редко изменяющихся данных, а кэширование — для тяжёлых, но повторяемых вычислений. Всё это критично в игровых циклах, где объекты запрашиваются сотни раз за кадр. Вот один из примеров кода:
void addObject(const GameObject& obj) {
size_t index = objects.size();
objects.push_back(obj);
idToIndex[obj.id] = index; // Обновляем индексы
nameToIndex[obj.name] = index;
// Инвалидируем кэш
activeObjectsCacheValid = false;
}
// Поиск по имени в хэш-таблице - O(1)
GameObject* findObjectByName(const std::string& name) {
auto it = nameToIndex.find(name);
if (it != nameToIndex.end())
return &objects[it->second];
return nullptr;
}
Одно из ключевых направлений — минимизация накладных расходов на вызовы функций в критических участках кода. Вызов функции в общем случае включает в себя создание нового стекового фрейма, передачу параметров, сохранение состояния регистров и последующее их восстановление при возврате. Если число итераций слишком велико, эти накладные расходы значительно снижают производительность. Особенно явно это проявляется в случаях, когда функции выполняют простые операции, время выполнения которых сопоставимо с временем самого вызова внешней функции:
// Неоптимизированный код
class Vector3D {
double x, y, z;
public:
double getX() const { return x; }
. . .
double magnitude() const { return sqrt(x*x + y*y + z*z); }
};
// Медленная версия с множественными вызовами функций
double calculateDistance(const Vector3D& v1, const Vector3D& v2) {
double sum = 0.0;
for (int i = 0; i < 1000000; ++i) {
double dx = v1.getX() - v2.getX();
double dy = v1.getY() - v2.getY();
double dz = v1.getZ() - v2.getZ();
sum += sqrt(dx*dx + dy*dy + dz*dz);
}
return sum;
}
Разработчики компиляторов знают об этом и для решения подобной проблемы применяются различные техники оптимизации. Инлайнинг позволяет компилятору встраивать код функции непосредственно в место вызова, устраняя накладные расходы.
Вынос инвариантных вычислений за пределы циклов (loop hoisting) предотвращает повторное выполнение одинаковых операций. Объединение нескольких простых функций в одну снижает количество переходов и улучшает локальность данных в кэше процессора. Да, компилятор, скорее всего, увидит наличие простых функций-геттеров и сможет оптимизировать это место, но он не в состоянии написать более оптимальное решение:
class OptimizedVector3D {
double x, y, z;
public:
inline double getX() const { return x; }
. . .
// Оптимизированная функция для вычисления расстояния
inline double distanceTo(const OptimizedVector3D& other) const {
const double dx = x - other.x;
const double dy = y - other.y;
const double dz = z - other.z;
return sqrt(dx*dx + dy*dy + dz*dz);
}
};
Альтернативный подход с OptimizedVector3D подразумевает инлайнинг геттеров, что позволяет компилятору встраивать их код непосредственно в место вызова, и создание нового функционала в виде функции distanceTo(), которая выполняет все вычисления локально без дополнительных вызовов. Особое внимание следует уделять аллокациям памяти внутри циклов или горячих функций. Динамическое выделение памяти — одна из самых затратных операций, особенно если оно происходит часто и в высоконагруженных участках программы. Более подробно методы оптимизации такого кода будут рассмотрены в главе "Нескучное программирование".
Оптимизации на уровне исходного кода и отдельных функций имеют разную степень воздействия на производительность в зависимости от аппаратной платформы. На высокопроизводительных системах (современных игровых консолях и настольных ПК с многоядерными процессорами, большими объёмами L2/L3 кэша и высокочастотной памятью) локальные улучшения кода редко дают прирост производительности более 2–3% и не оказывают заметного влияния в целом (но не стоит забывать о человеческих ошибках).
Такие улучшения редко оправдывают затраченные человеко-часы разработки с точки зрения бизнес-метрик, поскольку время разработчиков считается более ценным ресурсом для создания нового функционала или исправления критических багов.
Однако на портативных устройствах — мобильных процессорах ARM с ограниченными тактовыми частотами (1–3 ГГц против 4–5 ГГц на ПК), малыми объёмами кэша (512 КБ–2 МБ L2 против 8–32 МБ), строгими ограничениями энергопотребления и термальными лимитами — те же самые оптимизации могут давать ощутимый прирост производительности в несколько десятков процентов и существенно влиять на стабильность частоты кадров и время автономной работы.
Вот примеры кода:
long unpredictable_branches(const vector<int>& data) {
long sum = 0;
for (int val : data) {
// Данные случайны, ветвление непредсказуемо
if (val > 0)
sum += val;
}
return sum;
}
------------------------------------------------------------------------------------------------------------------
// Отказ от ветвлений за счет битовых операций
long branchless_version(const vector<int>& data) {
long sum = 0;
for (int val : data) {
// Используем маску вместо ветвления
sum += val & (-(val > 0));
}
return sum;
}
Нахождение и внедрение эффективных (микро)оптимизаций представляет собой отдельную область процесса разработки, требующую глубокого понимания архитектуры процессора и системы памяти. Написание оптимального кода объёмом в несколько строк может потребовать многодневного анализа, детального профилирования для выявления узких мест, анализа поведения кэша, изучения ассемблерного кода и понимания особенностей предсказания ветвлений на конкретной архитектуре.
Эффективность таких оптимизаций сильно зависит от контекста работы: паттернов доступа к памяти, объёма рабочего набора данных относительно размеров кэша разных уровней, выравнивания данных по границам кэш-линий, частоты выполнения конкретного участка кода и его взаимодействия с другими компонентами системы. Многие разработчики считают это последним аргументом, предпочитая изменения на других слоях архитектуры, и в большинстве случаев это оправдано.
Игровые реплеи — один из самых мощных инструментов отладки, особенно в сложных системах с большим числом взаимодействующих объектов и нелинейной логикой. Реплей — это не просто запись видео, а упорядоченный во времени набор входных данных (игровых команд, событий, состояний), который воспроизводится движком игры заново. Такой подход позволяет воспроизводить поведение игры с точностью до кадра, независимо от производительности машины или времени суток, что делает его идеальным для детерминированной отладки. По сути, это аналог обратимой отладки (time-travel debugging), но без необходимости вмешательства в компилятор, без "заморозки" состояния всей виртуальной машины, и с минимальными накладными расходами.
Технически, реализация программного реплея требует записи точек состояния (action points) объектов на каждой итерации игрового цикла: пользовательских команд, состояний AI, сетевых сообщений, изменений генератора случайных чисел и прочего. При правильной архитектуре реплей остаётся воспроизводимым даже спустя месяцы.
Такой механизм позволяет специалистам QA-отдела или разработчику воспроизвести баг, просто "прокрутив" матч до нужного момента. Сами реплеи можно адаптировать для написания автоматических тестов, симуляции игровых сценариев и даже для обучения AI. В продакшене он часто оказывается более практичным и мощным, чем классические отладчики или логи.
Модульная изоляция компонентов представляет собой архитектурный принцип, основанный на разделении интерфейсов и реализаций и на внедрении зависимостей. Правильно спроектированные модули взаимодействуют исключительно через определённые границы API (API boundaries), скрывая внутренние детали реализации и обеспечивая слабую связанность между подсистемами.
Аудиосистема может предоставлять интерфейс для работы со звуками через методы PlaySound(), SetVolume(), но детали скрыты — использует ли он конкретную реализацию DirectSound, OpenAL, или собственный аудиоконвейер. Такая изоляция позволяет заменять реализации без изменения клиентского кода. Изолированная архитектура упрощает процесс портирования и платформо-специфичных оптимизаций, поскольку каждый компонент можно адаптировать независимо от других частей системы. Рендер может иметь отдельные реализации для DirectX, Vulkan, Metal или OpenGL, выбираемые во время инициализации, при этом игровая логика остаётся неизменной. Аналогично, система ввода может использовать разные имплементации для клавиатуры/мыши, геймпада, сенсорного ввода или VR-контроллеров, сохраняя единый интерфейс для игрового кода. Все это позволяет создавать платформо-специфичные решения без конфликтов слияния, проводить изолированное тестирование производительности отдельных компонентов и внедрять изменения без риска для стабильности всей системы.
Существует распространённое заблуждение, что оптимизации на этом уровне требуют использования сложных и трудно понимаемых решений, таких как написание кода на ассемблере, наличия продвинутых и сложных алгоритмов или поголовного перевода в SIMD-реализацию. Хорошо, что на практике это не всегда так, и многие оптимизации получаются довольно простыми и интуитивно понятными. Часто чистый и понятный код сам по себе работает быстро, потому что он проще для компилятора и оптимизатора. Но не всегда.
Что можно сказать об этой части разработки? Обычно она включает в себя нормальные сборочные конвейеры, когда билд попадает в отдел тестирования через час после коммита и возвращается к автору через два, если были ошибки. Хорошие инструменты отладки и реплеи, современные инструменты игровой разработки (BT — behavior tree, VS — visual scripts, blueprints, AS — animation scripts). Очень сложно дебажить из-под отладчика, а баги с ними связанные, в 99% случаев не воспроизводятся, хотя это конечно зависит от профессионализма специалистов QA. Эти улучшения могут не оказывать прямого влияния на производительность игры во время её работы, но способствуют более быстрому выявлению и устранению проблем, а когда разработчик вместо починки бага делает фичу, то он непосредственно "наносит добро" проекту.
Тесты производительности (Benchmarks)
Поговорим о бенчмарках — краеугольном камне любого подхода к оптимизации. Невозможно улучшить то, что нельзя измерить. Качественный бенчмарк — это компас в океане оптимизаций, который даёт немедленную обратную связь о результативности каждого изменения. По сути, бенчмарк представляет собой сценарий выполнения, который воспроизводит специфические условия нагрузки и служит эталоном для количественного сравнения производительности различных версий кода.
Поэтому бенчмарк должен соответствовать четырём критическим требованиям.
Суть бенчмарков в том, что они позволяют количественно подтверждать сравнение между исходным и оптимизированным состоянием. Если есть возможность, то лучше всегда начинайте с измерений, так можно будет количественно подтвердить улучшение. Несмотря на кажущуюся простоту, при создании качественных бенчмарков полно подводных камней. Одна из самых частых ошибок — измерение "на горячую", когда первые прогоны игнорируются из-за прогрева или заполнения кэшей, но при этом забывают учесть, что в реальной игре "холодный старт" и "холодные данные" встречаются не реже "горячих".
Автоматизация бенчмарков через CI/CD призвана отслеживать регрессии производительности на ранних стадиях, а визуализация результатов в виде графиков помогает увидеть долгосрочные тренды и корреляции между различными метриками производительности.
Измерения
Следующий шаг оптимизации заключается в сборе и анализе метрик производительности, позволяющих точно определить узкие места в системе. Каждое изменение должно обеспечивать изменение производительности при минимальных затратах ресурсов и времени, т. е. обеспечивать высокую отдачу от инвестированных усилий (ROI). Это не всегда положительные изменения, но они помогают понять, в каком направлении будет следующий шаг.
Правильная диагностика дает понимание, что изменения применяются к реальной проблеме, а не к случайной части кода, которая не влияет на эффективность. Метрики, профили и логи играют роль источников информации, позволяя избежать "слепых" изменений и ненужных итераций.
Подход к работе с оптимизациями аналогичен инженерному анализу: сначала проводим оценку системы на макроуровне, потом постепенно переходим к частным случаям. На верхнем уровне важно определить, какой компонент ограничивает производительность — CPU, GPU или память. В большинстве случаев, которые требуют вмешательства, узкое место связано с конкретным модулем или участком кода (это уровень алгоритмов и структур данных). Для диагностики применяются профайлеры, системные мониторы, инструменты трассировки и метрики производительности движка. С их помощью можно идентифицировать "горячие точки" — функции или процедуры (это уровень исходного кода), потребляющие значительную часть процессорного времени, видеокарты или памяти.
При анализе стоит начать оценку загрузки CPU и GPU с помощью общих мониторов, и если GPU не перегружен, профайлер может помочь измерить время, затрачиваемое CPU на обновление и обработку логики игры. Выявление проблем на верхнем уровне позволяет сократить область анализа и сосредоточить оптимизацию в определённом месте.
Но при измерениях важно понимать, что "одна проблема" редко является единичной и искомой точкой, игра состоит из множества микроопераций, и узкие места часто представляют собой совокупность нескольких факторов. На уровне инструкций это может быть длительный вызов, приводящий к простоям процессора, на уровне графического конвейера — разбалансированная обработка команд рендеринга.
Это может выглядеть как две разные проблемы, но поправив одну, вы заметите изменения в другой. Использование различных уровней анализа — от микро- до макроуровня — позволяет определить наиболее критический компонент, сдерживающий производительность системы, и эффективно перераспределить ресурсы. Важно всегда начинать с анализа общей картины производительности и поэтапно углубляться до конкретных узких мест, комбинируя инструменты и метрики для точного выявления и устранения реальных проблем, не бросаясь решать первую попавшуюся. Лучше сделать для этого список возможных мест, часто он даёт общую картину, которая подсказывает направление поиска.
Наконец, грамотная оптимизация должна сочетаться с оценкой стоимости внедрения, иногда устранение конкретного узкого места может потребовать значительных усилий при минимальном выигрыше, и в таких случаях разумнее перенести внимание на более продуктивные направления. Соотношению "эффект/затраты" вполне может стать ещё одним параметром оптимизации, чтобы избежать чрезмерного увлечения микрооптимизациями. В результате оптимизация становится не набором случайных улучшений, а управляемым процессом, который улучшает эффективность всей системы.
Изменения и интуиция инженера
После того как вы собрали достаточное количество данных и определили узкое место, ограничивающее производительность, следует отложить эти результаты, и провести анализ с самого сначала, избегая этого места.
Это методика, известна как "hotspot avoidance" — временное исключение или игнорирование главного узкого места системы при анализе производительности. Суть этого действия (в 80% случаев мы действительно не находим других существенных "мест-тормозов") заключается в том, чтобы сначала не концентрироваться на уже выявленном "горячем" участке кода, а исследовать поведение остальных компонентов системы. Но этот шаг позволяет выявить скрытые узкие места, которые могут оставаться незамеченными, когда очевидное "горячее место" доминирует в профайлере, и даёт более полное понимание распределения нагрузки между компонентами.
Техническая ценность этого метода проявляется позже, когда уже есть большой опыт оптимизации разных систем. Исключение главного узкого места позволяет определить точки, которые в совокупности могут дать значительный прирост производительности или влияют на сам "hotspot". Кроме того, это облегчает анализ сложных систем, которые создают неожиданные взаимозависимости (интуиция инженера).
Теперь можно приступить к внесению изменений, они могут включать модификацию алгоритмов, оптимизацию структур данных, изменение паттернов доступа к памяти или "переписывание всего с нуля", но даже опытные оптимизаторы часто вынуждены делать несколько гипотез (и изменений) относительно наилучшего способа повышения производительности, поэтому рекомендуется тестировать несколько вариантов реализации, прежде чем остановиться на окончательном решении, и тут вам как раз помогут идеи, которые были собраны на шаге "hotspot avoidance".
После внесения изменений мы снова замеряем производительность с помощью бенчмарков и профайлеров, потому что невозможно определить, действительно ли исправление узкого места принесло улучшение, или же проблема крылась в других факторах. Систематическое измерение позволяет не только выявлять истинные причины узких мест, но и накапливать опыт и развивать "инженерную интуицию", позволяя быстрее и точнее решать схожие проблемы в будущих проектах. Мои коллеги с большим опытом работы над движком часто определяли проблему анализируя логи и общий снимок профайлера, даже не заглядывая в сам код, и в большинстве случаев данные ими рекомендации по изменению той или иной части системы или кода оказывались верными — это я подвожу вас к пониманию "интуиции инженера".
Автор — Сергей Кушниренко
Разработчик с более чем двадцатилетним опытом программирования и создания игр. Выпускник Национального исследовательского университета ИТМО. Начинал карьеру с разработки программного обеспечения для военно-морских тренажёров, навигационных систем и сетевых решений. Последние пятнадцать лет специализируется на разработке игр: в Electronic Arts занимался оптимизацией игр The Sims и SimCity BuildIt, в Gaijin Entertainment руководил переносом игр на платформы Nintendo Switch и Apple TV. Активно участвует в проектах с открытым исходным кодом, включая библиотеку ImSpinner и проект восстановления игры Pharaoh (1999).
Все части
Game++. Часть 1.1: С++, движки и архитектуры
Game++. Часть 1.2: С++, движки и архитектуры
0