2022. 5. 25. 16:59ㆍ개인 공부 및 연구
- 2022/05/25 (최초 작성)
- 2022/05/25 (마지막 수정)
발 번역 주의보, Figures나 예제 코드는 빠져있을 수 있습니다.
개인 공부 및 연습용으로 번역된 글입니다. 의역/오역/오타가 많을 수 있으며. 원저작권자의 별도의 허가 없이 작성되었으므로, 언제든지 삭제될 수 있습니다.
원본 아티클
GPU 메모리 앨리어싱 (GPU Memory Aliasing)
by Pavlo Muratov
목차
개요
DirectX 12나 Vulkan 같은 모던 그래픽 API들은 유저가 수동으로 생성한 힙에서 지정한 메모리 위치에 GPU 리소스를 할당해서 배치할 수 있도록 해줍니다. 그래서 메모리가 일부, 더 나아가 완전히 겹치는 텍스처나 버퍼를 만들 수 있게 되었습니다.
어느 상황에서 GPU 메모리 앨리어싱이 유용한가요?
렌더링 엔진은 각 단계가 GPU 리소스를 생성하고 소모하는 여러 단계로 이루어진 렌더링 작업으로 이루어진 컨베이어로써 조직되기 때문에. 렌더링 엔진은 흔히 파이프라인으로 취급되고는 합니다.
보통 게임 엔진에서의 한 프레임이 어떻게 렌더링 되는지 살펴보면 다음과 같을 것 입니다. 몇몇 지오메트리를 래스터라이즈 하고, 래스터라이즈 된 프래그먼트를 쉐이딩 하거나, 가끔은 부피가 있는 물체(volumetrics; 안개, 구름, 연기등)을 계산하기도 하며, 이후에 후처리(post-processing) 단계를 거칩니다. 이 때 여기서 표현된 각 단계는 텍스처 그리고 또는 버퍼에 그 결과들을 기록함으로써 프레임 안의 다음 단계들에서 읽혀질 출력을 만들어내게 됩니다.
여기서 주의 깊게 살펴보아야 할 점은, 이렇게 만들어진 리소스들이 오직 몇 안되는 특정 단계들에서만 사용 될 가능성이 높다는 것 입니다. 후 처리 렌더 패스들의 경우가 이런 경우에 대한 가장 좋은 예시 입니다. 블룸 패스가 만들어낸 출력 결과물은 톤 맵핑같은 바로 다음 스테이지에서만 쓰이고 그 외의 다른 단계들에서는 사실상 필요하지 않습니다. 이런 경우 리소스가 아주 짧은 시간의 수명을 가진다는 것을 볼 수 있습니다. 하지만 실제론 이 출력 리소스에 대한 메모리 공간을 미리 할당해두고, 전체 프레임 동안 이 메모리 공간을 유지한다는 사실을 알 수 있습니다. 이로부터 아무런 조치를 취하지 않는한, 아주 큰 파이프라인의 경우엔 앞의 예시와 같이 많은 수의 리소스들이 생성되서, 자신의 메모리 공간을 영구적으로 점유하고, 그 결과 많은 양의 VRAM을 낭비하게 된다는 사실을 알 수 있습니다.
이 문제를 해결하기위해 가장 먼저 생각 해볼 수 있는 방법은 리소스가 짧은 시간의 프레임 동안만 사용된다면, 리소스가 맨 처음 필요할 때 리소스를 할당해주고, 더이상 필요 없을 때 이전에 할당한 것을 해제 해주도록 만들어서 VRAM의 낭비를 줄이는 방법을 생각해볼 수 있을 것 입니다. 물론 이런 방식도 메모리 사용량 측면에서는 도움이 될지 모르지요. 하지만 실질적으로는 잦은 메모리 재 할당으로 인해 잠재적인 이점들도 모두 덮어 버릴 정도의 성능 저하에 부딪히게 됩니다. 그러므로 이 방법은 유효한 해결법이라고 할 수 없습니다.
이 문제를 해결하는 가장 일반적인 방법은 바로 호환되는 특성을 가진 리소스들을 가지는 풀(Pool)을 가지도록 하는 것 입니다. 리소스 풀은 사용되고 있지 않은 리소스를 풀에서 끄집어 내서 사용한 다음, 작업이 끝나면 다음 작업들에서 똑같은 방식으로 사용할 수 있도록 사용한 리소스를 다시 풀에다 넣는 방식으로 새로운 할당을 피합니다.
실제로 이런 방식은 후처리 단계들에서는 아주 잘 작동 합니다. 예를 들어, 후처리 단계들은 보통 풀스크린 렌더 패스로 써 구현되므로 이들이 입력으로 써 받는 리소스 그리고 출력 하는 리소스 모두 동일한 특성을 가지게 됩니다. 즉, 기본적으로 전체 후처리 파이프라인을 위해서는 고작 2개의 텍스처만 실질적으로 할당해주면 된다는 것을 의미합니다. 본질적으로 이런 방식을 고수준 메모리 앨리어싱(High Level Memory Aliasing)이라고 부릅니다. 하지만 고수준의 앨리어싱은 유연하지 못합니다. 이런 방식은 후처리의 경우에 잘 작동할지 몰라도, 엔진의 다른 부분에서는 다양한 종류의 서로 호환되지 않는 리소스들이 많기 때고 이들은 같은 풀안에 넣을 수 없기 때문에 후처리 파이프라인과는 다르게 메모리 재사용성이 비효율적이게 될 수 있으므로, 다소 효용성이 떨어지게 됩니다.
바로 이런 문제로 부터 그래픽 API들의 저수준 메모리 앨리어싱(Low Level Memory Aliasing)이 그 존재가치를 들어 내고 증명하기 시작합니다. 메모리 앨리어스를 위해 API를 사용하게 되면 더 이상 리소스의 타입에는 신경을 쓰지 않아도 되고, 오직 리소스의 크기와 메모리 주소에 대해서만 집중할 수 있습니다. 리소스 풀에서 그랬던 것 처럼 여전히 퍼포먼스 저하를 피하기 위해서 사전에 메모리를 한번 할당합니다. 하지만 풀에서 그랬던 것 과는 다르게 리소스 타입과 상관없이 서로 겹치지 않는 기간동안 사용되는 리소스들을 같은 메모리 공간 상에 쌓아(겹쳐)둠으로 써, 더 효율적으로 메모리를 팩킹해둘 수 있습니다. 이런일이 가능한 이유는 저수준의 입장으로 보았을때 리소스들은 그저 VRAM안에 있는 바이트 덩어리일 뿐이기 때문 입니다.
알고리즘
자 이제 앨리어싱이 유용할 수 있음은 알았으니, 실제로 어떻게 효율적으로 메모리에 팩킹을 위한 알고리즘을 구현할지 알아내기만 하면 되겠지요?
시작부터 너무 스스로에게 무거운 짐을 안겨주지는 맙시다. 일단 비동기 작업 실행이나 이와 관련된 나머지 것들은 전부 옆에 제쳐두고, 각 작업이 순차적으로 실행되는 간단한 렌더 파이프라인에 대해서 먼저 생각해보도록 합시다.
자 한번 다양한 크기를 가지는 리소스를 읽는 순차적으로 실행되는 임의의 렌더패스들의 예를 한번 살펴봅시다.

