目录
1.2.1.3 Unsupported C# features in HPC#
1.2.2 Static read-only fields and static constructor support
1.2.4.1 Using function pointers
1.2.6 C#/.NET System namespace support
1.2.7 DllImport and internal calls
2.1.4 Burst Intrinsics Common class
3.1.2 Burst Inspector window reference
5.1.1 Debugging and profiling tools
5.1.1.1 Profiling Burst-compiled code
5.1.3.2 Aliasing and the job system
5.1.5 Hint intrinsics
一:简介
- 不使用Burst的编译流程:
- 不使用IL2CPP:
- c#->Roslyn编译器编译成IL字节码->运行时通过Mono虚拟机转换成目标平台的机器码
- 使用IL2CPP:
- c#->Roslyn 编译器编译成IL字节码->IL2CPP编译器转换成C++代码->特定平台的编译器,编译成特定平台的机器码(绕过了mono虚拟机,增加了安全性,快捷性,裁剪了无用代码)
- 不使用IL2CPP:
- Burst是一个编译器,封装了LLVM编译器,把IL字节码,编译成优化后的机器码,它专注于优化那些通过Unity的Job System和ECS编写的高性能代码片段
- IL2CPP和Burst编译器可以并行工作:IL2CPP负责将项目中的大部分C#代码(包括Unity脚本、第三方库等)转换成本地机器代码,以便在不同平台上运行。
"本地机器码"通常指的就是CPU可以直接执行的指令,也被称为"CPU字节码"或简单地说是"机器码"。这些指令是针对特定CPU架构设计的,比如x86, ARM等,它们由一系列的二进制代码组成,这些代码可以直接被CPU解读和执行。 不同的CPU架构有不同的指令集,即它们能理解和执行的机器码指令集合。因此,将程序编译成本地机器码意味着它被转换成了特定CPU架构能够直接执行的指令序列。这是为什么同一个高级语言编写的程序(如C#或C++)需要为不同的目标平台(如Windows上的x86或Android上的ARM)分别编译的原因。
1.1 Getting started
Burst 主要用来和Unity's job system一起使用,为Job struct 添加[BurstCompile]属性, 或者给类型,静态方法,添加[BurstCompile]属性,标记改方法或者改类型,使用Burst编译器。
// Using BurstCompile to compile a Job with Burst
[BurstCompile]
private struct MyJob : IJob
{
[ReadOnly]
public NativeArray<float> Input;
[WriteOnly]
public NativeArray<float> Output;
public void Execute()
{
float result = 0.0f;
for (int i = 0; i < Input.Length; i++)
{
result += Input[i];
}
Output[0] = result;
}
}
1.2 C# language support
Burst 使用了C#的一个高性能子集,叫作High Performance C# (HPC#) ,它与c#有很多限制和区别
1.2.1 HPC# overview
HPC#支持c#中的大多数表达式和语句。它支持以下功能:
Supported feature | Notes |
---|---|
Extension methods. | 支持扩展方法 |
Instance methods of structs. | 支持结构体的实例方法 |
Unsafe code and pointer manipulation. | unsafe的code和指针 |
Loading from static read-only fields. | Static read-only fields and static constructors. |
Regular C# control flows. | if else、 switch case、 for while break continue |
ref and out parameters |
支持ref、out |
fixed |
支持fixed关键字,表示在fixed块被执行完之前,不能被垃圾回收,内存被固定 |
Some IL opcodes. | cpblk、 initblk、 sizeof |
DLLImport and internal calls. |
DLLImport and internal calls. |
|
Burst如果发生异常,和c#表现不同,c#会执行的finally块,burst不会执行到finally块,而是会抛出来
|
Strings、ProfilerMarker . |
Support for Unity Profiler markers. |
throw expressions. |
Burst 只支持简单的throw模式, 比如:
|
Strings and Debug.Log . |
String support and Debug.Log. |
Burst还提供了HPC#不能直接访问的C#方法的替代方案:
- Function pointers 替代委托
- Shared static 可以访问可以修改的静态字段
1.2.1.1 Exception expressions
Burst支持throw
exception.在editor下可以捕捉,在运行时,就会crash,所以要确保exception被捕捉到,通过给方法添加[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]捕捉异常,如果不添加,会有警告:
Burst warning BC1370: An exception was thrown from a function without the correct [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] guard. Exceptions only work in the editor and so should be protected by this guard
1.2.1.2 Foreach and While
Burst 对foreach、while 某种情况下不支持 - 采用一个或多个泛型集合参数的方法 T: IEnumerable<U>
不支持:
public static void IterateThroughConcreteCollection(NativeArray<int> list)
{
foreach (var element in list)
{
// This works
}
}
public static void IterateThroughGenericCollection<S>(S list) where S : struct, IEnumerable<int>
{
foreach (var element in list)
{
// This doesn't work
}
}
IterateThroughConcreteCollection()参数是一个确定的类型
NativeArray<int>
. IterateThroughGenericCollection()
参数是一个泛型参数,它的代码,不会被Burst编译器编译:
Can't call the method (method name) on the generic interface object type (object name). This may be because you are trying to do a foreach over a generic collection of type IEnumerable.
1.2.1.3 Unsupported C# features in HPC#
HPC# 不支持
try
/catch的catch
- 存储到静态字段,或者使用 Shared Static
- 任何关于托管对象的方法, for example, string methods.
1.2.2 Static read-only fields and static constructor support
不支持静态的非只读的数据,因为只读的静态数据,在编译时,就会替换了,如果编译失败,就会替换成默认值
1.2.3 String support
Burst支持下面几种string的用法:
- Debug.Log,支持字符串内插,内插的值必须是value type,除了half
Unity.Collection里的
FixedString 结构体,比如:FixedString128Bytes.- System.Runtime.CompilerServices 属性
[CallerLineNumber]
,[CallerMemberName]
,[CallerFilePath]
字符串不能传递给方法,或者作为struct的字段.可以使用 Unity.Collections库里面的
FixedString结构体:
int value = 256;
FixedString128 text = $"This is an integer value {value} used with FixedString128";
MyCustomLog(text);
// String can be passed as an argument to a method using a FixedString,
// but not using directly a managed `string`:
public static void MyCustomLog(in FixedString128 log)
{
Debug.Log(text);
}
1.2.4 Function Pointers
使用 FunctionPointer<T>替代c#的委托,因为delegates是托管对象,所以Burst不支持
Function pointers 不支持泛型委托. 也不要在另一个泛型方法中封装BurstCompiler.CompileFunctionPointer<T> ,否则,Burst不会生效,比如代码优化,安全检查
1.2.4.1 Using function pointers
在类上添加[BurstCompile]
属性在类的静态方法上添加[BurstCompile]
属性- 声明一个委托,标记这些静态方法
在绑定委托的方法上添加[MonoPInvokeCallbackAttribute]属性
. 这样IL2CPP才能正常使用
// Instruct Burst to look for static methods with [BurstCompile] attribute [BurstCompile] class EnclosingType { [BurstCompile] [MonoPInvokeCallback(typeof(Process2FloatsDelegate))] public static float MultiplyFloat(float a, float b) => a * b; [BurstCompile] [MonoPInvokeCallback(typeof(Process2FloatsDelegate))] public static float AddFloat(float a, float b) => a + b; // A common interface for both MultiplyFloat and AddFloat methods public delegate float Process2FloatsDelegate(float a, float b); }
- 然后在C#中声明这些委托:
FunctionPointer<Process2FloatsDelegate> mulFunctionPointer =
BurstCompiler.CompileFunctionPointer<Process2FloatsDelegate>(MultiplyFloat);
FunctionPointer<Process2FloatsDelegate> addFunctionPointer =
BurstCompiler.CompileFunctionPointer<Process2FloatsDelegate>(AddFloat);
- 在job中使用:
// Invoke the function pointers from HPC# jobs
var resultMul = mulFunctionPointer.Invoke(1.0f, 2.0f);
var resultAdd = addFunctionPointer.Invoke(1.0f, 2.0f);
Burst默认以异步方式编译function pointers,[BurstCompile(SynchronousCompilation = true)]强制同步编译
在C# 中使用function point,最好先缓存FunctionPointer<T>.Invoke
属性,它就是委托的一个实例:
private readonly static Process2FloatsDelegate mulFunctionPointerInvoke =
BurstCompiler.CompileFunctionPointer<Process2FloatsDelegate>(MultiplyFloat).Invoke;
// Invoke the delegate from C#
var resultMul = mulFunctionPointerInvoke(1.0f, 2.0f);
1.2.4.2 Performance表现
最好在job里面使用function pointer,Burst为job提供了better aliasing calculations
不能给
function pointers直接传递[NativeContainer]
结构体,比如NativeArray,必须使用
job struct,Native container包含了用于安全检查safety check的托管对象。
下面是一个不好的例子:
///Bad function pointer example
[BurstCompile]
public class MyFunctionPointers
{
public unsafe delegate void MyFunctionPointerDelegate(float* input, float* output);
[BurstCompile]
public static unsafe void MyFunctionPointer(float* input, float* output)
{
*output = math.sqrt(*input);
}
}
[BurstCompile]
struct MyJob : IJobParallelFor
{
public FunctionPointer<MyFunctionPointers.MyFunctionPointerDelegate> FunctionPointer;
[ReadOnly] public NativeArray<float> Input;
[WriteOnly] public NativeArray<float> Output;
public unsafe void Execute(int index)
{
var inputPtr = (float*)Input.GetUnsafeReadOnlyPtr();
var outputPtr = (float*)Output.GetUnsafePtr();
FunctionPointer.Invoke(inputPtr + index, outputPtr + index);
}
}
不好的点在于:
- Burst不能矢量化function pointer ,因为它的参数是标量,这会损失4-8倍的性能
MyJob知道
Input
和Output
是native arrays不能alisa,但是function pointer不知道- There is a non-zero overhead to constantly branching to a function pointer somewhere else in memory.
[BurstCompile]
public class MyFunctionPointers
{
public unsafe delegate void MyFunctionPointerDelegate(int count, float* input, float* output);
[BurstCompile]
public static unsafe void MyFunctionPointer(int count, float* input, float* output)
{
for (int i = 0; i < count; i++)
{
output[i] = math.sqrt(input[i]);
}
}
}
[BurstCompile]
struct MyJob : IJobParallelForBatch
{
public FunctionPointer<MyFunctionPointers.MyFunctionPointerDelegate> FunctionPointer;
[ReadOnly] public NativeArray<float> Input;
[WriteOnly] public NativeArray<float> Output;
public unsafe void Execute(int index, int count)
{
var inputPtr = (float*)Input.GetUnsafeReadOnlyPtr() + index;
var outputPtr = (float*)Output.GetUnsafePtr() + index;
FunctionPointer.Invoke(count, inputPtr, outputPtr);
}
}
好的点在于:
- Burst 矢量化了
MyFunctionPointer方法
. - Burst 在每一个function pointer 处理
count个item,
调用函数指针的任何开销都减少了count次. - 批处理的性能比不批处理的性能提高1.53倍。
Burst使用IL Post Processing自动把代码,转成function pointer调用
但是最好还是:
[BurstCompile]
struct MyJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> Input;
[WriteOnly] public NativeArray<float> Output;
public unsafe void Execute(int index)
{
Output[i] = math.sqrt(Input[i]);
}
}
addDisableDirectCall = true可以关闭自动转换
[BurstCompile]
public static class MyBurstUtilityClass
{
[BurstCompile(DisableDirectCall = true)]
public static void BurstCompiled_MultiplyAdd(in float4 mula, in float4 mulb, in float4 add, out float4 result)
{
result = mula * mulb + add;
}
}
1.2.5 C#/.NET type support
Burst使用 .NET的一个字集,不允许使用任何托管对象或者引用类型,比如class
- 内置类型
- 支持的:
bool
byte
/sbyte
double
float
int
/uint
long
/ulong
short
/ushort
- 不支持的:
char
decimal
string
:因为是托管类型
- 支持的:
- 数组类型:
- 支持的
- 静态只读的数组
- 不能作为方法的参数
- C#不使用job的代码,不能更改数组的数据,也就是只有job里面才可以更改,因为 Burst 编译器会在编译的时候copy一份数据.
- 不支持的
- 不支持多维数组
- 不支持托管数据,但可以使用NativeArray
- 支持的
- 结构体类型
- 支持的
- 含有上面类型的常规结构体
- 具有固定长度数组的结构体,就是数组一开始就声明长度
- 具有explicit layout的结构体可能不会生成最优代码
- 支持的layout:
LayoutKind.Sequential
LayoutKind.Explicit
StructLayoutAttribute.Pack
StructLayoutAttribute.Size
- 支持的layout:
- 支持含有
System.IntPtr、
System.UIntPtr字段,作为原生属性
- 支持的
Vector类型
- Burst会把 Unity.Mathematics 的vector类型转换成适合SIMD vector类型 :
bool2
/bool3
/bool4
uint2
/uint3
/uint4
int2
/int3
/int4
float2
/float3
/float4
- 优先使用
bool4
,uint4
,float4
,int4类型
- Burst会把 Unity.Mathematics 的vector类型转换成适合SIMD vector类型 :
- 枚举类型
- 支持
- 常规类型以及带有特定存储类型的类型,比如:public enum MyEnum : short
- 不支持
- 不支持枚举类型的方法,比如: Enum.HasFlag
- 不支持枚举类型的方法,比如: Enum.HasFlag
- 支持
- 指针类型
- 支持所有支持类型的指针
1.2.6 C#/.NET System namespace support
Burst 会把system命名空间下变量的转换成与Burst兼容的变量
System.Math
- 支持
System.Math下所有的方法
: double IEEERemainder(double x, double y)支持持
.NET Standard 2.1以上
- 支持
System.IntPtr
- 支持
System.IntPtr
/System.UIntPtr下的所有方法,包括静态字段
IntPtr.Zero、
IntPtr.Size
- 支持
System.Threading.Interlocked
- Burst支持
System.Threading.Interlocked下的所有方法,即线程安全
(比如Interlocked.Increment
).
- Burst支持
确保interlocked methods的source 位置是对齐的,比如:指针的对齐方式是指向类型的倍数:
[StructLayout(LayoutKind.Explicit)]
struct Foo
{
[FieldOffset(0)] public long a;
[FieldOffset(5)] public long b;
public long AtomicReadAndAdd()
{
return Interlocked.Read(ref a) + Interlocked.Read(ref b);
}
}
System.Threading.Thread
- 支持其中的
MemoryBarrier方法
- 支持其中的
System.Threading.Volatile
- Burst 支持非泛型的变量
Read、
Write
方法,该参数表示多个线程共享改变
- Burst 支持非泛型的变量
1.2.7 DllImport and internal calls
调用native plugin下面的方法,使用 [DllImport]:
[DllImport("MyNativeLibrary")]
public static extern int Foo(int arg);
Burst也支持Unity内部实现的的内部调用:
// In UnityEngine.Mathf
[MethodImpl(MethodImplOptions.InternalCall)]
public static extern int ClosestPowerOfTwo(int value);
DllImport仅支持
native plug-ins下的方法,不支持独立于平台的dll,比如:kernel32.dll
.
DllImport支持的类型有
:
Type | Supported type |
---|---|
Built-in and intrinsic types | byte / sbyte short / ushort int / uint long / ulong float double System.IntPtr / System.UIntPtr Unity.Burst.Intrinsics.v64 / Unity.Burst.Intrinsics.v128 / Unity.Burst.Intrinsics.v256 |
Pointers and references | sometype* : 指针类型ref sometype : 引用类型 |
Handle structs | unsafe struct MyStruct { void* Ptr; } : 只包含一个指针类型的Struct unsafe struct MyStruct { int Value; } : 只包含一个整数类型的Struct |
需要通过指针或者引用传递structs,不能通过值类型传递,除了上面支持的类型,handle structs
1.2.8 SharedStatic struct
如果想在C#、HPC#共享静态的可变数据,使用 SharedStatic<T>
public abstract class MutableStaticTest
{
public static readonly SharedStatic<int> IntField = SharedStatic<int>.GetOrCreate<MutableStaticTest, IntFieldKey>();
// Define a Key type to identify IntField
private class IntFieldKey {}
}
C#、HPC# 都可以通过下面访问:
// Write to a shared static
MutableStaticTest.IntField.Data = 5;
// Read from a shared static
var value = 1 + MutableStaticTest.IntField.Data;
使用SharedStatic<T>时,需要注意
:
T
inSharedStatic<T>
定义了数据类型- 为了识别static 字段,提供一个上下文,为两个包含类型都创建一个键,比如:
MutableStaticTest、
IntFieldKey
- 在从hpc#访问共享静态字段之前,现在c#里面初始化它
2.1 Burst intrinsics overview
2.1.3 简介
Burst 提供了低阶的Api,在 Unity.Burst.Intrinsics 命名空间下,如果想写SIMD程序集代码,可以使用它下面的代码,获取额外的性能,就类似于底层代码。
2.1.1 SIMD 和 SISD:
- SISD单指令,单数据,一条指令如果执行多条数据,是串行的
- SIMD单指令,多数据,一条指令如果执行多条数据,是并行的
- 不同点:处理器不同,SIMD的处理器,能够处理多条数据
- 相同点:都会有指令集存储器,和寄存器来保存数据
2.1.2 CPU单核和多核:
- 单核:一个中央处理单元,一个核心,处理任务,只能一个人干,串行,单核提升速度的方法:提高时钟频率
- 多核:一个中央处理单元,多个核心,处理任务,多个人干,并行。比如:多个应用开启多个线程,就可以多个核心一起干
- 时钟频率:单位Hz,表示一秒执行多少次周期,比如3GHz,表示一秒进行30亿次,一般说Hz越高,运行速度越快,一条指令可能需要多个周期,单核的时候,单纯的提高时钟频率,会增加耗能、发热
- HZ的单位有:
- - KHz(千赫兹):1 kHz = 10^3 Hz(一千赫兹)
- - MHz(兆赫兹):1 MHz = 10^6 Hz(一百万赫兹)
- - GHz(吉赫兹):1 GHz = 10^9 Hz(十亿赫兹)
- - THz(太赫兹):1 THz = 10^12 Hz(一万亿赫兹)
- HZ的单位有:
2.1.4 Burst Intrinsics Common class
Unity.Burst.Intrinsics.Common 提供了在Burst支持的硬件上通用的功能。
- Unity.Burst.Intrinsics.Common.Pause :CPU 暂停当前线程,在x86上
pause,在ARM上yield,
在多线程编程中,尤其是在实现自旋锁(spinlock)或者在等待某个条件变为真时,直接使用忙等待循环(即不断地检查条件是否满足,而不进行休眠)会导致CPU在这段时间内高速运行,消耗大量的处理器资源。如果使用`Pause`指令,它会提示CPU在这种忙等待的场景下稍微“放慢脚步”,这样可以减少对CPU资源的消耗,同时对于等待的线程来说,延迟的增加是非常小的,几乎可以忽略不计- 自旋锁:
- 自旋锁(Spinlock)是一种用于多线程同步的锁机制,主要用于保护共享资源或临界区。与传统的锁(如互斥锁)不同,当一个线程尝试获取一个已经被其他线程持有的自旋锁时,它不会立即进入休眠状态(即阻塞状态),而是在锁被释放之前,持续在一个循环中检查锁的状态(这个过程称为“自旋”)。这意味着线程会一直占用CPU进行循环,直到它能够获取到锁。
- 自旋锁的优点:
- 当锁被占用的时间非常短时,它可以避免线程的上下文切换开销,因为线程不会进入休眠状态。这使得自旋锁在某些情况下比传统的锁更高效,尤其是在多核处理器上处理高并发且锁持有时间短的场景。
- 缺点:
- 1. **CPU资源消耗**:自旋锁在等待锁释放期间会持续占用CPU,如果锁被长时间持有,这将导致大量的CPU资源浪费。
- 2. **不适用于单核处理器**:在单核处理器上,自旋锁可能导致更差的性能,因为持有锁的线程可能无法释放锁,因为等待锁的线程持续占用CPU不让出执行机会。
- 3. **饥饿问题**:在某些情况下,自旋锁可能导致线程饥饿,即某些线程可能永远无法获取到锁,因为总有其他线程比它更早地获取到锁。
- 因此,自旋锁的使用需要仔细考虑其适用场景,通常是在多核处理器、锁持有时间非常短且对性能要求极高的情况下。在其他情况下,可能需要考虑使用其他类型的锁或同步机制
- 互斥锁:
- 当一个线程尝试获取一个已经被其他线程持有的锁时,该线程会进入阻塞状态。在这种情况下,操作系统会进行线程上下文切换,将CPU的控制权转移给另一个线程。这个过程涉及到保存当前线程的状态(例如寄存器、程序计数器等)并恢复另一个线程的状态,以便另一个线程可以继续执行。线程上下文切换是一个相对昂贵的操作,因为它涉及到一系列的硬件和操作系统层面的操作。
Unity.Burst.Intrinsics.Common.Prefetch
是一个实验性的内在特性,用于将特定的内存地址上的数据预加载到CPU缓存中,目的是为了在实际访问这些数据之前减少访问延迟,从而提高执行效率。使用prefetch可以在进行大量内存读取操作的循环中提高性能,尤其是对于那些访问模式可预测的循环操作。然而,正确地使用prefetch需要对所处理数据的内存访问模式有很好的理解,错误的使用可能不会带来任何性能上的改善,甚至可能使性能更差。因为是实验行的,所以要UNITY_BURST_EXPERIMENTAL_PREFETCH_INTRINSIC来访问
- Unity.Burst.Intrinsics.Common.umul128:用于执行无符号的128位乘法操作。这个函数接受两个64位无符号整数作为输入,执行它们的乘法,并返回128位的乘积结果。由于直接在C#中进行128位整数运算并不直接支持,这个函数提供了一种在需要进行大数乘法时的有效手段,尤其是在性能敏感的应用场景中。
- 具体来说,`umul128`函数会返回一个包含两个64位无符号整数的元组或结构体,这两个整数分别代表乘积的低64位和高64位。这样,开发者可以在不丢失精度的情况下处理大于64位的乘法运算结果。
- 使用`umul128`可以在进行大整数运算、加密算法、随机数生成等需要高精度和大范围数值计算的场景中非常有用。然而,由于它是一个低级的内联函数,使用时需要对数字运算有一定的理解,以确保正确处理乘法的结果。
Unity.Burst.Intrinsics.Common.InterlockedAnd
和Unity.Burst.Intrinsics.Common.InterlockedOr 提供了int
,uint
,long
,ulong类型的原子属性上的且或操作,因为是实验行的,使用宏
UNITY_BURST_EXPERIMENTAL_ATOMIC_INTRINSICS声明访问
3.1 Editor Reference
3.1.1 Burst menu reference
Enable Compilation | Burst编译带有 [BurstCompile]的 jobs 和自定义 delegates |
Enable Safety Checks | Enable Safety Checks setting |
Off | 关闭安全检查在jobs和function-pointers上,可以获取额外的真实的性能 |
On | 对collection containers (e.g NativeArray<T> )开启安全检查,包括job data依赖和是否越界 |
Force On | 即使 DisableSafetyChecks = true也安全检查 |
Synchronous Compilation | Synchronous compilation. |
Native Debug Mode Compilation | 关闭burst对代码的优化 Native Debugging tools. |
Show Timings | 显示burst编译的时间 Show Timings setting |
Open Inspector | Opens the Burst Inspector window. |
3.1.1.1 Show Timings setting
开启Show Timings时,Unity会打印出来,Burst编译每个库的入口点时间,Burst会把一个程序集的所有方法,编译成一个单元,批处理,把多个entry-points成组,组成一个task.
Burst的工作主要分为下面几步:
- 找出需要burst编译的所有方法
- front end找到之后,Burst将c# IL转换为LLVM IR模块
- middle end然后Burst specializes, optimizes, cleans up
- back end最后Burst把LLVM IR module转换成native DLL)
front end编译的时间,和需要编译的方法,成正比关系,泛型越多,时间越长,因为每个类型都编译一遍
back-end 的时间与entry-point的数量成正比,以为每一个entry point都是一个单独的文件。比如一个脚本。
如果optimize花费了大量的时间,通过[BurstCompile(OptimizeFor = OptimizeFor.FastCompilation)]可以减少优化的的内容,同时也会变快。
3.1.2
Burst Inspector window reference
Burst Inspector窗口展示了所有Burst编译的jobs和其它对象,Jobs > Burst > Open Inspector.
3.2 Compilation
- 在Play mode,Burst通过just-in-time (JIT)编译,异步编译,表示在Burst编译完之前,都是使用Mono编译器编译的代码,如果不想异步编译,请看Synchronous compilation
- 在发布的时候,Burst通过ahead-of-time (AOT)编译,通过Playersetting窗口,控制编译的方式,详情:Building your project
3.2.1 Synchronous compilation
默认,Burst异步编译jobs,在play mode 模式下,通过 CompileSynchronously = true,表示同步编译。
[BurstCompile(CompileSynchronously = true)]
public struct MyJob : IJob
{
// ...
}
如果不设置的话,当第一次运行这个job时,Burst 在后台线程中异步编译,与此同时,运行的是托管的c#代码。
当CompileSynchronously = true,它会影响当前帧,体验不好
,一般在下面情况中使用:
- 当想测试Burst编译后的代码时,同时忽略首次调用的时间
- 调试托管和编译代码之间的差异。
3.2.2 BurstCompile attribute
- 对数学方法使用不同的精度,比如sin,cos.
- 放松对数学计算的限制,这样burst就可以重新安排浮点数的计算顺序
- 强迫synchronous compilation of a job
[BurstCompile(FloatPrecision.Med, FloatMode.Fast)]
- FloatPrecision:单位ulp,表示浮点数之间的空间
FloatPrecision.Standard
: 和FloatPrecision.Medium一样,精度
3.5 ulp.FloatPrecision.High
: 1.0 ulp.FloatPrecision.Medium
: 3.5 ulp.FloatPrecision.Low
: 每个函数都定义了精度,函数可以指定有限范围的有效输入。
- FloatMode
FloatMode.Default
: 和FloatMode.Strict一样
.FloatMode.Strict
: 不执行重排计算的顺序FloatMode.Fast
: 可以重排顺序,对于不需要精确的计算顺序可以使用FloatMode.Deterministic
: 为后面版本预留的
- ulp
- Unit in the Last Place,表示相邻两个浮点数之间的距离,它的大小取决于浮点数的精度和大小。 举个例子,假设我们有两个浮点数A和B,A < B,那么A和B之间的“空间”可以用它们之间相差的ULP数来描述。如果A和B之间正好相差1个ULP,那么意味着没有其他浮点数能够位于A和B之间;如果它们之间相差多个ULP,那么就存在其他浮点数可以位于A和B之间,指的是两个数值之间的差距,例如,比较两个浮点数是否接近可能需要计算它们之间的ULP差异,而不是直接比较它们的值。
给整个程序集添加burst编译属性
[assembly: BurstCompile(CompileSynchronously = true)]
3.2.3 BurstDiscard
添加上此属性,代码不会被Burst编译,也就是在job标价burst时,标记该属性的方法不会执行,添加该属性的方法,不能有返回值,可以通过ref、out来获取更改后的值
[BurstCompile]
public struct MyJob : IJob
{
public void Execute()
{
// Only executed when running from a full .NET runtime
// this method call will be discard when compiling this job with
// [BurstCompile] attribute
MethodToDiscard();
}
[BurstDiscard]
private static void MethodToDiscard(int arg)
{
Debug.Log($"This is a test: {arg}");
}
}
[BurstDiscard]
private static void SetIfManaged(ref bool b) => b = false;
private static bool IsBurst()
{
var b = true;
SetIfManaged(ref b);
return b;
}
3.2.4 Generic jobs
不支持嵌套job,如果使用了嵌套job,在editor下,burst能检测到,并使用burst编译,但是在build时,burst不会对这部分代码编译,所以editor下和运行时,两者的性能有差距。
比如:
直接使用泛型job
[BurstCompile]
struct MyGenericJob<TData> : IJob where TData : struct {
public void Execute() { ... }
}
或者包装一层,job不是泛型的,但可以使用Tdata数据
public class MyGenericSystem<TData> where TData : struct {
[BurstCompile]
struct MyGenericJob : IJob {
public void Execute() { ... }
}
public void Run()
{
var myJob = new MyGenericJob(); // implicitly MyGenericSystem<TData>.MyGenericJob
myJob.Schedule();
}
}
---------------------------------
嵌套类型,外部是泛型,内部也是泛型
public static void GenericJobSchedule<TData>() where TData: struct {
// Generic argument: Generic Parameter TData
// This Job won't be detected by the Burst Compiler at standalone-player build time.
var job = new MyGenericJob<TData>();
job.Schedule();
}
只能在editor下使用burst编译
GenericJobSchedule<int>();
3.2.5 Compilation warnings
使用 Unity.Burst.CompilerServices.IgnoreWarningAttribute可以忽略警告
- BC1370
- An exception was thrown from a function without the correct
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]
guard... - 如果使用了throw,但是没有catch,就会报这个,因为throw在运行时,会崩溃,加上
[Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")]属性,throw的方法在build时就会丢弃
- An exception was thrown from a function without the correct
- BC1371
- 当一个方法,被discard时,会报
4.1 Building your project
在build时,burst会编译代码,然后把它编译成一个动态链接库(dll),放在plugin文件夹下面,不同的平台,放的位置不一样,比如window:Data/Plugins/lib_burst_generated.dll
iOS例外,它生成的是一个静态库
job在运行时compile代码时,会首先加载dll,通过Edit > Player Settings > Burst AOT Settings设置Burst编译时的设置
5.1 Optimization
5.1.1 Debugging and profiling tools
- Editor:
- 可以使用rider、vs自带的debug工具,attach之后,unity会关闭burst优化,现在debug的就是托管代码。
- 也可以使用native debug工具,比如vs、xcode,同样会关闭burst优化,有两种方式:
- 一种是:开启Jobs > Burst > Native Debug Mode Compilation,它会关闭所有的burst优化
- 一种是:[BurstCompile(Debug = true)]只对某个job,关闭burst优化
- Player Mode
- 需要给debug tool指定burst生成的符号文件,一般是在plugin文件夹,在这之前需要开启生成符号文件的选项,有两种方式:
- Development Build
- Burst AOT Player Settings开启Force Debug Information
- 同时需要关闭 Burst optimizations,有两种方式:
Debug = true,关闭指定的job
- 关闭Burst AOT Player Settings,Enable Optimizations选项,是关闭所有job
- 需要给debug tool指定burst生成的符号文件,一般是在plugin文件夹,在这之前需要开启生成符号文件的选项,有两种方式:
- System.Diagnostics.Debugger.Break System.Diagnostics.Debugger.Break 方法,可以在debuger attach的时候,触发,其它的时候不触发,就相当于断点
5.1.1.1 Profiling Burst-compiled code
想要分析burst编译后的代码,可以在 Instruments 或者 Superluminal里,分析编译后的代码,前提先指定,burst编译后的符号文件
可以通过playermarker,对debug的代码做标记:
[BurstCompile]
private static class ProfilerMarkerWrapper
{
private static readonly ProfilerMarker StaticMarker = new ProfilerMarker("TestStaticBurst");
[BurstCompile(CompileSynchronously = true)]
public static int CreateAndUseProfilerMarker(int start)
{
using (StaticMarker.Auto())
{
var p = new ProfilerMarker("TestBurst");
p.Begin();
var result = 0;
for (var i = start; i < start + 100000; i++)
{
result += i;
}
p.End();
return result;
}
}
}
5.1.2 Loop vectorization
简单来讲就是把确定了的运算,进行SMID写法处理
[MethodImpl(MethodImplOptions.NoInlining)]
private static unsafe void Bar([NoAlias] int* a, [NoAlias] int* b, int count)
{
for (var i = 0; i < count; i++)
{
a[i] += b[i];
}
}
public static unsafe void Foo(int count)
{
var a = stackalloc int[count];
var b = stackalloc int[count];
Bar(a, b, count);
}
生成的汇编语言
.LBB1_4:
vmovdqu ymm0, ymmword ptr [rdx + 4*rax]
vmovdqu ymm1, ymmword ptr [rdx + 4*rax + 32]
vmovdqu ymm2, ymmword ptr [rdx + 4*rax + 64]
vmovdqu ymm3, ymmword ptr [rdx + 4*rax + 96]
vpaddd ymm0, ymm0, ymmword ptr [rcx + 4*rax]
vpaddd ymm1, ymm1, ymmword ptr [rcx + 4*rax + 32]
vpaddd ymm2, ymm2, ymmword ptr [rcx + 4*rax + 64]
vpaddd ymm3, ymm3, ymmword ptr [rcx + 4*rax + 96]
vmovdqu ymmword ptr [rcx + 4*rax], ymm0
vmovdqu ymmword ptr [rcx + 4*rax + 32], ymm1
vmovdqu ymmword ptr [rcx + 4*rax + 64], ymm2
vmovdqu ymmword ptr [rcx + 4*rax + 96], ymm3
add rax, 32
cmp r8, rax
jne .LBB1_4
汇编语言的意思:
这段代码是使用 x86-64 汇编语言(使用 AVX2 指令集)写的,它对应于提供的 C# 代码,实现了一个简单的向量加法操作。下面是一步一步地解释它的功能:
- 1. **函数 `Bar` 的循环展开与向量化**: 鉴于源代码的 `Bar` 函数负责将两个整数数组(或指针所指向的内存区域)的元素逐个相加,这段汇编代码采用了向量化的方式来提升性能。它一次处理128个字节(即32个`int`类型的数据,每个`int`占用了4个字节),这是因为使用的 `ymm` 寄存器可以一次性处理256位数据。
- 2. `vmovdqu ymm0, ymmword ptr [rdx + 4*rax]` 等四条 `vmovdqu` 指令用于从内存中加载数据到 `ymm0`、`ymm1`、`ymm2` 和 `ymm3` 寄存器中。在这里 `[rdx + 4*rax]` 和类似的表达式利用了 `rdx` 作为基址(表示数组 `b` 的起始地址),`rax` 作为索引值(起始值为 0,后续以32为步长递增,代替了循环变量 `i`,因为每次迭代处理32个 `int`),乘以4的原因是每个 `int` 占4字节,用于计算在 `b` 数组中正确的偏移量。
- 3. `vpaddd ymm0, ymm0, ymmword ptr [rcx + 4*rax]` 等四条 `vpaddd` 指令执行向量加法(`ymm` 寄存器与内存数据),将 `a` 数组中相应的值(由 `[rcx + 4*rax]` 等地址指定)与 `b` 数组的值相加,结果存回到相应的 `ymm` 寄存器里。
- 4. 接下来的四条 `vmovdqu` 指令将加法操作的结果 (`ymm0`, `ymm1`, `ymm2`, `ymm3`) 存回到 `a` 数组相应的位置。
- 5. `add rax, 32` 用于更新循环索引 `i`,跳到下一批次处理的起点,因为每次迭代处理了 32 个 `int`,所以 `rax` 每次增加 32。
- 6. `cmp r8, rax` 与 `jne .LBB1_4` 这一条件跳转指令组合实现了循环的继续判断。如果 `rax`(代表当前已处理的 `int` 的数量)还没有达到总数 `r8`(`count` 参数的值),汇编执行跳回标签 `.LBB1_4` 开始处理下一批数据。
综上所述,这段汇编代码通过一系列向量化指令并行地完成了数组 `a` 和 `b` 元素的加法操作,显著提高了原始 C# 代码循环中单个元素加法操作的执行效率。
5.1.3 Memory aliasing
它是一种告诉Burst代码中是如何使用数据的。这可以改善和优化应用程序的性能。
当内存中的位置相互重叠(overlap)时,就会发生Memory aliasing。下面概述了Memory aliasing和无Memory aliasing之间的区别。
[BurstCompile]
private struct CopyJob : IJob
{
[ReadOnly]
public NativeArray<float> Input;
[WriteOnly]
public NativeArray<float> Output;
public void Execute()
{
for (int i = 0; i < Input.Length; i++)
{
Output[i] = Input[i];
}
}
}
- No memory aliasing
如果Input
和Output
没有发生内存重叠, 就是它们的内存相互独立,就像下面的表示,如果是No Aliasing,Burst就会通过向量化,把已知的的标量给分成批次处理,比如一次处理32个int,而不是单独处理
Memory with no aliasing
Memory with no aliasing vectorized
- Memory aliasing
如果Output数组和
Input数组,有元素重叠,比如
Output[0]指向
Input[1],这就表示内存混叠,比如
:
Memory with aliasin
如果没有声明aliasing,它会自动矢量化,然后结果如下图所示,这样就会有bug,因为内存错位了,数值都变了:
Memory with aliasing and invalid vectorized code
- Generated code
CopyJob,针对x64指令集的子集AVX2生成的
汇编语言. 指令vmovups移动8个浮点数,所以一个自动向量化循环移动4 × 8个浮点数,这等于每次循环迭代复制32个浮点数,而不是一个:
.LBB0_4:
vmovups ymm0, ymmword ptr [rcx - 96]
vmovups ymm1, ymmword ptr [rcx - 64]
vmovups ymm2, ymmword ptr [rcx - 32]
vmovups ymm3, ymmword ptr [rcx]
vmovups ymmword ptr [rdx - 96], ymm0
vmovups ymmword ptr [rdx - 64], ymm1
vmovups ymmword ptr [rdx - 32], ymm2
vmovups ymmword ptr [rdx], ymm3
sub rdx, -128
sub rcx, -128
add rsi, -32
jne .LBB0_4
test r10d, r10d
je .LBB0_8
同样的代码,但是手动关闭了Burst aliasing,要比原来的性能低:
.LBB0_2:
mov r8, qword ptr [rcx]
mov rdx, qword ptr [rcx + 16]
cdqe
mov edx, dword ptr [rdx + 4*rax]
mov dword ptr [r8 + 4*rax], edx
inc eax
cmp eax, dword ptr [rcx + 8]
jl .LBB0_2
- Function cloning
- 对于不知道参数需不需要aliasing的方法,Burst通过copy一份方法副本,然后假设不生成alisa,来生成汇编代码,如果不报错,就替换原来的汇编代码(没有优化的代码)
[MethodImpl(MethodImplOptions.NoInlining)]
int Bar(ref int a, ref int b)
{
a = 42;
b = 13;
return a;
}
int Foo()
{
var a = 53;
var b = -2;
return Bar(ref a, ref b);
}
- 因为Burst不知道Bar方法里的a和b是否aliasing.,所以生成的汇编语言和其它编译器是一致的:
//dword ptr [rcx]先从rcx(寄存器)取值,然后把42赋值给它,mov
mov dword ptr [rcx], 42
mov dword ptr [rdx], 13
//取值rcx,把它赋值给eax
mov eax, dword ptr [rcx]
//表示控制权结束,return
ret
Burst比这更聪明,通过function cloning,Burst创建了Bar的副本,它推断这个副本中的属性不会发生混叠,生成不发生混叠时的代码,替换原来的调用,这样就不用从寄存器里面取数了:
mov dword ptr [rcx], 42
mov dword ptr [rdx], 13
mov eax, 42
ret
- Aliasing checks
- 因为aliasing是Burst进行优化的关键,所以提供了一些内部的检测方法:
- Unity.Burst.CompilerServices.Aliasing.ExpectAliased 表示两个指针会alias,如果没有则生成编译器错误。
- Unity.Burst.CompilerServices.Aliasing.ExpectNotAliased表示两个指针不会alias, 如果不是,则生成编译器错误。
- 因为aliasing是Burst进行优化的关键,所以提供了一些内部的检测方法:
比如:
using static Unity.Burst.CompilerServices.Aliasing;
[BurstCompile]
private struct CopyJob : IJob
{
[ReadOnly]
public NativeArray<float> Input;
[WriteOnly]
public NativeArray<float> Output;
public unsafe void Execute()
{
// NativeContainer attributed structs (like NativeArray) cannot alias with each other in a job struct!
ExpectNotAliased(Input.getUnsafePtr(), Output.getUnsafePtr());
// NativeContainer structs cannot appear in other NativeContainer structs.
ExpectNotAliased(in Input, in Output);
ExpectNotAliased(in Input, Input.getUnsafePtr());
ExpectNotAliased(in Input, Output.getUnsafePtr());
ExpectNotAliased(in Output, Input.getUnsafePtr());
ExpectNotAliased(in Output, Output.getUnsafePtr());
// But things definitely alias with themselves!
ExpectAliased(in Input, in Input);
ExpectAliased(Input.getUnsafePtr(), Input.getUnsafePtr());
ExpectAliased(in Output, in Output);
ExpectAliased(Output.getUnsafePtr(), Output.getUnsafePtr());
}
}
5.1.3.1 No Alias attribute
不需要alias,对于native container、job struct,一般不需要添加该属性,因为burst会主动推断是否需要Alisa。
只有那些Burst推断不出来的,可以添加该属性,前提是,明确知道标记的参数,不会进行Alisa,如果标记No Alisa的,实际情况需要Alisa,有可能产生bug
- 添加Alisa的情况:
- 方法的参数
- 方法的返回值
- 不会Alisa的结构体
- 不会Alisa的结构体的字段
比如:
int Foo([NoAlias] ref int a, ref int b)
{
b = 13;
a = 42;
return b;
}
----------------------------------------
struct Bar
{
[NoAlias]
public NativeArray<int> a;
[NoAlias]
public NativeArray<float> b;
}
int Foo(ref Bar bar)
{
bar.b[0] = 42.0f;
bar.a[0] = 13;
return (int)bar.b[0];
}
----------------------------------------
[NoAlias]
unsafe struct Bar
{
public int i;
public void* p;
}
float Foo(ref Bar bar)
{
*(int*)bar.p = 42;
return ((float*)bar.p)[bar.i];
}
----------------------------------------
[MethodImpl(MethodImplOptions.NoInlining)]
[return: NoAlias]
unsafe int* BumpAlloc(int* alloca)
{
int location = alloca[0]++;
return alloca + location;
}
unsafe int Func()
{
int* alloca = stackalloc int[128];
// Store our size at the start of the alloca.
alloca[0] = 1;
int* ptr1 = BumpAlloc(alloca);
int* ptr2 = BumpAlloc(alloca);
*ptr1 = 42;
*ptr2 = 13;
return *ptr1;
}
5.1.3.2 Aliasing and the job system
Unity's job system对job中的参数alias有一些限制:
- [NativeContainer] (比如 NativeArray and NativeSlice) 不能alisa
- Job struct中的字段标有 [NativeDisableContainerSafetyRestriction] 属性的,可以和其它字段Alisa.
[NativeContainer]
不能作为其它[NativeContainer]的子项
. 比如:NativeArray<NativeSlice<T>>
.
5.1.4 AssumeRange attribute
AssumeRange 属性,表示告诉burst标量的范围,如果burst知道该范围,会进行相关的优化,比如:
[return:AssumeRange(0u, 13u)]
static uint WithConstrainedRange([AssumeRange(0, 26)] int x)
{
return (uint)x / 2u;
}
有两个限制:
- 只可以添加到 (signed or unsigned) 整形上面.
- range的参数类型,必须和添加属性的类型一致.
Burst 已经对 NativeArray、
NativeSlice
的.Length属性做了替换,因为它永远是正的,比如
:
static bool IsLengthNegative(NativeArray<float> na)
{
// Burst 总是用常量false替换它
return na.Length < 0;
}
比如:下面表示_length是永远>0的
struct MyContainer
{
private int _length;
[return: AssumeRange(0, int.MaxValue)]
private int LengthGetter()
{
return _length;
}
public int Length
{
get => LengthGetter();
set => _length = value;
}
// Some other data...
}
5.1.5 Hint intrinsics
它告诉Burst优先优化分支内的代码,减少编译时间:
- Unity.Burst.CompilerServices.Hint.Likely: 很大可能为真
- Unity.Burst.CompilerServices.Hint.Unlikely: 很大可能为假
- Unity.Burst.CompilerServices.Hint.Assume: 为真,谨慎使用,相当于宏定义
判断的值还是b,只不过告诉burst,b很大可能为true
if (Unity.Burst.CompilerServices.Hint.Likely(b))
{
// 这里的任何代码都将被Burst优化
}
else
{
// 这里的代码不会被优化
}