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

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

Это очень отличает игры от большинства других типов программного обеспечения, где небольшая задержка в несколько миллисекунд не так незаметна или вообще не критична для пользовательского опыта. Вот вы открыли эту статью и ваш браузер грузил эту страницу на секунду дольше чем мог бы, потому что я как обычно вставил большую КДПВ, но вы как пользователь этого, скорее всего, даже не заметили. Или редактор Хабра, в котором я сейчас пишу эту статью и который сохраняет документ на секунду медленнее чем мог бы, это абсолютно не влияет на работу и вы об этом вообще никогда не задумывались.

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


Эволюция требований

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

Если говорить про какие-то более сложные игры (в плане борьбы за fps), особенно про экшены, шутеры или гоночные игры, то нам нужно минимум 45, а лучше шестьдесят FPS, потому что при более низкой частоте кадров само движение на экране начинает выглядеть прерывистым и дерганым, и становится трудно точно контролировать своего персонажа или прицеливаться. Если говорить про киберспорт, где миллисекунды решают исход матча и профессиональные игроки тренируют свою реакцию за пределами обычных человеческих возможностей, то там уже игроки хотят 120 FPS, а лучше 144 или даже 240 FPS, и соответственно надо отслеживать, чтобы изменения не привели к каким-то просадкам производительности, которые дадут одним игрокам преимущество над другими просто из-за качества их оборудования.

Исторически требования к FPS постоянно росли вместе с развитием технологий и привыканием игроков к более высокому качеству. В 1990-х годах игры вроде Doom и Quake работали на 15-25 FPS на типичном железе того времени, и это считалось совершенно приемлемым, более того, многие консольные игры той эпохи работали на 20 FPS или даже ниже, и никто особо не жаловался, потому что альтернативы просто не существовало, но с появлением в мониторов с частотой обновления 60+ Гц и консолей PlayStation 2 и Xbox стандартом стало именно 60 FPS для динамичных игр, а с популяризацией киберспорта и появлением мониторов с частотой 144 Гц и выше планка поднялась еще выше.

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

Причин накосячить у художников и дизайнеров намного больше

Геометрия

  • Переполигонаж

    отсутствие LOD’ов;
    слишком высокая плотность там, где объект занимает 20 пикселей экрана.

  • Невалидные LODы

    слишком поздно переключается
    слишком мало LOD-уровней

  • Скрытая геометрия
    вложенные меши
    геометрия под землёй / внутри зданий
    забытые proxy-объекты

  • Дробление мешей
    один визуальный объект, но десятки drawcalls

Текстуры и материалы

  • Текстуры несоразмерные экранному размеру

    4K на объекте размером с ноготь
    особенно больно для UI и декалей

  • Отсутствие или неправильные mipmaps
    aliasing + cache thrash на GPU.

  • Материалы с тяжёлым шейдером
    PBR «по умолчанию» везде;
    динамические ветвления в пиксельных шейдерах.

  • Materials hell

    десятки уникальных материалов, которые визуально неотличимы

  • Анимации
    Слишком высокая частота семплирования скелета
    Скелеты с избыточным числом костей
    Анимации, обновляющиеся вне камеры
    Blending без ограничений

Рендер

  • Уникальные частицы для тумана, UI, постэффектов (если не батчатся)

  • Полупрозрачность поверх полупрозрачности

  • Эффекты, которые не отключаются по distance / quality

  • Слишком большие shadow maps

  • Слишком много источников теней

  • Динамические тени везде

Видимость и кулинг

  • Отсутствие или неправильный frustum / occlusion culling

  • Стриминг областей с плохими границами

  • Сцены с идеей “всё всегда загружено”

  • Длинные коридоры с прозрачностью

  • Открытые пространства без естественных occluder’ов

  • Камера, смотрящая на «всё сразу»

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

