Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
>
>
>
Game++. Часть 2.2: Какие бывают...

Game++. Часть 2.2: Какие бывают оптимизации?

23 Окт 2026

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

"Горячие" функции и "бутылочные горлышки"

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

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

Это также включает написание "красивого" кода вместо "быстрого" кода, когда критичные для производительности участки обременяются ненужной практичностью и проверками ("будьте проще, и люди к вам потянутся").

Плавность, как отмечает большинство разработчиков, важнее пиковой производительности: неравномерная частота кадров из-за спорадических выделений памяти в главном потоке, непостоянное время кадра от алгоритмов переменной сложности, "заикания" от блокирующей загрузки ресурсов. Суть в том, что стабильные 30 кадров в секунду лучше, чем прыжки между 60 и 20 кадрами — игрок воспринимает процесс глазами, постоянный fps можно сделать измеряемой и понятной оценкой качества.

Ограничения железа — это "творческие ограничения". Попытки превзойти физические ограничения платформы "грубой силой" вместо разумной оптимизации приведут к увеличению сложности без реального влияния на систему. Увеличение количества полигонов без соответствующих систем детализации игрок увидит, как низкий fps, сложные шейдеры без учёта ограничений скорости заполнения игрок увидит, как низкий fps, создание "жадных" до памяти структур данных без учёта иерархии кэша игрок увидит, как низкий fps и т. д.

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

Правильная и стабильная работа достигается при сбалансированной нагрузке всех компонентов, но даже так вам не удастся загрузить процессор больше 70–80%, но вы сможете лучше занять видеокарту, эффективнее использовать пропускную способность памяти и кэши. И надо найти хрупкий баланс на грани возможного между этим всем, иначе будут конфликты за ресурсы — когда один компонент становится критическим звеном, вызывая остановки конвейера, накладные расходы переключения контекста и снижение пропускной способности в остальной части системы.

"Горячие" функции (Hotspots)

В программных системах "горячие" функции представляют собой локальные участки кода с аномально высоким потреблением ресурсов (процессора, памяти, дисковых операций). Они характеризуются диспропорционально высокой частотой исполнения (high execution frequency), интенсивным использованием процессорных циклов (high cpu utilization), частыми обращениями к памяти (high memory pressure) или избыточным количеством системных вызовов (system call overhead).

Принцип Парето (80–20 rule) в контексте оптимизации кода означает, что 20% исходного кода ответственны за 80% общего времени выполнения программы, что делает целенаправленную оптимизацию "горячих" точек крайне эффективной стратегией. Итеративный поиск таких мест позволяет выявлять участки кода с наивысшим потреблением ресурсов и применять целевые оптимизации для снижения их "температуры". После устранения проблемы нагрузка изменяется, и ранее менее значимые участки становятся новыми "горячими" точками, требующими внимания. Этот процесс продолжается до достижения приемлемых результатов или исчерпания времени на эту задачу. Стоит отметить, что время инженера тоже является своего рода исчерпаемым ресурсом, в том плане, что чем больше времени разработчик тратит на изменения в одном участке кода, тем меньше изменений там может быть внесено.

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

"Бутылочное горлышко" (Bottlenecks)

Представьте себе приготовление некоторого блюда в кафе. Повар не может сделать всё блюдо сразу и делегирует помощнику задачу подготовить гарнир, а сам занимается готовкой основы, затем оба компонента соединяются и готовое блюдо уносит официант. Каково будет общее время приготовления? Предположим, что повар сделал свою часть, но помощник быстрее работать не может, — именно он будет "тормозить" всю систему.

В играх работает абсолютно та же логика: всегда есть "медленный помощник", узкое место — процессор, видеокарта, память или что-то в коде, что сдерживает общий темп. Наша задача — найти это слабое звено и постараться его заставить работать не хуже остальных. Оптимизация почти всегда связана с поиском компромиссов, в 99% случаев у вас не будет "серебряной пули", чтобы одним махом решить проблему.

Первый и самый частый компромисс — размен скорости против памяти, иногда выгоднее хранить лишние данные, чем пересчитывать их заново. Это называется "мемоизация" или кэширование: посчитал что-то один раз, сохранил и потом просто достаёшь результат из памяти. Да, памяти тратится больше, но зато результат получается быстрее.

