Skip to main content

๐Ÿš€ Lesson 3.4: Parallel Jobs & Burst Compilation

A single IJob runs on one worker thread โ€” better than the main thread, but still one core. This lesson unlocks the other two multipliers: IJobParallelFor to spread one loop across every core, and the Burst compiler to turn that job's C# into vectorised native machine code. Stacked together, they're how Unity moves from "a bit faster" to "an order of magnitude faster."

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Write an IJobParallelFor whose Execute(int index) processes one element
  • Schedule it across the worker pool and choose a sensible batch count
  • Respect the parallel-write rule โ€” each iteration writes only its own index
  • Enable Burst with [BurstCompile] and explain what it does (native + SIMD)
  • Recognise Burst's constraints and verify the speedup with the Profiler

Estimated Time: 60 minutes  ยท  Prerequisite: Lessons 3.2โ€“3.3 (jobs and NativeContainers)

In This Lesson

IJobParallelFor

When your work is "do the same independent thing to every element of an array," IJobParallelFor is the tool. Instead of one Execute() that loops, you write an Execute(int index) that handles one element โ€” and the Job System calls it for every index, spread across all the worker threads:

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
struct MoveJob : IJobParallelFor
{
    public float deltaTime;
    [ReadOnly] public NativeArray<float3> velocities;
    public NativeArray<float3> positions;   // element i is written by iteration i

    public void Execute(int index)          // called once per element
    {
        positions[index] += velocities[index] * deltaTime;
    }
}

You schedule it with two numbers โ€” the length (how many indices) and the batch count (how many indices each worker grabs at a time) โ€” plus the usual optional dependency:

var job = new MoveJob {
    deltaTime  = Time.deltaTime,
    velocities = velocities,
    positions  = positions
};

// length = positions.Length, innerloopBatchCount = 64
JobHandle handle = job.Schedule(positions.Length, 64);
handle.Complete();

That single Schedule call fans 50,000 iterations out across every core. The main thread is free until Complete(), exactly as before โ€” the only change is that many workers now share the loop.

Batches & the Worker Pool

The batch count (the second argument) controls granularity. The Job System doesn't hand each worker a single index โ€” that would spend more time coordinating than working. Instead it hands out batches of consecutive indices, and idle workers "steal" more batches as they finish. This work-stealing keeps every core busy even if some elements are cheaper than others.

A parallel-for loop split into batches across four workers An array of indices 0 to 255 divided into batches of 64. Four worker threads each take a batch and run Execute over its indices at the same time; as a worker finishes it steals the next available batch. Schedule(length = 256, batchCount = 64) โ†’ 4 batches indices 0โ€“6364โ€“127128โ€“191192โ€“255 worker 1 Execute(0..63) worker 2 Execute(64..127) worker 3 Execute(128..191) worker 4 Execute(192..255) all four run at the same time ยท a finished worker steals the next free batch
Figure 1: IJobParallelFor divides the index range into batches; workers process them concurrently and steal more as they free up.

Rule of thumb for batch count: too small (like 1) and coordination overhead dominates; too large (the whole array in one batch) and you lose load balancing. For cheap per-element work, larger batches (64โ€“128); for heavy per-element work, smaller batches (1โ€“16) so stealing balances the load. Start around 32โ€“64 and profile.

The Parallel-Write Rule

Because many workers run Execute at once, there's a hard rule: iteration index may write only to [index] of its output container. If two iterations could write the same slot, you have a race โ€” and the safety system will refuse to schedule it.

So positions[index] += ... is fine (each iteration owns its slot), but "add my value into a shared total" is not โ€” every iteration would fight over total. Reductions and many-to-one writes need different tools: a NativeParallelHashMap, a per-index output you sum afterward, or the .AsParallelWriter() variants of some containers.

โš ๏ธ Reading anywhere, writing only your slot