Существует "эмпирическая" зависимость скорости исправления контентных багов от возраста, и равна она 1.6-2х, т.е. если баг обнаружен день назад, то оптимистично он будет исправлен сегодня-завтра, неделю назад - соответственно на следующей неделе. Тут есть правда вариант получить "эффект последней недели": "Я же сказал вам, что пофикшу на следующей неделе. Чего вы ко мне уже второй месяц об этом напоминаете?". Ну а если месяц и больше, то тут уже зависимость становится очень нелинейной и возвращаясь к багу с зубами в CS2, он был обнаружен, по слухам, за 2 месяца до релиза игры, потом игру отложили еще на три месяца, а исправлем через три месяца позже её выхода.

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

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

Drawcalls

В русскоязычном геймдеве термин "drawcalls" обычно произносится как: "дро́-коллы" или "дро́-колы" с ударением на первый слог. Менее распространённые варианты: "драв-коллы" - иногда встречается, но реже, и "вызовы отрисовки" - буквальный перевод, редко в разговорной речи и больше в документации, или ангоязычное написание, как общепринятый стандарт. А всякие драу и дроу призывы оставьте эльфам, морлокам и прочим жижихам.

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

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

В целом можно считать "профайлером", наверное, и разные debug режимы отрисовки, как wireframe mode, который показывает сетку полигонов, или overdraw визуализацию, которая показывает, сколько раз каждый пиксель перерисовывается. Это всё вещи, которые позволяют на раннем этапе обнаружить проблему, детектировать её природу и поправить до того, как она попадет в репо.

Есть внешние профайлеры, которые запускаются отдельно от игры и подключаются к ней для сбора данных, и они всем хорошо знакомы (Tracy, Pix, Razor, VTune, Optick, Dr, RGP, IGPA, ASP, Systrace, Perfetto). Даже встроенный в Visual Studio можно использовать для профилирования, чтобы посмотреть, как и что выполнялось, сколько времени заняла каждая функция, сколько памяти было выделено и так далее.

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

Но RenderDoc это прежде всего инструмент анализа и отладки, а не классический профайлер в смысле таймингов и статистики и он отлично отвечает на вопросы «что именно мы рисуем» и «в каком порядке», но почти не даёт ответа на вопрос «сколько это реально стоит по времени на железе». В реальной работе RenderDoc почти всегда используется в паре с внутренним GPU-профайлерами или вендорскими инструментами, вроде Nsight, которые позволяют уже смотреть на кадр с точки зрения исполнения на GPU: сколько времени занял каждый вызов, где графика ждёт сpu или наоборот, упираемся ли мы в память, и собственно только эта пара даёт возможность совместить временную шкалу CPU и GPU и увидеть, где именно CPU не успевает кормить видеокарту, а где GPU уже сам становится узким местом.

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

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

   Время выполнения функций:
   • physics_update:  5 мс
   • render_scene:   10 мс
   • process_ai:      3 мс
   • audio_update:    2 мс
   
   Всего сэмплов собрано: 52:
   audio_update: 5 сэмплов (9.6%)
   game_loop: 2 сэмплов (3.8%)
   physics_update: 13 сэмплов (25.0%)
   process_ai: 8 сэмплов (15.4%)
   render_scene: 24 сэмплов (46.2%)

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

Проблема быстрых функций:
═══════════════════

Время:  0    100  200  300  400  500  600  700  800 ns
        │    │    │    │    │    │    │    │    │
        ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓

        [────────── process_particles ──────────][other_work]
        └─────────── 500 ns ───────────┘└─ 300ns ─┘

Внутри  [f][f][f][f][f]...[f][f][f]  (5000 вызовов)
процесса:↑  ↑  ↑  ↑  ↑     ↑  ↑  ↑
         0.1мс каждый

fast_function (0.1 мс каждая):
│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│▪│ ... (5000 штук)

Интервал сэмплирования (2 мс):
▼              ▼              ▼              ▼
│              │              │              │
S₁             S₂             S₃             S₄

Вероятность попасть В одну функцию:
0.1 мс / 2 мс = 5%

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

ВРЕМЕННАЯ ЛИНИЯ:
Время (мс): 0    2    4    6    8    10   12   14   16   18   20
            │    │    │    │    │    │    │    │    │    │    │
            ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓

Реальное    [physics][  render_scene    ][AI ][ audio ]
выполнение: └─5ms──┘ └───────10ms───────┘└─3─┘└──2ms─┘

МОМЕНТЫ:         ▼         ▼         ▼         ▼         ▼
сэмплирования:   S₁        S₂        S₃        S₄        S₅
(каждые 2 мс)

ТИКИ:       [physics] [render]  [render]     [AI]      [audio]
                 ↑         ↑         ↑         ↑         ↑
               "Сейчас   "Сейчас   "Сейчас   "Сейчас   "Сейчас
                в         в         в         в         в
             physics"  render"   render"    AI"      audio"

Сэмплов всего: 5
physics_update:  1 сэмпл  (20%)  ← Реально было 25%
render_scene:    2 сэмпла (40%)  ← Реально было 50%
process_ai:      1 сэмпл  (20%)  ← Реально было 15%
audio_update:    1 сэмпл  (20%)  ← Реально было 10%

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

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

#define PROFILE_SCOPE(name) \
    ProfileScope profile_scope_##__LINE__(name)

#define PROFILE_FUNCTION() \
    PROFILE_SCOPE(__FUNCTION__)

// RAII класс для автоматического begin/end
class ProfileScope {
private:
    const char* name;
    
public:
    ProfileScope(const char* n) : name(n) {
        g_profiler.begin_event(name);
    }
    
    ~ProfileScope() {
        g_profiler.end_event(name);
    }
};

void physics_update() {
    PROFILE_FUNCTION();  // ← Метка профайлера, автоматически записывает время
    
    std::this_thread::sleep_for(std::chrono::milliseconds(5));
}

void render_scene() {
    PROFILE_FUNCTION();  // ← Метка
    
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    
    {
        PROFILE_SCOPE("RenderScene::DrawCalls");  // ← Вложенная метка
        std::this_thread::sleep_for(std::chrono::milliseconds(3));
    }
}

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

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

Но нужно понимать "цены", которую мы платитим за точность такого профилирования и первая цена - это накладные расходы времени выполнения. Если профайлер добавляет десять строк кода на каждую профилируемую строку, то игра станет работать как минимум в 10 раз медленнее. Реальные цифры конечно показывают, что падение не такое критичное и в среднем мы теряем от 10% до 20% FPS при профилировании таким спобсобм. А еще мы можем минимизировать эти накладные расходы через выборочное инструментирование и не ставить метки на каждую функцию в проекте, а только на тех системах, которые реально важны.

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

Есть подвид инструментирования - называется трассировка (tracing), когда собирается поток событий во времени: начало/конец задач, блокировки, ожидания, сообщения между потоками, очереди задачи, но в отличие от классического сбора статистики профиля кадра например, который агрегирует данные, трассировка наоборот раскладывает данные во времени, сохраняя хронологию. Хронология событий часто является ключевым моментом для понимания поведения разных систем, task-графов и асинхронных пайплайнов и именно поэтому современные инструменты вроде Tracy, PIX, ETW-трейсов больше похожи на осциллограф, чем на таблицу с процентами выполнения по каждой функции.

АГРЕГИРОВАННОЕ ПРОФИЛИРОВАНИЕ:
┌────────────────────────────────────────┐
│ Функция          │ Время  │ % от кадра │
├──────────────────┼────────┼────────────┤
│ PhysicsUpdate()  │ 4.2ms  │   25%      │
│ RenderScene()    │ 8.1ms  │   48%      │
│ UpdateAI()       │ 2.3ms  │   14%      │
│ Other            │ 2.1ms  │   13%      │
└──────────────────┴────────┴────────────┘

ТРАССИРОВКА (timeline):
Thread 1: ████PhysicsUpdate████░░░░░░RenderScene██████████░░
Thread 2: ░░░░░UpdateAI░░░█████LoadTexture█████░░░░░░░░░░░░
Thread 3: ░░░░░░░░░░░░░░░░░░░░░ProcessAudio████░░░░░░░░░░░░
          |----|----|----|----|----|----|----|----|----|----|
          0ms  2ms  4ms  6ms  8ms  10ms 12ms 14ms 16ms 18ms

