무엇을 찾고 계신가요?
Technology

버스트 컴파일러로 앨리어싱 개선

NEIL HENNING Compiler Warlock on Burst
Sep 7, 2020|18 Min
Hero image

Unity 버스트 컴파일러는 C# 코드를 고도로 최적화된 기계어로 전환합니다. 안정된 버전의 버스트 컴파일러가 1년 전 처음 출시된 이후 품질, 환경, 성능이 지속적으로 개선되었습니다. 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

위에서 볼 수 있듯이 컴파일러는 다음을 수행합니다.

  • b에 13 저장
  • a에 42 저장
  • b에서 값을 다시 로드하여 반환

여기에서 b가 다시 로드되는 이유는 a와 b가 동일한 메모리를 참조하는지 여부를 컴파일러가 알지 못하기 때문입니다. a와 b가 동일한 메모리를 참조한다면 b는 42라는 값을 갖게 되고 그렇지 않다면 13이라는 값을 갖게 됩니다.

보다 복잡한 예시

간단한 잡을 살펴보겠습니다.

[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];
}
}
}

위 예시는 한 버퍼에서 다른 버퍼로 복사하는 단순한 잡입니다. 위에서 Input과 Output 간에 앨리어싱 현상이 일어나지 않는다면, 예를 들어 Input과 Output이 참조하는 메모리 위치가 중복되지 않는다면 이 잡은 다음을 출력합니다.

위 코드 예시에서의 버스트 컴파일러처럼 두 버퍼 간에 앨리어싱 현상이 일어나지 않는다는 사실을 컴파일러가 알고 있는 경우, 컴파일러는 한 번에 한 개씩이 아닌 N개를 복사할 수 있도록 코드를 벡터화할 수 있습니다.

위에서 Input과 Output 간에 앨리어싱 현상이 일어나는 경우 어떤 일이 발생하는지 살펴보겠습니다. 먼저 안전 시스템이 이러한 일반적인 사례를 포착하고 실수가 발생했을 경우 사용자 피드백을 제공합니다. 하지만 안전 점검을 해제했다면 어떻게 될까요?

위에서 보듯, 메모리 위치가 다소 중복되므로 Input의 값 a가 Output 전체에 전파됩니다. 컴파일러가 메모리 위치에 앨리어싱 현상이 일어나지 않았다고 잘못 판단하여 이 예시도 벡터화했다고 가정해 보겠습니다. 무슨 일이 벌어질까요?

최악의 상황이 벌어집니다. Output에 예상했던 데이터가 들어 있지 않습니다.

앨리어싱은 코드를 최적화하는 Burst 컴파일러의 기능을 제한합니다. 이는 특히 벡터화에 심각한 영향을 미치는데, 그 이유는 컴파일러가 루프에 사용된 변수 중 하나라도 앨리어싱이 발생할 수 있다고 생각하는 경우, 일반적으로 루프를 안전하게 벡터화할 수 없기 때문입니다. Burst 1.3.0 이후 버전에서는 앨리어싱 지원이 확장되고 개선되어 앨리어싱 관련 성능이 크게 향상되었습니다.

[NoAlias] 속성 소개

Burst 1.3.0 버전에서는 [NoAlias] 속성이 배치될 수 있는 곳이 다음과 같이 네 곳으로 늘었습니다.

  • 함수 파라미터: 파라미터가 함수의 다른 파라미터나 ‘this’ 포인터와의 사이에서 앨리어싱 현상이 일어나지 않음을 나타냅니다.
  • 필드: 필드가 구조체의 다른 필드 사이에서 앨리어싱 현상이 일어나지 않음을 나타냅니다.
  • 구조체: 구조체의 주소가 해당 구조체 내부에 나타날 수 없음을 나타냅니다.
  • 함수 반환값: 반환된 포인터가 동일한 함수에서 반환된 다른 어떤 포인터와도 앨리어싱 현상이 일어나지 않음을 나타냅니다.

필드 유형이나 파라미터의 유형이 구조체인 경우, “X와 앨리어싱 현상이 일어나지 않는다”라는 말은 해당 구조체의 어떤 필드를 통해서든(심지어 간접적으로라도) 찾을 수 있는 포인터라면 모두 X와 앨리어싱 현상이 일어나지 않는다는 것을 뜻합니다.