우리가 먼저 해야할 것은 바로 각 리소스의 실효(實效) 수명(Effective Lifetime; EL)을 정하는 것 입니다. 왜냐하면 먼저 서로 동일한 시점에 사용되지 않는 리소스들만 얼라이어스해야지 메모리 커럽션을 피할 수 있기 때문입니다. 렌더 패스들이 순차적으로 실행되므로, 각각에 대해 실행 순서(index)를 정해줄 수 있고, 이 실행 순서들을 수명을 계산하는데 사용할 것 입니다.
각 리소스에 대해 리소스가 맨 처음 사용되는 렌더 패스의 인덱스를 리소스 EL 범위의 시작 점으로 사용 합시다. 그리고 이와 동일하게 리소스가 마지막으로 사용되는 렌더 패스의 인덱스는 EL 범위의 끝 점으로 사용합시다.
그렇게 각 리소스에 대해 EL을 정해주고 나면 아래와 같은 EL 범위들이 각 리소스에 대해 정해지게 됩니다.

다시 메모리 팩킹으로 돌아와서. 이 수명 범위들을 가지고 어떻게 하면 가능한한 작은 메모리 풋프린트를 가지도록 할 수 있을까요? 예시를 살펴보면 리소스들 중 가장 큰 리소스는 15 메가바이트의 크기를 가지는 D임을 알 수 있습니다, 그러니까 일단 수명을 신경쓰지 않는다면 모든 리소스가 리소스 D의 공간 위에 배치될 수 있으므로, 가장 최상의 시나리오에서는 필요한 메모리의 총량은 최소 가장 큰 크기의 리소스가 요구하는 메모리 양과 똑같다는 사실을 알 수 있습니다. (*역: 가장 큰 리소스가 모든 다른 작은 리소스들과 겹치지 않고, 한번에 사용되는 작은 리소스들의 메모리 사용량이 이 큰 리소스의 크기 이하인 경우)
이 사실은 가장 큰 크기의 리소스로 부터 시작해서 이 리소스의 메모리 공간 안에 마트료시카 인형처럼 더 작은 리소스들을 채울려고 시도하는게 메모리를 중첩시키는 좋은 아이디어라는 힌트를 알려 줍니다.
이 사실을 근거로 알고리즘의 다음 단계를 설계할 수 있게 되었습니다. 리소들을 리소스 크기에 대해 내림차순으로 정렬 해줍시다.