Если посмотреть в противоложную трассировке сторону, то там будет event-based профилирование (логирование), которое является родственником трассировки, но отличается принципиально другим подходом, что именно мы измеряем и как интерпретируем результаты.

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

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

Игродев же развивал, как обычно, свои специализированные инструменты профилирования, которые изначально фокусировались на измерении времени выполнения функций и участков кода, но постепенно эволюционировали в сторону более детального анализа событий, особенно с появлением сложных многопоточных архитектур и необходимости понимать взаимодействие CPU и GPU. Общие инструменты как Intel VTune, которые начинались как sampling профайлеры в итоге превратились в мощные комбайны анализа производительности с поддержкой трассировки аппаратных событий процессора, работы графического конвейера, профилирования графики. Параллельно специализированные игровые профайлеры, вроде Telemetry или Optick, которые начинались с трассировки, добавляли возможности event-based компоненты и сэмплирование, как дополнение к основному функционалу.

Отличие event-based подхода заключается в том, что он позволяет не столько ответить на вопрос «где мы тратим время», сколько на более фундаментальный вопрос «что вообще происходит в системе, когда и почему», что открывает несколько другой уровень понимания поведения программы. Вы видите в обычном профайлере, что какая-то функция занимает много времени, но не понимаете почему именно она стала медленной в этом конкретном кадре или при этих конкретных условиях, потому что традиционный профайлер показывает вам только время выполнения этой функции, а детальная картина того какие именно системные события происходили в этот момент остаётся скрытой.

ТРАДИЦИОННОЕ ПРОФИЛИРОВАНИЕ:
═══════════════════════════════════════════════════════════════
Frame #1042: 116.8ms (SLOW)

Function Call Tree:
├─ GameLoop()              16.8ms   100%
│  ├─ UpdatePhysics()       4.2ms    25%
│  ├─ UpdateAI()           110.3ms   61%  SLOW!
│  │  ├─ PathFinding()      90.8ms   58%  WHY SO SLOW?
│  │  └─ BehaviorTrees()    0.5ms    3%
│  └─ Render()              2.3ms    14%

А еvent-based профилирование решает эту проблему, давая детальную хронологию событий, где можно увидеть что именно в этот момент произошло три аллокации памяти общим объёмом пятьдесят мегабайт, или случилась подкачка страниц памяти с диска, потому что операционная система решила, что у неё недостаточно свободной RAM, или произошло переклю��ение контекста на другой поток, который ждал на мьютексе.

EVENT-BASED ПРОФИЛИРОВАНИЕ (упрощенно):
═══════════════════════════════════════════════════════════════
Frame #1042: 116.8ms (SLOW)

Timeline с событиями:
0ms     PathFinding() START
        │
20.2ms  ├─ EVENT: Memory Alloc (15 MB, NavMesh data)
        │           ↓ Triggered by: LoadNavigationData()
        │           Location: pathfinding.cpp:142
        │
20.8ms  ├─ EVENT: Memory Alloc (20 MB, A* open list)
        │           ↓ Heap fragmentation detected!
        │           Location: pathfinding.cpp:187
        │
30.1ms  ├─ EVENT: Memory Alloc (15 MB, path cache)
        │           ↓ Total allocated: 50 MB
        │
3.5ms   ├─ EVENT: Page Fault (major)
        │           ↓ OS swapping from disk!
        │           Duration: 4.2ms 
        │           Reason: Working set exceeded
        │           Pages faulted: 12,800
        │
7.7ms   ├─ EVENT: Context Switch
        │           ↓ From: WorkerThread_2
        │           ↓ To: System
        │           ↓ Reason: Page fault I/O completion
        │           Duration: 1.8ms
        │
