Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Task Driver - Improve Dependency Resolution and Fulfillment in the Job #224

Open
mbaker3 opened this issue Apr 20, 2023 · 7 comments
Open
Labels
effort-high Focused - 4 to 8 hours priority-medium Standard task, plan as you see fit. type-feature New feature or request

Comments

@mbaker3
Copy link
Member

mbaker3 commented Apr 20, 2023

NOTE: This task has changed purpose somewhat as of #224 (comment)


Currently, task drivers key their data streams on type alone. This is a good default behaviour but there are situations where multiple streams of the same type are required.

For example, a task driver that supports a cancellable and non-cancellable request stream.

Today, the workaround is to create separate types either through private subtypes or completely separate types.

Provide a way to register multiple data streams of the same type.

Example Workaround

public readonly struct MyRequest
{
    private interface IMyRequest : IEntityProxyInstance
    {
        public MyRequest Request { get; }
    }

    public readonly struct NonCancellable : IMyRequest
    {
        public MyRequest Request { get; }

        public Entity Entity
        {
            get => Request.Entity;
        }

        public NonCancellable(in MyRequest request)
        {
            Request = request;
        }
    }

    public readonly struct Cancellable : IMyRequest
    {
        public MyRequest Request { get; }

        public Entity Entity
        {
            get => Request.Entity;
        }

        public Cancellable(in MyRequest request)
        {
            Request = request;
        }
    }

    
    public readonly int SomeVal;
    public Entity Entity { get; }

    public MyRequest(Entity requestingEntity, int someVal)
    {
        Entity = requestingEntity;
        SomeVal = someVal;
    }

    public static implicit operator Cancellable(in MyRequest request) => new Cancellable(in request);
    public static implicit operator NonCancellable(in MyRequest request) => new NonCancellable(in request);
}
@mbaker3 mbaker3 added effort-high Focused - 4 to 8 hours priority-medium Standard task, plan as you see fit. type-feature New feature or request labels Apr 20, 2023
@jkeon
Copy link
Member

jkeon commented Apr 21, 2023

I'm not sure we want to do this.

If we do, we would have to introduce the concept of an ID for each DataStream.

Which could be user provided:

StartDataStream = CreateDriverDataStream<FollowPathStart>(IDs.FOLLOW_PATH_START);

Or generated and returned:

//Could return id and out the stream instead or you just get it off the stream
StartDataStream = CreateDriverDataStream<FollowPathStart>(out uint id);

But then we would have to be explicit on all the usages of that in job scheduling:

 actorMoveTaskDriver.ConfigureDriverJobTriggeredBy(
                    IDs.FOLLOW_PATH_START,
                    StartJob.Schedule,
                    BatchStrategy.MaximizeChunk)
                .RequireEntityPersistentDataForRead(FollowPathBufferEntityPersistentData)
                .RequireEntityPersistentDataForWrite(FollowPathStatusEntityPersistentData)
                .RequireDataStreamForWrite(actorMoveTaskDriver.GetDataStreamByID(IDs.MOVE_START));

Which might be fine but we lose out on some direct referencing and type safety. (Although maybe we could make that nicer by having the ID be contained inside the DataStream so you still direct reference but then pass in an ID to get an inner sibling collection)

But... the main concern is that Unity itself only keys on Type for all of ECS. If you need to have multiples of a type, then you wrap the struct in a different struct type which is what we can do as well for TaskDrivers.

Which is annoying, I agree, but just raising the question of whether we should do this or not?

@mbaker3
Copy link
Member Author

mbaker3 commented Apr 21, 2023

I'm not sure about that last code block there. Why would we need the IDs? We have reference to the actual data stream instance. It's possible that the IDs are completely internal to TaskDriver so I can create two data streams of the same type and behind the scenes they resolve to their individual IDs.

I suppose there would be a limitation on the job side when you try to fetch the instance through the jobdata instance. It could work so long as you only want one of the streams in your job.

What if we provided the task driver instance in the job's constructor? Then you can fetch using the stream instance directly.

But... the main concern is that Unity itself only keys on Type for all of ECS. If you need to have multiples of a type, then you wrap the struct in a different struct type which is what we can do as well for TaskDrivers.

