18.10.2026 15 мин чтения Просмотров:1

ProfilerMarker и ProfilerRecorder: профилирование из кода в Unity

Размечаем свои системы маркерами, читаем время кадра, GC-аллокации и draw calls из C#, собираем внутриигровой оверлей и закрепляем результат тестами производительности.

ProfilerMarker и ProfilerRecorder: профилирование из кода в Unity

После этой статьи вы сможете размечать собственный код маркерами ProfilerMarker, читать из C# время кадра, GC-аллокации, число draw calls и память через ProfilerRecorder, собрать внутриигровой оверлей со статистикой, который работает прямо на телефоне тестировщика, и закрепить замеры в автоматических тестах производительности. Всё на коде, без ручного щёлканья по окну Profiler.

Зачем мерить производительность из кода

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

  • в стандартной разметке видно Update.ScriptRunBehaviourUpdate и имена методов, но не видно, какая именно часть вашей системы тормозит — поиск пути, пересчёт инвентаря или сортировка юнитов;
  • тестировщик на другом конце страны не будет подключать Profiler к своему телефону, а вот число на экране «кадр 41 мс, GC 12 КБ» он сфотографирует и пришлёт;
  • регрессии производительности хочется ловить автоматически, до того как они попадут в сборку, а не когда кто-то заметит падение FPS.

Для всех трёх задач в Unity есть низкоуровневое API в пространстве имён Unity.Profiling: структуры ProfilerMarker, ProfilerRecorder и ProfilerCategory. Они появились в Unity 2020.2 и с тех пор почти не менялись, так что всё ниже работает и в Unity 2022 LTS, и в Unity 6.

ProfilerMarker: своя разметка вместо BeginSample

Исторически участки кода размечали так:

using UnityEngine.Profiling;

void Update()
{
    Profiler.BeginSample("Pathfinding.Rebuild");
    RebuildPaths();
    Profiler.EndSample();
}

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

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

using Unity.Profiling;
using UnityEngine;

public class PathfindingSystem : MonoBehaviour
{
    static readonly ProfilerMarker s_RebuildMarker =
        new ProfilerMarker("Pathfinding.Rebuild");

    void Update()
    {
        using (s_RebuildMarker.Auto())
        {
            RebuildPaths();
        }
    }

    void RebuildPaths() { /* ... */ }
}

Метод Auto() возвращает структуру-обёртку, которая вызывает End() в Dispose. Блок using гарантирует закрытие маркера при любом выходе — по return, по исключению, по break из цикла. Если блок охватывает весь метод, удобен короткий синтаксис C# 8:

void RebuildPaths()
{
    using var _ = s_RebuildMarker.Auto();

    if (_dirtyNodes.Count == 0)
        return; // маркер всё равно закроется

    // ...
}

Правила хорошей разметки

  • Маркер — статическое поле. Создавать new ProfilerMarker(...) внутри метода бессмысленно: регистрация имени стоит дороже, чем сам замер. Поле static readonly создаётся один раз на тип.
  • Имена с точкой-иерархией. Схема Система.Операция («Inventory.Recalculate», «AI.Perception.Scan») позволяет быстро найти маркер поиском в окне Profiler и сгруппировать их в Profile Analyzer.
  • Не размечайте всё подряд. Маркер на каждом методе превращает иерархию в шум. Ставьте их на границы систем и на подозрительные места, а детали добавляйте по мере расследования.
  • Begin/End парами — только при необходимости. Иногда начало и конец замера находятся в разных местах (например, начало в одном колбэке, конец в другом). Тогда используйте Begin() и End() напрямую, но помните, что они должны вызываться в одном потоке и в одном кадре.

Привязка к объекту

Если одна и та же система работает на десятках объектов, полезно видеть, какой именно объект тормозит. У Begin есть перегрузка, принимающая UnityEngine.Object: профайлер свяжет сэмпл с объектом, и в режиме Hierarchy при выборе сэмпла будет видно, к какому GameObject он относится.

static readonly ProfilerMarker s_ThinkMarker = new ProfilerMarker("AI.Think");

void Think()
{
    s_ThinkMarker.Begin(this);
    try
    {
        EvaluateGoals();
    }
    finally
    {
        s_ThinkMarker.End();
    }
}

Пара try/finally здесь выполняет ту же роль, что using с Auto(): гарантирует, что End() будет вызван.

