Deep Dive: Profiler Source Generation
Posted: January 2, 2025
Last Updated: February 1, 2025
In a prior update, I showed a small clip that graphed the time it takes for the NSprites rendering system to run, averaged over frames every 0.5 seconds. This was really cool, and it would be great to have it continue to work in-game for other systems as well. In this post, I take a deeper look into my process for measuring job timings using profiler source generation. This outlines my attempts using C# source generation in the context of Unity and its own ECS source generation.

Example from Update 7 showing output from the profiler for the sprite rendering system.
OUTLINE:
- Generic Helpers: Trying without source generation.
- Naive Generation: Copy-paste a slightly different function automatically.
- DOTSCompilerPatchedMethod: Okay, I guess I need another method (or two)?
- Getting a Handle on TypeHandle: There’s more, and it’s still annoying.
- Fixing the Build: Works in the editor, breaks while building. Classic.
- Limitations: Where to improve.
PROBLEM(S):
It is easy enough to time a system’s update function when it is not Bursted and runs on the main thread, as was the case for the SpriteRenderingSystem. I just use a StopwatchRecorderContext and wrap the logic with:
using (var _ = StopwatchTracker.GetRecorderContext("Sprite Rendering/Sprite Rendering System")) {
// Run logic here...
}
// Dispose of StopwatchRecorderContext automatically logs time and stops stopwatch.
However, ideally we also want it to work for jobs scheduled on another thread or threads, but this becomes a bit more involved with other restrictions such as Burst and dependencies.
Problem 1 – Burst:
In many systems I have and want to continue having a Bursted OnUpdate method, even if they mostly just schedule jobs. Unfortunately, you may notice the presence of a pesky little string in the various calls to StopwatchTracker, and, unfortunately, this is a managed type so cannot be used in a Bursted function. The manual solution I came up with is to create a NativeText and a NativeText.ReadOnly field representing the key for the stopwatch. This way the call itself can at least be in a Bursted function (though this isn’t entirely accurate, as touched on later). Implementation of this requires that we create and dispose these keys in OnCreate and OnDestroy:
// An example system that we want to profile
public partial struct BulletMovementSystem : ISystem {
// NativeText key fields for burstability
private NativeText _profilerKey;
private NativeText.ReadOnly profilerKey;
private void OnCreate(ref SystemState state) {
// Assign values to key fields
_profilerKey = new NativeText("Update/Bullet/Movement", Allocator.Persistent);
profilerKey = _profilerKey.AsReadOnly();
}
private void OnDestroy(ref SystemState state) {
// Dispose created keys
_profilerKey.Dispose();
}
}
This fixes the problem for setting up the profiler, but we still need to use this in the Bursted OnUpdate method. Regardless of whether our key is Burstable, StopwatchTracker accesses a static managed class, so any direct calls to it is not Burstable anyways (˘・_・˘). I get around this by basically turning the calls to StopwatchTracker into Jobs. Even if the Jobs cannot be bursted, the context in which they are called can be, thus maintaining the integrity of OnUpdate. The jobs themselves can be defined as:
/// <summary>
/// A job that initiates a StopwatchRecorder.
/// </summary>
public partial struct BeginProfilingJob : IJob
{
[ReadOnly] public NativeText.ReadOnly recorderName;
public void Execute()
{
StopwatchRecorder recorder = StopwatchTracker.GetRecorder(recorderName.ConvertToString());
recorder.Restart();
}
}
/// <summary>
/// A job that stops and logs a StopwatchRecorder.
/// </summary>
public partial struct EndProfilingJob : IJob
{
[ReadOnly] public NativeText.ReadOnly recorderName;
public void Execute()
{
StopwatchRecorder recorder = StopwatchTracker.GetRecorder(recorderName.ConvertToString());
recorder.LogTime();
recorder.Stop();
}
}
And the procedure for profiling a section of code can look like:
// An example system that we want to profile
public partial struct BulletMovementSystem : ISystem {
// ...
private NativeText.ReadOnly profilerKey;
private void OnCreate(ref SystemState state) { // ...
}
private void OnDestroy(ref SystemState state) { // ...
}
[BurstCompile]
private void OnUpdate(ref SystemState state) {
// Start measuring
new BeginProfilingJob {
recorderName = profilerKey
}.Run();
// Place my logic here...
// Ex. Running a job...
new BulletMoveJob {
Timepass = 0.01667f
}.Run();
// Stop measuring
new EndProfilingJob {
recorderName = profilerKey
}.Run();
}
}
Problem 2 – Jobs:
So far so good, but at the moment, we are also limited by jobs or operations that run on the main thread. We don’t want the EndProfilingJob to execute before all the internal logic has completed. This is especially true for jobs scheduled on other threads with Schedule or ScheduleParallel. Fortunately, our job-sandwiching with the profiling jobs actually works in our favor here since we can fiddle with the dependencies to make sure the profiling and logic code at least execute in the correct order:
// An example system that we want to profile
public partial struct BulletMovementSystem : ISystem {
// ...
private NativeText.ReadOnly profilerKey;
private void OnCreate(ref SystemState state) { // ...
}
private void OnDestroy(ref SystemState state) { // ...
}
[BurstCompile]
private void OnUpdate(ref SystemState state) {
// Schedule measurement to start
JobHandle beginProfilingJob = new BeginProfilingJob {
recorderName = profilerKey
}.Schedule(state.Dependency);
// Schedule our logic sometime after we
// start measuring our time.
JobHandle logicHandle = new BulletMoveJob {
Timepass = 0.01667f
}.Schedule(beginProfilingJob);
// Schedule measurement to stop
// after logicHandle has completed
new EndProfilingJob {
recorderName = profilerKey
}.Schedule(logicHandle);
// We want to make sure this system's dependency
// is based on the logic of the system,
// not the measurement of the system.
state.Dependency = logicHandle;
}
}
What does all this mean? Well, it means that I just need some way to do most of this automatically. If there are multiple jobs in a system and many systems that I want to measure, then I definitely don’t want to be going through every one setting up profiler keys and sandwiching profiling jobs around all my logic jobs.
GOALS:
- Minimal setup/teardown of profiling logic.
- Easy to use.
- Allow profiling from an ISystem’s Bursted OnUpdate method.
- Implicitly and automatically measure any IJobEntity using dependencies.
With all this in mind, let’s get started on my attempts at tackling this.
ATTEMPT 1 – Generic Helpers:
The most obvious solution to me at the time was generic helper methods. Just make a static helper class, slap 3 generic static methods in there (for Run, Schedule, and ScheduleParallel) and everything should be hunky-dory. It’s as simple as:
[BurstCompile]
public static class StopwatchJobHelpers {
// Execute a logic job with Run.
// Measure using profiling jobs.
[BurstCompile]
public static JobHandle Profile_Run<U>(U job, JobHandle dependencies, NativeText.ReadOnly key) where U : unmanaged, IJobEntity{
new BeginProfilingJob{
recorderName = key
}.Run();
job.Run();
new EndProfilingJob{
recorderName = key
}.Run();
return dependencies;
}
// Execute a logic job with Schedule.
// Measure using profiling jobs scheduled
// around the logic job.
// Return the logic job handle.
[BurstCompile]
public static JobHandle Profile_Schedule<U>(U job, JobHandle dependencies, NativeText.ReadOnly key) where U : unmanaged, IJobEntity{
JobHandle startHandle = new BeginProfilingJob{
recorderName = key
}.Schedule(dependencies);
JobHandle logicHandle = job.Schedule(startHandle);
return new EndProfilingJob{
recorderName = key
}.Schedule(logicHandle);
return logicHandle;
}
// Execute a logic job with ScheduleParallel.
// Measure using profiling jobs scheduled
// around the logic job.
// Return the logic job handle.
[BurstCompile]
public static JobHandle Profile_ScheduleParallel<U>(U job, JobHandle dependencies, NativeText.ReadOnly key) where U : unmanaged, IJobEntity{
JobHandle startHandle = new BeginProfilingJob{
recorderName = key
}.Schedule(dependencies);
JobHandle logicHandle = job.ScheduleParallel(startHandle);
return new EndProfilingJob{
recorderName = key
}.Schedule(logicHandle);
return logicHandle;
}
}
And invoke it as:
// An example system that we want to profile
public partial struct BulletMovementSystem : ISystem {
// Profiler key setup is still the same...
private NativeText.ReadOnly profilerKey;
private void OnCreate(ref SystemState state) { // ...
}
private void OnDestroy(ref SystemState state) { // ...
}
[BurstCompile]
private void OnUpdate(ref SystemState state) {
// ScheduleParallel with static helper
BulletMoveJob logicJob = new BulletMoveJob {
Timepass = 0.01667f
};
state.Dependency = StopwatchJobHelpers.Profile_ScheduleParallel<BulletMoveJob>(logicJob, state.Dependency, profilerKey);
}
}
I couldn’t just pass in an IJobEntity instead of generic U because IJobEntity is nullable, which doesn’t play nice with Burst here. This seemed to be my only real option while testing generic helpers here.
The story could have ended here if things were easy, but of course none of this ever ends up being easy. See, while this code would compile in Unity, runtime was a different story. While testing in the editor, I was immediately hit with exceptions claiming that some thing had been replaced with source-generated code that could not be found. This was news to me and the first time I had really heard about C# source generation, let alone learned that it was actively being used here.
ATTEMPT 2 – Naive Generation:
So the problem was some source-generated code- something that I didn’t really have any control over as far as I could tell. After some days and this really helpful introduction, I found out how to perform some simple source generation: a cool new tool to play with! I tried it out immediately by basically just copy-pasting the code similar to the static helpers right into the system type:
// New attribute marks this system for profiler source generation
[JobProfiler(typeof(BulletMoveJob), "Update/Bullet/Movement")]
public partial struct BulletMovementSystem : ISystem {
private void OnCreate(ref SystemState state) {
// automatically generates profiler key fields for each JobProfiler attribute.
SetupJobProfilers(ref state);
}
private void OnDestroy(ref SystemState state) {
// automatically disposes profiler key fields for each JobProfiler attribute.
DisposeJobProfilers();
}
private void OnUpdate(ref SystemState state) {
BulletMoveJob logicJob = new BulletMoveJob {
Timepass = 0.01667f
};
// No longer call the static helper class.
// Use the instance method instead.
state.Dependency = Profiler_ScheduleParallel(logicJob, state.Dependency, profilerKey_BulletMoveJob);
}
// AUTO-GENERATED
// Auto-generated in another file using 'partial'...
private JobHandle Profiler_ScheduleParallel(BulletMoveJob job, JobHandle dependencies, ref SystemState state) {
JobHandle startHandle = new BeginProfilingJob{
recorderName = key
}.Schedule(dependencies);
JobHandle logicHandle = job.ScheduleParallel(startHandle);
return new EndProfilingJob{
recorderName = key
}.Schedule(logicHandle);
return logicHandle;
}
}
Fantastic! The only problem with this is that it didn’t work. No compiler errors were thrown when I tabbed back in, but I kept getting similar exceptions as before. Some kind of source generated method was STILL not being called or didn’t exist in the first place.
ATTEMPT 3 – DOTSCompilerPatchedMethod?:
This required more digging. With a bit more time (and this video), I found out how to show Unity’s output source gen’d files, too. With all that in hand, I first went about inspecting some of the generated code associated with the “manual” way of doing things. For instance, when I went to schedule one of my logic jobs, it ended up looking like this:
// Translated from:
// state.Dependency = new BulletMoveJob {
// Timepass = tickPeriod
// }.ScheduleParallel(state.Dependency);
state.Dependency = __ScheduleViaJobChunkExtension_0(
new BulletMoveJob
{
Timepass = tickPeriod
},
__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery,
state.Dependency,
ref state,
false
);
// BulletMovement.cs
public partial struct BulletMovementSystem : ISystem {
[BurstCompile]
public JobHandle CustomTestParallel(BulletMoveJob job, JobHandle dependencies, NativeText.ReadOnly key, ref SystemState state) {
JobHandle startHandle = new BeginProfilingJob{
recorderName = key
}.Schedule(dependencies);
JobHandle logicHandle = job.ScheduleParallel(startHandle);
new EndProfilingJob{
recorderName = key
}.Schedule(logicHandle);
return logicHandle;
}
}
// AUTO-GENERATED ********************************************************
// BulletMovement.g.cs (generated file, slightly formatted)
[global::System.Runtime.CompilerServices.CompilerGenerated]
public partial struct BulletMovementSystem : global::Unity.Entities.ISystemCompilerGenerated {
// ...
[global::Unity.Entities.DOTSCompilerPatchedMethod("CustomTestParallel_T0_BulletHell.Movement.BulletMoveJob_Unity.Jobs.JobHandle_Unity.Collections.NativeText/ReadOnly_ref_Unity.Entities.SystemState&")]
JobHandle __CustomTestParallel_3947B63A(
BulletMoveJob job,
JobHandle dependencies,
NativeText.ReadOnly key,
ref SystemState state)
{
#line 218 "<path-to-project-file>/BulletMovement.cs"
JobHandle startHandle = new BeginProfilingJob{
recorderName = key
}.Schedule(dependencies);
#line 221 "<path-to-project-file>/BulletMovement.cs"
JobHandle logicHandle = __ScheduleViaJobChunkExtension_3(
job,
__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery,
startHandle,
ref state,
false
);
#line 222 "<path-to-project-file>/BulletMovement.cs"
new EndProfilingJob{
recorderName = key
}.Schedule(logicHandle);
#line 225 "<path-to-project-file>/BulletMovement.cs"
return logicHandle;
#line hidden
}
}
Umm. Okay? What is __ScheduleViaJobChunkExtension_0? Where does __TypeHandle come from? What in oblivion is __BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery? Also, why wasn’t this automatically happening to the code that I auto-generated (e.g. Profiler_ScheduleParallel)? The answer to that is that I am kinda stupid. It turns out that all the source generators function independently from one another, so I can’t take the output from my source generator and feed it into Unity’s source generators. That is to say: I would need to REDO everything that Unity’s source generation ALREADY DOES by itself to get the true new source-generated code that I needed.
That sounds like a massive pain, but there are some clues that could let me spoof, copy, and generally fake my way to a working generated product. The first thing I noticed is that it isn’t as bad as it seemed at first glance. The profiling job calls didn’t change at all. They both still call Run or Schedule in Unity’s version of the generated code, so I don’t need any modifications there.
The second thing I looked at was the method attribute, DOTSCompilerPatchedMethod. Of course, I couldn’t find anything about this anywhere, but I know that it exists, and I can (almost) decode the argument input string. The string "CustomTestParallel_T0_BulletHell.Movement.BulletMoveJob_Unity.Jobs.JobHandle_Unity.Collections.NativeText/ReadOnly_ref_Unity.Entities.SystemState&"
can be translated. First, it starts with CustomTestParallel, the name of the test function being translated/generated. We follow this with… T0? Still not sure what this is. Hopefully I can just keep it as T0. The rest is just the full type name of each input argument separated by underscores. All this to say, I can translate my original generated function into a string like this by feeding in the function name and the full type path of the job type I am profiling.
Our reference to __TypeHandle later on is a similar story. We can just compose our access as:
__{jobTypeFullPath}_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery
I think this is safe to do as long as you do not intend to pass a custom entity query into the job while scheduling.
Last but not least is our call to __ScheduleViaJobChunkExtension_3. There were a few other similar calls in the test file, too, for 0 through 2. Looking at the Unity DOTS source generation later showed that this was just supposed to be a unique ID number, so as long as my custom generated ones don’t match/conflict with the ones Unity produces, then I should be good. I simply use an incrementing ID starting at 256 to make my calls to __ScheduleViaJobChunkExtension_ID methods.
At this point, my source generation used the JobProfiler attribute to generate 2 different methods for profiling a specific job type:
// BulletMovement.cs
[JobProfiler(typeof(BulletMoveJob), "Update/Bullet/Movement")]
public partial struct BulletMovementSystem : ISystem
{
private void OnUpdate(ref SystemState state) {
// ...
BulletMoveJob job = new BulletMoveJob
{
Timepass = tickPeriod
};
state.Dependency = Profiler_ScheduleParallel(job, profilerKey_BulletMoveJob, ref state);
}
}
// From this, I generate...
// ||
// \/
// AUTO-GENERATED ********************************************************
// BulletMovement.g.cs
public partial struct BulletMovementSystem : ISystem
{
// We generate this method as an entrypoint for
// interacting with the profiler. E.g. we would
// call this method with Profiler_ScheduleParallel
// in BulletMovementSystem's OnUpdate method.
[BurstCompile]
private JobHandle Profiler_ScheduleParallel(BulletMoveJob job, NativeText.ReadOnly profilerKey, JobHandle dependencies, JobProfilerSettingsComponent profilerSettings, ref SystemState state)
{
// If job profiling is active, then schedule stopwatch jobs
// around the logic job
if (profilerSettings.Enabled) {
JobHandle profileHandle = new BulletHell.Debugging.StartMeasureJob{
recorderName = profilerKey
}.Schedule(dependencies);
JobHandle logicHandle = job.ScheduleParallel(profileHandle);
new BulletHell.Debugging.StopMeasureJob{
recorderName = profilerKey
}.Schedule(logicHandle);
return logicHandle;
}
// Otherwise, just schedule the logic job
else {
JobHandle logicHandle = job.ScheduleParallel(dependencies);
return logicHandle;
}
}
// Another method that corresponds to Profiler_ScheduleParallel.
// This is what Unity SHOULD generate from Profiler_Schedule_Parallel.
[global::Unity.Entities.DOTSCompilerPatchedMethod("Profiler_ScheduleParallel_T0_BulletHell.Movement.BulletMoveJob_Unity.Collections.NativeText/ReadOnly_Unity.Jobs.JobHandle_Orcsune.Core.Generators.JobProfilerSettingsComponent_ref_Unity.Entities.SystemState&")]
private JobHandle Profiler_ScheduleParallel_54D4A97E(BulletMoveJob job, NativeText.ReadOnly profilerKey, JobHandle dependencies, JobProfilerSettingsComponent profilerSettings, ref SystemState state)
{
// Check a Profiler settings component that
// I won't really talk about here...
// If profiling is enabled, do the job sandwiching
if (profilerSettings.Enabled) {
// same
JobHandle startHandle = new BeginProfilingJob{
recorderName = profilerKey
}.Schedule(dependencies);
// Call a __ScheduleViaJobChunkExtension_259 method (we'll get to this).
JobHandle logicHandle = __ScheduleViaJobChunkExtension_259(
job,
__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery,
startHandle,
ref state,
false
);
// same
new EndProfilingJob{
recorderName = profilerKey
}.Schedule(logicHandle);
return logicHandle;
}
// Otherwise, just schedule the logic job
else {
JobHandle logicHandle = __ScheduleViaJobChunkExtension_259(job, __TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.DefaultQuery, dependencies, ref state, false);
return logicHandle;
}
}
}
I’ve glossed over this until now, but we do also need to generate the __ScheduleViaJobChunkExtension_X methods. To gloss over this even more, I copied and pasted a bunch of the Unity DOTS source generation code into my source generator so that I could set up the necessary structures that they use for creating __ScheduleViaJobChunkExtension_X methods. This turned out to be the best decision I made. I solved multiple problems by searching their source generation code for the types, data structures, and methods called and copying and modifying the relevant code for my purposes. This time, it boiled down to finding code relevant to JobEntityModule.JobEntityInstanceInfo.SchedulingMethodWriter. Of course, there were a bunch of dependencies and I had to modify my incremental source generator to properly construct and call its WriteTo method, but all in all, my plan worked and I was left with:
// AUTO-GENERATED ********************************************************
// BulletMovement.g.cs
public partial struct BulletMovementSystem : ISystem
{
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
global::Unity.Jobs.JobHandle __ScheduleViaJobChunkExtension_259(global::BulletHell.Movement.BulletMoveJob job, global::Unity.Entities.EntityQuery query, global::Unity.Jobs.JobHandle dependency, ref global::Unity.Entities.SystemState state, bool hasUserDefinedQuery)
{
global::BulletHell.Movement.BulletMoveJob.InternalCompiler.CheckForErrors(2);
if (Unity.Burst.CompilerServices.Hint.Unlikely(hasUserDefinedQuery))
{
int requiredComponentCount = global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.GetRequiredComponentTypeCount();
global::System.Span<Unity.Entities.ComponentType> requiredComponentTypes = stackalloc Unity.Entities.ComponentType[requiredComponentCount];
global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.AddRequiredComponentTypes(ref requiredComponentTypes);
if (!global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.QueryHasRequiredComponentsForExecuteMethodToRun(ref query, ref requiredComponentTypes))
{
throw new global::System.InvalidOperationException(
"When scheduling an instance of `global::BulletHell.Movement.BulletMoveJob` with a custom query, the query must (at the very minimum) contain all the components required for `global::BulletHell.Movement.BulletMoveJob.Execute()` to run.");
}
}
dependency =
__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.UpdateBaseEntityIndexArray(ref job, query,
dependency, ref state);__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.AssignEntityManager(ref job, state.EntityManager);
__TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.__TypeHandle.Update(ref state);
return __TypeHandle.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.ScheduleParallel(ref job, query, dependency);
}
}
ATTEMPT 4 – Getting a Handle on TypeHandle:
Wait, why isn’t the post over? I thought everything worked. No. It doesn’t. See those calls to fields in __TypeHandle? The TypeHandle struct is created for the ISystem and it seems to register all the unique jobs being run in the system according to Unity’s source generation. Here’s the problem, Unity can’t see us scheduling any logic jobs in our source code because we are just calling something like Profiler_ScheduleParallel. Our own source generation creates the code that schedules the jobs; therefore, when Unity generates the TypeHandle struct, it does not include information for things like our BulletMoveJob unless we schedule a BulletMoveJob somewhere Unity can see it.
Oh no. Am I going to have to find and modify the code that generates the TypeHandle struct? Yes. And it’s even worse because Unity does not generate it with the ‘partial’ keyword, meaning I can’t just extend it with new code. Since source generation doesn’t let you modify existing code, my only choice was to generate a new TypeHandle struct that does EXACTLY THE SAME THING as TypeHandle, it just allows me to register jobs from my own custom source generation.
This is the genesis of TypeHandleCustom- a new struct same as the old struct, just with the word “Custom” tacked on the end. Making the TypeHandleCustom struct is the responsibility of the QueriesAndHandles struct, and involved me finding all the MemberDeclarationSyntax items in my system type and passing them to QueriesAndHandles to output the required generated TypeHandleCustom struct string. I also had to further modify the SchedulingMethodWriter to call __TypeHandleCustom instead of __TypeHandle, further complicating things. That gave me:
// AUTO-GENERATED ********************************************************
// BulletMovement.g.cs
public partial struct BulletMovementSystem : ISystem
{
// New instance of TypeHandleCustom, __TypeHandleCustom
TypeHandleCustom __TypeHandleCustom;
struct TypeHandleCustom
{
// This BulletMoveJob field shows up because it was registered in my source generation code with:
// QueriesAndHandles.WriteCustomTypeHandleStruct(queriesAndHandlesIndentedWriter, new QueriesAndHandles[]{queriesAndHandles});
public global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData __BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle;
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public void __AssignHandles(ref global::Unity.Entities.SystemState state)
{
__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.Init(ref state, true);
}
}
// New ScheduleJobViaChunkExtension method uses new TypeHandleCustom
[global::System.Runtime.CompilerServices.MethodImpl(global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
global::Unity.Jobs.JobHandle __ScheduleViaJobChunkExtension_259(global::BulletHell.Movement.BulletMoveJob job, global::Unity.Entities.EntityQuery query, global::Unity.Jobs.JobHandle dependency, ref global::Unity.Entities.SystemState state, bool hasUserDefinedQuery)
{
// Same until the end
// ...
// Note how calls here use the __TypeHandleCustom instance
dependency =
__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.UpdateBaseEntityIndexArray(ref job, query,
dependency, ref state);__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.AssignEntityManager(ref job, state.EntityManager);
__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.__TypeHandle.Update(ref state);
return __TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.ScheduleParallel(ref job, query, dependency);
}
}
ATTEMPT 5 – Fixing the Build:
And wow! It actually didn’t break when I ran it in the Unity Editor’s play mode! It’s beautiful. I even tested it out with a few more systems (bullet acceleration and jerk jobs) and it still worked! With that all out of the way, the only thing left to do is test a build to make sure the stopwatches are still acting right and… oh… What the hell is this?:

Exceptions showing that the compiler is unable to find the GetRequiredComponentTypeCount, AddRequiredComponentTypes, and QueryHasRequiredComponentsForExecuteMethodToRun methods in our BulletMoveJob.InternalCompilerQueryAndHandleData struct.
We seem to be getting errors from BulletMoveJob.InternalCompilerQueryAndHandleData. Here’re the problems, though. First, things work in Unity’s play mode during testing, so why should this same code fail in the build pipeline? Second, it’s incredibly strange that all the failing references are to static methods. These methods should be tied to the InternalCompilerQueryAndHandleData type. This is so strange because the compiler is not complaining that the InternalCompilerQueryAndHandleData type doesn’t exist, rather the compiler knows that this type exists and yet it does not know that these three static methods are implemented. I don’t even understand how this could be the case, but being static methods (should) give one saving grace- I (shouldn’t) need any kind of struct instance to properly extract this from the static methods. Of course, this didn’t end up being true, but it was a nice thought. Let’s tackle them one at a time:
GetRequiredComponentTypeCount:
public static int GetRequiredComponentTypeCount() => {m_ComponentTypesInExecuteMethod.Count}{m_AspectTypesInExecuteMethod.Select(a => $" + {a.TypeSymbol.ToFullName()}.GetRequiredComponentTypeCount()").SeparateBy("")};
We’re off to a terrible start. Immediately, we need to use 2 local instance fields, m_ComponentTypesInExecuteMethod and m_AspectTypesInExecuteMethod. This function and these fields come from the JobEntityDescription class which I also needed to copy, modify, and populate in my source generator.
- AddRequiredComponentTypes: This one is even grosser, but fortunately it uses the same local fields as GetRequiredComponentTypeCount, so there shouldn’t be any curveballs associated with it.
- QueryHasRequiredComponentsForExecuteMethodToRun: This was the simplest of the bunch. It outputs a straight-up string with no modifications, variables, or anything. Easy-peasy.
With all this knowledge, I could solve my compiler problem. If the compiler was having issues with calling some static function somewhere, I decided to just skip the whole charade and substitute the method calls with the contents of the calls themselves. For example, instead of returning a string that calls GetRequiredComponentTypeCount, I would just return the would-be result string of that method call. For example, I would generate a string of the number “2” instead of a call to the static method. This resulted in a final version of my __ScheduleViaJobChunkExtension_X source generation:
global::Unity.Jobs.JobHandle __ScheduleViaJobChunkExtension_259(global::BulletHell.Movement.BulletMoveJob job, global::Unity.Entities.EntityQuery query, global::Unity.Jobs.JobHandle dependency, ref global::Unity.Entities.SystemState state, bool hasUserDefinedQuery)
{
global::BulletHell.Movement.BulletMoveJob.InternalCompiler.CheckForErrors(2);
if (Unity.Burst.CompilerServices.Hint.Unlikely(hasUserDefinedQuery))
{
// This line used to be:
// int requiredComponentCount = global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.GetRequiredComponentTypeCount();
int requiredComponentCount = 1;
global::System.Span<Unity.Entities.ComponentType> requiredComponentTypes = stackalloc Unity.Entities.ComponentType[requiredComponentCount];
// This line used to be:
// global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.AddRequiredComponentTypes(ref requiredComponentTypes);
requiredComponentTypes[0] = Unity.Entities.ComponentType.ReadWrite<global::BulletHell.Movement.BulletMovementComponent>();
// These lines used to be:
// if (!global::BulletHell.Movement.BulletMoveJob.InternalCompilerQueryAndHandleData.QueryHasRequiredComponentsForExecuteMethodToRun(ref query, ref requiredComponentTypes)) {
bool queryHasRequiredComponentsForExecuteMethodToRun = global::Unity.Entities.Internal.InternalCompilerInterface.EntityQueryInterface.HasComponentsRequiredForExecuteMethodToRun(ref query, ref requiredComponentTypes);
if (queryHasRequiredComponentsForExecuteMethodToRun)
{
throw new global::System.InvalidOperationException(
"When scheduling an instance of `global::BulletHell.Movement.BulletMoveJob` with a custom query, the query must (at the very minimum) contain all the components required for `global::BulletHell.Movement.BulletMoveJob.Execute()` to run.");
}
}
dependency =
__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.UpdateBaseEntityIndexArray(ref job, query,
dependency, ref state);__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.AssignEntityManager(ref job, state.EntityManager);
__TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.__TypeHandle.Update(ref state);
return __TypeHandleCustom.__BulletHell_Movement_BulletMoveJob_WithDefaultQuery_JobEntityTypeHandle.ScheduleParallel(ref job, query, dependency);
}
Finally. FINALLY it WORKS. It runs in the editor and it also doesn’t break in a build. It works. All that just so I can measure how long things take to run in-game.

Timings for various jobs/systems. The scale on the left is in milliseconds of runtime. Tests had about 80,000 bullets. Left: Red=Firing point logic, Cyan=Sprite rendering logic, Black=Combined bullet movement logic. Right: Black=Combined bullet movement logic, Green=Bullet position update, Blue=Bullet acceleration update, Red=Bullet jerk update. Notice how the total (black) adds up from all the sub-parts.
LIMITATIONS:
Despite how much I would like it to be, this solution is not perfect. There are a few areas that I would like to improve, but will save for another time:
- Can no longer Burst OnCreate or OnDestroy. This is hopefully fine because these are setup/teardown methods that shouldn’t hurt performance for the majority of play time.
- LOTS of new methods. I don’t know if this would cause a slowdown if many of them are not needed, especially because some of them link in with the ILPostProcessor, which could account for some slowness compilations but I’m not sure. I also add some additional methods with other signatures for more common use cases. There are about 15 methods being generated behind the scenes for every job type you want to profile.
- Limited to profiling a single job at a time in Bursted context. The drawbacks of this are minimal. We can always get the runtime of the entire group of jobs by just querying the StopwatchGroup for the group of jobs.
- Limited job calls. It would be good to add support for jobs with custom input queries in the future. Right now it uses a default query.
CONCLUSION:
I can call Profiler_Run, Profiler_Schedule, and Profiler_ScheduleParallel on IJobEntity jobs in Bursted ISystem OnUpdate methods now :). It also isn’t a massive pain to set up or tear down depending on which jobs I want to measure.
UPDATES:
- Feb. 1, 2025 – New Job type support and general utilities added in this update: Job Profiler Improvements – BB – Update 10. IJobEntity, IJobChunk, IJob, ITriggerEventsJob all supported. Extended IJobEntity support to allow custom input EntityQuery.