Just so I understand, is this a consistency with ECS concern or a limitation of the safety system and the way we manage job handles with Task Drivers?

@jkeon
Copy link
Member

jkeon commented Apr 22, 2023

I'm not sure about that last code block there. Why would we need the IDs? We have reference to the actual data stream instance. It's possible that the IDs are completely internal to TaskDriver so I can create two data streams of the same type and behind the scenes they resolve to their individual IDs.

I suppose there would be a limitation on the job side when you try to fetch the instance through the jobdata instance. It could work so long as you only want one of the streams in your job.

What if we provided the task driver instance in the job's constructor? Then you can fetch using the stream instance directly.

Hmmn, yeah that's interesting. I could see that working. I wonder if you even need the require/get flow then...

The whole point before was that you needed to say what data you would need so we could make sure we got all the dependencies in order for you, then construct your job and give you an easy way to grab the data for your job and catch any issues where you hadn't requested access for it. And then we schedule.

But if we give the typed TaskDriver instance to your job, then the constructor is still main thread land before scheduling.
Which means you we probably could:

  1. Create the instance and pass in the TaskDriver.
  2. Job Constructor grabs everything it needs off the TaskDriver via Require-like functions that handle getting the access.
  3. Job then gets scheduled using all the access that was asked for in the constructor.

Instead of this:

            private StartJob(DataStreamJobData<FollowPathStart> jobData)
            {
                m_PathBuffers = jobData.GetEntityPersistentDataReader<FollowPathBuffer>();
                m_FollowPathStatusWriter = jobData.GetEntityPersistentDataWriter<FollowPathStatus>();
                m_ActorMoveWriter = jobData.GetDataStreamWriter<ActorMove>();
                m_VerboseLogger = new SXVerboseBurstableLogger<FixedString64Bytes>(typeof(StartJob).ToMessagePrefix());
            }

You'd get this:

            private StartJob(FollowPathTaskDriver taskDriver)
            {
                m_PathBuffers = taskDriver.RequireForRead(taskDriver.FollowPathBufferEntityPersistentData);
                m_FollowPathStatusWriter = taskDriver.RequireForWrite(taskDriver.FollowPathStatusEntityPersistentData);
                m_ActorMoveWriter = taskDriver.RequireForWrite(taskDriver.MoveDataStream);
                m_VerboseLogger = new SXVerboseBurstableLogger<FixedString64Bytes>(typeof(StartJob).ToMessagePrefix());
            }

That would eliminate the need for the method chaining require blocks

actorMoveTaskDriver.ConfigureDriverJobTriggeredBy(
                    StartDataStream,
                    StartJob.Schedule,
                    BatchStrategy.MaximizeChunk)
                .RequireEntityPersistentDataForRead(FollowPathBufferEntityPersistentData)
                .RequireEntityPersistentDataForWrite(FollowPathStatusEntityPersistentData)
                .RequireDataStreamForWrite(actorMoveTaskDriver.MoveDataStream);

Becomes:

actorMoveTaskDriver.ConfigureDriverJobTriggeredBy(
                    StartDataStream,
                    StartJob.Schedule,
                    BatchStrategy.MaximizeChunk);

We would probably want to make sure that API wise we've guarded against getting the end data without going through a special Require method.

This would be an annoying bug to track down since it MIGHT still work and just be a race condition:

            private StartJob(FollowPathTaskDriver taskDriver)
            {
                m_PathBuffers = taskDriver.RequireForRead(taskDriver.FollowPathBufferEntityPersistentData);
                m_FollowPathStatusWriter = taskDriver.RequireForWrite(taskDriver.FollowPathStatusEntityPersistentData);
               //OOPSIE DOOPSIE
                m_ActorMoveWriter = taskDriver.MoveDataStream.AcquireWriterAsync();
                m_VerboseLogger = new SXVerboseBurstableLogger<FixedString64Bytes>(typeof(StartJob).ToMessagePrefix());
            }

Just so I understand, is this a consistency with ECS concern or a limitation of the safety system and the way we manage job handles with Task Drivers?

Yeah just a consistency with ECS and a "if they restrict that way, what do they know that we don't." However in seeing your ideas above and thinking more about it, I've switched sides and I'm for this idea now.