Второй компромисс — замена точности на скорость. Иногда можно "схалтурить" и ускорить систему, упростив тип данных и реализовав менее точный алгоритм. В шейдерах есть функции log и logp: первая точная, но медленная, вторая — быстрая, но менее аккуратная. На финальной картинке вы вряд ли заметите разницу, если соседний полигон займет позицию на четверть пикселя правее, зато игра будет работать ощутимо шустрее. Этот принцип работает почти с любыми моделями данных, поиска пути или игрового ИИ, тут главное решить, насколько вы готовы "схалтурить". Если по-научному, то это называется — перераспределять ресурсы низкоприоритетных вычислений, в пользу более высокоприоритетных.

И третий классический компромисс — простота против скорости (это уже больше про людей и команду). Классический случай — молодой крутой разработчик, который открыл для себя шаблоны и кэширование с пятью уровнями вложенности в попытке оптимизировать систему загрузки текстур.

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

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

Да, вы можете выжать дополнительные 5% производительности через пользовательские пулы памяти, но на это потребуется две недели реализации и ещё месяц на отладку странных крайних случаев. Возможно, стоит просто реализовать простой линейный аллокатор и разменять память на время?

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

Заблуждения и мифы

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

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

Одна из самых распространённых ошибок — работа на основе предположений. Нельзя "угадать", что именно замедляет систему, поскольку такие "догадки" чаще всего ошибочны. Единственный надёжный путь — это измерения и анализ, анализ и повторные измерения, превращающие догадки и субъективные впечатления в обоснованные инженерные решения.

Оптимизация не ограничивается этапом постфактум, после написания кода, поскольку 80% проблем с производительностью устраняются ещё на стадии проектирования архитектуры. Грамотная декомпозиция, чёткая иерархия объектов и продуманное управление зависимостями позволяют избежать "бутылочных горлышек". Даже с накладными расходами языков уровня C# или Java хорошо структурированная система нередко показывает лучшие результаты, чем код, написанный без учёта архитектурных ограничений на "чистых плюсах".

При проведении оптимизации важно ориентироваться не на средние, а на худшие сценарии. Большинство разработчиков тестируют игру или приложение на мощных рабочих машинах, что приводит, зачастую, к искажённой картине, в этом случае, "слабое" железо выявляет совершенно другие узкие места: вместо GPU приложение может уткнуться в пропускную способность памяти или маломощный CPU.

Для правильного разбора проблем с оптимизацией нужно целенаправленно использовать тестовые конфигурации с минимальными или слабыми характеристиками — именно они демонстрируют поведение в условиях, близких к реальным у части аудитории. Игроки "видят" разницу между 55 и 60 fps, однако редко придираются к этому, но падения до 20 fps (пусть и редкие) воспринимаются как серьёзные лаги, поэтому устойчивость времени кадра зачастую важнее абсолютных средних показателей.

Сравнение debug- и release-сборок даёт принципиально разные результаты (рис. 3.1). В debug-режиме отключены оптимизации компилятора, присутствуют дополнительные проверки, и код может замедляться в разы, причём неравномерно. Для валидного профилирования производительности нужно работать только с release-сборками, включая при необходимости debug-символы или отключая оптимизацию лишь отдельных мест — это позволяет балансировать между возможностью трассировки и измерениями, и реалистичной оценкой скорости.

Рисунок 3.1

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

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

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

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

Следующий миф связан с необходимостью выделять "большие блоки времени" на оптимизацию. На практике гораздо эффективнее внедрять её постепенно, методы вроде "бюджета производительности" позволяют заранее определить лимиты по fps, использованию CPU и GPU и строить работу команды вокруг этих показателей. Постоянный мониторинг и корректировка кода дают лучший результат, чем редкие рывки в последние недели разработки.

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

Наконец, миф о том, что производительность — это одна метрика. На самом деле "медлительность" игры имеет множество измерений: fps, время отклика, нагрузка на CPU и GPU и стабильность частоты кадров. Чтобы эффективно оптимизировать даже один из показателей, нужно сначала понять, какие из них критичны для конкретного проекта, как они связаны с остальными и что реально мешает.