자 이제 정렬된 리소스 리스트를 사용해서 EL이 충돌하지 않으면서도 타이트하게 리소스의 메모리를 구성해보도록 합시다.
리스트의 제일 첫 번째 이자 가장큰 리소스인 (D)를 새로운 메모리 버킷에다 배치해봅시다.

당연하게도 맨 첫 번째 리소스는 버킷에 항상 들어 갑니다, 하지만 그 이후에 들어올 리소스들은 그렇지 않죠.
한번 리스트의 두 번째 리소스인 (A)를 가져와서 EL 충돌을 검사해서 (D)가 속해있는 버킷에 같이 넣어둘 수 있을지 봅시다.

이 둘의 수명이 서로 충돌하지 않기 때문에 (또는 겹치지 않기 때문에) 리소스 (A)는 (D)와 같은 버킷에 들어갈 수 있습니다.
어라 잠깐만요, (A)는 10 메가바이트의 크기를 가지는 것 외엔 다른 정보가 없으니까 15 메가바이트 버킷의 어떤 주소에도 배치될 수 있습니다. 그럼 실제로 어디다가 나두어야 할까요? 이때는 주소 0이 가장 자연스러운 선택인 것 같아보이니, 일단 (A)를 주소 0에다 배치해 줍시다.

다음으로 볼 리소스는 (J) 입니다. 하지만 이제 버킷안에 이미 두 개의 앨리어스된 리소스가 있는데, 이 다음엔 어떻게 진행해야 할까요? 이젠 (J)를 가져와서 이 리소스의 EL이 버킷 안에 있는 리소스들의 EL들과 잘 맞는지 보아야 합니다.

불행히도 리소스 (J)는 (A)와는 수명이 충돌하지 않지만, (D)와 충돌합니다. 즉, 메모리 측면에서 (J)는 (D)와 동일한 메모리 버킷상에 배치할 수 없습니다. 그러니 (J)를 버킷에 추가하는것은 일단 넘어가도록 하죠.
다음 리소스는 (K) 입니다, 하지만 (K)또한 (J)때와 마찬가지로 (D)와 충돌합니다. 그러니 (K)도 넘어가도록 하겠습니다.
다음은 (E)인데 여기서부턴 이제 조금 흥미로워 집니다.

이제 (D)가 아닌 (A)와 (E) 사이에 충돌이 생겼습니다. 그리고 이 충돌은 앞에서 살펴보았던 충돌 케이스들과는 다릅니다. 왜냐하면 (A)가 버킷 전체가아닌 오직 2/3 만을 점유하고 있다는 것입니다. 즉, 5 메가바이트의 메모리가 버킷에 남아있다는 것이죠. 그럼 리소스 (E)를 가져와서 (A)가 끝나는 지점인 10 메가바이트를 오프셋으로 설정해서 버킷안에 배치해보도록 합시다. 하지만 (E)의 크기는 8 MB이기 때문에 해당 버킷은 (E)를 위해 충분한 메모리 공간을 제공해줄 수 없으므로, 이번에도 결국 스킵되어야 합니다.
여러분이 지겨워 하실 수 도 있으니, 한번 리스트의 끝까지 바로 가보도록 합시다.
(I), (L), (B), (H)는 (D)와 충돌하기 때문에 스킵됩니다.
이제 다음으로 (F)인데 (F)는 (D)와 충돌하지 않지만 (E) 때 처럼 (A)와는 충돌합니다. 하지만 (F)의 크기는 3 메가바이트로 (A)다음에 배치해도 버킷안에 딱 들어갈 정도로 작습니다. 이 경우 (F)는 10에서 15 메가바이트 사이의 메모리 공간에 배치 될 수 있고, 실제로 그 공간에 (F)를 배치해볼 것 입니다.