Свои категории

Каждый маркер принадлежит категории. По умолчанию это ProfilerCategory.Scripts, но можно указать встроенную (Ai, Physics, Render, Loading и другие) или завести свою:

public static class GameProfiling
{
    public static readonly ProfilerCategory Gameplay =
        new ProfilerCategory("Gameplay");

    public static readonly ProfilerMarker Combat =
        new ProfilerMarker(Gameplay, "Gameplay.Combat");

    public static readonly ProfilerMarker Economy =
        new ProfilerMarker(Gameplay, "Gameplay.Economy");
}

Собрать маркеры в одном статическом классе удобно: их легко найти, переименовать и потом подписать на них ProfilerRecorder, о котором дальше.

Что происходит в релизной сборке

Методы Begin и End помечены атрибутом [Conditional("ENABLE_PROFILER")]. Этот символ определён в редакторе и в Development Build, а в обычной релизной сборке его нет, поэтому компилятор просто выкидывает вызовы. Маркеры можно оставлять в коде навсегда — в релизе они ничего не стоят. Обратная сторона: читать собственные маркеры в релизной сборке не получится, для этого нужна сборка с галочкой Development Build.

Маркеры работают и в многопоточном коде: их можно использовать внутри джобов Job System, в том числе скомпилированных Burst. В профайлере такие сэмплы появятся на дорожках рабочих потоков.

ProfilerRecorder: читаем метрики из C#

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

Создаётся он статическим методом StartNew, у которого две основные формы: по категории и имени или по экземпляру маркера.

// по имени: встроенный маркер или счётчик движка
var mainThread = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread", 15);

// по экземпляру: свой маркер
var combat = ProfilerRecorder.StartNew(GameProfiling.Combat, 15);

Третий параметр — ёмкость буфера в кадрах. Она должна быть больше нуля, иначе StartNew бросит исключение. Пятнадцать–тридцать кадров достаточно для сглаженного среднего.

Важно: ProfilerRecorder держит неуправляемую память, поэтому его обязательно нужно освобождать вызовом Dispose(). Классическая пара — OnEnable и OnDisable:

using Unity.Profiling;
using UnityEngine;

public class FrameStats : MonoBehaviour
{
    ProfilerRecorder _mainThread;
    ProfilerRecorder _gcAlloc;

    void OnEnable()
    {
        _mainThread = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread", 15);
        _gcAlloc = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame");
    }

    void OnDisable()
    {
        _mainThread.Dispose();
        _gcAlloc.Dispose();
    }
}

Как читать значения

У рекордера несколько свойств, и путать их — частая ошибка:

  • Valid — удалось ли найти маркер или счётчик с таким именем. Если вы опечатались или счётчик недоступен в этой сборке, рекордер будет невалидным, а значения — нулевыми.
  • LastValue — значение за последний завершённый кадр. Это то, что нужно почти всегда.
  • CurrentValue — значение прямо сейчас, в середине текущего кадра. Для счётчиков памяти это бывает полезно, для маркеров времени — обычно нет, потому что кадр ещё не закончился.
  • Count и Capacity — сколько сэмплов накоплено и сколько помещается в буфер.
  • GetSample(i) — сэмпл из буфера; у сэмпла есть Value и Count (сколько раз маркер срабатывал за кадр).
  • UnitType — единица измерения: наносекунды, байты, штуки. Время маркеров приходит в наносекундах, память — в байтах.

Функция усреднения по буферу:

static double Average(ProfilerRecorder recorder)
{
    int count = recorder.Count;
    if (count == 0)
        return 0;

    double sum = 0;
    for (int i = 0; i < count; i++)
        sum += recorder.GetSample(i).Value;

    return sum / count;
}

// использование: наносекунды в миллисекунды
double mainThreadMs = Average(_mainThread) * 1e-6;

Для массового копирования есть CopyTo и ToArray, но ToArray аллоцирует массив, а в коде, который сам измеряет аллокации, это немного иронично. Цикл с GetSample не выделяет памяти.

Опции записи