Только такой системный подход позволяет избежать ловушек распространённых заблуждений об оптимизации. Другой вопрос — дадут ли ход таким изменениям? Скорее всего, нет, менять архитектуру ни архитектор проекта, ни техлид или технический директор с вероятностью 99,9999% не разрешат. Это не забота программиста, и такими решениями вы просто отбираете хлеб архитектора, а кроме того, изменяя существующую, работающую и отлаженную систему вы ломаете соседние, и когда эта ошибка всплывёт, а она обязательно всплывёт, никто сказать не может. Вот эта последняя причина и не дает пришедшему вчера разработчику переписать половину движка и сделать "как правильно" и остается довольствоваться изменениями на уровне кода и алгоритмов, что однако тоже дает возможность "причинять добро" в размере 10%+ к производительности.

И тут мы подходим к тому, что реальность разработки игр заставляет нас балансировать между нашими "идеальными" техническими решениями и практическими ограничениями проектов. Даже при понимании всех мифов об оптимизации, программист часто вынужден работать в рамках ограниченной существующей архитектуры, довольствуясь локальными улучшениями только на уровне алгоритмов и кода. Но и такой подход может дать ощутимые результаты — те самые 10–15% прироста производительности, которые могут стать залогом успешного релиза.

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

Инструменты

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

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

Современные программы и игры, особенно игры, содержат сотни тысяч строк кода, которые работают поверх сложных операционных систем, драйверов видеокарт и различных библиотек, в которых кода ещё больше. Без специальных инструментов анализа мы можем только гадать, что и где именно происходит в проблемном коде. Может быть, процессор не справляется с вычислениями, а может быть, узкое место в загрузке текстур с диска, или память заканчивается из-за утечек. Профилировщики, мониторы производительности и отладчики — это инструменты, которые тоже могут обманывать, но без них не обойтись. Они показывают, какие функции работают дольше других, сколько памяти потребляет каждая часть программы, как часто происходят обращения к диску и где процессор простаивает в ожидании данных. Когда у нас нет этих данных, мы вообще слепые, когда они у нас есть, решение часто становится очевидным, но умение правильно измерять и анализировать производительность — это сложная наука, порою смахивающая на алхимию.

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

Степень вмешательства зависит от того, насколько много и как часто нужны вам данные. Простой счётчик fps практически не влияет на игру, а вот детальный анализатор, который записывает каждый вызов функции и время её выполнения, может замедлить программу в несколько раз. Некоторые инструменты, например valgrind, идут ещё дальше и запускают всю программу внутри виртуальной машины, получая максимально подробную информацию о каждой операции, сами понимаете, как это сказывается на производительности, программа может работать на порядок медленнее обычного.

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

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

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

Выбор подходящего инструмента зависит от конкретных целей и готовности пожертвовать производительностью ради детальности анализа. Главное правило — инструмент не должен искажать результаты измерений сильнее, чем проблема, которую вы ищете. Бессмысленно оптимизировать функцию, которая занимает 1% времени выполнения, если сам профилировщик замедляет программу на 50%, поэтому часто применяют все три вида инструментов, но подключаются они при разных условиях.

Теория и термины

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

Классический сценарий — измерение затрат процессорного времени, но современные профайлеры способны отслеживать гораздо больше: промахи кэша, объём выделений памяти, обращения к диску, использование потоков и синхронизацию. В зависимости от уровня инструментирования можно либо предоставлять агрегированную картину (где и какие модули или системы нагружают процессор больше всего), либо показывать покадровый или интервальный срез, стек вызовов и точное распределение нагрузки по инструкциям.

На практике профайлеры делятся на универсальные и специализированные. Первые обычно встроены в среду разработки или SDK платформ (например, Visual Studio Profiler) и позволяют получить общее понимание, где теряется производительность. Вторые — это низкоуровневые инструменты (например, Sony Razor, Intel VTune, PIX или Tracy), которые собирают данные на уровне процессорных счётчиков и событий ОС, что даёт возможность намного точнее анализировать поведение кода. Только низкоуровневый инструментарий позволяет оценить влияние конкретных шагов алгоритма или выбранной схемы использования памяти на итоговую производительность этого участка кода.

Общее время (total time) — показывает совокупное время выполнения функции, включая все вложенные вызовы других функций. В теории даёт полную картину того, сколько времени программа тратит на выполнение определённого участка кода с учётом всех его зависимостей и полезно для определения "горячих точек" в приложении, но на практике может создавать искажённую картину производительности.

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

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

Собственное время (self time) — измеряет исключительно время, потраченное на выполнение инструкций внутри самой функции, исключая время выполнения всех функций, которые она вызывает. Это помогает найти блоки кода, которые действительно выполняют затратную работу, а не просто делегируют её другим функциям, обычно большое собственное время имеют функции со сложными алгоритмами, циклами или "числодробилки".