다음은 (M)인데 이 친구도 (D)와 충돌하니까 넘기도록 하죠.
그 다음에는 리소스 (G)가 있는데 이 친구는 살짝 모호한 포지션을 가집니다. 왜냐하면 버킷안의 모든 얼라이어스된 리소스중에서 오직 (F)에게만 충돌하는데, 동시에 버킷 안에 들어갈 수 있을 정도로 작은 크기(2 메가바이트)를 가지기 때문 입니다. 즉, 버킷 안의 한 군데 이상의 메모리 영역에 배치될 수 있다는 것 이죠.

(G)는 0-10 그리고 13-15 범위 모두에 들어갈 수 있습니다. 그럼 도대체 어느 범위가 (G)를 배치하기에 더 적절할까요? 이런 경우에는 가장 작은 영역을 선택하는 것이 좋습니다. 왜냐하면 (G)를 더 큰 범위에 넣으면, 아직 버킷안에 앨리어스 되지 않은 잠재적인 리소스들의 수를 줄일 수 있기 때문입니다.
마지막으로 리소스인 (C) 프레임 안의 거의 대부분의 리소스와 충돌하기 때문에 스킵됩니다.
자 이제 4개 리소스 (D, A, F, G)의 앨리어싱이 끝이 났습니다. 그렇게 많지는 않지만, 분명히 아무것도 하지 않는 것 보다는 낫습니다. 그럼 이제 우리가 그냥 넘긴 리소스들은 어떻게 해야할까요? 남은 리소스들에 대해서는 이제 그냥 남은 리소스들중에 가장 큰 리소스(D,A,F,G를 제외한)를 사용해서 새로운 버킷을 만들고 앞의 알고리즘을 모든 리소스들이 메모리 버킷에 속할 때 까지 반복하기만 하면 됩니다.

위 예시에서 모든 리소스에 대한 앨리어싱을 끝나게 되면, 아무런 앨리어싱을 적용하지 않았을때 필요한 82 MB 보다 적은 52 MB의 메모리만 필요하게 되므로, 약 ~37%의 메모리 사용률 감소가 일어난 것을 알 수 있습니다. 물론 이경우엔 추상적인 예시이므로 실제 렌더 파이프라인에서는 다른 압축률을 보이게 될 것 입니다.
자, 이제 리소스들을 버킷들에 배치하는 직관적인 알고리즘을 개발했으니. 이제 이 알고리즘을 일반화시켜서 공식화 시킬 시간입니다.
일반적으로, 리소스를 버킷에 추가할려고 시도하면 새로운 리소스를 배치할 메모리 영역들을 찾아내야 합니다. 만약 이미 버킷안에 있는 그 어떤 리소스들과도 실효 수명이 충돌하지 않는다면, 추가할 새로운 리소스는 그 어떤 메모리 영역에라도 배치될 수 있습니다.
이러한 여유 영역들은 먼저 버킷 안에 있는 모든 이미 앨리어스된 리소스들을 순회해서 새로운 리소스와 수명이 교차하는지에 대한 여부를 확인해서 먼저 새로운 리소스와 충돌하는 모든 영역들을 수집함으로써 찾습니다. 이렇게 한다면, 메모리 영역은 앨리어스할 수 없게 됩니다.