파라미터의 경우, 파라미터에 [NoAlias] 속성이 있다면 구조체의 모든 데이터를 포함하는 this(주로 잡 구조체)와의 사이에서 앨리어싱 현상이 일어나지 않습니다. Entities.ForEach() 시나리오에서 this는 람다가 포착한 모든 변수를 포함합니다.

이제 각 사례의 예시를 차례대로 살펴보도록 하겠습니다.

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<int> a;
public NativeArray<float> 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, 즉0f).
  • ‘a’에 있는 데이터의 주소를 rcx에 로드한다.
  • 여기에 13을 저장한다.
  • ‘b’의 데이터를 다시 로드하고 반환하기 위해 정수로 바꾼다.

두 NativeArray가 동일한 메모리를 참조하지 않는다는 사실을 사용자가 알고 있다고 가정할 경우 다음과 같이 코드를 작성할 수 있습니다.

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];
}

a와 b 모두에 [NoAlias] 속성을 부여하여 컴파일러에 구조체 내에서 a와 b 간에 앨리어싱 현상이 절대로 일어나지 않는다는 사실을 알리면 다음과 같은 어셈블리가 출력됩니다.

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’가 구조체 bar의 주소를 가리키는지 여부를 컴파일러가 알지 못하기 때문입니다. 따라서 컴파일러는 42를 ‘p’에 저장한 다음 만약을 위해 ‘bar’에서 ‘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’가 ‘bar’의 포인터가 될 수 없다고 컴파일러에 알려주었기 때문에 ‘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[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’를 갖는다.
  • ‘ptr1’에 42를 저장한다.
  • ‘ptr2’에 13을 저장한다.
  • ‘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처럼 고유 포인터를 출력하는 것이 100% 확실한 함수에만 사용해야 합니다. 컴파일러가 성능을 고려하여 함수를 적극적으로 인라인 함수로 처리한다는 점도 중요합니다. 위와 같은 작은 함수는 부모 함수 안에 인라인 처리될 가능성이 높으며 해당 속성 없이도 같은 결과를 출력합니다(위 예시에서는 호출된 함수에 인라인 처리를 강제로 금지했습니다).

우수한 앨리어싱 추론을 위한 함수 클로닝

파라미터 간의 앨리어싱 현상에 대해 알고 있는 함수 호출의 경우, 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는 이보다 효과적으로 대처합니다. 함수 클로닝 과정에서 Burst는 ‘a’와 ‘b’의 앨리어싱 프로퍼티에 앨리어싱 현상이 일어나지 않는다는 사실을 아는 Bar의 복사본을 만들고, 원본 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<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());
}
}

위 내부 함수를 통해 사용자가 알고 있는 모든 정보를 컴파일러도 가지고 있다는 것을 확인할 수 있습니다. 이들은 컴파일 시간 점검에 사용됩니다. 내부 함수용 인수를 출력하기 위해 작성한 코드에 부작용이 없을 경우 앨리어싱 내부 함수에 런타임 비용이 들지 않습니다. 위 함수는 코드가 성능에 민감하며, 향후 코드 변경에도 컴파일러가 앨리어싱에 대해 하는 가정이 변경되지 않게 하려는 경우에 특히 유용합니다. Burst와 컴파일러에 대한 제어를 바탕으로 유니티는 컴파일러에서 발생하는 심도 있는 피드백을 사용자에게 제공하여 코드를 최적화 상태로 유지합니다.

잡 시스템 앨리어싱

Unity 잡 시스템에는 앨리어싱에 대한 몇 가지 가정이 내장되어 있습니다. 규칙은 다음과 같습니다.

[JobProducerType]이 있는 모든 구조체(IJob, IJobParallelFor 등)는 [NativeContainer](NativeArray, NativeSlice 등)인 구조체의 어떤 필드도 동일한 [NativeContainer]인 다른 필드와 앨리어싱 현상이 일어나지 못한다는 사실을 안다.

위는 [NativeDisableContainerSafetyRestriction] 속성이 있는 필드를 제외하고 참이다. 이러한 필드의 경우 사용자는 해당 필드가 구조체의 다른 모든 필드와 앨리어싱 현상이 일어날 수 있다는 것을 명시적으로 잡 시스템에 알린 상태이다.

[NativeContainer]가 있는 어떤 구조체도 해당 구조체 내부에 구조체의 ‘this’ 포인터를 가질 수 없다.

공식적인 정의를 마쳤으므로 이제 위 규칙을 더 잘 설명할 수 있는 코드를 살펴보도록 하겠습니다.

[BurstCompile]
private struct JobSystemAliasingJob : IJobParallelFor
{
public NativeArray<float> a;
public NativeArray<float> b;

[NativeDisableContainerSafetyRestriction]
public NativeArray<float> c;

public unsafe void Execute(int i)
{
// a & b do not alias because they are [NativeContainer]'s.
ExpectNotAliased(a.GetUnsafePtr(), b.GetUnsafePtr());

// But since c has [NativeDisableContainerSafetyRestriction] it can alias them.
ExpectAliased(b.GetUnsafePtr(), c.GetUnsafePtr());
ExpectAliased(a.GetUnsafePtr(), c.GetUnsafePtr());

// No [NativeContainer]'s this pointer can appear within itself.
ExpectNotAliased(in a, a.GetUnsafePtr());
ExpectNotAliased(in b, b.GetUnsafePtr());
ExpectNotAliased(in c, c.GetUnsafePtr());
}
}

위 앨리어싱 점검을 살펴보면 다음과 같습니다.

  • a와 b 모두 [JobProducerType] 구조체 안에 [NativeContainer]가 포함되어 있기 때문에 둘 사이에는 앨리어싱 현상이 일어나지 않는다.
  • 그러나 c에 [NativeDisableContainerSafetyRestriction] 필드 속성이 있기 때문에 a 또는 b와 앨리어싱 현상이 일어날 수 있다.
  • a, b, c를 가리키는 각 포인터는 그 내부에서 나타날 수 없다(예를 들면 여기에서 NativeArray의 기반이 되는 데이터는 배열 콘텐츠의 기반이 되는 데이터일 수 없다).

Burst는 여러 빌트인 앨리어싱 규칙을 사용하여 대부분의 사용자 코드를 훌륭한 품질로 최적화합니다.

일반적인 사용 사례

많은 사용자가 아래의 BasicJob을 따라 코드를 작성합니다.

[BurstCompile]
private struct BasicJob : IJobParallelFor
{
public NativeArray<float> a;
public NativeArray<float> b;
public NativeArray<float> c;
public NativeArray<float> o;

public void Execute(int i)
{
o[i] = a[i] * b[i] + c[i];
}
}

코드는 3개의 배열에서 로드한 후 그 결과를 결합하여 4번째 배열에 저장합니다. 이런 종류의 코드는 컴파일러에서 벡터화된 코드를 생성하기 좋기 때문에 현재 모바일과 데스크톱에서 사용되는 강력한 CPU를 최대한 활용합니다.

위 잡의 Burst Inspector 뷰를 살펴보면 다음과 같습니다.

컴파일러가 효과적으로 작동하여 코드가 벡터화된 것을 볼 수 있습니다. 컴파일러가 벡터화할 수 있는 이유는 위에서 설명했듯이 Unity 잡 시스템에는 잡 구조체에 있는 각 변수와 해당 구조체의 다른 멤버 간에 앨리어싱 현상이 일어나지 못하도록 하는 규칙이 있기 때문입니다.

그러나 앨리어싱이 어떻게 작동하는지에 관해 Burst가 아무런 정보를 갖고 있지 않은 데이터 구조물을 만드는 개발자들도 있습니다. 예를 들면 다음과 같습니다.

[BurstCompile]
private struct NotEnoughAliasingInformationJob : IJobParallelFor
{
public struct Data
{
public NativeArray<float> a;
public NativeArray<float> b;
public NativeArray<float> c;
public NativeArray<float> o;
}

public Data d;

public void Execute(int i)
{
d.o[i] = d.a[i] * d.b[i] + d.c[i];
}
}

위 예시에서는 새로운 구조체 Data에 BasicJob의 데이터 멤버를 래핑하고, 이 구조체를 부모 잡 구조체의 유일한 변수로 저장했습니다. 이제 Burst Inspector에서 무엇이 출력되는지 확인해 보겠습니다.

Burst는 이 예시를 효과적으로 벡터화했지만, 사용된 모든 포인터가 루프 초반에 중복되지 않는지 확인해야 합니다.

이는 잡 시스템의 앨리어싱 규칙이 구조체의 멤버 변수에만 적용되고, 나머지 부차적인 요소에는 적용되지 않기 때문입니다. 따라서 Burst는 변수 a, b, c, o의 기반이 되는 원시 배열이 동일한 변수라고 가정해야 합니다. 결국 ‘이 포인터가 실제로 서로 동일한가?’라는 복잡하면서도 성능을 저하시키는 문제가 발생합니다. 그렇다면 어떻게 이 문제를 해결해야 할까요? [NoAlias] 속성을 사용하면 됩니다.

[BurstCompile]
private struct WithAliasingInformationJob : IJobParallelFor
{
public struct Data
{
[NoAlias]
public NativeArray<float> a;
[NoAlias]
public NativeArray<float> b;
[NoAlias]
public NativeArray<float> c;
[NoAlias]
public NativeArray<float> o;
}

public Data d;

public void Execute(int i)
{
d.o[i] = d.a[i] * d.b[i] + d.c[i];
}
}

위의 WithAliasingInformationJob 잡에서 Data 필드에 새로운 [NoAlias] 속성이 설정된 것을 확인할 수 있습니다. [NoAlias] 속성은 Burst에 다음과 같은 사실을 전달합니다.

  • a, b, c, o와 [NoAlias] 속성이 있는 데이터의 다른 멤버 간에는 앨리어싱 현상이 일어나지 않는다.
  • 따라서 모든 변수에는 [NoAlias] 속성이 있으므로 각 변수와 구조체의 다른 모든 변수 간에는 앨리어싱 현상이 일어나지 않는다.

다시 Burst Inspector를 살펴보도록 하겠습니다.

이렇게 변경하면 비용이 많이 드는 런타임 포인터 확인 과정을 생략하고 벡터화된 루프를 실행하는 데에만 집중할 수 있습니다.

새로운 Unity.Burst.CompilerServices.Aliasing 내장 함수를 사용하면 실수로 코드를 변경하여 이후 앨리어싱에 영향을 미치는 일이 없도록 보장할 수 있습니다. 예를 들면 다음과 같습니다.

[BurstCompile]
private struct WithAliasingInformationAndIntrinsicsJob : IJobParallelFor
{
public struct Data
{
[NoAlias]
public NativeArray<float> a;
[NoAlias]
public NativeArray<float> b;
[NoAlias]
public NativeArray<float> c;
[NoAlias]
public NativeArray<float> o;
}

public Data d;

public unsafe void Execute(int i)
{
// Check a does not alias with the other three.
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).
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).
ExpectNotAliased(d.c.GetUnsafePtr(), d.o.GetUnsafePtr());

d.o[i] = d.a[i] * d.b[i] + d.c[i];
}
}

앨리어싱 확인 과정은 위 잡에서 컴파일러 오류를 발생시키지 않습니다. [NoAlias] 속성을 추가했기 때문에 Burst는 이 사례를 감지하고 최적화하기에 충분한 정보를 가지고 있습니다.

이는 간결한 포스팅을 위해 고안해 낸 예시이긴 하지만, 이와 같은 앨리어싱 힌트를 통해 실제 환경에서 코드의 성능을 크게 향상할 수 있습니다. 코드 수정을 반복하여 수행할 때 Burst Inspector를 사용하면 한 단계 더 최적화된 성능을 경험하실 수 있습니다.

마무리

Burst 1.3.0의 릴리스를 통해 유니티는 코드의 성능을 극대화할 수 있는 새로운 도구를 제공합니다. 확장되고 개선된 [NoAlias] 지원을 통해 데이터 구조의 작동 방식을 완벽하게 제어할 수 있습니다. 새로운 컴파일러 내부 함수를 사용하면 컴파일러가 코드를 이해하는 방식을 살펴볼 수 있습니다.

아직 Burst를 사용해본 적이 없고, 새로운 데이터 지향 기술 스택(DOTS)에 대해 자세히 알아보고 싶다면 DOTS 페이지를 방문해 보세요. 추후 더 많은 학습 리소스와 강연 링크 등이 추가될 예정입니다.

유니티는 언제나 사용자의 의견을 환영합니다. 여기에서 포럼에 참여하여 유니티가 Burst를 개선할 수 있도록 도와주세요.