Функция ObjectUpdate() может иметь высокое общее время, поскольку вызывает все остальные подсистемы обновления игрового состояния, но низкое собственное время, поскольку сама по себе выполняет мало операций. В то же время функция расчёта физики столкновений может показать значительное собственное время, указывая на необходимость оптимизации именно этого участка. Проблемы практически те же, что и у предыдущего параметра: собственное время не способно отразить реальное влияние функции на производительность системы в целом, функция может показывать низкое собственное время, но при этом вызывать множество дорогостоящих операций через другие функции, создавая ложное впечатление о её эффективности. Кроме того, параметр не учитывает накладные расходы на вызовы функций: если функция делает тысячи мелких вызовов, накладные расходы на создание кадров стека, передачу параметров и возврат значений могут быть значительными или даже близкими ко времени выполнения, но останутся "невидимыми" в показателе собственного времени.

Ещё одна проблема заключается в "агрессивной оптимизации". Это очень характерно для компиляторов семейства Clang, когда они активно применяют inline-оптимизации, или частичное встраивание кода небольших функций непосредственно в место вызова. В результате показатели собственного времени искажаются, поскольку код, который логически принадлежит отдельной функции, будет засчитан как собственное время вызывающей функции (рис. 3.2).

Рисунок 3.2

Число вызовов (сalls) — параметр показывает, как часто определённый участок кода выполняется в течение сессии профайлинга, помогает понять характер использования различных функций в приложении. Обычно функции с большим количеством вызовов часто становятся кандидатами на оптимизацию, даже если их индивидуальное время выполнения невелико, поскольку совокупный эффект множественных вызовов (в виде накладных расходов) может быть значительным.

Анализ числа вызовов стоит первым пунктом при выявлении неэффективных алгоритмов или избыточных вычислений (с одной оговоркой, см. примечание далее). Если функция вызывается тысячи раз за кадр, это может указывать на необходимость внедрения различных методов оптимизации, большое количество вызовов может свидетельствовать о рекурсии, утечках ресурсов или о других проблемах в самой архитектуре приложения (рис. 3.3).

Рисунок 3.3

В целом это достаточно важный параметр, но само по себе число вызовов функции не даёт информации о распределении этих вызовов во времени, функция вполне может вызываться 100 000 раз за сессию, но если все эти вызовы происходят во время загрузки уровня, то на производительность игрового процесса они не влияют. Тут профайлер покажет высокое число вызовов, но не укажет на то, что функция неактивна во время рендеринга кадров. Или число вызовов может вводить в заблуждение относительно реальных проблем архитектуры: большое число вызовов может быть результатом хорошо продуманного дизайна с множеством мелких, специализированных функций, а не признаком неэффективности.

ПРИМЕЧАНИЕ

Современные компиляторы хорошо освоили встраивание (inlining), поэтому фокус исключительно на числе вызовов может привести к преждевременным и неправильным "выводам".

Общее время ожидания (total waste time) — время функции, в течение которого поток находился в заблокированном состоянии, ожидая освобождения ресурсов, завершения операций ввода-вывода или системных событий (рис. 3.4). Это время не связано с активным выполнением функции, а отражает задержки, вызванные внешними факторами. Высокое время ожидания может указывать на косвенные проблемы в работе, которые повлияли на общее время функции. Обычно к анализу этой метрики приходят при рефакторинге кода или для уменьшения зависимостей между функциями.

Рисунок 3.4

Автор — Сергей Кушниренко

Разработчик с более чем двадцатилетним опытом программирования и создания игр. Выпускник Национального исследовательского университета ИТМО. Начинал карьеру с разработки программного обеспечения для военно-морских тренажёров, навигационных систем и сетевых решений. Последние пятнадцать лет специализируется на разработке игр: в Electronic Arts занимался оптимизацией игр The Sims и SimCity BuildIt, в Gaijin Entertainment руководил переносом игр на платформы Nintendo Switch и Apple TV. Активно участвует в проектах с открытым исходным кодом, включая библиотеку ImSpinner и проект восстановления игры Pharaoh (1999).

Все части

Подписаться на рассылку
Хотите раз в месяц получать от нас подборку вышедших в этот период самых интересных статей и новостей? Подписывайтесь!
Популярные статьи по теме

Комментарии (0)

Следующие комментарии next comments
close comment form