DOTS//CORE local · not synced

m04 · Jobs · reading · 9 min

IJobEntity and IJobChunk — the chunk becomes the work unit

Independence you already have

The last two modules kept promising this payoff, so let’s collect it. Chunks are independent by construction: each holds its own entities’ component data contiguously, and — because iteration order was never guaranteed (Module 1) — no entity’s processing depends on another’s order. Two different chunks share no memory and impose no sequence. That is exactly the precondition for parallel work: independent units with no shared writes and no required ordering. The runtime doesn’t have to create parallelizable pieces out of your data; the chunk already is one.

So the natural unit of parallel distribution is the chunk. Hand chunk 0 to core 0, chunk 1 to core 1, and so on; each core runs the same per-entity logic over its own chunk’s dense arrays, and because chunks don’t overlap, there is nothing to race over. The 16 KB chunk size from Module 1 was chosen partly for this: it’s a clean, bounded unit of work to hand a thread — big enough to amortize scheduling overhead, small enough to balance load across cores when entity counts are uneven.

IJobEntity: write per-entity, run per-chunk

IJobEntity is the ergonomic way to iterate entities in a job. You write what reads like a per-entity function — an Execute that takes the components of one entity — and the source generator turns it into a job that iterates chunks:

[BurstCompile]
partial struct MoveJob : IJobEntity
{
    public float DeltaTime;

    void Execute(ref LocalTransform transform, in Velocity velocity)
    {
        transform.Position += velocity.Value * DeltaTime;
    }
}

// scheduled across cores in one call:
state.Dependency = new MoveJob { DeltaTime = dt }.ScheduleParallel(state.Dependency);

Read the two layers. The Execute you wrote describes one entity: here’s a transform, here’s a velocity, integrate. But ScheduleParallel runs it as per-chunk parallel work — the generated job walks matching chunks, and the scheduler distributes those chunks across worker threads, each thread calling your Execute for every entity in its chunk. You expressed the logic at the entity level; the system executed it at the chunk level across cores. This is the same separation as always: you say what happens to an entity, the runtime decides how it’s spread over hardware.

The parameter annotations carry the access declaration the safety system needs: ref LocalTransform says this job writes transforms, in Velocity says it only reads velocity (the in is the per-parameter equivalent of [ReadOnly]). From those, the scheduler knows this job conflicts with anything else writing LocalTransform and can run concurrently with other readers of Velocity — the entire safety analysis from the last lesson, driven by how you declared each component parameter.

cache line · 64 B fetched 64 B useful · 100% of the bandwidth you paid for ScheduleParallel: core 0 runs Execute over chunk 0's entities, core 1 over chunk 1's, each streaming its own dense component spans — per-entity code, per-chunk parallelism

IJobChunk: the level underneath

IJobEntity is generated on top of a lower-level interface, IJobChunk, whose Execute receives a whole chunk at once — the archetype chunk object, from which you pull the component arrays and loop yourself:

[BurstCompile]
struct MoveChunkJob : IJobChunk
{
    public ComponentTypeHandle<LocalTransform> TransformHandle;
    [ReadOnly] public ComponentTypeHandle<Velocity> VelocityHandle;
    public float DeltaTime;

    public void Execute(in ArchetypeChunk chunk, int unfilteredChunkIndex,
                        bool useEnabledMask, in v128 chunkEnabledMask)
    {
        var transforms = chunk.GetNativeArray(ref TransformHandle);
        var velocities = chunk.GetNativeArray(ref VelocityHandle);
        for (int i = 0; i < chunk.Count; i++)
            transforms[i] = /* integrate transforms[i] with velocities[i] */ transforms[i];
    }
}

This is more verbose, and most of the time IJobEntity is the right choice — it generates essentially this and spares you the handles and the loop. But IJobChunk gives you the chunk itself, and that unlocks things the per-entity view hides: operating on the component arrays as arrays (a manual SIMD pass, a bulk memcpy), reading chunk-level metadata, custom handling of the enabled-mask for enableable components, or any logic that is genuinely about the chunk rather than one entity. You drop to this level when you need the chunk as a first-class thing; you stay at IJobEntity when you’re really just processing entities.

What this buys you

You can now see why the chunk is the unit parallelism was waiting for, how IJobEntity presents entity-level code that executes as chunk-level parallel work, and when the lower-level IJobChunk is worth its verbosity. What remains is the connective tissue: how jobs declare that one must finish before another, how JobHandle chains them into a dependency graph, and how that graph relates to the sync points from Module 2. That’s the next lesson.

m04.l04