한 번 이번에는 앨리어스 불가능한 메모리 영역들의 시점에서의 예시를 살펴봅시다.
우리는 어디에 여유 영역들이 위치해있는지에 대해서 말할 수는 있지만, 이들이 실제로 어떻게 주어지고, 어떤 패턴을 가지는지에 대해서는 말할 수 있을까요?
여유 영역들을 특정하기 위해서는 먼저 앨리어 앨리어스가 불가능한 메모리 오프셋을 시작점과 끝점 두개의 점으로 이루어진 스퀸스로써 다루어야 합니다.
위의 예시로부터 오프셋들을 모아서, 시작/종료 마크를 부여하고, 오름차순으로 정렬한다음에, "종료" 마커와 함께 버킷의 시작 주소를 리스트의 시작 부분에 넣고 버킷의 끝 주소를 "시작" 마커와 함께 리스트의 끝에 넣습니다 (리소스들이 메모리 측면에서 정렬되어 있지 않을 수 있기때문에 정렬이 필요합니다).

이제 여유 영역을 정의할 수 있습니다. 여유 영역은 두개의 이웃하는 종료 그리고 시작 점들 사이에 위치합니다. 위 예시에서는 0-2 또는 0-11 처럼요. 하지만, 모든 종료-시작 짝들이 예시의 6-7과 같이 오버랩(overlap)으로 인해 여유 메모리 패치를 표현하는 것은 아닙니다. 예시에서 6-7은 리소스 범위 5-8에 의해 오버랩됩니다.
그러므로 완전힌 정의는 다음과 같아야 할 것 입니다:
앨리어스 가능한 여유 메모리 영역은 다른 그 어떤 시작-종료 짝(pair)과도 오버랩 되지않는 이웃하는 종료-시작 오프셋 사이의 공간이다.
그럼 오버랩이 생기는지는 어떻게 알 수 있을까요? 이 문제를 해결하기 위해 오버랩 카운터를 사용합니다. 오버랩 카운터는 시작점을 만났을 때 상승하고, 종료점을 만나면 감소합니다. 이 방법을 통해 종료-시작 짝이 앨리어스 가능한 메모리 영역인지를 카운터의 값을 검사해서 만약 0이면 오버랩이 존재하지 않는다고 지정할 수 있게 됩니다.

새로운 리소스를 배치하기 위한 마지막 단계는, 위의 단계들을 거쳐서 찾아낸 배치될 수 있는 여유 영역들중 가장 작은 영역을 골라서 그 안에 새로운 리소스를 배치하는 것 입니다.
지금 까지 한 내용들을 정리 해봅시다.
리소스를 앨리어스 하기 위해서는, 리소스의 크기에 따라 내림차순으로 정렬하고. 가장 처음 그리고 가장 큰 리소스를 집어서 리소스와 같은 크기를 가지는 새로운 메모리 버킷에다가 배치한다. 리소스 리스트의 나머지 부분을 순회하면서 각 리소스에 대해 실효 수명 충돌을 검사함으로써 정렬된 앨리어스 불가능한 메모리 영역 리스트를 만든다. 앨리어스 불가능한 메모리 리스트를 통해 리소스가 딱 배치될 수 있는 가장 크기가 작은 앨리어스 가능한 여유 메모리 영역을 정한다. 만약 그런 영역이 버킷안에 존재하지 않는다면, 버킷에 리소스를 추가하지 않고 넘긴다.
만약 모든 리소스들이 하나의 버킷안에 들어가지 않는다면, 원래 리스트에서 앨리어스된 리소스들은 제외해서 모든 리소스가 N개의 버킷들 안에 앨리어스 될 때 까지 알고리즘을 반복한다. 이때, $ N \le TotalResourceCount $ 이다.
복잡도 분석
이 알고리즘은 메모리를 타이트하게 팩킹하지만 그리디 합니다. 그러므로 실제 렌더 파이프라인들에 활용할 수 있는지 알기 위해서 시간 복잡도를 검사해보아야 합니다.
일단 루프 안에서 순회될 N개의 리소스들이 있다고 해봅시다 (외부 루프). 각 외부 루프 순회에서는 앨리어스 될 수 없는 영역 오프셋들을 찾기위해 이미 앨리어스된 리소스들의 리스트를 선형 탐색합니다. 그 다음 정렬을 한다음에 최적의 여유 영역을 찾기 위해 또 다른 선형 탐색을 진행합니다.
시간 측면에서 내부 루프의 최악의 경우는 무엇일까요? 이 경우는 사실 메모리 측면에서는 최고의 케이스인 모든 리소스들이 하나의 메모리 버킷안에 배치될 수 있는 경우입니다. 그럼 내부 르프에서 작업할 수 있는 리소스의 최대 양은 얼마나 될까요? 이건 외부 루프 순회의 현재 인덱스인 i 입니다. 왜냐하면 내부 루프에서는 바깥에서 이미 처리한 리소스보다 더 많이 처리할 수 없기 때문입니다.
즉 오직 외부 루프의 리소스 순회와 내부 루프에서의 앨리어스 할 수 없는 영역 탐색만을 고려한다면 다음과 같은 최악의 경우 복잡도를 얻을 수 있습니다:

