Unsplash

Chunk’s change version works in chunk level. It is obvious from the name, I know! But I have tripped myself. What is a chunk? Please think carefully.

I have a system that works on component A. I want it to do work only when A changes. I also want to use chunk iteration, because chunk iteration API should be the absolute base and I would have the most freedom there (inside what’s possible with ECS).

Chunk’s change version could be your ultimate design tool in creating a smart/optimized system. You better know it inside out! “Dumb” system is a system that use your precious CPU cycle to do work that does not make any difference. Chunk’s change version is all about skipping works. We all like skipping works right?

So I put DidChange in it while iterating chunks. I tested it with 1 Entity and it works! The system always update, but it early exit if nothing changed.

Versioning concepts

It starts at this 2 methods that are in the main flow of your system. (Considering your system passed ShouldRunSystem check that is, there is something to inject, etc.)

What’s inside?

Global System Version

An int shared globally, global as in “ECS World” (literally “globe”) because the EntityManager is keeping it and you can have only one EntityManager per world.

We need 2 side of version to detect change

“DidChange” answer in boolean but this global version is an integer. We need 2 of this integer to detect change by comparing which one is larger. Imagine if you are holding an apple and got asked “did it changed?” you would be confused. But if he asked “did it looks worse than yesterday?” then you would be able to answer. The current version number would be the time right now. The reference version number would be the time your friend last saw the apple.

Side A : Version on the system

On each Update of your system

  • Before OnUpdate that global number increases by 1.
  • The system version is not yet updated to this newly increased global number. This not-yet-updated system version number is exposed in a protected property called LastSystemVersion .
  • After OnUpdate your system then remembers that increased global version as its own version number.
  • All of your group’s filter will get that still-not-up-to-date system’s version automatically. If you are not using filter, you are going to use this property manually with chunk iteration.

We got one side now, that is the global version distributed to systems. The other side is the global version distributed to the data. (System is not a data!)

Side B : Chunk’s version

You know global version affects 2 version number (current system version + LastSystemVersion ) remembered per each system, now let’s see how the version number baked in the data updates. So finally, when we get 2 side we can compare “did it change?”.

Each time you get ECS data chunk with write access, the version of all relevant chunks are immediately updated to the current global int. The code cannot detect if you really write any data or not. Because the write is “direct to the memory” that there is no more wrapper and place to detect writes other than this “get with write access”.

  • Methods like EntityManager.SetComponentData<T> obviously do this.
  • Just getting the ComponentDataArray<T> with write access does not yet update the version until you really change the data with that int indexer. (But CDA is being deprecated anyways)
  • More later on controlling to not update the chunk version.

One chunk can hold change version for EACH component type

You know that one chunk is strictly belonging to one Archetype . An archetype contains multiple types. Each of these types is versioned!

That’s why when you do chunk.DidChange(___) you have to say which type when you get the chunk out from ArchetypeChunkComponentType ‘s <T> in the first place.

Reference point

You notice that it asks for LastSystemVersion . That’s protected property of each of your system.

Each chunk already have a change version tracked per component of that chunk. Change version is not yet a true/false state but just a running number. To get boolean state we need a reference point. (did change from what?) Which is an another version number.

DidChange is a method on chunk that means “did this chunk changed relative to some system version number?” You already know system version number is updated to globalVersion+1 before OnUpdate. BUT by using LastSystemVersion you are comparing to the point in time on the previous OnUpdate of this system.

Going by the apple analogy you can think of your friend as a system that is looking at your apple. Yesterday he looked and remember the time of day as his own version number + also bake that time to the apple (data). And today he do it again and find that the time on the apple is not the same as yesterday. Probably a rotting system updates the apple’s state and refresh the version number to be larger. Because the number is larger than his version he remember on himself yesterday, he know it changed. (Ok consider him blind too, he can just compare numbers and cannot look at the apple)

Version 0

