Managing Native Container Dependencies

Inspired by ** this thread ** and this thread I’ve created what feels like a nice solution to job dependency management for NativeContainers. I haven’t seen any implementations shared so I thought I’d post it here to get feedback and hopefully help someone else on their journey.

What I like about this implementation is that

  • There’s minimal boilerplate needed to consume the NativeContainer

  • It’s much easier to manage than exposing the host system’s Dependency property directly with no risk of accidentally overwriting the existing dependencies.

  • It’s relatively easy to debug during development when you forget to call ReleaseAsync() after an AcquireAsync().

  • The last method to call Acquire or AcquireAsync is recorded so it can be included in the error message if needed.

  • No knowledge of other systems interacting with the container is required to use and parallel read access is granted when possible.

Of course, If you try to force complete the dependency (JobHandle.Complete()) you still run the risk of waiting on a substantial amount of work to complete synchronously up the chain. I don’t see any way around that issue unless Unity provides the ability to reorder and traverse a JobHandle’s dependency tree.

Implementation and sample use below. I’m interested to hear any questions, feedback or if this helps you out!

NativeArrayAccessController

/*
MIT License

Copyright (c) 2022 Decline Cookies

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

using System;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

namespace Anvil.Unity.DOTS.Jobs
{
    /// <summary>
    /// A wrapper class for managing async access to a NativeArray{T}.
    /// </summary>
    public class NativeArrayAccessController<T> : IDisposable where T : struct
    {
        private enum AcquisitionState
        {
            Unacquired,
            ReadOnly,
            ReadWrite,
        }

        private readonly NativeArray<T> m_Value;
        /// <summary>
        /// The handle to wait on before <see cref="m_Value"> can be read.
        /// </summary>
        private JobHandle m_ReadAccessDependency = default;
        /// <summary>
        /// The handle to wait on before <see cref="m_Value"> can be written to.
        /// </summary>
        private JobHandle m_WriteAccessDependency = default;
        private AcquisitionState m_State = AcquisitionState.Unacquired;
        private bool m_IsDisposed = false;

        /// <summary>
        /// Creates a new <see cref="NativeArrayAccessController{T}"/> with a given length and init options.
        /// </summary>
        /// <param name="length">The length of the <see cref="NativeArray{T}".</param>
        /// <param name="options">The init options for the <see cref="NativeArray{T}".</param>
        public NativeArrayAccessController(int length, NativeArrayOptions options)
        {
            m_Value = new NativeArray<T>(length, Allocator.Persistent, options);
        }

        public void Dispose()
        {
            // NOTE: If these asserts trigger we should think about calling Complete() on these job handles.
            Debug.Assert(m_ReadAccessDependency.IsCompleted, "The read access dependency is not completed");
            Debug.Assert(m_WriteAccessDependency.IsCompleted, "The write access dependency is not completed");
            (m_Value as IDisposable)?.Dispose();
        }

        /// <summary>
        /// Get the <see cref="NativeArray{T}"/> returning a JobHandle to wait on before consuming.
        /// After the work with the array is scheduled <see cref="ReleaseAsync"/> must be called before any other calls to
        /// <see cref="AcquireAsync" /> or <see cref="Acquire" /> are made for this array.
        /// </summary>
        /// <param name="isReadOnly">The access level required for the value. Accessing readonly will tend to require less waiting.</param>
        /// <param name="value">The array.</param>
        /// <returns>A JobHandle to wait on before consuming the array.</returns>
        public JobHandle AcquireAsync(bool isReadOnly, out NativeArray<T> value)
        {
            Debug.Assert(!m_IsDisposed);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            ValidateAndUpdateAcquireCaller();
#endif

            value = m_Value;
            return GetAcquisitionDependency(isReadOnly);
        }

        /// <summary>
        /// Schedule the release of the array's ownership after an asynchronous operation.
        /// </summary>
        /// <param name="releaseAccessDependency">The JobHandle that describes when work with the array will be complete.</param>
        public void ReleaseAsync(JobHandle releaseAccessDependency)
        {
            Debug.Assert(!m_IsDisposed);
            Debug.Assert(m_State != AcquisitionState.Unacquired, "There is no outstanding acquisition to release.");

            switch (m_State)
            {
                case AcquisitionState.ReadOnly:
                    m_WriteAccessDependency = JobHandle.CombineDependencies(m_WriteAccessDependency, releaseAccessDependency);
                    break;

                case AcquisitionState.ReadWrite:
                    m_ReadAccessDependency =
                        m_WriteAccessDependency = JobHandle.CombineDependencies(m_WriteAccessDependency, releaseAccessDependency);
                    break;

                default:
                    throw new ArgumentOutOfRangeException(Enum.GetName(typeof(AcquisitionState), m_State));
            }
            m_State = AcquisitionState.Unacquired;
        }

        /// <summary>
        /// Get the <see cref="NativeArray{T}"/> immediately.
        /// This blocks the calling thread until the array is available.
        /// <see cref="Release"/> must be called before any other calls to <see cref="AcquireAsync" />
        /// or <see cref="Acquire" /> are made for this value.
        /// </summary>
        /// <remarks>
        /// This method and its compliment <see cref="Release" /> are intended to be used for synchronous work
        /// on the main thread.
        /// </remarks>
        public NativeArray<T> Acquire(bool isReadOnly)
        {
            Debug.Assert(!m_IsDisposed);
#if ENABLE_UNITY_COLLECTIONS_CHECKS
            ValidateAndUpdateAcquireCaller();
#endif
            GetAcquisitionDependency(isReadOnly)
                .Complete();

            return m_Value;
        }

        /// <summary>
        /// Releases access to the array immediately.
        /// Generally paired with the use of <see cref="Acquire"/>.
        /// </summary>
        /// <remarks>
        /// This method can be called after <see cref="AcquireAsync"/> has been called but it will
        /// block the calling thread until the array's access dependency is resolved. <see cref="ReleaseAsync" />
        /// is typically the better option.
        /// </remarks>
        public void Release()
        {
            Debug.Assert(!m_IsDisposed);
            Debug.Assert(m_State != AcquisitionState.Unacquired, "There is no outstanding acquisition to release.");
            m_State = AcquisitionState.Unacquired;

            m_ReadAccessDependency.Complete();
            if (m_State == AcquisitionState.ReadWrite)
            {
                m_WriteAccessDependency.Complete();
            }
        }

        private JobHandle GetAcquisitionDependency(bool isReadOnly)
        {
            if (isReadOnly)
            {
                m_State = AcquisitionState.ReadOnly;
                return m_ReadAccessDependency;
            }

            m_State = AcquisitionState.ReadWrite;
            return m_WriteAccessDependency;
        }

#if ENABLE_UNITY_COLLECTIONS_CHECKS
        private string m_AcquireCallerInfo;

        private void ValidateAndUpdateAcquireCaller()
        {
            Debug.Assert(m_State == AcquisitionState.Unacquired, $"Release must be scheduled before scheduling acquisition again. Last ScheduleAcquire caller hasn't scheduled release yet. {m_AcquireCallerInfo}");

            System.Diagnostics.StackFrame frame = new System.Diagnostics.StackFrame(2);
            m_AcquireCallerInfo = $"{frame.GetMethod().Name} at {frame.GetFileName()}:{frame.GetFileLineNumber()}";
        }
#endif
    }
}

ExampleNativeArrayProviderSystem

public class ExampleNativeArrayProviderSystem : SystemBase
{
   public NativeArrayAccessController<int> ExampleArray {get;}

   protected override void OnCreate()
   {
      base.OnCreate();

      Enabled = false;

      ExampleArray = new NativeArrayAccessController<int>(500, NativeArrayOptions.UninitializedMemory);
   }

   protected override void OnDestroy()
   {
      ExampleArray.Dispose();

      base.OnDestroy();
   }
}

ExampleNativeArrayConsumerSystem

public class ExampleNativeArrayConsumerSystem : SystemBase
{
   private ExampleNativeArrayProviderSystem m_ArrayProvider;

   protected override void OnCreate()
   {
      base.OnCreate();

      m_ArrayProvider = World.GetSystem<ExampleNativeArrayProviderSystem>();
   }

   protected override void OnUpdate()
   {
      Dependency = JobHandle.CombineDependencies(Dependency,
         m_ArrayProvider.ExampleArray.AcquireAsync(false, out NativeArray<int> exampleArray));

      Dependency = new ExampleJob()
      {
         ExampleArray = exampleArray
      }.SheduleParallel(Dependency);

      m_ArrayProvider.ExampleArray.ReleaseAsync(Dependency);
   }
}

I’m a little disappointed you missed mine because this is actually one of the most popular features of my framework.

This is what your example looks like using the framework:

public struct ExampleNativeArrayWrapperTag : IComponentData { }

public struct ExampleNativeArrayWrapper : ICollectionComponent
{
    public NativeArray<int> exampleArray;

    public Type AssociatedComponentType => typeof(ExampleNativeArrayWrapperTag);

    public JobHandle Dispose(JobHandle inputDeps) => exampleArray.Dispose(inputDeps);
}

public class ExampleNativeArrayProducerSystem : SubSystem
{
    protected override void OnCreate()
    {
        worldBlackboardEntity.AddCollectionComponent(new ExampleNativeArrayWrapper
        {
            exampleArray = new NativeArray<int>(500, Allocator.Persistent, NativeArrayOptions.UninitializedMemory)
        });
        Enabled = false;
    }

    protected override void OnUpdate()
    {
    }
}

public class ExampleNativeArrayConsumerSystem : SubSystem
{
    protected override void OnUpdate()
    {
        var array = worldBlackboardEntity.GetCollectionComponent<ExampleNativeArrayWrapper>(readOnly: false).exampleArray;

        Dependency = new ExampleJob
        {
            exampleArray = array
        }.ScheduleParallel(array.Length, 32, Dependency);
    }

    struct ExampleJob : IJobFor
    {
        public NativeArray<int> exampleArray;

        public void Execute(int index)
        {
            exampleArray[index] = index;
        }
    }
}

Consumer boilerplate is practically non-existent. The consumer doesn’t even need to know the type of system that produced the data. By default, the JobHandles are automatically combined into Dependency and extracted after OnUpdate, but there are overloads for more manual control.

1 Like

Oh no, don’t be! I’m not surprised that you have a solution but I’ve been purposely ignoring your framework while I get comfortable with the basics. I don’t want to adopt what looks like a monolithic framework before first understanding what’s lacking in DOTS and what it might take to build it myself.

If you did share this implementation in another forum post then I didn’t come across it in my searches. That’s on me!

I took a read through your Collection Components docs. The way you handle dependency management by piping handles back to the system is a really neat idea. I definitely appreciate the benefits of a holistic approach like this.

If I understand correctly you’ve paired each collection to an entity indirectly though the AssociatedComponentType getter. If that’s the case, does your framework handle moving the collections when the entities are moved to a different world?

I actually linked to it in one of the threads you linked. But don’t worry about it. :stuck_out_tongue:

No it doesn’t, as SystemState components are not copied between worlds, and collection components behave like SystemState. Right now I am not aware of a good reason to support this either. But I can think of a way to support it via an EntityManager extension if that were truly necessary.

The AssociatedComponentType works the way it does because Entities.ForEach can’t handle generic components. I hope to remove the need for that once we have source generators for Entities.

1 Like

lol, you’re right! When I first read your description I misunderstood and thought “nope, don’t like that approach”. hah, your explanation makes a lot more sense now.

Makes sense. What happens to those associated entities then? Do they just get orphaned from their collections when you call EntityManager.MoveEntitiesFrom(SomeWorld)? Or are you doing a cleanup pass?

When I worked on this initially I was comparing DynamicBuffers on an Entity vs NativeArrays on a System for environment grid storage (temperature, humidity, etc…). I’m planning to put each environment in a world or at least have one World for the focused/rendered environment and one for off screen environments (TBD). The plan is to move the entities in each environment between the worlds when the player is no longer focusing on them. In that case I’d need the grids to follow along between worlds.

Ultimately I ended up going the DynamicBuffer route so it’s not really an immediate concern. Not that manually transferring NativeContainers between worlds would be all that difficult.

Looking at the EntityManagerMoveEntities.cs code and dependent files, it looks like moving entities between worlds does not correctly account for system state to begin with. That’s a bug if not a design flaw in Entities, which means that the containers probably leak as well since the reactive systems in the old world don’t have a chance to do cleanup.

Use Shared Components and Shared Component Filters in your queries instead of worlds. You will have way less remapping and encounter fewer Entities bugs.

That’s in line with my understanding of how SystemStateComponents are intended to be used. I wouldn’t have expected SystemStateComponents to get dragged along since the systems in the new world are different instances and are seeing these Entities for the first time. Being a different context I don’t think you’d want to assume that the internal system state for an entity would be the same between worlds.

I’ll keep this in mind but I think it’s a path I’m going to have to explore for myself.

Really, I don’t want to be moving entities between worlds but the hybrid renderer can only be used on the default world…for now. So I’m stuck moving entities.

The big benefits of multiple worlds in my eyes are:

  • Easier control over update cadence the simulation of off screen worlds vs the on screen/focused one. I think I can even decouple the sync points of the background worlds from the foreground world.

  • There’s very limited interaction between my worlds so having an off screen world fall behind the simulation’s current time isn’t a big deal.

  • The lifecycle of worlds don’t affect each-other.

  • More generic systems don’t need to be shared component aware to limit the scope of entities that they evaluate

  • Any state stored on systems is simplified (Ideally this is extremely limited anyway)

  • Conceptually the intent is a bit more clear

These are my pre-exploration thoughts though. Talk to me in a couple weeks and I’ll probably be singing a different tune :slight_smile: