[BUG] Burst compiler bug when modifying and returning struct value

Burst: 0.2.4-preview.39
Entities: 0.0.12-preview.21
Incremental Compiler: 0.0.42-preview.31
Unity Version: 2018.2.10f1

The following code does not produce correct result when burst is enabled:

    public struct Data : IComponentData
    {
        public MyVector Value1;
        public MyVector Value2;
    }

    public struct MyVector
    {
        public long X;
        public long Y;
        public long Z;
    }

    public static MyVector UpdateMyVector(MyVector value)
    {
        value = new MyVector
        {
            X = value.X + 5678,
        };
        return value;
    }

    public class TestBurstComponentSystem : JobComponentSystem
    {
        [BurstCompile]
        private struct Job : IJobProcessComponentData<Data>
        {
            public void Execute(ref Data data)
            {
                data.Value2 = UpdateMyVector(data.Value1);
            }
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            return new Job().Schedule(this, inputDeps);
        }
    }

The only part of interest is the UpdateMyVector method. It takes a MyVector struct as input and produces a MyVector struct as output. It’s expected to return a new struct with modified X value while Y, Z being 0. When burst compiler is disabled, it works as expected. However, when burst compiler is enabled, the method returns the original input value as if the assignment doesn’t exist.

I have put up a simple scene to reproduce this bug here:
https://github.com/xfxyjwf/burstbug

Open “Assets/Scenes/SampleScene.unity” and click play. At the center of the screen you are expected to see the number 5678, but when burst compiler is enabled you will see 0 instead. By toggling on/off burst jobs, you can see different values on the screen.

I have also observed that the bug only happens when the size of the struct is larger than 16 bytes. In the above example, if you remove the unused “Y”, “Z” field from MyVector struct, burst compiler will start to produce correct results.

More info from dnSpy and burst inspector:

Regardless the size of the MyVector struct, the IL instructions for UpdateMyVector (shown in dnSpy) are always :

0    0000    nop
1    0001    ldloca.s    V_0 (0)
2    0003    initobj    TestBurstBug/MyVector
3    0009    ldloca.s    V_0 (0)
4    000B    ldarga.s    value (0)
5    000D    ldfld    int64 TestBurstBug/MyVector::X
6    0012    ldc.i4    0x162E
7    0017    conv.i8
8    0018    add
9    0019    stfld    int64 TestBurstBug/MyVector::X
10    001E    ldloc.0
11    001F    starg.s    value (0)
12    0021    ldarg.0
13    0022    stloc.1
14    0023    br    15 (0028) ldloc.1
15    0028    ldloc.1
16    0029    ret

However, depending on the size of MyVector, the IL instructions show in burst compiler are different.

When sizeof(MyVector) <= 16:

TestBurstBug/MyVector .TestBurstBug, Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null::UpdateMyVector(.TestBurstBug+MyVector, Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null)(TestBurstBug/MyVector value)
[0] TestBurstBug/MyVector var.0.
[1] TestBurstBug/MyVector var.1.
[2] TestBurstBug/MyVector var.value <-- From parameter `value` due to instruction `IL_000b: ldarga.s value`

--------------------------------------------
BL.entry: Block
--------------------------------------------
IL_000b: ldarg value
IL_000b: stloc value args(IL_000b) var.value
IL_0000: br BL.0000

--------------------------------------------
BL.0000: Block
--------------------------------------------
IL_0001: ldloca.s V_0 var.0.
IL_0003: initobj TestBurstBug/MyVector args(IL_0001)
IL_0009: ldloca.s V_0 var.0.
IL_000b: ldloca value var.value
IL_000d: ldfld System.Int64 TestBurstBug/MyVector::X args(IL_000b)
IL_0012: ldc.i4 5678
IL_0017: conv.i8 args(IL_0012)
IL_0018: add args(IL_000d, IL_0017)
IL_0019: stfld System.Int64 TestBurstBug/MyVector::X args(IL_0009, IL_0018)
IL_001e: ldloc.0 var.0.
IL_001f: stloc value args(IL_001e) var.value
IL_0021: ldloc var.value
IL_0022: stloc args(IL_0021) var.1.
IL_0023: br BL.0028

--------------------------------------------
BL.0028: Block
--------------------------------------------
IL_0028: ldloc.1 var.1.
IL_0029: ret args(IL_0028)

When sizeof(MyVector) >= 16:

void .TestBurstBug, Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null::UpdateMyVector(.TestBurstBug+MyVector, Assembly-CSharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null)(TestBurstBug/MyVector* $___struct_ret, TestBurstBug/MyVector* value)
[0] TestBurstBug/MyVector var.0.
[1] TestBurstBug/MyVector var.1.
[2] TestBurstBug/MyVector var.value <-- From parameter `value` due to instruction `IL_000b: ldarga.s value`

--------------------------------------------
BL.entry: Block
--------------------------------------------
IL_000b: ldarg value
IL_000b: ldobj value args(IL_000b) <-- load of indirect argument
IL_000b: stloc value args(IL_000b) var.value
IL_0000: br BL.0000

--------------------------------------------
BL.0000: Block
--------------------------------------------
IL_0001: ldloca.s V_0 var.0.
IL_0003: initobj TestBurstBug/MyVector args(IL_0001)
IL_0009: ldloca.s V_0 var.0.
IL_000b: ldarg value <-- load of indirect argument address
IL_000d: ldfld System.Int64 TestBurstBug/MyVector::X args(IL_000b)
IL_0012: ldc.i4 5678
IL_0017: conv.i8 args(IL_0012)
IL_0018: add args(IL_000d, IL_0017)
IL_0019: stfld System.Int64 TestBurstBug/MyVector::X args(IL_0009, IL_0018)
IL_001e: ldloc.0 var.0.
IL_001f: stloc value args(IL_001e) var.value
IL_0021: ldarg.0
IL_0021: ldobj args(IL_0021) <-- load of indirect argument
IL_0022: stloc args(IL_0021) var.1.
IL_0023: br BL.0028

--------------------------------------------
BL.0028: Block
--------------------------------------------
IL_0028: ldloc.1 var.1.
IL_0029: ldarg.0
IL_0029: stobj args(IL_0029, IL_0028) <-- write result to caller allocated struct
IL_0029: ret

It seems to me the burst inspector is not showing the original IL instructions but instead a modified version. In both cases (sizeof(MyVector) <= 16 and sizeof(MyVector) > 16), the modified IL introduced a new local variable “var.value” to be used in place of the argument “value”. The difference between the two lies in instruction IL_0021. The instructions above IL_0021 are writing the newly created MyVector to “var.value”, but in one version, IL_0021 loads from “var.value”, but in the other version, IL_0021 loads from arg.0. The one loading from arg.0 ends up producing incorrect LLVM IR code returning the original input value rather than the modified one. I think the bug lies in the code that transforms original IL to the modified version.

This looks like a serious bug. Makes me wonder if any ECS app is in production

This issue has been fixed and the code behaves correctly with the latest Burst version.

2 Likes