This is a special number that is always considered “changed” as it is indicating a newly minted chunk no matter what reference number you put in. Because if not this, we have no way to detect the change from nonexistence into chunk being exist.

There are 3 versions in play now :

  1. Global system version : increased on any system’s before update. Continuously distributing to 2 sides : system and chunks.
  2. System’s version : updates after system’s update. Also keep tracking of its previous version. Distributed to changed filters automatically. Used with chunk iteration manually.
  3. Version in each chunk : updates to newest on write access.

You are not alone in a chunk : “changed” cause by other entity.

But also when I have multiple entities, when any of them change then the flag is set for the whole chunk. Chunk is the most granular AND performant level for Unity to implement this feature. Per-entity change detection has to be by other (more costly) means.

I have been designing ECS UI library called “Izumi” which works kind of like Flux/Redux where changes to the data are broadcasted to view, via ECS chunk change version. Instead of trying to detect per-entity change I constrained myself with other design restriction and embrace the “whole chunk change”.

Chunk movement : “changed” caused by changing archetype.

Later I want to add functionality Hidden so I created a tag component. Attching Hidden to some entity with A cause a chunk movement. I didn’t touch the component A at all but the entity that was attached with only A is now in a brand-new chunk, which has version 0 and would be detected by DidChange .

This might be not intuitive at first, when what you say in the code was chunk.DidChange(typeofA) and you didn’t touch A , you didn’t “add” A either, yet the change was triggered because of chunk movement by adding/removing some other unrelated component that now A is considered “Added”. (Remember version 0 = added = changed = DidChange)

Case 1 : The first mover

I shoot myself when, the system that works on changed A used DidChange and that work is attaching Hidden on that entity the change happen. Imagine we only have one entity with A , no one in the World has both A and Hidden yet. This A is the first one that will get Hidden component. I then touch some data in A to cause Hidden attach.

After the attach, it cause the system to process A again recursively, immediately! Why, because that new chunk (with archetype A + Hidden ) is fresh. (In the case that this A is the first one to be attached with Hidden ) the version is still 0 for both types, fitting the criteria of DidChange .

Case 2 : More immigrants

What happen if the destination chunk contains several other entities, that is, A which already had Hidden on it. Later, one more A has been attached with Hidden and will join force with the others.

Let’s start from the basic, what happens when an entity is changing archetype? We would have to look at EntityDataManager.AddComponent . Adding a component 100% change chunk, if not then it would result in a classic noob error saying the component is duplicated.

It all comes down to this SetArchetype . Changing an archetype of one Entity

The code reads, “ ChunkDataUtility will convert one thing at the old chunk to new chunk. Then, we have a hole in the old chunk which we will just move the last thing in the old chunk to fill the gap.”

ChunkDataUtility.Convert here is the highlight. What is happening at the destination chunk?

…seems like nothing about the destination’s version change is here. Then we know that your entity discards its old chunk’s version completely and now using the destination chunk’s version. Destination chunk version is not bumped in any way by adding/removing a component. (But if you then use SetComponent to give it a value, then of course the version is updated)

Imagine this possible scenario. Chunk X : A , Chunk Y : A and Hidden

  1. You created an entity with A , you change some data and its version is now 4. A system updating with prev version lower than 4 will detect change by DidChange
  2. You add Hidden to that one Entity, it moves to chunk Y. Chunk Y was constructed fresh and so the version for both A and Hidden are 0. The system doing chunk iteration with DidChange(A) see that it is “Add” (= 0) and runs endlessly.
  3. You created 1000 more entities with only A . You change the value on them so many times, but then you decided to add Hidden to all of them at once. Now your system see that all of them are “new” because the destination chunk is still at version 0 for both A and Hidden .

DidChange ‘s Add status (version = 0) is worth noting here. The “add” status will not be removed until you touch the chunk. But if you are not touching the chunk and other entities decided to move into this chunk, all of them will get this “add” status altogether. How to use this behaviour is up to you.

