Burst-компилятор в Unity: что он умеет и как им пользоваться правильно
Burst я первый раз попробовал лет шесть назад, и тогда подумал: «прикольная штука, но возни много, и не всегда понятно, ускорилось ли». Сейчас Burst — один из главных инструментов в моём ящике, и я тащу его в каждый проект, где есть тяжёлая математика. Расскажу, как он устроен, где даёт реальный буст, и какие конструкции в C# мешают ему работать.
Опираюсь на Unity 6 (6000.x), Burst 1.8.x. В новых версиях фичи могут отличаться, но основа уже стабилизировалась.
Что такое Burst и зачем он
Burst — это AOT-компилятор, который превращает определённый подмножество C# в нативный SIMD-код через LLVM. Не сам C#, а специальные методы, помеченные [BurstCompile], и работающие с blittable-типами (без managed-объектов).
Главный выигрыш — векторизация. На обычном Mono/IL2CPP цикл
for (int i = 0; i < positions.Length; i++)
positions[i] += velocities[i] * deltaTime;
выполняется по одному элементу за раз. Burst замечает, что операция идентична для всех i, и компилирует код, обрабатывающий 4 или 8 элементов одновременно через SSE/AVX/NEON-инструкции. На практике — ускорение от 2x до 30x в зависимости от характера задачи.
Burst работает не везде, а только в Job'ах и ISystem
Это первое заблуждение. Burst не работает на обычных MonoBehaviour.Update'ах, даже если ты повесишь атрибут. Burst компилирует:
IJob,IJobParallelFor,IJobChunk— Job System.ISystemв Entities (DOTS).- Статические методы, помеченные
[BurstCompile]— можно вызывать через function pointers.
Если у тебя обычный класс с тяжёлой математикой, и ты вешаешь [BurstCompile] на метод, ничего не произойдёт. Чтобы Burst его подхватил — нужно вынести в Job или сделать static-метод и вызывать через BurstCompiler.CompileFunctionPointer.
Минимальный пример: Job с Burst
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
[BurstCompile]
public struct UpdatePositionsJob : IJobParallelFor
{
public NativeArray<float3> positions;
[ReadOnly] public NativeArray<float3> velocities;
public float deltaTime;
public void Execute(int index)
{
positions[index] += velocities[index] * deltaTime;
}
}
Запуск:
var job = new UpdatePositionsJob
{
positions = _positions,
velocities = _velocities,
deltaTime = Time.deltaTime
};
var handle = job.Schedule(_positions.Length, 64);
handle.Complete();
На моей машине обновление 200 000 объектов:
- Без Burst, на main thread: 110 мс.
- С Burst, на main thread (Run): 18 мс.
- С Burst, ParallelFor на 8 ядрах (Schedule): 3 мс.
Сама векторизация дала 6x, параллелизм — ещё 6x. Это типично для математически-плотного кода.
Что Burst умеет, а что нет
Burst поддерживает только подмножество C#. Главные ограничения:
- Никаких managed-типов:
string,List<T>,class,delegate. Только value-типы иNativeArray/NativeList/NativeHashMapи подобные. - Никакого reflection.
- Никаких throw exception (теоретически throw разрешён, но чаще всего — антипаттерн, влияет на производительность).
- Никаких virtual вызовов.
- Никаких аллокаций GC. Если в Job'е аллоцируется managed-объект — Burst отвалится с ошибкой.
Что Burst любит:
- Чистая математика на
float,int,float2/3/4,int2/3/4изUnity.Mathematics. - Линейные циклы по NativeArray'ам.
- Бранчинг минимально —
if'ы можно, ноswitchи?:чаще ложатся в SIMD без потерь. - SIMD-friendly типы:
float4,quaternion,float4x4.
Unity.Mathematics: не путать с UnityEngine
Это критично. Unity.Mathematics — отдельная библиотека с типами float3, quaternion, float4x4, оптимизированными под Burst и SIMD. UnityEngine.Vector3 — старый тип, который тоже работает, но Burst не векторизует его так же эффективно.
using Unity.Mathematics;
float3 a = new float3(1, 2, 3);
float3 b = new float3(4, 5, 6);
float3 c = a + b; // компилируется в SIMD
float distance = math.distance(a, b);
float3 normalized = math.normalize(c);
quaternion q = quaternion.AxisAngle(math.up(), math.PI / 4);
В Job'ах под Burst — всегда используй float3 вместо Vector3. Конвертация между ними неявная (есть operator implicit), но в горячих циклах лучше держать единый тип.
Burst Inspector: твой инструмент
Window → Analysis → Burst Inspector — must-have. Показывает, как Burst скомпилировал твой Job: ассемблер, статистику ALU, использование SIMD-регистров.
Что искать:
- Векторизуется ли цикл. В asm видно
vfmadd213ps,vaddps,vmulps— это AVX. Если их нет, и цикл идёт по одному элементу — векторизация сорвалась. - Есть ли function call'ы внутри hot loop. Каждый call ломает векторизацию. Помечай вспомогательные функции
[MethodImpl(MethodImplOptions.AggressiveInlining)]. - Branch'и в hot loop. Если у тебя в Job'е
if (condition) { ... }, Burst может развернуть вselect, но если ветки разные — векторизация падает.
FloatMode и FloatPrecision
В [BurstCompile] есть параметры:
[BurstCompile(FloatMode = FloatMode.Fast, FloatPrecision = FloatPrecision.Low)]
public struct FastJob : IJobParallelFor { ... }
- FloatMode.Fast — разрешает Burst переупорядочивать операции (например,
(a + b) + c→a + (b + c)). Может дать выигрыш в векторизации, но точность может незначительно отличаться. - FloatPrecision.Low — использует приближённые SIMD-инструкции для
sin,cos,sqrt(черезrsqrt). Быстрее, но менее точно.
Для геймдевовских задач (физика частиц, AI, движение) Fast/Low почти всегда подходит. Для научных расчётов — оставь Default.
Function pointers: Burst для callback'ов
Burst может компилировать static-методы и вызывать их через function pointers. Полезно, когда у тебя Job, который ходит по Native-массиву и применяет какую-то функцию (sort, filter, transform).
using AOT;
using Unity.Burst;
private static FunctionPointer<CompareDelegate> _cmp;
public delegate int CompareDelegate(int a, int b);
[BurstCompile, MonoPInvokeCallback(typeof(CompareDelegate))]
private static int CompareInts(int a, int b) => a - b;
static void Init()
{
_cmp = BurstCompiler.CompileFunctionPointer<CompareDelegate>(CompareInts);
}
Внутри Job'а вызываешь _cmp.Invoke(x, y). Это Burst-compiled call, без managed overhead'а.
Когда Burst не помогает
Не везде ты увидишь буст. Кейсы, в которых Burst даёт мало или ничего:
- Memory-bound задачи. Если узкое место — пропускная способность памяти (например, копирование больших массивов), векторизация не спасёт.
- Малый объём данных. На массиве из 100 элементов overhead на запуск Job'а съест выигрыш. Burst окупается на тысячах и десятках тысяч итераций.
- Random access. Если ты прыгаешь по массиву случайно (например, dictionary lookup в цикле), кэш-friend попадания плохие, и SIMD не поможет.
- Бранч-тяжёлый код. Если у тебя в hot loop пять разных веток, ни одну Burst не векторизует нормально.
Безопасность и Native Collections
Burst требует NativeArray и подобные коллекции. Они ручно-управляемые, аллокатор должен быть указан:
var positions = new NativeArray<float3>(1000, Allocator.TempJob);
// ... используем
positions.Dispose();
- Allocator.Temp — на один кадр.
- Allocator.TempJob — для Job'ов, до 4 кадров.
- Allocator.Persistent — долгоживущие массивы. Не забудь Dispose в OnDestroy.
Если забудешь Dispose, Unity ругнётся в логе про утечку. На репродукциях — почини, иначе через 5 минут игры ELlay-allocator переполнится.
Обычный код vs Job: когда переписывать
Не вижу смысла переписывать все циклы на Job'ы. Стоимость — читаемость, dispose, sync points. Я переношу в Burst Job только то, что:
- Больше 1 мс на main thread.
- Можно распараллелить.
- Работает с однотипными данными.
- Будет вызываться часто (каждый кадр или регулярно).
Для одноразовых тяжёлых вычислений (постройка уровня при загрузке) — тоже подходит, но требования к параллелизму ниже.
Реальные кейсы
Где Burst у меня окупился:
- Boids/flocking 5000 юнитов — было 30 мс, стало 1.5 мс.
- Voxel mesh generation — 800 мс, стало 60 мс.
- Pathfinding A* для сетки 200×200 — 50 мс, стало 4 мс.
- Particle physics (взрыв 10 000 частиц) — 12 мс, стало 0.8 мс.
Где не окупился:
- Логика инвентаря — мало данных, много branch'ей. Burst не выигрывает.
- UI-расчёты — managed-зависимости, не подходит.
Чек-лист, прежде чем тащить Burst
- Я измерил CPU-время и нашёл конкретный hot spot? (Profiler даст ответ.)
- Этот hot spot — математика на массивах, не managed-логика?
- Я могу выделить алгоритм в чистую функцию без зависимостей от MonoBehaviour?
- Данные можно положить в NativeArray?
- Логика поддаётся параллелизму или хотя бы векторизации?
Если на все «да» — переписывай в Job, добавляй [BurstCompile], прогоняй Burst Inspector. На большинстве реалистичных кейсов выигрыш в 5–20 раз.
Что почитать
Документация Burst в Unity Manual. Examples в репозитории Unity-Technologies/EntityComponentSystemSamples на GitHub — там и Burst, и Jobs, и ECS вместе. Презентации с GDC и Unite — например, доклад Joachim Ante про Burst и DOTS, всё ещё актуален в общих принципах.
Главный совет: не пытайся ускорить весь проект разом. Найди топ-3 узких места через Profiler, перепиши их на Job + Burst, измерь. Скорее всего, у тебя останется бюджет, чтобы 90% кода оставить простым и читаемым.