Unity 2019.3 and Burst 1.2 bring us support for function pointers! Behind the scenes, these power everyday C# functionality like virtual and abstract functions, delegates, and interfaces. Today we’ll look at how to use them and what Burst compiles them to.

Until now, all function calls made in a Burst-compiled job needed to be direct. That is, the Burst compiler needed to be able to determine which function was going to be executed by the function call at compile time. This meant that C# features like interfaces, delegates, and virtual functions weren’t available to us when using Burst.

Now we have a low-level tool to regain some of that functionality. We start by declaring a static class with [BurstCompile] on it:

[BurstCompile]
public static class BinaryFunctions
{
}

Then we add static functions that also have [BurstCompile] on them to that class:

[BurstCompile]
public static class BinaryFunctions
{
    [BurstCompile]
    public static float Divide(float a, float b)
    {
        return a / b;
    }
}

The same restrictions apply to these functions as with jobs. We can’t use classes, interfaces, delegates, etc. The Burst compiler still doesn’t support these and possibly never will.

Now that we have this function, we can open the Burst Inspector and see what it compiled to. Just look under Compile Targets on the left side for BinaryFunctions.Divide(float, float). On the right side, checking Enhanced Disassembly now shows syntax-highlighted code. Unchecking Safety Checks, Unity 2019.3.3f1 and Burst 1.2.3 shows the following under the body of the function which is labeled === TestScript.cs (16, 16)    }:

divss  xmm0, xmm1
ret

This is what we should expect: a single division instruction followed by returning the quotient to the caller.

We can obviously call this function directly, but we really want to get a pointer to this function so we can call it indirectly. To do that, we first need to declare a delegate type that the function conforms to:

delegate float BinaryFunction(float a, float b);

Now we use a new function and a new type to create the function pointer:

FunctionPointer<BinaryFunction> fp =
    BurstCompiler.CompileFunctionPointer<BinaryFunction>(
        BinaryFunctions.Divide);

BurstCompiler.CompileFunctionPointer is a generic function whose type parameter is a delegate of the function to compile. It returns a FunctionPointer&lt;Del&gt; where Del is that same delegate. FunctionPointer is a struct that just contains the pointer (memory address) of the Burst-compiled function. Here’s what it looks like:

/// <summary>
/// Base interface for a function pointer.
/// </summary>
public interface IFunctionPointer
{
    /// <summary>
    /// Converts a pointer to a function pointer.
    /// </summary>
    /// <param name="ptr">The native pointer.</param>
    /// <returns>An instance of this interface.</returns>
    IFunctionPointer FromIntPtr(IntPtr ptr);
}
 
/// <summary>
/// A function pointer that can be used from a Burst Job or from regular C#.
/// It needs to be compiled through <see cref="BurstCompiler.CompileFunctionPointer{T}"/>
/// </summary>
/// <typeparam name="T">Type of the delegate of this function pointer</typeparam>
public struct FunctionPointer<T> : IFunctionPointer
{
    [NativeDisableUnsafePtrRestriction]
    private readonly IntPtr _ptr;
 
    /// <summary>
    /// Creates a new instance of this function pointer with the following native pointer.
    /// </summary>
    /// <param name="ptr"></param>
    public FunctionPointer(IntPtr ptr)
    {
        _ptr = ptr;
    }
 
    /// <summary>
    /// Gets the delegate associated to this function pointer in order to call the function pointer.
    /// This delegate can be called from a Burst Job or from regular C#.
    /// If calling from regular C#, it is recommended to cache the returned delegate of this property
    /// instead of using this property every time you need to call the delegate.
    /// </summary>
    public T Invoke => (T)(object)Marshal.GetDelegateForFunctionPointer(_ptr, typeof(T));
 
    IFunctionPointer IFunctionPointer.FromIntPtr(IntPtr ptr)
    {
        return new FunctionPointer<T>(ptr);
    }
}

Note that there’s no safety net when directly calling the constructor. Passing IntPtr.Zero and then calling the function pointer will crash the game or the editor!

The Invoke property is the next step toward calling it. It calls Marshal.GetDelegateForFunctionPointer which returns a delegate that, when invoked, calls the Burst-compiled function:

// Get the delegate that calls the Burst-compiled function
BinaryFunction binFunc = fp.Invoke;
 
// Call the delegate
float result = binFunc(6, 3); // result = 2

In non-Burst code, storing the delegate returned by Invoke rather than calling it over and over to create more and more delegates will really reduce the amount of garbage created. In Burst-compiled code, it doesn’t matter as we’re about to see.

Let’s create a Burst-compiled job that calls a function pointer. This job takes two floats, passes them to the function pointer, and stores the result in the first element of a NativeArray:

[BurstCompile]
struct FunctionPointerJob : IJob
{
    public FunctionPointer<BinaryFunction> BinaryFunctionPointer;
    public float A;
    public float B;
    public NativeArray<float> Sum;
 
    public void Execute()
    {
        Sum[0] = BinaryFunctionPointer.Invoke(A, B);
    }
}

Here’s how we’d run it:

class TestScript : MonoBehaviour
{
    void Start()
    {
        using (NativeArray<float> sum = new NativeArray<float>(1, Allocator.TempJob))
        {
            FunctionPointer<BinaryFunction> fp =
                BurstCompiler.CompileFunctionPointer<BinaryFunction>(
                    BinaryFunctions.Divide);
            new FunctionPointerJob
            {
                BinaryFunctionPointer = fp,
                A = 6,
                B = 3,
                Sum = sum
            }.Run();
            print(sum[0]);
        }
    }
}

As expected, this prints 2.

Now let’s look at FunctionPointerJob in Burst Inspector to see what Burst compiled it to. Here’s the important part with the function pointer call annotated in a comment:

push   rbx
mov    rbx, rdi
movss  xmm0, dword ptr [rdi + 8]
movss  xmm1, dword ptr [rdi + 12]
call   qword ptr [rdi]             ; <-- Function pointer call
mov    rax, qword ptr [rbx + 16]
movss  dword ptr [rax], xmm0
pop    rbx
ret

There are two main aspects of this to notice. First, the call instruction goes to a memory address (the function pointer) that isn’t a compile-time constant. Second, there is no divss instruction listed here. The divide is happening elswhere in the function that’s being called.

At this point we’ve seen how to compile a non-job function with Burst, create a pointer to it, and execute it from within a Burst-compiled job. We now have the foundation on which higher-level constructs such as jump tables and virtual functions can be built. While it’s not quite as nice to use as language-level features such as delegates, function pointers do give us a powerful new tool for when we need them.