Post | Sanctuary
top of page
  • Join our Discord!
  • Join our Kickstarter!

Burst로 작업하기

이건 제가 올린 다른 개발자 로그에 비하면 짧고, 기술적인 글이 될 겁니다.


이번에, 저는 Burst를 통한 AVX 경험을 공유하려고 합니다. 저와 같은 개발자들에게 굉장히 유용할지도 모르는 지식이니까요.



Burst의 세계는 어떻게 돌아갈까?

간단한 루프 생성


간단하다고 써놨으니 간단하겠죠?

제가 경험해본 바로는, 아니었습니다.

여기 예시를 하나 들어봅시다 :

[BurstCompile] public struct VectorTest: IJob { public int size; public NativeArray<float> data; public void Execute() { for (int i = 0; i < size; i++) { data[i] += 10; } } }


이게 그 루프가 방출하는 코드입니다. 벡터화된 코드는 마우스 포인터가 가리키고 있고요.

8 x float는 AVX 256에서 예상되듯이 크기가 8이라는 의미입니다.



또다른 예시는 sse나 SIMD 확장을 스트리밍할 때입니다. SIMD는 병렬 컴퓨팅의 한 종류로, 하나의 명령어로 여러 개의 값을 동시에 계산하는 방식입니다. 128비트 크기니, 4x죠.



half 데이터 타입을 바꿔봅시다. half는 float의 절반이기 때문에 폭은 2배, 그러니 32여야 한다는 뜻이죠?



흠...

보시다시피, 32는 나오지 않았습니다. 무슨 일이 있었던 걸까요?

이는 avx2도, sse도 half를 지원하지 않아서 생기는 현상입니다. 이들은 short, byte, 혹은 double을 지원하죠. 지금 이 코드가 하는 것은 half를 float로 변환해 느린, 비 벡터화된 방식으로 더하는 것입니다.


아무튼, 다음 예시로 옮겨가봅시다.


[BurstCompile] public struct VectorTest : IJob { public int size; public NativeArray<float3> data; public void Execute() { for (int i = 0; i < size; i++) { data[i] *= 10; } } }


float를 float3로 바꾸었을 뿐이니까 괜찮겠죠? 크기가 3배 커졌을 뿐이지 데이터의 내용은 동일하니까요.




이런, 뭐가 잘못된 거죠? 작동하지 않는군요. 무작위 숫자를 넣어서 시도해봅시다.

[BurstCompile] public struct VectorTest : IJob { public int size; public NativeArray<float3> data; public void Execute() { for (int i = 0; i < 8; i++) { data[i] *= 10; } } }


자, 크기를 8로 바꿨습니다. 크게 달라진 건 없죠?

기술적으로 말해서 벡터화된 것은 맞지만, 방출된 IR을 보면, AVX 지시가 사용되지 않았다는 것을 알 수 있습니다. sse로 변경해서 지시 사항의 변경이 없다는 것을 통해 검증해볼 수 있죠.



실질적으로 방출되는 최종 결과물은 이러합니다:



보시다시피, 여기서는 sse 지시가 사용되었죠.


이것이 생성된 어셈블리입니다.




지시 vaddps를 포함하고 있는데,

에서 찾아봐서 벡터 확장자라는 것을 확인하실 수 있습니다.

하지만 여기서 실제로 일어나고 있는 현상은 무엇일까요?

음, float2/3/4를 사용할 때 컴파일러는 벡터 지시를 개별적으로 생성합니다.

당신의 코드는 이런 식으로 나오게 되죠:


[BurstCompile] public struct VectorTest : IJob { public int size; public NativeArray<float3> data; public quaternion rotation; public void Execute() { for (int i = 0; i < 1; i++) { var innerData = data[i]; for (int j = 0; j < 3; j++) { innerData[i]+=10; } } } }


그래서 코드가 내부 루프를 벡터화하고 작업을 완료했다고 하는 겁니다. 외부 루프의 경우, 외부 루프가 너무 커졌을 때 풀어내는 게 불가능하고요.

이는 루프 풀어내기가 같은 코드를 복사 후, 다른 인덱스를 사용해 붙여넣는 과정이기 때문입니다. 최적화에 도움이 되지만 계속 이걸 반복할 수도 없는데, 새 지시를 로딩하는 데 필요한 연산이 루프화를 피해서 절감되는 연산보다 더 늘어나게 되버리니까요

아무튼, 이것도 벡터화할 필요가 있다면, 내부 루프와 외부 루프를 이런 식으로 구성하면 됩니다.


이건 제가 벡터화 방식으로 유닛들을 루프한 예시입니다:

