Jobs that executed on the main thread with `Run()` and UnityEngine API

As we know the UnityEngine API can be executed only on the main thread. The IJobExtensions.Run() perform the job’s Execute() method immediately on the same thread, so if we invoke Time.deltaTime for example within a burstified job using the OnCreate, OnUpdate, or OnDestroy in JobComponentSystem it’s being executed on the main thread, but we still get: UnityException: get_deltaTime can only be called from the main thread. Therefore, we can’t benefit from LLVM-compiled code by Burst which supports native methods that are UnityEngine API essentially is.

So my question is, this is the Unity runtime checking a thread ID or a call-stack incorrectly or this is an intended restriction? It prevents me from using the Burst possibilities to build a native framework for Unity, reduce interoperability overhead, and keep stuff away from Mono.

1 Like

Here’s a code for reproducing:

using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

public class TestSystem : JobComponentSystem {
   [BurstCompile]
   private struct CreateJob : IJob {
       public void Execute() {
           var deltaTime = Time.deltaTime;
       }
   }

   [BurstCompile]
   private struct UpdateJob : IJob {
       public void Execute() {
           var deltaTime = Time.deltaTime;
       }
   }

   [BurstCompile]
   private struct DestroyJob : IJob {
       public void Execute() {
           var deltaTime = Time.deltaTime;
       }
   }

   protected override void OnCreate() {
       var createJob = default(CreateJob);

       createJob.Run();
   }

   protected override JobHandle OnUpdate(JobHandle inputDependencies) {
       var updateJob = default(UpdateJob);

       updateJob.Run();

       return inputDependencies;
   }

   protected override void OnDestroy() {
       var destroyJob = default(DestroyJob);

       destroyJob.Run();
   }
}

Notice that the code is produced correctly in the Burst Inspector.

It is a bit strange, the managed thread id and execution context in Run are the same as the main thread. But is this really an issue? I imagine in most cases api’s that are not thread safe are likely mostly implemented in native code, where burst isn’t really going to help anyways.

My main goal is to eliminate the influence of the Mono’s JIT when Unity’s native methods are being invoked at the runtime since P/Invoke calls themselves are costly.

Out of curiosity, what struct or static Unity API are you using for enough iterations where you think you can obtain measurable performance gains? The only ones I know are either already made thread-safe or can be easily replicated with Blobs.

I’m not using Unity’s Entities, in my native framework, the API is mapped one to one with GameObject and Transform where I iterate through large batches on the main thread. I’ve almost finished building the Overwatch ECS equivalent where all logic is performed using custom workers and fiber-based task scheduler similarly to Naughty Dog’s parallelization (Job workers are disabled). Fibers are enqueuing messages with a final shape of data to the Event Bus which is multi-producer single-consumer queue essentially, and this is where Mono’s JIT is being involved to execute Unity’s native methods. I want to mitigate this using Burst, but the exception that Unity’s runtime is throwing not allows me to do this nor make any sense in this case.

To give you an idea of the interop cost, here’s a basic test which just invokes native methods of Mathf:

using Unity.Burst;
using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

public class InteropPerformanceSystem : JobComponentSystem {
   const int iterations = 1000000;

   [BurstCompile]
   private struct CreateJob : IJob {
       public void Execute() {
           int a = 32;
           float b = 32f;

           for (int i = 0; i < iterations; i++) {
               a = Mathf.ClosestPowerOfTwo(a);
               a = Mathf.NextPowerOfTwo(a);
               b = Mathf.GammaToLinearSpace(b);
               b = Mathf.LinearToGammaSpace(b);
           }
       }
   }

   protected override void OnCreate() {
       var stopwatch = new System.Diagnostics.Stopwatch();
       long time = 0;

       {
           var createJob = default(CreateJob);

           stopwatch.Restart();
           createJob.Run();

           time = stopwatch.ElapsedTicks;

           Debug.Log("Burst: " + time + " ticks");
       }

       {
           var createJob = default(CreateJob);

           stopwatch.Restart();
           createJob.Execute();

           time = stopwatch.ElapsedTicks;

           Debug.Log("Mono JIT: " + time + " ticks");
       }
   }

   protected override JobHandle OnUpdate(JobHandle inputDependencies) {
       return inputDependencies;
   }
}

AMD FX-4300

Standalone:
Burst: 703,391 ticks
Mono JIT: 1,203,632 ticks

Editor:
Burst: 1,662,773 ticks
Mono JIT: 1,615,007 ticks

3 Likes

I have no idea why Burst is producing anything for Time.deltaTime in a job. That seems weird to me. I will have to look later to see how the asm attempts to fetch that value.

But also, I really don’t understand how you would get a performance boost on individual functions avoiding interop when calling a Burst-compiled function would also produce such an interop. The only way you would get any real speedup is by batching all your work in a job and then writing out the output. .Run() is perfect for that.

Lastly, besides deltaTime which is an easily cache-able variable, and Mathf which if you want Burst you should opt for the Unity.Mathematics library instead, is there any other Unity API that could even take advantage of Burst in your game? Unity does not expose their GameObject API in a native context that I am aware of, and I doubt they ever will.

Time.deltaTime is static and Burst can not access to static variables. Perhaps wrong error message?

Time.deltaTime is a property that call a C++ function, not a field.

I think Time.deltaTime is not marked as being thread safe i.e. [ThreadSafe] which triggers the error message when it is run as a Burst Job (even though in this particular instance it is running in place on the main thread)

It’s not a burst thing, it throws without burst also.

So far i now, it’s not only field but all sort of “static” data. (except static method which only work with the arguments)

All Job examples copy the deltatime.