@mbaker3
Copy link
Member Author

mbaker3 commented Apr 22, 2023

Nice, yea that's interesting. Maybe a bit more refinement on how exactly you get references in the constructor but this is an interesting direction to pursue some day.

Maybe we let people do the actual acquire in the constructor? So long as they get the job handles out the caller of the constructor we'd be fine right? Rather than "Require" it could be something like:

taskDriver.AddDependency(MoveDataStream.AcquireWriterAsync(out m_ActorMoveWriter));

or even a required out param on the constructor that was some sort of dependency collection instance.

@jkeon
Copy link
Member

jkeon commented Apr 24, 2023

Yeah I think we're saying the same thing:

My example of:
m_ActorMoveWriter = taskDriver.RequireForWrite(taskDriver.MoveDataStream);

Would do what you said under the hood:
taskDriver.AddDependency(MoveDataStream.AcquireWriterAsync(out m_ActorMoveWriter));

Names of course all being subject to change for better clarity etc.

The Taskdriver internally would already know about the Dependency collection for the job it was scheduling because it just instantiated you and is about to call schedule on it. So we don't need the developer to be explicit about it, we can handle it for them.

The explicit AcquireWriterAsync could also be hidden inside just so you don't see a bunch of Acquires without the corresponding Releases because we'd handle all the releases for you internally like we do today.

@mbaker3
Copy link
Member Author

mbaker3 commented Apr 24, 2023

oh I see. Yea good point about the auto handling of release. I was just trying to avoid all the annoying wrapper types to maintain. But I think that may be settled now anyway

@mbaker3 mbaker3 changed the title Task Driver - Support Multiple Data Streams of the Same Type Task Driver - Improve Dependency Resolution and Fulfillment in the Job Jul 21, 2023
@mbaker3
Copy link
Member Author

mbaker3 commented Jul 21, 2023

Changed the issue title to better reflect the work that needs to be done. Particularly after #245 gets closed with the workaround solution that will land soon.

Lots of discussion has happened around what we'd like to support some day. Here are some high level thoughts:

  • We should consider a more dependency injection like approach where the current jobConfig.Request/Require methods setup the instance mappings and reflection is used to map the instances to the job fields automatically.
    • In cases where there are two dependencies/fields of the same type an [InjectionName("MyName")] can be used to resolve which dependency goes where.
    • This injection should be recursive through job parts.
    • Any post injection work that needs to be done (IE: Not part of Execute but needs reference to the injected fields (writers, CDFE, etc..) can be done in a method decorated with a [PostInjection] attribute.
  • Alternatively, we can get rid of the jobConfig.Request/Require calls and produce a mapping in the job's constructor.
    • A reference to the host TaskDriver would be passed into the job's constructor just as it is with AddRequirementsFrom/ConfigureRequirements methods today.
    • The mapping would likely need three entries:
      • Destination field (on the job)
      • Source instance (data stream, CDFE type, etc...)
      • Usage (Write, Read, etc..) - We could maybe infer this from the destination field 🤷
    • The suitability of this approach will likely be influenced by the results of Jobs - Explore Stateless Optimization #243. If we go this route then an expensive constructor isn't a big deal.

mbaker3 added a commit that referenced this issue Jul 21, 2023
Add the ability to require multiple cancel streams in a single job. Requiring is done as you would expect, just target the different streams on the JobConfig. Fulfilling the writer/instance is done by passing the stream instance into the `Fulfill` method.

The access wrappers now represent access to the cancel type not a specific cancel stream instance. This works because all of the cancel streams share the same SharedWrite Pending collection so getting write access for one stream grants write access for all. The instance passed through `Fulfill` will get safety checked in the access wrapper and delivered back to be used for writer creation.

Behaviour:
 - One cancel request stream required: `jobData.Fulfill` behaves as normal, no explicit instance required.
 - N cancel request stream required: `jobData.Fulfill` must be provided the instance for each stream to fulfill otherwise the safety system will throw an exception.

Note: This is a workaround implementation. #224 will make this cleaner.

This approach can be applied to all write wide (shard write) types. Support will be added in an upcoming commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
effort-high Focused - 4 to 8 hours priority-medium Standard task, plan as you see fit. type-feature New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants