Unity 社区
7
1

Burst 1.3.0 新功能介绍 | 加强版混叠支持

Unity技术博客
阅读 1499
2020年9月19日
Unity Burst Compiler可将C#代码转换成高度优化的机器码。自一年前Burst Compiler首个稳定版发布以来,编译器的质量、使用体验和耐用度一直在提升。在本次发布的Burst 1.3新主要版本中,介绍一个专门针对性能优化的功能——加强版混叠支持。
新的编辑器内部指令Unity.Burst.CompilerServices.Aliasing.ExpectAliased和Unity.Burst.CompilerServices.Aliasing.ExpectNotAliased可以让用户深入了解编译器理解代码的方式。指令加上拓展后的[Unity.Burst.NoAlias]属性支持,可以让用户在追求性能时如虎添翼。
本文将解释什么是混叠,如何使用[NoAlias]属性来弄清数据结构内存的混叠现象,及如何使用新的编译器指令来保证编译器能以你的思路来理解代码。
什么是混叠
混叠是指两个数据指针同时指向一个内存位置的现象。
int Foo(ref int a, ref int b) { b = 13; a = 42; return b; }
上方是一个经典的混叠问题——如果没有额外信息,编译器无法得知a与b是否有混叠,导致生成如下汇编码:
mov dword ptr [rdx], 13 mov dword ptr [rcx], 42 mov eax, dword ptr [rdx] ret
可以看到它会:
储存13到b。
储存42到a。
重新加载b的值,返回值。
之所以重新加载b,是因为编译器并不清楚a和b是否占有同样的内存。如果是,则b将包含值42,如果没有,则包含值13。
复杂示例演示
请观察下方的简单任务:
[BurstCompile] private struct CopyJob : IJob { [ReadOnly] public NativeArray Input; [WriteOnly] public NativeArray Output; public void Execute() { for (int i = 0; i < Input.Length; i++) { Output[i] = Input[i]; } } }
上方任务只是简单地将一个缓存复制到另一个缓存。如果输入和输出没有混叠,即每个内存位置相互不重叠,则任务的输出应为:
如果编译器明确两个缓存并没有混叠,如上方代码示例中的Burst,则编译器能同时复制N个数据,不会逐个复制。
接着来看看上方的输入和输出出现混叠现象时会发生什么。首先,安全系统会捕捉到混叠,向用户反馈可能有错误出现。但这里假设安全系统被关闭,那会发生什么呢?
可以看到,由于内存位置有轻微重叠,输入端的数值会覆盖整个输出缓存。假设编译器错误地认为内存位置没有混叠,仍旧直线复制数据会发什么?
可怕的事发生了——输出不会储存你想要的数据。
混叠现象限制了Burst编译器优化代码的能力,对矢量化的影响尤大:如果编译器认为循环中的变量有混叠的可能,便无法安全地矢量化循环。在Burst 1.3.0及以上版本中,混叠的技术支持经过拓展与改进,性能有了极大的提升。
[NoAlias]属性介绍
在Burst 1.3.0中,[NoAlias]属性在拓展后可放置于四处位置:
放置于一个函数上时,表示函数不会与其它任何函数、或“this”指针混叠。
放置于字段中时,表明字段不会与结构的其它字段混叠。
放置于结构本身时,表明结构的地址并不会出现在自身内。
放置于函数返回值时,表明返回的指针不会与同个函数返回的任何指针混叠。
在字段和参数中,如果字段或参数类型为结构体,“不与X混叠”意味着结构体字段中的所有指针(包括间接引用的字段)一定不会与X混叠。
而在参数中,带[NoAlias]属性的参数必然不会与参数本身(通常为包含数据的Job结构体)混叠。比如Entities.ForEach()会储存所有lambda表达式取得的变量。
接下来我们将用例子逐个介绍这些用例。
NoAlias函数参数
再次看到上方的Foo示例,如果在其中添加进属性[NoAlias],看看会发生什么:
int Foo([NoAlias] ref int a, ref int b) { b = 13; a = 42; return b; }
函数将转换为:
mov dword ptr [rdx], 13 mov dword ptr [rcx], 42 mov eax, 13 ret
注意“b”的数据被替换成了将常量13移动到返回缓存中。
NoAlias结构字段
我们将上方示例用结构体来表示:
struct Bar { public NativeArray a; public NativeArray b; } int Foo(ref Bar bar) { bar.b[0] = 42.0f; bar.a[0] = 13; return (int)bar.b[0]; }
上方代码将生成以下汇编码:
mov rax, qword ptr [rcx + 16] mov dword ptr [rax], 1109917696 mov rcx, qword ptr [rcx] mov dword ptr [rcx], 13 cvttss2si eax, dword ptr [rax] ret
用口头语言可表达为:
将“b”的数据地址加载到rax寄存器。
存入42(1109917696即0x42280000,等于42.0f)。
将“a”的数据地址加载到加载到rcx寄存器。
存入13。
重新加载“b”数据,将其转化为整数来返回。
假定用户清楚两个NativeArray数据并未储存在同个内存上,则可以编写如下代码:
struct Bar { [NoAlias] public NativeArray a; [NoAlias] public NativeArray b; } int Foo(ref Bar bar) { bar.b[0] = 42.0f; bar.a[0] = 13; return (int)bar.b[0]; }
通过给a和b同时添加[NoAlias]属性,我们通知编译器不能在结构中混淆两个变量,则汇编码将变成如下:
mov rax, qword ptr [rcx + 16] mov dword ptr [rax], 1109917696 mov rax, qword ptr [rcx] mov dword ptr [rax], 13 mov eax, 42 ret
注意编译器现在只能返回整数常量42了。
结构体上的NoAlias
在近乎所有创建起来的结构体中,结构体的指针并不会出现在函数本身之内。但也有一些例外:
unsafe struct CircularList { public CircularList* next; public CircularList() { // The 'empty' list just points to itself.这个“空”列表指向了自己。 next = this; } }
列表是少数几种可在结构体内访问自身的结构之一。
接着来看看[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]; }
它会生成如下汇编码:
mov rax, qword ptr [rcx + 8] mov dword ptr [rax], 42 mov rax, qword ptr [rcx + 8] mov ecx, dword ptr [rcx] movss xmm0, dword ptr [rax + 4*rcx] ret
能看到脚本会:
加载“p”到rax。
存入42到“p”。
再次加载“p”到rax。
加载“i”到ecx。
返回索引“i”到“p”。
为什么“p”会加载两次?因为编译器并不清楚“p”是否指向了结构体本身。当编译器储存42到“p”上后,为了以防万一还会从“结构体”上重新加载“p”的地址,造成性能浪费。
现在添加[NoAlias]:
[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]; }
脚本会生成如下汇编码:
mov rax, qword ptr [rcx + 8] mov dword ptr [rax], 42 mov ecx, dword ptr [rcx] movss xmm0, dword ptr [rax + 4*rcx] ret
注意代码仅加载了一次“p”的地址,因为编译器清楚“p”不会指向“结构体”。
NoAlias函数返回值
部分函数只能返回独一无二的指针,比如malloc。这种情况下,[return:NoAlias]可以向编译器提供实用的信息。
我们来使用堆栈分配和线性分配器举例:
// Only ever returns a unique address into the stackalloc'ed memory.(仅返回一个独特的地址到分配堆栈后的内存。) // We've made this no-inline as the compiler will always try and inline(由于编译器会内联此类小函数,我们将其设为了强制不内联,) // small functions like these, which would defeat the purpose of this(防止示例不能展示出效果。) // example! [MethodImpl(MethodImplOptions.NoInlining)] 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的起始处。) alloca[0] = 1; int* ptr1 = BumpAlloc(alloca); int* ptr2 = BumpAlloc(alloca); *ptr1 = 42; *ptr2 = 13; return *ptr1; }
脚本会生成如下汇编码:
push rsi push rdi push rbx sub rsp, 544 lea rcx, [rsp + 36] movabs rax, offset memset mov r8d, 508 xor edx, edx call rax mov dword ptr [rsp + 32], 1 movabs rbx, offset "BumpAlloc(int* alloca)" lea rsi, [rsp + 32] mov rcx, rsi call rbx mov rdi, rax mov rcx, rsi call rbx mov dword ptr [rdi], 42 mov dword ptr [rax], 13 mov eax, dword ptr [rdi] add rsp, 544 pop rbx pop rdi pop rsi ret
汇编码比较多,其中关键步骤为:
在rdi中建立“ptr1”。
在rax中建立“ptr2”。
储存42到“ptr1”。
储存13到“ptr2”。
再次加载“ptr1”来返回值。
接着添加[return: NoAlias]属性:
// We've made this no-inline as the compiler will always try and inline // small functions like these, which would defeat the purpose of this // example! [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; }
它会生成:
push rsi push rdi push rbx sub rsp, 544 lea rcx, [rsp + 36] movabs rax, offset memset mov r8d, 508 xor edx, edx call rax mov dword ptr [rsp + 32], 1 movabs rbx, offset "BumpAlloc(int* alloca)" lea rsi, [rsp + 32] mov rcx, rsi call rbx mov rdi, rax mov rcx, rsi call rbx mov dword ptr [rdi], 42 mov dword ptr [rax], 13 mov eax, 42 add rsp, 544 pop rbx pop rdi pop rsi ret
注意编译器不会再加载“ptr2”,而会将42移动到返回缓存中。
[return: NoAlias]只能用在一定会生成独特指针的函数上,比如上方的线性分配示例,或malloc函数。同时出于性能考虑,编译器会激进地内联函数,像上方的小函数有可能会被归到夫函数,产生相同的结果(所以函数被设为强制不内联)。
复制函数来减少混叠
在调用函数时,如果Burst明确函数参数间有混叠现象,编译器可以推测出混叠模式,将模式复制到调用的函数中,方便进一步优化。一起来看看下方示例:
// We've made this no-inline as the compiler will always try and inline // small functions like these, which would defeat the purpose of this // example! [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); } Previously the code for Bar would be: mov dword ptr [rcx], 42 mov dword ptr [rdx], 13 mov eax, dword ptr [rcx] ret
之所以会这样,是因为在Bar函数中,编译器并不清楚“a”与“b”是否有混叠。这种处理方式与其它编译技术是相同的。
但是Burst要更高明些,它能通过复制函数生成Bar的拷贝,拷贝中“a”与“b”并不会混叠,而原本对Bar的调用会被替换成对拷贝的调用,从而形成以下汇编码:
mov dword ptr [rcx], 42 mov dword ptr [rdx], 13 mov eax, 42 ret
编译器并未第二次加载“a”。
混叠检查
混叠对性能优化十分关键,因此我们添加了一些混叠内部函数:
Unity.Burst.CompilerServices.Aliasing.ExpectAliased表明两个指针的确有混叠,若没有则生成编译器错误。
Unity.Burst.CompilerServices.Aliasing.ExpectNotAliased表明两个指针没有混叠,若有则生成编译器错误。
示例:
using static Unity.Burst.CompilerServices.Aliasing; [BurstCompile] private struct CopyJob : IJob { [ReadOnly] public NativeArray Input; [WriteOnly] public NativeArray Output; public unsafe void Execute() { // NativeContainer attributed structs (like NativeArray) cannot alias with each other in a job struct!(由NativeContainer组成的结构,如NativeArray,不能在Job中互相混叠。) ExpectNotAliased(Input.getUnsafePtr(), Output.getUnsafePtr()); // NativeContainer structs cannot appear in other NativeContainer structs.(NativeContainer结构体不能出现在其它NativeContainer结构体中。) 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()); } }
这些内部函数可以将用户的意图传达给编译器。当代码生成的参数不满足内部函数的触发条件,函数并不会产生额外的运行时间。在对性能敏感的代码中,如果想要防止后期改动更改编译器对混叠模式的预测,可以使用这些函数。有了Burst和编译器控制后,我们就能用这类深度数据反馈让代码保持优化。
Job System混叠
Unity Job System有一些内置的混叠推测法,其规则如下:
任何带有[JobProducerType]的结构体(如应用了IJob、IJobParallelFor等的函数)都认定结构的所有字段都为[NativeContainer](如NativeArray、NativeSlice等),字段不会与同类型字段混叠。
以上规则不包括带[NativeDisableContainerSafetyRestriction]属性的函数。属性确定字段可与结构中其它任意字段相混叠。
任何带有[NativeContainer]的结构不得包含“this”指针。
在介绍完规则后,我们再用代码来详细了解它们:
[BurstCompile] private struct JobSystemAliasingJob : IJobParallelFor { public NativeArray a; public NativeArray b; [NativeDisableContainerSafetyRestriction] public NativeArray c; public unsafe void Execute(int i) { // a & b do not alias because they are [NativeContainer]'s.(a与b因为有[NativeContainer],并不会相互重叠。) ExpectNotAliased(a.GetUnsafePtr(), b.GetUnsafePtr()); // But since c has [NativeDisableContainerSafetyRestriction] it can alias them.(而c带有[NativeDisableContainerSafetyRestriction],可以重叠。) ExpectAliased(b.GetUnsafePtr(), c.GetUnsafePtr()); ExpectAliased(a.GetUnsafePtr(), c.GetUnsafePtr()); // No [NativeContainer]'s this pointer can appear within itself.(不带[NativeContainer]时,指针可出现在自身之内。) ExpectNotAliased(in a, a.GetUnsafePtr()); ExpectNotAliased(in b, b.GetUnsafePtr()); ExpectNotAliased(in c, c.GetUnsafePtr()); } }
来逐个剖析上方的混叠检查函数:
[JobProducerType]结构中的a与b应为都有[NativeContainer],因此不会混叠。
而c带有[NativeDisableContainerSafetyRestriction],可以与a或b混叠,a、b、c的指针并不能出现在结构中(即储存NativeArray的数据不能成为内容数组的数据)。
遵循这些混叠规则后,Burst可针对用户代码产生性能极高的优化。
常见用例
大部分用户会照下方BasicJob语法编写代码:
[BurstCompile] private struct BasicJob : IJobParallelFor { public NativeArray a; public NativeArray b; public NativeArray c; public NativeArray o; public void Execute(int i) { o[i] = a[i] * b[i] + c[i]; } }
代码会从三个数组加载数值、组合成果,并将其储存到第四个数组内,方便编译器生成矢量化代码,充分利用先进移动端和桌面端的强大CPU。
上方Job在Burst检视器中的样子如下:
能看到代码被矢量化了——编译器很好地完成了任务!正如之前解释的,Unity Job System认定Job结构中的变量不能与其它元素相混叠,所以才能完成矢量化。
但是在部分案例中,开发者在搭建数据结构时并没有声明结构间的混叠规则,如下方例子:
[BurstCompile] private struct NotEnoughAliasingInformationJob : IJobParallelFor { public struct Data { public NativeArray a; public NativeArray b; public NativeArray c; public NativeArray o; } public Data d; public void Execute(int i) { d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
在上方示例中,我们在BasicJob内以一个新结构Data来定义了数据内容,将其储存为唯一变量到父Job结构内。接着来看看Burst检视器里是怎么样的:
Burst很聪明地完成了矢量化,但是却需要在循环开始时检查指针是否有重叠。
根据Job系统的混叠规则,Burst可以明确结构体的直属变量成员不会有任何派生,进而推断储存变量a、b、c和o的本地数组是相同的变量,造成编译器执行一系列复杂而费劲的“这些指针是否相等?”绕圈圈式询问。那么怎么修复这些问题呢?用[NoAlias]属性向Burst解释。
[BurstCompile] private struct WithAliasingInformationJob : IJobParallelFor { public struct Data { [NoAlias] public NativeArray a; [NoAlias] public NativeArray b; [NoAlias] public NativeArray c; [NoAlias] public NativeArray o; } public Data d; public void Execute(int i) { d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
在上方的WithAliasingInformationJob中,我们在Data字段上新添了[NoAlias]属性,告知Burst:
a、b、c和o并不与带有[NoAlias]的Data数据混叠。
而每个变量带有[NoAlias]属性时,相互之间都不会混叠。
再次看到Burst检视器:
在改动之后,我们成功移除了所有耗时耗力的指针检查,可以运行矢量化的循环了——很棒!
使用Unity.Burst.CompilerServices.Aliasing内部函数还能防止未来意外的代码改动影响到混叠设定。比如:
[BurstCompile] private struct WithAliasingInformationAndIntrinsicsJob : IJobParallelFor { public struct Data { [NoAlias] public NativeArray a; [NoAlias] public NativeArray b; [NoAlias] public NativeArray c; [NoAlias] public NativeArray o; } public Data d; public unsafe void Execute(int i) { // Check a does not alias with the other three.(检查a是否与其它三个变量混叠。) ExpectNotAliased(d.a.GetUnsafePtr(), d.b.GetUnsafePtr()); ExpectNotAliased(d.a.GetUnsafePtr(), d.c.GetUnsafePtr()); ExpectNotAliased(d.a.GetUnsafePtr(), d.o.GetUnsafePtr()); // Check b does not alias with the other two (it has already been checked against a above).(检查b是否与剩余两个变量混叠。) ExpectNotAliased(d.b.GetUnsafePtr(), d.c.GetUnsafePtr()); ExpectNotAliased(d.b.GetUnsafePtr(), d.o.GetUnsafePtr()); // Check that c and o do not alias (the other combinations have been checked above).(检查剩下的c和o是否有混叠。) ExpectNotAliased(d.c.GetUnsafePtr(), d.o.GetUnsafePtr()); d.o[i] = d.a[i] * d.b[i] + d.c[i]; } }
在上方Job中,变量的检查并不会造成编译器错误,意味着[NoAlias]属性正确地帮助Burst检测、优化了脚本的混叠现象。
本文中的示例虽然都是人为杜撰的例子,但所讲的提示完全可以为现实中的代码带来不少好处。此外,我们推荐在修改代码时用上Burst检视器,方便向更优的代码跨进。
后记
Burst 1.3.0中的新工具可以进一步发掘代码的性能潜力,拓展、改进后的[NoAlias]属性可以很好地协助控制数据结构的运行,而新的编译器内部函数可带你了解编译器解读代码的方式。
如果尚未尝试过Burst,或想要进一步了解我们在面向数据技术栈(DOTS)上的努力,请访问DOTS页面。更多的学习资源和介绍演讲将陆续上线。
文中提及的相关链接:
[1] Unity Burst Compiler:
https://unity.com/dots/packages#burst-compiler https://unity.com/dots/packages#burst-compiler
[2] DOTS页面:
https://unity.com/dots https://unity.com/dots
发布于技术交流
1条评论

AI

全新AI功能上线

1. 基于Unity微调:专为Unity优化,提供精准高效的支持。

2. 深度集成:内置于团结引擎,随时查阅与学习。

3. 多功能支持:全面解决技术问题与学习需求。

AI