ComponentGroup’s “changed filter”

You can put changed filter on your component group up to 2 types. This is a different beast than chunk’s version. ComponentGroup contains multiple chunks, that is not actually here yet until you do GetComponentDataArray (or go chunk iteration with CreateArchetypeChunkArray) so how can a group be filtered even before we do anything?

It does not affect system’s activation

The filter on component group I think affects only iterators made from them (like ComponentDataArray ) make their length decrease possible down to 0. But even if it is 0 the system will run as if there is no filter.

Demystifying with example situation

With these knowledge we can already simulate some situations. SystemA and SystemB both has a ComponentGroup of Z. So it works with the data with ComponentDataArray<Z>. Note that CDA is to be deprecated, but you can imagine writing to data with chunk iteration instead.

  • SystemA runs its OnUpdate with global version 1. The current SystemA’s version is 0. SystemA make some change to the ComponentDataArray<Z> unconditionally, making all chunks that includes the Z has version 1. After that SystemA now also has version 1.
  • SystemB runs its OnUpdate with global version 2. SystemB’s filters all get version 0 because that’s the version number of SystemB. Can SystemB detects changes? YES because the chunk has version 1, it is newer than filter’s 0. After that SystemB is now holding version 2. SystemB only reads data, those chunks are still at version 1.
  • SystemA updates again, but do nothing. SystemA is now at version 3. Those chunks are at version 1 still.
  • SystemB runs again, will SystemB detect changes? NO because currently SystemB’s filter is at version 2 but those chunks are still at version 1. The chunk is older than the filter.
  • Next, SystemA decides to attach Changed filter too on its OnUpdate. Will SystemA detects “no change” since previously SystemB only reads? NO, SystemA see that “everything changed” because the filter’s version is still at zero. Any chunk is newer than version 0.

Lesson Learned

  • “Changed” definition lasts only for one round of Update. “Round” is regarding to system’s update cycle.
  • Imagine you have system A B C D A B C D A B C D … running
  • If B right here A [B] C D A B C D A B C D make changes and all other updates only read, then A [B] C D A B C D A B C D, the bold system can detect that change but all highlighted systems cannot detect changes.
  • B cannot detect its own change on the previous round.
  • Attaching the Changed filter is not immediately usable right in that OnUpdate , you have to wait one more round.

CalculateLength is bugged? (preview 21)

I notice that sometimes CalculateLength would use a version number that is not up to date, giving “unchanged” even if actually it changed. Best to avoid them for now?

Note that there is this comment suggesting that the component chunk iterator is not working well with change version?

/*
 Can't be enabled yet because a bunch of tests rely on it, need to make some decisions on this...
#if ENABLE_UNITY_COLLECTIONS_CHECKS
                if ((filter.Type & FilterType.Changed) != 0)
                {
                    throw new System.ArgumentException("CalculateLength() can't be used with change filtering since it requires all component data to have been processed");
                }
#endif
*/

Chunk iteration’s version number gives expected result at the same time that CG bugged

When CG returns 0 length due to the filter, I tried making ArchetypeChunk array from the same CG and DidChange reveals that it is in fact changed (correctly). Oh well…

“Missing a change” problem — when jobs mixed with main thread

  • You schedule some jobs from JobComponentSystem which modifies a value.
  • The 2nd JobComponentSystem it runs after that JobComponentSystem . You check for chunk changed or CG’s changed version in the OnUpdate in order to avoid scheduling a job at all if the data is not changed.
  • You expect the 2nd system to run because you changed value, but no! Unity’s job system does not actually complete or even start before someone request a dependency on it! By the time you check for version, it is still unchanged.
  • The right way to check for changed from jobs is to check in a job also. That way you could not avoid scheduling cost, but you could early out from the job.
  • Or use JobHandle.ScheduleBatchedJobs() ? Not sure if it is a good idea.. or even the required change version would happen immediately?