왜냐하면 각 순회된 리소스 i 에 대해서 최대 i-1 개의 이미 버킷 안에 들어가있는 리소스들만이 존재할 수 있기 때문입니다. 그러므로 총 복잡도는 급수 같은 모양으로 주어지게 될 것 입니다 : 1 + 2 + 3 + 4 + 5 + ... + N.
이제 여기에 오프셋 정렬을 더합시다. 정렬 알고리즘은 log 시간 복잡도를 가지는 알고리즘을 사용했다고 가정합시다.
그러면 복잡도 수식은 다음과 같이 살짝 커지게 됩니다:

추가로 여유 영역 탐색 까지 더해준다면 수식은 다음과 같은 형태가 됩니다:

오프셋의 양이 이미 앨리어스된 리소스의 두배이므로 2i 를 더해주었고. 버킷의 시작과 끝 오프셋을 고려해서 상수인 2를 더해줬습니다.
또한 각 외부 루프 순회에서 이루어지는 상수 작업과 초기 리소스 정렬 또한 여기에 더해 주어야 합니다:

드디어 N의 증가에 따라 가장 빠르게 증가하는 부분을 빼놓고 다른부분은 모두 식으로 부터 제외시켜서 점진적인 시간 복잡도를 예측할 수 있게 되었습니다.

이는 이차 시간 복잡도보다 살짝 나쁘긴 하지만, 그렇게 까지 나쁘진 않습니다.
그래서 이걸 실제 렌더러에 사용할수 있을까요?
이 질문에 대한 답은 입력 데이터 크기들을 고려했을때 사용할 수 있다 입니다. 현대 렌더 파이프라인이 실제로 할당하는 GPU 리소스들이 얼마나 많을까요? 수십개, 수백개? 백이나 두개라도, 실제로 리소스 할당이 초기화 단계에서 아주 드물게 일어나거나 리소스의 속성이 사용자의 요청에 의해 변경된다 하더라도 평균적으로 하나의 같은 버킷에 모든 리소스들을 배치할 수 없기 때문에 더욱 빠를 것 입니다, 이를 통해 이 알고리즘이 합리적이고 실현가능하다고 결론 내릴 수 있습니다.
병렬 작업 실행 에서의 앨리어싱
이제 마지막으로 그래픽 API들에 의해 제공된 동시성 큐에서의 렌더링 작업 실행에 대한 문제만이 남았습니다.
리소스가 하나 이상의 동시적으로 실행된 렌더 패스들에 의해 읽혀지는 상황이 생길 수 있습니다. 그러면 이 경우엔 어떻게 해야 다른 실행 인덱스를 가지고 어떤 것이 마지막으로 끝날 지 모르는 병렬 작업들을 고려해서 리소스를 위한 실효 수명 범위를 제대로 정해줄 수 있을까요?

이 문제를 해결하기 위해선 더 낮은 단위로 수명 범위를 정해주어야 합니다. 바로 렌더 패스의 실행 인덱스들이 아닌 패스의 의존성 레벨 인덱스를 대신 사용 하는 것 이죠. 자세한 내용은 유향 비순환 그래프를 사용한 GPU 작업 조직화를 참고 해주세요.
* 원문에 가면 예시 코드가 있습니다.
'개인 공부 및 연구' 카테고리의 다른 글
| [번역] Resource State Tracking in D3D12 (0) | 2022.06.04 |
|---|---|
| [번역] Implementing Dynamic Resources with Direct3D12 (0) | 2022.05.31 |
| [번역] DX12를 사용 시 지향 그리고 지양할 점 (0) | 2022.05.22 |
| [번역] 유향 비순환 그래프(DAG)를 이용한 GPU 작업 조직화 (0) | 2022.05.16 |
| [번역] 렌더 그래프 (Render Graphs) (0) | 2022.05.11 |