Maybe some job preparation in the scheduler work on another thread?

@DreamingImLatios As @Guerro323 said Time.deltaTime is essentially a native function. Burst is now able to link with shared libraries and it supports DLLImport. Roughly speaking, LLVM-compiled code is faster since everything is done natively while the Mono runtime invokes unmanaged functions using the virtual machine with some additional safety checks on top via stack walking. Interoperability in .NET is especially costly if the marshaling is being involved when non-blittable types are used in method arguments.

@JakeTurner As Chris said above, Burst is not the reason, the problem sits in the Unity’s runtime itself which checks a thread ID or a call-stack incorrectly since a thread remains the same, but the exception is still there.

If this issue will be solved, it will be possible to add overloads to UnityEngine API where blittable pointers will be used instead of reference types, then utilize built-in memory allocator, and implement new safety checks to burstify 90% of traditional engine’s API and escape from MonoBehaviour as well as from Mono itself. This requires a certain amount of work, but it’s definitely possible.

2 Likes

This was the disconnect for me. I’m very curious what this looks like and how you access the native APIs instead of the Mono ones. Have you tried it with any of the thread-safe class types?

What I’m doing is just exploiting Unity’s assembly and Burst-generated shared library to get access to native functionality which bound to internal headers. We have two options to make everything blittable: one is spartan which requires to edit the compiled managed assembly, and another is more elegant since we can exploit the way how Unity is handling interoperability, but we will need to write/generate a lot more code for reflection instead of adding a simple overload.

When it’s done, Burst compiles every single function natively as a job and then exporting entry points for dynamic linking. At startup, a structure with all required pointers is composed and passed as a pointer to the native plugin which de-references it and keeps a copy statically during a session. I’m using there dlopen() / LoadLibrary() and dlsym() / GetProcAddress() to load any required shared library. Then, native API invokes the appropriate pointers as functions so essentially UnityEngine.Time.frameCount becomes int (SYMBIOTIC_FUNCTION *frameCount)(void); for example. The prototype of C function looks like this:

int symbiotic_time_framecount(void) {
   return symbiotic.time.frameCount();
}

Events remains on the main thread as jobs executed with Run() within a system:

[BurstCompile]
private struct CreateJob : IJob {
   public Symbiotic symbiotic;

   public void Execute() {
       if (Native.LoadMain(ref symbiotic))
           Native.Awake();

       Native.Start();
   }
}

[BurstCompile]
private struct UpdateJob : IJob {
   public void Execute() {
       Native.Update(UnityEngine.Time.deltaTime);
   }
}

[BurstCompile]
private struct DestroyJob : IJob {
   public void Execute() {
       Native.Destroy();
       Native.UnloadMain();
   }
}

C prototypes in the native plugin looks like this:

void symbiotic_awake(void) {
   if (events.awake != NULL)
       events.awake();
}

void symbiotic_start(void) {
   if (events.start != NULL)
       events.start();
}

void symbiotic_update(float deltaTime) {
   if (events.update != NULL)
       events.update(deltaTime);
}

void symbiotic_destroy(void) {
   if (events.destroy != NULL)
       events.destroy();
}

C implementation of custom logic which compiled as a separate shared library:

#define SYMBIOTIC_MAIN

#include "symbiotic.h"

void symbiotic_awake(void) {
   symbiotic_debug_log_info("Awake");

   // Assert test
   #ifdef SYMBIOTIC_DEBUG
       symbiotic_debug_log_info("Testing assert:");
   #endif

   SYMBIOTIC_ASSERT(1 < 0);
   SYMBIOTIC_ASSERT_MESSAGE(1 < 0, "Assert message");

   // Time test
   symbiotic_debug_log_info("Testing time:");
   symbiotic_debug_log_info(symbiotic_string_format("Frame count: %i", symbiotic_time_framecount()));
 
}

void symbiotic_start(void) {
   symbiotic_debug_log_info("Start");
}

void symbiotic_update(float deltaTime) {
 
}

void symbiotic_destroy(void) {
   symbiotic_debug_log_info("Destroy");
}

Compiled with GCC and executed right in the Unity:
4753037--451325--symbiotic.PNG

It works well and fast. The only problem is the strange exception which, I hope, will be fixed.

A native framework is just my personal preference. It’s possible to wrap it in Go/Java/Lua, you name it (with a bit of C), and it’s also possible to burstify the UnityEngine API in C# itself, but it’s quite tricky for not in-house developers since we only have C# code for reference and the engine’s source code is closed with its internal headers.

That makes a lot of sense. Seems like a good approach for a team that already has a solid C++ codebase but wants to switch to Unity for authoring and presentation.

The part that I’m not sure about is how you bind class types in a Burst job to get direct access to them in native. Have you tested this approach with a ThreadSafe class type like AnimationCurve?

Well, I just did some basic tests. Haven’t tried the AnimationCurve yet, but it should work fine. I’m looking at native method signatures there, this is standard interop, we only need to replace all reference types and use a custom memory allocator for Keyframe arrays so no need any marshaling, just pass blittable pointers instead. I’m using the same approach with ENet for example here. Look at enet_packet_create_offset() where one overload accepts a managed array and another a blittable pointer. Simple as that you get the same bit-level representation in managed and native code of any arguments.

You don’t need to bind any abstracted classes of Unity on top of native functionality, you only need the native method signatures themselves and then you abstract them the way you like and not necessarily the same way as Unity did. This requires some excavation of the managed assembly or exploiting the way how Unity is handling interoperability and write/generate the reflection code yourself. Notice in the example above that Debug.Log() is invoked from the native code within a burstified job which is not possible in Unity since it requires a managed object as an argument.