Последний параметр StartNew — флаги ProfilerRecorderOptions. Самые полезные:

  • SumAllSamplesInFrame — если маркер срабатывает много раз за кадр, рекордер сложит все вызовы в одно значение. Без этого флага в буфер попадёт только последний вызов. Для маркера, который стоит внутри цикла по юнитам, флаг обязателен.
  • CollectOnlyOnCurrentThread — учитывать только сэмплы того потока, где создан рекордер. Помогает, когда маркер вызывается и в главном потоке, и в джобах, а интересует только главный.
  • WrapAroundWhenCapacityReached — перезаписывать старые сэмплы, когда буфер заполнен. Входит в набор по умолчанию.
  • StartImmediately — начать запись сразу при создании. Тоже входит в набор по умолчанию; без него запись запускают вручную методом Start().

Если вы передаёте флаги явно, набор по умолчанию заменяется, поэтому нужные флаги перечисляйте все:

var combat = ProfilerRecorder.StartNew(
    GameProfiling.Combat,
    30,
    ProfilerRecorderOptions.Default | ProfilerRecorderOptions.SumAllSamplesInFrame);

Какие счётчики движка доступны

Помимо маркеров, Unity публикует десятки встроенных счётчиков. Полный список есть в разделе документации Profiler counters reference, а вот те, что нужны чаще всего:

  • Memory: Total Used Memory, Total Reserved Memory, System Used Memory, GC Used Memory, GC Reserved Memory — доступны и в релизных сборках;
  • Memory: GC Allocated In Frame, GC Allocation In Frame Count — байты и число управляемых аллокаций за кадр, только в редакторе и Development Build;
  • Render: Draw Calls Count, SetPass Calls Count, Triangles Count, Vertices Count;
  • Internal: маркер Main Thread — полное время кадра на главном потоке.

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

Имена счётчиков — обычные строки, и опечатка не вызывает ошибку компиляции. Поэтому после создания рекордера стоит проверить Valid и вывести предупреждение:

static ProfilerRecorder Start(ProfilerCategory category, string name, int capacity = 15)
{
    var recorder = ProfilerRecorder.StartNew(category, name, capacity);
    if (!recorder.Valid)
        Debug.LogWarning($"Profiler counter not found: {category.Name}/{name}");
    return recorder;
}

Время CPU и GPU через FrameTimingManager

Маркер Main Thread показывает время главного потока, но не GPU. Если нужно понять, во что упирается кадр — в процессор или видеокарту, — используйте FrameTimingManager. Для него в Player Settings нужно включить опцию Frame Timing Stats, а в коде раз в кадр захватывать тайминги:

readonly FrameTiming[] _timings = new FrameTiming[1];

void Update()
{
    FrameTimingManager.CaptureFrameTimings();
    if (FrameTimingManager.GetLatestTimings(1, _timings) > 0)
    {
        double cpuMs = _timings[0].cpuFrameTime;
        double gpuMs = _timings[0].gpuFrameTime;
        // gpuMs заметно больше cpuMs — упираемся в видеокарту
    }
}

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

Собираем внутриигровой оверлей

Теперь соберём всё в компонент, который можно бросить на сцену и показывать в Development Build. Задачи: среднее время главного потока, аллокации за кадр, draw calls, используемая память и время собственной системы боя.

using System.Text;
using Unity.Profiling;
using UnityEngine;

public class StatsOverlay : MonoBehaviour
{
    [SerializeField] KeyCode _toggleKey = KeyCode.F3;
    [SerializeField] float _refreshInterval = 0.25f;

    ProfilerRecorder _mainThread;
    ProfilerRecorder _gcAlloc;
    ProfilerRecorder _gcCount;
    ProfilerRecorder _drawCalls;
    ProfilerRecorder _usedMemory;
    ProfilerRecorder _combat;

    readonly StringBuilder _sb = new StringBuilder(256);
    string _text = string.Empty;
    float _nextRefresh;
    bool _visible = true;
    GUIStyle _style;

    void OnEnable()
    {
        _mainThread = ProfilerRecorder.StartNew(ProfilerCategory.Internal, "Main Thread", 30);
        _gcAlloc    = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame", 30);
        _gcCount    = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocation In Frame Count", 30);
        _drawCalls  = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Draw Calls Count", 30);
        _usedMemory = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "Total Used Memory");
        _combat     = ProfilerRecorder.StartNew(
            GameProfiling.Combat, 30,
            ProfilerRecorderOptions.Default | ProfilerRecorderOptions.SumAllSamplesInFrame);
    }

    void OnDisable()
    {
        _mainThread.Dispose();
        _gcAlloc.Dispose();
        _gcCount.Dispose();
        _drawCalls.Dispose();
        _usedMemory.Dispose();
        _combat.Dispose();
    }

    void Update()
    {
        if (Input.GetKeyDown(_toggleKey))
            _visible = !_visible;

        if (!_visible || Time.unscaledTime < _nextRefresh)
            return;

        _nextRefresh = Time.unscaledTime + _refreshInterval;
        RebuildText();
    }

    void RebuildText()
    {
        _sb.Clear();
        _sb.Append("CPU main: ").Append((Average(_mainThread) * 1e-6).ToString("F1")).AppendLine(" ms");
        _sb.Append("Combat:   ").Append((Average(_combat) * 1e-6).ToString("F2")).AppendLine(" ms");
        _sb.Append("GC/frame: ").Append((Average(_gcAlloc) / 1024.0).ToString("F1"))
           .Append(" KB (").Append(Average(_gcCount).ToString("F0")).AppendLine(" allocs)");
        _sb.Append("Draw calls: ").Append(_drawCalls.LastValue).AppendLine();
        _sb.Append("Memory: ").Append(_usedMemory.LastValue / (1024 * 1024)).Append(" MB");
        _text = _sb.ToString();
    }

    void OnGUI()
    {
        if (!_visible)
            return;

        _style ??= new GUIStyle(GUI.skin.label) { fontSize = 18 };
        GUI.Label(new Rect(10, 10, 360, 140), _text, _style);
    }

    static double Average(ProfilerRecorder recorder)
    {
        int count = recorder.Count;
        if (count == 0)
            return 0;

        double sum = 0;
        for (int i = 0; i < count; i++)
            sum += recorder.GetSample(i).Value;
        return sum / count;
    }
}

Несколько решений, которые здесь приняты намеренно:

  • Текст обновляется четыре раза в секунду, а не каждый кадр. Цифры, меняющиеся каждый кадр, невозможно прочитать, а форматирование строк само по себе аллоцирует. Оверлей, который создаёт мусор каждый кадр, искажает именно ту метрику, которую показывает.
  • Один StringBuilder на весь срок жизни. ToString() всё равно создаёт строку, но раз в 250 мс это пренебрежимо мало, а Clear() переиспользует буфер.
  • Используется Time.unscaledTime. Если игра на паузе через Time.timeScale = 0, оверлей продолжит обновляться.
  • Вывод через OnGUI. Для отладочного инструмента это самый простой вариант без зависимостей от Canvas и TextMeshPro. В проекте с UI Toolkit тот же текст можно выводить в Label.

Если в проекте используется новая Input System, вместо Input.GetKeyDown возьмите Keyboard.current.f3Key.wasPressedThisFrame. А чтобы оверлей не попал в релиз, оберните его создание в #if DEVELOPMENT_BUILD || UNITY_EDITOR или удаляйте компонент в Awake, если Debug.isDebugBuild равен false.

Пороги и предупреждения

Цифры полезнее, когда сразу видно, что они плохие. Добавьте бюджет кадра и подсветку:

[SerializeField] float _frameBudgetMs = 16.6f;
[SerializeField] float _gcBudgetKb = 1f;

string Colorize(double value, double budget, string text)
{
    if (value > budget)       return "<color=#FF5555>" + text + "</color>";
    if (value > budget * 0.8) return "<color=#FFCC33>" + text + "</color>";
    return text;
}

Стиль GUIStyle поддерживает rich text при richText = true. Бюджет на GC в 1 КБ за кадр — разумная цель для мобильного проекта: всё, что выше, через несколько секунд игры превращается в паузу сборщика мусора.

Логирование пиков

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

struct TrackedMarker
{
    public string Name;
    public ProfilerRecorder Recorder;
}

TrackedMarker[] _tracked;

void OnEnable()
{
    _tracked = new[]
    {
        Track("Gameplay.Combat", GameProfiling.Combat),
        Track("Gameplay.Economy", GameProfiling.Economy),
    };
}

static TrackedMarker Track(string name, ProfilerMarker marker) => new TrackedMarker
{
    Name = name,
    Recorder = ProfilerRecorder.StartNew(marker, 1,
        ProfilerRecorderOptions.Default | ProfilerRecorderOptions.SumAllSamplesInFrame)
};