IJobProcessComponentData

Add [ChangedFilter] to your ref which affects the ComponentGroup like explained earlier. IJPCD works in parallel per chunk, you could be skipping some chunks or even all chunks.

IJobChunk

You get one ArchetypeChunk per worker thread. Just do .DidChange on it!

Deciding NOT to write

You now realize there’s a lot of speed up potential with the change version system. Now you have to do your best to not cause a version change where data stays the same.

One trick to make the most out of changed filter is to precisely dirty or not dirty things. If you are always dirtying things then your changed check will be wasted..

Imagine a job code which check on some data first, if some conditions are met then you compute more things and write to some data. If not, then you don’t want to touch the chunk’s version. You should avoid the “write anyways even if the value is the same” usually caused by calculations with clamping values, or bad conditionals with common catch-all case that over-assign values. You have to always plan a “do nothing” path.

Avoiding a write with ComponentDataArray

  • If you use ComponentDataArray , GetComponentDataArray with write access does not yet considered write. When you use the indexer to write in job, that’s the moment chunk number increased. Very easy to not cause a write! But when I do want to cause a write, sometimes the new version number is not getting applied correctly? (preview 21)

Avoiding a write with chunk iteration

  • If you use chunk iteration, the write is decided when you use isReadOnly : false ArchetypeChunkComponentType to get the NativeArray of your type. That will dirty the chunk. You may check for conditions before deciding to do GetNativeArray(ACCT)
  • That means you cannot decide to write or not to write in chunk iteration based on its own data if you brought in ACCT with write access, just “peeking” the data requires getting the NativeArray and that’s already a write. This is different from CDA, you could read without writing on a writable CDA.
  • Also you cannot bring both readonly and writable ACCT to the job, it will says : InvalidOperationException: The writable NativeArray … is the same NativeArray as …, two NativeArrays may not be the same (aliasing). (ACCT is a native array??)
  • Unfortunately it is often the case to have to look at the current data to decide if the write is necessary or not.
  • However you may peek with the readonly version outside of the job, if you wants to write then schedule the job with writable version.
  • Or if you do not want to break dep chain by doing something in the main thread which might requires completing some jobs, make a new job just for peeking data with readable version and send peek result via NativeArray out for the 2nd job that use the 1st job’s JobHandle with writable version, to early exit without writing, at the same time avoid aliasing problem because the aliasing arrays are now in separated jobs. Holy shit! All these trouble just for avoiding a write.
  • In a way controlling chunk version with ComponentDataArray seems to be the easiest since version is bumped or not very intuitively with the indexer. But CDA is to be deprecated…

Weakness of IJobProcessComponentData

If you use IJobProcessComponentData , you may prepare your write destination as ref without [ReadOnly] . That already count as write and will increase chunk version immediately. You can not check on other things and then decide not to write later. (Impossible to NOT write)

The ref uses pointer, it has no mechanism to know if you actually assign something new to it or not.

This is the work of ComponentChunkIterator contained inside your IJPCD, method UpdateCacheToCurrentChunk . On execute of each chunk any component without [ReadOnly] will be immediately version increased.

Also with IJPCD, you cannot prevent a job’s scheduling (and thus the change) with one prior job that check for conditions since Unity does not allow scheduling a job from job. Both jobs are already on its way. If you don’t use IJPCD, that trick is possible and you just early out from the 2nd job before it can write data.

Beware of feedback changes!

Imagine system A B C running in that order in a frame.

  • A : Use IComponentData X to update some hybrid objects UI position in ComponentArray. You think there is no prior system that always run that could change X so it must be efficient.
  • B : Scan those UI objects and grab their calculated RectTransform back, save it as Rect hoping to do some raycasting in ECS next. You use the same changed condition as A thinking that if A does not happen and so this system should not run.
  • C : Calculated rect are saved back to X.