var indexOffset = 0; for (var i = 0; i < (unitCount + vectorSize - 1) / vectorSize; i++) { //this loop is vectorized for (var index = 0; index < vectorSize; index++) { var realIndex = index + indexOffset; var slot = freeSlots[realIndex]; var result = slot * spacing; result = math.mul(rotation, result); result += futureSight; result += formationCenter; tempMem[index] = result; } for (int index = 0; index < vectorSize; index++) { var result = tempMem[index]; var realIndex = index + indexOffset; if (realIndex >= unitCount) break; var unit = units[realIndex]; formationOffsets[unit.id] = result; Assert.IsTrue(math.isfinite(formationOffsets[unit.id]).All()); } indexOffset += 8; }

보시다시피, 벡터화된 루프 1은 연산에 많은 값을 소모합니다. 특히 대략 비슷하게 분해되는 float3 증가의 4원수에서 말이죠.




보시다시피 float3 증가가 스팸되는데, 좀 더 나은 작업을 할 수 있을 겁니다.



약간의 수동 벡터화


유니티 엔진의 자동 벡터화 기능을 어떻게 사용하는지 알아내기 전에 이 코드를 작성해서, 전부 수동으로 이루어졌습니다

이 코드가 하는 일은, AVX에서 지점 사이의 거리들을 계산하는 것입니다.


우리가 하려는 작업은 이 기능들을 실제로 적용하는 것이고요.

public static float dot(float2 x, float2 y) { return x.x * y.x + x.y * y.y; }


public static float lengthsq(float2 x) { return dot(x, x); }


public static float distancesq(float2 x, float2 y) { return lengthsq(y - x); }



var cellUnitPosVector = X86.Avx.mm256_load_ps(cellPositionsPtr + unitInCellIndex); ///4개의 유닛 위치를 포함하고 있습니다

//vectorPosition은 4번 복붙된 vector 2입니다 var offsetCellPositions = X86.Avx.mm256_sub_ps(cellUnitPosVector, vectorPosition); //cellUnitPosVector - vectorPosition var dotMul = X86.Avx.mm256_mul_ps(offsetCellPositions, offsetCellPositions); //x.x * y.x 고 x.y * y.y니, x와 y가 같다는 점을 이용할 수 있습니다

// x1 y1 x2 y2...

// *

// x1 y1 x2 y2... var dotAdd = X86.Avx.mm256_hadd_ps(dotMul, dotMul); //이것은 모든 두 번째 값이 이전과 같게 나오는 벡터를 만들어냅니다. 두 번째 값을 전부 버려서 v128로 전환할 필요가 있죠. if (sqrtDistance) { dotAdd = X86.Avx.mm256_sqrt_ps(dotAdd); } result = new float4 { [0] = dotAdd.Float0, [1] = dotAdd.Float1, [2] = dotAdd.Float4, [3] = dotAdd.Float5 };


보시다시피, 모든 avx들은 x86.Avx 네임스페이스 안에 포함되어있습니다.

동일한 float2를 4번 로드하는 것이죠. 예시: [1,2,1,2,1,2,1,2]

y - x를 mm256_sub_ps와 같이 진행하세요./

그 다음 dot 함수에서 우리가 기본적으로 해야 할 일은:

x.x * x.x , x.y * x.y

하지만 + 파트도 진행해야 합니다.

그러니 우리는 모든 쌍을 합쳐야 하고, 결과물은 4 float 크기가 나오게 되죠.


인텔 웹사이트를 보시면, 이 지시가 mm256_hadd_ps인 것을 알 수 있습니다.



그냥 brace들의 값을 32로 나누면 됩니다. 보시다시피,

dst[0] = a[1] + a[0]

dst[1] = a[3] + a[2]

...

dst[4] = a[5] + a[4]

dst[5] = a[7] + a[6]


와 같이 이어지게 되죠.

그리고 우리는 수동으로 모든 값을 추출해내면 됩니다. 다행히도, 복잡하고 사양을 많이 잡아먹는 과정은 AVX가 해결해준 덕분에 비교적 단순한 편이죠.


작업을 끝내기 전에, 제곱근을 구할 필요가 있는지 확인해야만 합니다. 그리고 8개의 루트 중 4개만 제곱하면 되기 때문에 다소 비효율적일 수 있지만, 그렇게 하면 실제로 데이터를 준비하는 데 더 많은 작업이 필요할 수 있습니다.


작업에 대해서 질문하고 싶은 것이 있다면, 디스코드를 통해 물어보세요.


여기까지 읽어주셔서 감사합니다, Badump가.




15 views0 comments
bottom of page