void LateUpdate()
{
    double frameMs = _mainThread.LastValue * 1e-6;
    if (frameMs < 33.0)
        return;

    string worst = null;
    double worstMs = 0;
    foreach (var t in _tracked)
    {
        double ms = t.Recorder.LastValue * 1e-6;
        if (ms > worstMs)
        {
            worstMs = ms;
            worst = t.Name;
        }
    }

    Debug.LogWarning($"Spike: frame {frameMs:F1} ms, worst system {worst} {worstMs:F1} ms (frame {Time.frameCount})");
}

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

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

Тесты производительности

Последний шаг — превратить замеры в проверки. Пакет Performance Testing (com.unity.test-framework.performance) расширяет Unity Test Framework: тест помечается атрибутом [Performance], а замеры собираются через класс Measure.

using NUnit.Framework;
using Unity.PerformanceTesting;

public class PathfindingPerformanceTests
{
    [Test, Performance]
    public void Rebuild_100x100_Grid()
    {
        var grid = TestGrids.CreateOpen(100, 100);

        Measure.Method(() => grid.RebuildPaths())
            .WarmupCount(5)
            .MeasurementCount(20)
            .Run();
    }
}

WarmupCount отбрасывает первые прогоны, в которых срабатывает JIT и прогреваются кэши, MeasurementCount задаёт число замеров. После запуска в окне Test Runner и в отчёте пакета видны медиана, минимум, максимум и стандартное отклонение.

Для участков, которые удобнее измерить блоком, есть Measure.Scope():

[Test, Performance]
public void Inventory_Recalculate_1000_Items()
{
    var inventory = TestInventories.WithItems(1000);

    for (int i = 0; i < 20; i++)
    {
        using (Measure.Scope())
        {
            inventory.Recalculate();
        }
    }
}

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

Аллокации в обычных тестах

Для проверки «этот метод не должен аллоцировать» пакет производительности не обязателен. В Unity Test Framework есть ограничение Is.Not.AllocatingGCMemory() из пространства имён UnityEngine.TestTools.Constraints:

using NUnit.Framework;
using UnityEngine.TestTools.Constraints;
using Is = UnityEngine.TestTools.Constraints.Is;

public class DamageTests
{
    [Test]
    public void ApplyDamage_DoesNotAllocate()
    {
        var health = new HealthModel(100);
        health.ApplyDamage(1); // первый вызов — прогрев

        Assert.That(() => health.ApplyDamage(10), Is.Not.AllocatingGCMemory());
    }
}

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

Частые ошибки

  • Забыли Dispose. Рекордер держит нативную память; при каждом перезапуске Play Mode неосвобождённые рекордеры копятся. Освобождайте их в OnDisable или OnDestroy.
  • Перепутали единицы. Время — в наносекундах, память — в байтах. Деление на 1000 вместо умножения на 1e-6 даёт микросекунды, и оверлей начинает показывать «16600 ms».
  • Нет SumAllSamplesInFrame для маркера в цикле. Рекордер покажет только последний вызов за кадр, и система, которая на самом деле ест 5 мс, будет выглядеть как 0,05 мс.
  • Ждут своих маркеров в релизе. Вызовы Begin/End там вырезаны, рекордер будет показывать ноль. В релизе работают только счётчики движка, отмеченные как доступные в release players.
  • Измеряют в редакторе. Редактор добавляет свои накладные расходы, а скрипты работают через Mono без оптимизаций IL2CPP. Цифры из Play Mode годятся для сравнения «до и после», но окончательные выводы делайте по Development Build на целевом устройстве.
  • Оверлей мусорит сам. Форматирование строки каждый кадр, интерполяция строк, ToArray() — и счётчик GC показывает аллокации инструмента, а не игры.

Итог

Связка из трёх инструментов закрывает весь цикл работы с производительностью. ProfilerMarker размечает ваши системы так, что в профайлере видно не абстрактный Update, а конкретную операцию, и ничего не стоит в релизе. ProfilerRecorder даёт доступ к этим маркерам и к счётчикам движка из C#, а значит — к оверлею на устройстве тестировщика, к логам спайков и к телеметрии памяти. Тесты производительности и проверка на отсутствие аллокаций фиксируют достигнутое, чтобы оптимизация не откатилась через месяц.

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

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также