9.5ms   ├─ EVENT: Mutex Wait
        │           ↓ NavMeshMutex held by RenderThread
        │           Duration: 0.3ms
        │
9.8ms   PathFinding() END

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

Оно скорее представляет собой попытку заранее оценить потенциальные проблемы производительности путём анализа самого кода и его структуры во время компиляции до того как код начнёт реально исполняться (https://johnnysswlab.com/loop-optimizations-interpreting-the-compiler-optimization-report/)

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

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

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

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

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

Вторая - это near-duplicate instantiations, когда код разных инстанциаций практически идентичен, но компилятор вынужден хранить их как отдельные копии из-за формальных различий в параметрах шаблона, и классический пример это vector<int*> и vector<double*>, где указатели имеют одинаковый размер и одинаковое представление в памяти на любой платформе, поэтому сгенерированный машинный код для обеих версий будет абсолютно идентичным, но компилятор всё равно создаёт и хранит две отдельные копии.

Третья - это источник excessive inlining в заголовках, когда даже небольшие методы шаблонных классов, которые могли бы быть вынесены в отдельные cpp файлы в случае обычных классов, оказываются в заголовочных файлах, и компилятор агрес��ивно инлайнит их во всех местах использования, что многократно увеличивает размер кода.

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

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

И последняя отдельная область профилирования - это реплеи, которые представляют собой не столько отдельный тип профилирования, сколько возможность сделать любое профилирование гораздо более надёжным и полезным.

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

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

Изначально системы реплеев появились как игровая фича, чтобы игроки могли пересматривать и анализировать свои матчи, и классическим примером здесь служат старые RTS игры вроде StarCraft и Warcraft, которые сохраняли не видеозапись игры а последовательность команд игроков, позволяя точно воспроизвести всю партию используя минимальный объём данных.

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

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

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

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

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

Трассирующий профайлер

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

┌──────────────────────────────────────────────────────────┐
│ #define PROFILING_ENABLED 1                              │
│ #define PROFILING_LEVEL_FULL                             │
│                                                          │
│ PROFILE_SCOPE("Physics") ──> [записывает все данные]     │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ #define PROFILING_ENABLED 1                              │
│ #define PROFILING_LEVEL_BASIC                            │
│                                                          │
│ PROFILE_SCOPE("Physics") ──> [базовые метрики]           │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ #define PROFILING_ENABLED 0                              │
│                                                          │
│ PROFILE_SCOPE("Physics") ──> [полностью удалено]         │
│                              [нет кода вообще]           │
│ Накладные расходы: (нет)                                 │
└──────────────────────────────────────────────────────────┘
МАКРОСЫ ПРЕПРОЦЕССОРА          ДРУГИЕ МЕХАНИЗМЫ ЯЗЫКА
═════════════════════          ══════════════════════
if (PROFILING_ENABLED) {       if (config.profiling) {
  [код профилирования]           [код профилирования]
}                              }
        ▼                              ▼
┌───────────────────┐          ┌───────────────────┐
│ Release:          │          │ Release:          │
│ [пусто]           │          │ if (false) {      │
│                   │          │   [мёртвый код]   │
│                   │          │ }                 │
│ Накладные расходы:│          │ Накладные расходы:│
│ 0 байт            │          │ + проверка if     │
│ 0 инструкций      │          │ + мёртвый код     │
│                   │          │ + размер бинарника│
└───────────────────┘          └───────────────────┘

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

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

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

zero-allocation архитектура

Если заглянуть глубже внутрь реализации профайлера, то здесь выяснится очень важный момент, профайлер не может делать динамических аллокаций во время работы (не делать вообще, это назвается alloc-free architecture), то есть весь необходимый пул памяти выделяется заранее при инициализации, и все эти объекты заранее созданы и только ждут своего использования. Делать то он их конечно может, но тогда все результаты начинают плыть, потому что сам malloc плавает достаточно сильно при аллокации.

Если вы попробуете избавиться от всех (всех, Карл) аллокаций, то в какой-то момент вы придете к концепции буферов (buffer provider), когда у вас есть набор буферов фиксированного размера, которые предварительно аллоцированы и сложены в очереди, и соответственно различные записывалки для этих буферов: для событий CPU, GPU, IO, физики, ии и так далее - их задача в том, чтобы брать свободные буферы из этой очереди и писать в них данные о происходящих событиях по мере их возникновения.

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

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

Как только все данные обработаны возвращает буферы обратно в pool для переиспользования, что позволяет в принципе не делать никаких аллокаций, не париться об утечках и работать быстро и эффективно, если вы хотите собирать больше 100к событий за кадр с приемлимым fps.

string handle

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

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

БИНАРНИК (.exe/.dll)                    СОБЫТИЯ В БУФЕРЕ
════════════════════                    ════════════════
┌─────────────────────┐                ┌──────────────────┐
│ .rodata секция      │                │ Event 1:         │
│                     │                │  timestamp: 123  │
│ 0x00401000:         │<───────────────│  name_ptr:       │
│  "Physics::Update"  │    указатель   │  0x00401000      │
│                     │                │  duration: 42    │
│ 0x00401010:         │                └──────────────────┘
│  "Render::DrawCall" │<───┐           ┌──────────────────┐
│                     │    │           │ Event 2:         │
│ 0x00401020:         │    │           │  timestamp: 156  │
│  "AI::Think"        │    │указатель  │  name_ptr:       │
│                     │    └───────────│  0x00401000      │
└─────────────────────┘                │  duration: 38    │
         ▲                             └──────────────────┘
         │                             ┌──────────────────┐
    Строки существуют                  │ Event 3:         │
    весь lifetime программы            │  timestamp: 189  │
    Гарантированно валидны             │  name_ptr:       │
                                       │  0x00401010      │
                                       │  duration: 12    │
                                       └──────────────────┘

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

RUNTIME СОЗДАНИЕ:                STRING HANDLE BUFFER
                                 (отдельный буфер)
┌──────────────────┐             ═══════════════════
│ Игровой код:     │             
│                  │             ┌─────────────────────┐
│ sprintf(buf,     │             │ 0x10000000:         │
│  "Enemy_%d", id) │────────────>│ "Enemy_42"          │
│                  │  добавить   │                     │
└──────────────────┘  если новая │ 0x10000010:         │
                                 │ "Player_John"       │
┌──────────────────┐             │                     │
│ Геймплей код:    │             │ 0x10000020:         │
│                  │────────┐    │ "Level_3_Boss"      │
│ entity.getName() │        │    │                     │
│                  │        │    │ 0x10000030:         │
└──────────────────┘        │    │ "Enemy_42"          │<─┐
                            │    └─────────────────────┘  │
                            │            ▲                │
                            │    ┌───────┴────────┐       │
                            └───>│ Hash Table:    │       │
                                 │ "Enemy_42"     │───────┘
                                 │  → 0x10000000  │
                                 │ Дедупликация   │
                                 └────────────────┘

И в принципе этого достаточно, чтобы написать профайлер уровня Tracy или Pix и начать им пользоваться, я сейчас про CPU часть, потому что с GPU все и сложнее и легче одновременно, но об этом как-нибуль в следующий раз.

А что в итоге...

В итоге мы пришли к тому, что современное профилирование представляет собой многоуровневую систему, и каждая деталь архитектуры написана кровью, потом и низким фпс. От выбора между сэмплированием, инструментированием и трассировкок до реализации zero-allocation архитектуры с пулами и строками нестроками - все эти решения направлены на то, чтобы получить максимально точную картину производительности, не влияя при этом на саму производительность, что очень и очень непросто сделать. Путь от простого измерения FPS в первых движках до комбайнов уровня Tracy и PIX, способных обрабатывать почти всё что захочется, показывает как эти системы эволюционировали от игровых фич и инструментов отладки до самостоятельных решений для оптимизации. И выросло это все не просто в инструменты отладки, а отдельную инфраструктуру, без которой разработка игр потеряет как по скорости разработки, так и по срокам.

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