Search Unity

Entities-as-events : Yes or No?

Discussion in 'Entity Component System' started by PhilSA, Nov 7, 2019.

  1. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Has anyone made performance tests for creating entities as events? Compared to, let's say, storing events in a NativeQueue/NativeStream or something of the sort?

    Imagine we take bullet hit VFX spawning as a use case:
    • Solution 1: In our parallel hit detection job, whenever our raycasting bullet detects a hit, it tells the ECB to create a new Entity with the hit event info in a component. Another system will later process those events and spawn a hit VFX for each one
    • Solution 2: In our parallel hit detection job, whenever our raycasting bullet detects a hit, it stores the event struct in a NativeQueue.Concurrent or NativeStream.Writer. Another system will later process those events and spawn a hit VFX for each one
    Just assume that for some reason, we don't want to spawn the VFX directly from that hit detection job. (we might want those events to be used by several other systems for various reasons, etc...). And also pretend this is a game where there will potentially be hundreds of these events created every frame

    How much of a performance difference can we expect between these two solutions? I do expect solution 2 to be at least a bit more performant, but since Entities-as-Events are so convenient, they might be an attractive solution even if it's not the most performant thing. Depends on how big the performance cost is
     
    Last edited: Nov 7, 2019
  2. eizenhorn

    eizenhorn

    Joined:
    Oct 17, 2016
    Posts:
    2,685
    We using both ;) For example in my navigation path requests filled in NativeQueue, systems in dependency chain put requests in concurrent queue, pathfinding schedule jobs nd only one synch point is before next update when I apply pathfinding results to agents. With Entity as event we need some synch point in a middle of this case, for creating entities on main thread, not matter how - by ourselfs or by queued command buffer, then gather them in path finding and second synch point for applying pathfinding results. (of course we can implement it with one synchpoint in place where we create entities we process previous path request results, but it's bit longer way, and not always possible). Or attack system, when we ofen produce attack events, and on huge armies it happens veeeery ofen. And opposite example - we have indicators system which just spawn event entities for showing some indicators and system just handle all of them and decide (depend on data in this event entity) add new indicator, remove current, add it to queue of indictors etc. Or event entities on some actions - spawn some objecs (like our own particles), it happens rare, and in most case order independant.
    I think case really matter than performance in most scenarious, I don't think is so much difference (queue in my thoughts faster a bit), but for ofen cases with dependency and high frequency, personaly, I prefer event queue.
     
  3. snacktime

    snacktime

    Joined:
    Apr 15, 2013
    Posts:
    3,356
    The challenge with using entities as events is that often you really want to keep a single global sync point for your own logic, to allow the most time for your jobs to run. If you force all entities to be created in that global sync point it starts to add constraints like not being able to queue work for jobs later in the same update cycle.

    So even as efficient as batch creation is, an entity based event system requires it's own abstraction layer just to queue them them all up for creation/destruction in that global sync point, so you end up using some sort of logical queue system anyways.

    Some features only really work by having their own entities, and they get created/destroyed often. Characters and world items are our two main cases that fit that. So their creation/destruction which is triggered by network messaging we queue it all up to happen in a single sync point.

    Other things like projectiles and pathfinding are all done without structural changes.

    However, having a fairly mature projectile and VFX system that works at the scale of up to several hundred running concurrently, there are specific challenges there that are not easy to deal with.

    For example our projectiles have transforms and we render them with DrawMeshInstanced. We move them and the effects that move with them with the low level transform job api's. Which is far more performant then using the hybrid renderer for that use case.
     
    NotaNaN and PhilSA like this.
  4. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    I would guess that a drawback of the events queue is that you can only read from it once, unlike entity events which can be read any amount of times by any amount of systems

    ...unless you dequeue your event queue into a native array first. But then there are some caveats:
    • If you do it on the main thread, you need a sync point. You have to complete the event-generating job to make sure everything is up to date, and then dequeue into an array. I'm guessing this would give us a similar penalty as entity events
    • If you do it in a job, you don't need a sync point, but you do need a NativeArray with a worst-case-scenario capacity. And if your events count exceed that capacity, you'll lose some events
    Correct me if I'm wrong though. I don't have a project in front of me where I can type this out and reason about it properly
     
    Last edited: Nov 7, 2019
    TheHartPony likes this.
  5. tertle

    tertle

    Joined:
    Jan 25, 2011
    Posts:
    3,761
    That to me right there is where you should draw the line on using one or the other.
    If you only need to read once, use a queue. If you need to read multiple times use an event.

    Queue approach is definitely more performant. Events created using ECB don't scale that well but you won't run into issues until you hit 100-1000s per/frame which is a lot. Once you reach this point though you really need to look at batch operations.
     
    dzamani, PhilSA and florianhanke like this.
  6. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    This made me think: can you read a NativeStream multiple times?

    Because if so, then storing events in NativeStreams could be the best of both worlds. Ideal performance and re-readability

    Here's a NativeStream example for those who, like me, didn't know it existed:



    Edit: Wait actually maybe not. You'd still need sync points to do myNativeStream.AsReader() I believe....
     
    Last edited: Nov 7, 2019
    NotaNaN likes this.
  7. tertle

    tertle

    Joined:
    Jan 25, 2011
    Posts:
    3,761
    Seems reasonable but there are reasons I'm not a fan of this solution a lot of the time - primarily you tend to start coupling all your systems together. I think it comes down to using whatever is right for your situation.
     
    Last edited: Nov 7, 2019
  8. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    After all this, I think my favorite solution would be this:
    • Create your events in a NativeStream.Writer, because this gives you the best parallel-writing performance apparently
    • Have one sync point where you call myStream.ToNativeArray() to store your events in a NativeArray
    • From that point on, you can read your events from the nativeArray as many times you you want
     
    GilCat likes this.
  9. Joachim_Ante

    Joachim_Ante

    Unity Technologies

    Joined:
    Mar 16, 2005
    Posts:
    5,203
    You can schedule a dependent job that with AsReader(). You can simply call it, before the job producing it has completed. There is no reason for introducing any sync point.

    There is also no reason to use ToNativeArray.
     
  10. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,271
    Back to the original question, that are a surprising number of tools to do events in DOTS, each with their tradeoffs. There's definitely no silver bullet here.

    NativeQueue:
    • Faster than NativeStream in my personal testing.
    • Strongly typed with its contents.
    • Parallel writing.
    • Single-threaded reading.
    • Read once.
    • Non-deterministic ordering.
    NativeQueue & NativeList:
    • Uses a NativeQueue for parallel writing.
    • Copies to NativeList which can then be transformed into a NativeArray.
    • Parallel writing.
    • Parallel reading (using NativeArray).
    • Read multiple times.
    • Non-deterministic ordering.
    • Requires single-threaded memcpy.
    NativeStream:
    • Parallel writing.
    • Parallel reading.
    • Read multiple times.
    • Deterministic ordering.
    • Typeless (great for dynamic event payloads, but not helpful if type is constant)
    • Write only once to any stream index.
    NativeHashmap of arrays of UnsafeLists:
    • Parallel writing.
    • Parallel reading.
    • Read and write multiple times.
    • Event types are pre-filtered when reading.
    • Difficult to get right.
    • Even more difficult to get right when you want safety checks.
    Events as Entities:
    • Easy to implement.
    • Allows for events to have archetypes.
    • Event types are pre-filtered when reading.
    • Requires non-Burst step (playback or EET).
    • Generates sync point (or uses existing one delaying when event handlers can run).
    *You might be able to get rid of the sync point for events as entities by building the entities in a second world, and then using an IJobForEach with a query created in the second world that has ComponentDataFromEntity from the main world.
     
  11. Deleted User

    Deleted User

    Guest

    @DreamingImLatios correction, NativeQueue are pretty much determenistic to write / read because of jobIndex that it uses.
     
  12. tertle

    tertle

    Joined:
    Jan 25, 2011
    Posts:
    3,761
    What happens if you have 2 machines with different thread counts?
     
  13. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,271
    It uses threadIndex, which is nondeterministic.
     
  14. Deleted User

    Deleted User

    Guest

    It is incremental. Which means that when NativeQueue writes in parallel it writes in the order of execution JobThreads, meaning that if your data is outside of a chunk for instance splits onto 2 different job threads the result output will be ordered.
     
  15. Deleted User

    Deleted User

    Guest

    Why is that matter for a local simulation? Events will be ordered regardless.
     
  16. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    Here's another question:

    We've established that using the events nativeQueue is one of the best approaches if you only need to read your events once. But if you only need to read your events once, why not just process those events directly in the job that's supposed to generate them in the first place?

    What should be the deciding factor, performance-wise, to store events in a queue and make another job process those events instead of processing them directly in the original job? I imagine there are data-layout considerations in this, but right now I have a pretty poor understanding of what exactly I should be looking for. When passing things to a job, how much data is too much data?

    (I kinda expect the answer to be "just profile your things", but I'm asking anyway)
     
    Last edited: Nov 8, 2019
  17. Antypodish

    Antypodish

    Joined:
    Apr 29, 2014
    Posts:
    10,779
    My thought would be, if event can be called from multiple different jobs.

    For example, call kill instance event, when clicked on instance entity, or raycast on instance entity, or health is low, or timeout, or game reset/game over. So you can have potentially multiple systems, which could trigger such even.
     
    dannyalgorithmic, NotaNaN and PhilSA like this.
  18. DreamingImLatios

    DreamingImLatios

    Joined:
    Jun 3, 2017
    Posts:
    4,271
    Sure. The queue block order might be chained together using the threadIndex, but that doesn't mean the same input slice of data is going to be run on the same thread or have the same threadIndex. There's a reason why EntityCommandBuffer has an argument for an index. That is so that regardless of which chunks end up on which threads, the commands get played back deterministic.

    This is actually what I usually do. My complaints about not being able to schedule generic jobs from generic methods with Burst in build (works in editor amazingly well) is because of this. However, there are cases where the producer can be done in parallel but the event handler needs to be done in a single thread. Most of the time, I actually process the NativeQueue in the sync-point period and use batched EntityManager commands and some initialization jobs to speed up spawning procedures.

    It depends on how well-optimized your producer job is and how much the event handler disrupts that algorithm's "flow". This is something I will eventually need to investigate for my broadphase after I optimize the algorithm more. Definitely stay tuned for that!
     
  19. Singtaa

    Singtaa

    Joined:
    Dec 14, 2010
    Posts:
    492
    Also to note is that Burstable ECB playback is on ECS's roadmap (though I have no idea what kind of perf considerations will come with that).
     
    PhilSA likes this.
  20. PhilSA

    PhilSA

    Joined:
    Jul 11, 2013
    Posts:
    1,926
    I totally forgot about ECB commands not being Burst'd right now. It'll be interesting to see if that brings entity events closer to NativeStream/Queue events in terms of performance

    Although I gotta say, I'm finding less and less reasons not to use NativeStream events, now that I know they don't need sync points to be used or to be read multiple times



    They even have the advantage of having possible support for "complex event archetypes", because they can be read like a network serialization buffer with headers and such (the headers tell you how to read the next X bytes of data). That way the events can even contain lists
     
    Last edited: Nov 8, 2019
  21. MikeMarcin

    MikeMarcin

    Joined:
    May 15, 2012
    Posts:
    17
    If there's just a single event handler then sure maybe you just call a function instead of generating an event. But if there are multiple handlers that can be triggered from posting an event handling them inline can lead to jumping wildly around your code and memory as it is often the case that the event handler is quite distinct from the event emitter. Instead you could pool up events and batch process all events for a given handler together.

    Additionally handling events in situ can lead to a cascade of events being generated and handled and at the limit overflow your stack but more frequently create some unintended perverse consequences like deadlocks.
     
  22. MikeMarcin

    MikeMarcin

    Joined:
    May 15, 2012
    Posts:
    17
    I think it's more interesting to consider the ways you can bind an event to its handler and target entity and execute that handler as opposed to the various ways to post and store the events themself.

    Take the interface
    EventManager.Post( targetEntity, new MyEvent { ... } )
    .

    This could use any of the above methods for storing the event for processing. But how do you dispatch it to the appropriate message handler which takes the target entity plus the event data and allows you to read/modify relevant components of the target entity efficiently?

    Here's the obvious one to get started:

    Add the MyEvent component to the targetEntity, Query for MyEvent + the components you wish to read/modify & ForEach (or equivalent) to execute the handler.
    • Easy to implement
    • Optimal message handler batching
    • Pay binding cost on publish side
    • Target entity changes archetypes multiple times as event is added/removed
    • Supports only one event in-flight per target
    A related alternative would be to change MyEvent to a buffer element which is always on the targetEntity.
    • Stable archetype
    • Good message handler batching
    • Pay binding cost on publish side
    • Supports multiple events per target
    • Memory overhead for buffers
     
    Last edited: Nov 9, 2019