The first loop DidChange will enter a special case that anything against version 0 return true. A B C runs, then in the next frame A will detect change from C from the previous frame which in turn cause C to do a write again. Only the first activation is enough to kick off this “feedback loop”. With a normal system design without changed version trick this will produce normal behaviour, but you overworked.

You have to carefully design some other stopping condition in some case. In this case A’s condition should not be based on X or purely X.

WHERE is the change? Help!

You scattered Debug.Log under each DidChange and found your system still runs when you do nothing to the game. You don’t remember writing anything!

Basic : Look for non-readonly containers

Since native containers should be decorated with [ReadOnly] when you are not going to write or have some kind of flag on it (CDA uses [ReadOnly], ACCT uses flag, ACCT’s field in the job also use [ReadOnly] and so on) and since ECS lib is based on strongly typed generic works you can use your IDE to search for problematic type that unknowingly change (remember that change version is per component per chunk).

Expert : See the change version number and figure out what’s going on

You can go to the source code in Library/PackageCache file ChangeVersionUtility and put something in the method DidOrChange.

And also try to debug the protected field LastSystemVersion around the change checking.

Challenge : Debug.Log the source of version change

One difficult spot to find is your IJobProcessComponentData ref argument without [ReadOnly] in which case it cause a write right at the job’s schedule.

In this case you can go to ComponentChunkIterator.cs and in method UpdateCacheToCurrentChunk and UpdateChangeVersion put something like Debug.Log($”Wow! changed to {m_GlobalSystemVersion}”); and you will see it when IJPCD scheduled! Then just follow the stack trace.

Other sources includes ArchetypeChunkArray.cs which is related to chunk iteration’s native array generation (that I said it is written immediately on getting the array) Or ChunkDataUtility.cs which is a bit higher level.

Singleton entity strategy with changed filter

I have so many IComponentData that just hold a general state of the game. Now the question, should I create 1 Entity with all those data or 1 Entity per data?

One singleton

You can use IJobProcessComponentData to shape shift ALL general states data to and from each other, since IJPCD works on the same entity. If you do multiple singleton this would be impossible.

Multiple singletons + changed filter

This I want to introduce. Imagine a singleton GameTime : IComponentData containing various running number derived from delta time. Then an another singleton Weather : IComponentData containing stage’s condition. There is only 1 stage at any given time hence the singleton status.

If I merged Weather and GameTime , it would be impossible to design an IJPCD system that “do something only if Weather changed values” because GameTime is always changing every frame, the chunk version would keep updating always. Using [ChangedFilter] on Weather will have no effect since changed is checked in chunk level, not per-component.

If I do separated singletons, in IJPCD it is possible to use [ChangedFilter] with the Weather and also with chunk iteration it is possible to do .DidChange on the Weather chunk (that contains just 1 entity anyways, but you could be skipping a lot of work automagically)

Butterfly effect!

It sounds like a little thing to separate singletons into its own chunk, but usually big changes down the line is caused by this one singleton. If they are not doing work because these singletons do not change, more changed filter down the line might stay perfectly still and save you more work. I could say “changed filter” should be the default thing you put on to systems unless you know you want to do work with the same value as the previous frame, to cause something different or “incremental”.

Summary

Knowing all these you know precisely how to not increase chunk version unnecessarily. And then your carefully planned DidChange or [ChangedFilter] should do its magic to skip works!

Bugs

Preview 19

Please see the comment of this article.

Preview 20

They changed DidAddOrChange to accept one arbitrary version to compare against chunk version, in which you should use the newly exposed LastSystemVersion . But they didn’t add this new argument for DidChange so you have to go add it manually. ( TransformSystem is heavily using DidAddOrChange and no other internal code use DidChange so I guess they fix it just enough)

Preview 23

DidAddOrChange is gone, only remaining is DidChange which now detecting the same thing as DidAddOrChange (Version = 0 will now trigger DidChange )