You may read any element of a [ReadOnly] input (a flocking job reads all neighbours' positions), but you may only write your own index in a read-write output. Keep that split clear and parallel jobs stay race-free by construction.

Enter Burst

Parallelism spreads the work; Burst makes each piece dramatically faster. Burst is a compiler that takes the C# in your jobs and compiles it โ€” ahead of time โ€” into highly optimised native machine code, bypassing the general-purpose Mono/IL2CPP path. Turning it on is almost insultingly easy: add one attribute.

[BurstCompile]                       // โ† that's the whole opt-in
struct MoveJob : IJobParallelFor
{
    public float deltaTime;
    [ReadOnly] public NativeArray<float3> velocities;
    public NativeArray<float3> positions;
    public void Execute(int index)
    {
        positions[index] += velocities[index] * deltaTime;
    }
}

The first time a Burst job runs, Unity compiles it (you'll see it in the Burst menu and a brief one-time cost); after that it's native. It's common to see a Burst-compiled parallel job run 5โ€“20ร— faster than the same code without Burst โ€” on top of the parallel speedup. This is the concrete reason the 25,600-entity scene in Lesson 4.1 is comfortable rather than a slideshow: its systems are Burst-compiled jobs.

What Burst Actually Does

Burst gets its speed from a few compounding wins:

  • Native code via LLVM โ€” it compiles to optimised machine code with aggressive inlining and no managed-runtime overhead.
  • SIMD vectorisation โ€” it packs several floats into one wide CPU register and operates on them together. This is why Unity.Mathematics (float3, float4) matters: those types map onto SIMD lanes, so a float3 add can be one instruction.
  • Assumptions it's allowed to make โ€” because jobs forbid managed state and aliasing, Burst can optimise far more boldly than a general C# compiler ever could.
  • Fast math โ€” optionally, it can relax IEEE floating-point strictness for extra speed where you don't need bit-exact results.

โœ… Pro Tip โ€” the Burst Inspector

Open Jobs โ–ธ Burst โ–ธ Open Inspectorโ€ฆ to see the actual assembly Burst generates for your job. You don't need to read assembly fluently, but spotting vector instructions (like addps/mulps) confirms your loop vectorised โ€” and their absence hints your data layout or types are blocking it.

Burst's Constraints

Burst only compiles code that plays by the data-oriented rules โ€” which is the same discipline jobs already demand:

  • No managed types inside a Burst job โ€” no classes, no managed arrays, no string (use FixedString), no boxing.
  • No calls into most of the Unity managed API โ€” stick to math, NativeContainers, and Burst-compatible functions.
  • No try/catch in release โ€” exceptions are supported only as editor-side safety checks, not as control flow.
  • Both the struct and, for systems later, the relevant methods carry [BurstCompile].

If Burst can't compile something, it tells you at compile time (or falls back to non-Burst with a warning) โ€” it won't silently ship slow code. In practice these constraints overlap almost perfectly with what a job is allowed to do anyway, so "make it a clean job" and "make it Burst-able" are nearly the same task.

Measuring the Win

Never assume a speedup โ€” measure it, using the Profiler from Module 2. The Job System has its own timeline lane; a well-scheduled parallel job shows up as many short bars filling several worker threads while the main thread stays thin. Compare three versions of the same workload:

flowchart LR A["Serial loop
on main thread
(baseline)"] --> B["IJobParallelFor
no Burst
~coresร— faster"] B --> C["IJobParallelFor
+ [BurstCompile]
~coresร— ร— Burstร— faster"]

Figure 2: The two multipliers stack โ€” parallelism across cores, then Burst's native/SIMD speedup on each core.

The exact numbers depend on the work and the hardware, but the pattern is reliable: parallelism gives you roughly a per-core multiplier, and Burst multiplies again on top. Toggle Burst off with Jobs โ–ธ Burst โ–ธ Enable Compilation to feel the difference in a single scene.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Parallelise and Burst a job

Objective: Convert a serial loop into a Burst-compiled parallel job.

  1. Start from a serial loop that sets heights[i] = math.sin(positions[i].x) * math.cos(positions[i].z) over 100,000 elements.
  2. Rewrite it as an IJobParallelFor with Execute(int index), a [ReadOnly] positions input and a heights output.
  3. Add [BurstCompile]. Schedule with a batch count of 64 and complete it.
  4. Profile it against the serial loop; open the Burst Inspector and look for vector instructions.
โœ… Solution sketch
[BurstCompile]
struct HeightJob : IJobParallelFor {
    [ReadOnly] public NativeArray<float3> positions;
    public NativeArray<float> heights;
    public void Execute(int i) {
        heights[i] = math.sin(positions[i].x) * math.cos(positions[i].z);
    }
}
var h = new HeightJob { positions = pos, heights = hgt }.Schedule(pos.Length, 64);
h.Complete();

๐Ÿ‹๏ธ Exercise 2: Why won't it Burst?

A teammate's job has [BurstCompile] but a field public List<GameObject> targets; and calls Debug.Log in Execute. Name why Burst rejects it and how to fix the data.

โœ… Answer

List<GameObject> is a managed collection of managed references โ€” forbidden in a job at all, and doubly so under Burst. Debug.Log is a managed Unity API call. Fix: copy the data you need into a NativeArray of blittable values (e.g. target positions as float3) before scheduling, and remove the logging from the hot loop.

๐ŸŽฏ Quick Quiz

Question 1: In an IJobParallelFor, iteration index may safely write toโ€ฆ

Question 2: What does adding [BurstCompile] to a job do?

Question 3: Why does Unity.Mathematics (float3) help Burst specifically?

Summary

๐ŸŽ‰ Key Takeaways

  • IJobParallelFor turns "loop over an array" into one Execute(int index) the pool runs across every core.
  • Schedule with length + batch count; batches enable work-stealing โ€” start ~32โ€“64 and profile.
  • Parallel-write rule: read anywhere ([ReadOnly]), but write only your own [index]; reductions need special tools.
  • Burst is one attribute ([BurstCompile]) that compiles jobs to native, SIMD-vectorised code โ€” often 5โ€“20ร— on top of parallelism.
  • Burst needs the data-oriented discipline (no managed types/API, no release exceptions); float3/float4 vectorise well.
  • Always measure in the Profiler and confirm vectorisation in the Burst Inspector.

๐Ÿš€ What's Next?

You have all three DOTS multipliers โ€” packed data, parallel jobs, and Burst. In Lesson 3.5 you'll put them together in a mini-project: a Burst-compiled parallel field simulation that moves tens of thousands of points every frame โ€” the direct on-ramp to ECS in Module 4.

๐Ÿš€ Every core, every lane

Spread the loop across cores with IJobParallelFor, then compile each core's work to native SIMD with Burst. That combination is the engine under DOTS โ€” and the reason data-oriented Unity scales to the tens of thousands.