在 Unity 中使用 Burst Compiler 可以有效提高计算密集型任务的运算速度,问题是 Burst Compiler 做了什么魔法?我们还有什么优化空间?
1. Burst Compiler 是啥
Burst 可以认为是一个 Unity 的 LLVM 编译器。而 LLVM (Low Level Virtual Machine) 是一个编译器基础设施,提供了丰富的中间表示(Intermediate Representation, IR)和高级优化工具。
一般的 C# 代码,会经过如下编译步骤 (针对IL2CPP编译)
C# → CLR IL → CPP → 机器码
而 Burst 的代码,会经过如下编译步骤
C# → CLR IL → LLVM IR → 机器码
看似只是改变了一个 LLVM 的步骤,但其实因为 LLVM 的自动优化特性获得了更高的运算速度,包括
- LLVM 的 SIMD 优化
- LLVM 的 很多编译 Pass
- IL2CPP 还是带托管内存的,而 LLVM 没有这个机制,限制了内存管理灵活性,但优化了性能。
LLVM 非常多的编译优化机制
图源:Deep dive into the Burst compiler - Unite LA 2018
LLVM 优化生成机器码
2. Burst 的优化建议
2.1 别从 “实现抽象” 的角度理解 SIMD,而是把它理解成 128 bit 的单元
来自(Intrinsics: Low-level engine development with Burst - Unite Copenhagen 2019)
上面这个例子,3D点乘,直接自己写实现可能都比 math.dot 快
最后还是要看 机器码 和自己 profile 看结果
2.2 让编译器完成 Loop Vectorization 和 提升 Aliasing
Loop Vectorization 优化中,编译器会一次 loop 多个值,而不是一个值,这样利用 SIMD 机制优化
Loop vectorization | Burst | 1.8.10 (unity3d.com)
一个典型的机器码,会发现编辑器自动 unroll,一次 loop 计算了很多值。
1 | .LBB1_4: |
Enhanced Aliasing with Burst | Unity Blog
Memory Aliasing 是指不同的内存地址被用来访问内存中的同一位置,这些不同的地址互相为 Aliasing(别名)
告诉编译器 No Aliasing 后,主要好处是
- 减少加载和存储操作
- 让编辑器更容易进行 SIMD 优化
2.3 用 Burst Intrinsic 指令优化
来自(Intrinsics: Low-level engine development with Burst - Unite Copenhagen 2019)
一开始的代码
1 | [BurstCompile] |
之前机器码
1 | .LBB0_6: |
优化后
1 | for (int j = 0; j < Doors.Length; ++j) { |
对应机器码
1 | .LBB1_3: |
Arm @ GDC 2021 : Supercharging mobile performance with Arm Neon and Unity Burst Compiler - YouTube 在 Unity 里直接写 ARM 的硬件指令优化了一倍性能
2.4 忘记上述原则,做 Profile
这个是笔者总结的,
看指令不能完全确信进行了优化,上面原则也有不成立的时候。
最后落到比较还是还是靠 Profile。
3. Burst 内存管理
众所周知,Burst Compiler 作用域中不能创建托管对象,那么创建对象时候,发生了什么?
3.1 New stuct
我们测试一个 Job
1 | [StructLayout(LayoutKind.Sequential)] |
看到编译后的机器码
1 | mov eax, dword ptr [rcx + 48] // x 放进 eax |
所以说,new struct 可能没有分配堆内存,而是直接用寄存器了。同样array的复制也就是按地址拷贝。
3.2 New Array
编译出来的代码看不太懂,但可以反编译进 NativeArray.cs 看看
1 | private static unsafe void Allocate(int length, Allocator allocator, out NativeArray<T> array) |
ok, 所以是用 malloc 做的堆上内存分配,但是打了标记,防止之后泄露。
3.3 New List
我们看看 NativeList Add 会发生什么
1 | [BurstCompile] |
编译出来
1 | # NativeList.cs(332, 1) m_ListData->Add(value); |
看代码看不太懂,想必也是 malloc 出来的内存,然后 Add 时候动态扩容。
3.4 stackalloc
Burst现在可以直接分配栈内存,我们试试直接 stackalloc 一个
1 | [BurstCompile] |
看一下机器码,
1 | vmovss xmm0, dword ptr [rcx + 56] |
好家伙,直接没分配内存,编译器优化掉了,赋值直接进寄存器了
但如果我没给一个编译期不固定的尺寸,
1 | [BurstCompile] |
看一下机器码
1 | mov r8d, dword ptr [rcx + 60] |
可以看到这次确实分配了栈空间,机器码上用寄存器代表栈顶位置。
3.5 内存分配的结论
在 burst compiler 作用域中,new 一个固定尺寸的 stackalloc,或者一个 struct,是有可能被优化成直接使用寄存器的,并不会分配内存。
而 stackalloc 一个不定长的内存,确实发生了栈内存的分配。如果要局部缓存控件,确实这种方式更快,但受栈内存大小限制。
NativeArray 和 NativeList 都是 malloc 申请的堆内存,好在 burst 的机制一般要求使用完 dispose,减少堆内存碎片。
burst 中无法使用别的托管对象,一般不用担心内存分配和GC问题。
4. 优化例子
我们构造一个 loop voxel 的任务,
voxel 是 3D 的,但我们排进了一个 1D array,现在我们想从中抽取一小部分。
inputs是所有 voxel 的数组,而 outputs 是抽取出的一部分。chunkStartPos 是我们抽取出的部分的起始位置。
4.1 基础版
一个最基础版本的如下,per voxel 计算 id 然后读取
1 | [BurstCompile] |
其中 最内层 loop 的机器码是这样
1 | .LBB0_4: |
有意思的是,new int3 并并没有构造栈内存,而是直接放进寄存器了。
在 dim = 16,运行 10000 次,耗时 152.03 ms
4.2 尝试 点乘 优化 index 计算
我们优化一下函数计算,
1 | idMultiplier = new int3(1, Dim, Dim * Dim) |
看一下生成的机器码
1 | .LBB0_4: |
可以发现,点乘计算和之前手动写的没啥区别,甚至还多了一次 乘1 的计算指令
如前面 1.1 讲的,这个 math.dot 这里确实没啥用
最后时间 155.57 ms,确实没什么用
4.3 尝试 强制 SIMD乘法 优化 index 计算
再优化一下计算 LocalXYZToGlobalIndex, 强制用 simd 乘法
1 | private int LocalXYZToGlobalIndex(int3 localXyz) |
看一下生成的机器码,
1 | .LBB0_4: |
看到确实是少了一些指令,
最后, 144.25ms,快了一点点
4.4 Index 计算不构造 int3
再优化一下计算 LocalXYZToGlobalIndex,可以发现构造 int3 到寄存器还是花了一些时间的,改成
1 | private int LocalXYZToGlobalIndex(int x, int y, int z) // 这里之前参数是 int3 |
看一下生成的机器码,
1 | .LBB3_9: |
LBB3_9 这里应该是在计算 ID,
LBB3_12 这里,很神奇,出现了 Loop Vectorization 的痕迹!四个一组一起unroll赋值了,
最后时间 131.56 ms,比上面还快。这里因为没构造 int3,莫名其妙被编译器弃用了 Loop Vectorization 做了加速。
4.5 手动 Unroll
上文基础上,我们直接在 loop 里面手动 unroll 试试
1 | public void Execute() |
看一下生成机器码,
1 | .LBB3_4: |
赋值的部分确实和上面 Loop Vectorization 一样,一次赋四个了,
然后感觉主要省略的计算是 index 的计算,
这时,耗时变成了 102 ms。这或许告诉我们,
- 有时候手动 unroll 可能效果和编译器 Loop Vectorization 差不多。
- 减少 ALU 计算量可能是优化的最方便途径
4.6 Index 局部自增
我们直接在 Loop X 这层不算index了,直接自增。另外我们预先计算 chunkStartPos
1 |
|
看一下代码,没问题自动做了 Loop Vectorization,
最后耗时 96.02 ms,比上面快一点。
4.7 Baseline
这个我们发挥编译器最大优势,直接写个最简单的
1 | public void Execute() |
最后耗时 85.02 ms,这个应该是极限了。
4.8 优化案例的总结
ID | 方法描述 | 耗时 | 性能 |
---|---|---|---|
1 | 原始方法 | 152.03 | 1 |
2 | 尝试点乘计算 Index | 155.57 | 0.98x |
3 | 尝试 SIMD点乘 计算Index | 144.25 | 1.05x |
4 | Index 计算直接使用XYZ | 131.56 | 1.18x |
5 | 手动 unroll | 102.00 | 1.49x |
6 | 内层 Index 自增 | 96.02 | 1.58x |
7 | 基准版本 | 85.02 | 1.79x |
相比最初版本,理论极限性能应该能提升 1.79x,而我们符合业务逻辑的构造里面,性能提升了 1.58 倍 还算不错。
这个例子中我们学到的:
- 不要相信 Unity.Mathematic 会自动做 SIMD 运算,而且SIMD 运算不一定能变快
- new 一个 struct 不一定会分配栈内存,有可能直接进寄存器了。
- loop vectorization 确实能变快,手动 unroll loop 可能也可以
- 减少 ALU 计算量,做缓存永远是值得相信的
5. 结论
从上面测试和分析我们可以看出,虽然使用 Burst Compiler 能较大提升计算密集型任务的性能,但了解其实现机制仍然有助于性能优化,在例子中我们直接将性能提升了 1.6 倍。
Burst Compiler 是基于 LLVM 的,因此编译器自身的优化能力较强,但也有一些限制使得编译器无法自动优化。
从上面的结论中我们学到的:
- 就算加上了 [BurstCompile] 的 Attribute 也还有不少优化空间
- 和 C# 不太一样,Burst 里面没有托管内存,有 SIMD,有人叫它 HPC# ,它优化起来更像 shader
- 不要相信一些数学计算会自动做 SIMD 运算,而且 SIMD 运算不一定能变快。但使用 intrinsic SIMD 计算说不定会变快
- 尽量利用好编译器的机制,比如 Loop Vectorization,如果利用不好,手动 unroll 可能也行
- 减少 ALU 计算量,做缓存永远是值得相信的
- 上面原则都可以忘了,但别忘了看 Profile 结果
参考资料
Supercharging mobile performance with ARM Neon and Unity Burst Compiler
Intrinsics: Low-level engine development with Burst - Unite Copenhagen 2019 (slides)