Search Unity

Topics covered
Share

The Unity Burst Compiler transforms your C# code into highly optimized machine code. One question that we get often from our amazing forum users like @dreamingimlatios surrounds in parameters to functions within Burst code. Should developers use them and where? We’ve put together this post to try and explain them a bit more in detail.

What are in parameters

C# 7.2 introduced in parameter modifiers as a way to pass something by reference to a function where the called function is not allowed to modify the data.

int Foo(in int a, ref int b)
{
    a = 42; // This would be a compiler error!
    b = a; // This is fine because b is passed by reference.
    return a;
}

In parameters are a really useful language concept because it enforces a contract between the developer and the compiler as to how data will be used and modified. The in parameter modifier allows arguments to be passed by reference where the called function is not allowed to modify the data. It pairs up with the out parameter modifier (where parameters must be modified by the function) and the ref parameter modifier (where parameter values may be modified).

Indirect arguments and the ABI

Let's look at the following simple job:

[BurstCompile]
public struct MyJob : IJob
{
    	public struct SomeStruct
    	{
        	public float3 Position;
        	public float4x4 Rotation;
    	}

    	public SomeStruct InDataA;
    	public SomeStruct InDataB;
    	public float3 OutData;

    	[MethodImpl(MethodImplOptions.NoInlining)]
    	private float3 DoSomething(SomeStruct a, SomeStruct b)
   	 {
        	return math.rotate(a.Rotation, a.Position) +
                math.rotate(b.Rotation, b.Position);
   	 }

    	public unsafe void Execute()
    	{
        	OutData = DoSomething(InDataA, InDataB);
    	}
}

The above code can be broken down into:

  •  Call the DoSomething method which takes two structs passed by value.
  •  It performs some operation on the data, then returns the result (the operation doesn’t really matter for the purposes of this demo).
  •  Note that we’ve placed [MethodImpl(MethodImplOptions.NoInlining)] on the DoSomething method. We do this for two reasons:
    • It lets us pinpoint the specific method in the resulting assembly using the Burst Inspector.
    • It lets us simulate what would happen if the DoSomething method was really a very large function that Burst would not have inlined anyway. 

Now if we pull up the Burst Inspector, we can begin to dive into what is actually produced by the compiler for the above code:

Note the assembly we’ve highlighted in the red box - this is the number of bytes of stack required by the function. And now the Execute method itself:

And again note the highlighted red rectangle region - this is doing a bunch of copies between some memory address in the register rax, and the stack in rsp. So why is it doing this you might ask?

Welcome to the wonderful world of ABI - Application Binary Interface. Aeons and aeons ago when computers were bigger than most modern houses, some smart computer people realised that if two different people were going to write programs such that code from both of these people could be used together - they’d have to agree on the rules for doing that.

When data is passed from a caller to a callee, using a function, the compiler has to agree where function parameters are located so that the caller knows where to put the data, and the callee knows where to retrieve the data from.

Passing data from one function to another has a set of rules that the caller and callee have to both understand so that they can make sense of the right data in the right location. The rules in this case are called calling conventions, and there are lots of weird and wonderful varieties. Each operating system tends to have a different convention, some operating systems have multiple, but what is important is that both sides follow the same rules and not behave in a way you didn’t expect!

Most calling conventions allow simple data (primitive types or small structs) to be passed by value and in registers - the most efficient way to pass data. But large structs, anything more than 16 bytes in size or so, will generally have to be passed indirectly. If we look again at the simple job we showed above, we’ve now modified it to show you what the compiler has had to do to the code to conform to the ABI:

[BurstCompile]
public struct MyJob : IJob
{
    	public struct SomeStruct
    	{
        	public float3 Position;
        	public float4x4 Rotation;
    	}

    	public SomeStruct InDataA;
    	public SomeStruct InDataB;
    	public float3 OutData;

    	[MethodImpl(MethodImplOptions.NoInlining)]
    	private float3 DoSomething(ref SomeStruct a, ref SomeStruct b)
   	 {
        	return math.rotate(a.Rotation, a.Position) +
                math.rotate(b.Rotation, b.Position);
   	 }

    	public unsafe void Execute()
    	{
                var InDataACopy = InDataA;
                var InDataBCopy = InDataB;
        	OutData = DoSomething(ref InDataACopy, ref InDataBCopy);
    	}
}

So the compiler has:

  •  Changed the arguments a and b to the ‘DoSomething’ function to be passed by reference instead.
  •  Added two new local variables InDataACopy and InDataBCopy in the Execute method.
  •  It has to take a copy of the data from InDataA and InDataB into these variables.
  •  Then call the DoSomething function passing these local variables by reference.

Now if we look again at the Burst Inspector output:

November 25, 2020 in Technology | 11 min. read
Topics covered