После этой статьи вы сможете размечать собственный код маркерами 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#, а значит — к оверлею на устройстве тестировщика, к логам спайков и к телеметрии памяти. Тесты производительности и проверка на отсутствие аллокаций фиксируют достигнутое, чтобы оптимизация не откатилась через месяц.
Начните с малого: заведите статический класс с пятью маркерами на главные системы игры и добавьте оверлей из этой статьи в отладочную сборку. Уже на следующем плейтесте вы увидите, какая из систем первой выходит за бюджет кадра.