lenec ru

← все посты

Burst-компилятор в Unity: что он умеет и как им пользоваться правильно

15K

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) + ca + (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

  1. Я измерил CPU-время и нашёл конкретный hot spot? (Profiler даст ответ.)
  2. Этот hot spot — математика на массивах, не managed-логика?
  3. Я могу выделить алгоритм в чистую функцию без зависимостей от MonoBehaviour?
  4. Данные можно положить в NativeArray?
  5. Логика поддаётся параллелизму или хотя бы векторизации?

Если на все «да» — переписывай в 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% кода оставить простым и читаемым.

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

  • Будьте первым, кто оставит комментарий.

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