렌더링 중 GPU내부에서 크래시를 유발하는 버그를 찾아서 수정했다.
재현이 쉽지 않기 때문에 요 몇 개월 사이에 간헐적으로 나오던 GPU크래시의 원인이라고 단정지을 수는 없지만, 이것까지 해서 적어도 내가 인지하고 있는 GPU크래시 유발 가능한 버그는 다 잡았다.
D3D12는 리소스가 gpu에서 사용중인지 아닌지를 판별하지 않고 자동 관리도 하지 않기 때문에 GPU에 명령을 제출하고 나서 작업이 완료됐는지를 확인하지 않고 리소스를 해제하면 gpu내부에서 크래시할 수 있다.
뭐 이건 다들 아는 얘기고 나도 이 점에 대해선 상당히 신경써 왔다.
D3D9/D3D11는 ID3D11xxxx타입의 COM객체를 렌더링 API에 전달함으로서 GPU에 리소스를 전달하지만 D3D12는 ID3D12Resource를 직접 전달하지 않고 ID3D12Resource를 가리키고 있는 Descriptor를 전달한다. 이 Descriptor는 ID3D12DescriptorHeap이라 명명된 GPU 리소스(메모리)에 기록되기 때문에 실제로 GPU에 전달하는 리소스는 실질적으로 2가지이다. ID3D12Resource(GPU에서 접근가능 메모리), 그리고 이걸 가리키는 Descriptor Heap의 메모리.
D3D11까지는 런타임이 알아서 중첩렌더링을 구현한다. Present()가 리된 그 순간에 앞서 실행한 렌더링 명령들의 실행이 완료되었다는 보장은 없다. 대부분의 경우 Present()호출 후에 CPU가 다음 프레임을 준비하고 있는 동안에도 GPU는 렌더링 작업을 수행하고 있다. 그러나 이에 대해 프로그래머가 신경쓸 것은 아무것도 없다. 그냥 완전히 동기화 된다고 믿고 코드를 작성하면 된다.
D3D12는 중첩렌더링이 필요하면 수동으로 구현해야한다. 이번 프레임을 렌더링 하고 렌더링이 안료될때까지 대기하면 문제 될 것이 전혀 없다.
그러나 이렇게 하면 정말 느리다. D3D11대비 1/2이 아니고 1/5쯤 프레임이 토막나는걸 볼 수 있다.
따라서 연습코드가 아니라면 중첩렌더링은 반드시 구현해야 한다. 이번 프레임 렌더링 커맨드를 제출하고나면 대기하지 말고 즉시 다음 프레임 렌더링의 커맨드를 작성해야 한다.
현재 프레임을 준비할 때 앞의 프레임이 렌더링이 끝났다는 보장이 없으므로 앞의 렌더링이 완료되었음을 확인하기 전까지는 제출한 렌더링 커맨드에서 참조하는 리소스를 해제하면 안된다.
이 때문에 DefReleaseManager란 놈을 만들었고 엔진 내에서 관리하는 모든 ID3D12Resource는 중첩렌더링 되는 프레임 수만큼 지연해서 해제하도록 했다. 즉 2프레임을 중첩해서 렌더링 한다면 해제요청이 들어온지 2프레임이 경과해야 실제로 해제가 된다.
모든 ID3D12Resource는 DefReleaseManager를 통해 해제된다. ID3D12Resource를 가리키는 Descriptor도 결국 ID3D12DescriptorHeap의 메모리이므로 이 또한 DefReleaseManager를 통해 해제된다. 따라서 해제 관련된 GPU크래시는 내가 인지하고 있는 선에서는 없다. 없어야 한다.
그런데 해제 외에 신경 써야할 문제가 있다.
아직 렌더링이 완료되지 않았는데 전달한 리소스에 다른 내용을 써넣으면 어떻게 될까? GPU가 접근중일 때 메모리의 데이터가 바뀐다면 의도한 대로는 돌아가지 않을 것이다.
다음의 경우를 생각해보자.
1)
할당한 리소스A 내용을 써넣고 렌더링 제출
렌더링 완료 전에 리소스에 다른 내용을 써넣기
또는
2)
Custom Heap에서 할당한 리소스A에 내용을 써넣고 렌더링 제출
리소스 A를 해제 ( 지연 해제로 처리)
방금 해제한 리소스 A에 다시 써넣기
1이든 2든 미치지 않고서야 이런 코드를 작성할 이유가 없다.
특히 2의 경우는 언제나 D3D12 API를 직접 호출해서 리소스를 할당하고 쪼개 쓰는 경우가 없다면 해제한(논리적으로만, 물리적으로는 아직) 메모리에 써넣을 일은 없다.
그런데 겉으로는 정상적인 코드를 짜도 렌더링 끝나기 전에 리소스에 써넣기를 할 수 있는 상황이 있다.
내 엔진의 경우 CreateCommitedResource()호출 회수를 줄이고 GPU메모리를 낭비 없이 사용하기 위해서 D3D12 Resource에 대한 Custom Heap을 만들어서 사용한다.
malloc/free처럼 비교적 큰 덩어리 메모리 안에서 할당/해제를 해주는 자료구조이다. ID3D12Resource의 포인터와 시작 인덱스, 사이즈를 묶어서 한 block으로 관리한다. 일반적인 heap과 마찬가지로 block해제시 인접한 block과 병합 가능하면 병합해서 최대한 큰 덩어리 block들을 유지한다. 일반적인 Heap은 덩어리 메모리에 직접 head/tail을 써넣지만 GPU메모리를 대상으로 하기 때문에(GPU메모리 바로 쓸 수 없으므로) head/tail에 해당하는 데이터는 시스템 메모리에 shadow copy로 기록해둔다. 뭐 자세한 내용은 다 적기 어렵고 하여간 그렇다.
VertexBuffer, IndexBuffer, Descriptor에 대해 이 Custom Heap을 적용해 놓았다. 예를 들어 Vertex Buffer를 1000개쯤 할당해도 해도 내부적으로는 큰 덩어리의 D3D12Resource를 10개도 할당하지 않는다. 이로 인해서 GPU메모리 소모를 크게 줄였고 CreateCommitedResource()호출도 크게 줄였다.
Custom Heap에서 할당받는 2차 D3DResource객체(ID3D12Rdresource의 포인터, 시작 인덱스, 사이즈로 구성)는 해제도 당연히 Custom Heap에다 요청한다. Custom Heap에서는 덩어리 ID3D12Resource를 쪼개서 할당한 2차 리소스들이 모두 해제된 상태로 판별되면 그 덩어리 ID3D12Resource를 물리적으로 해제한다. 이 때도 DefReleaeManager를 사용하므로 절대로(내가 인지하는 선에서는) GPU작업중에 해제될 일은 없다.
그럼 뭐가 문제냐…
Custom Heap을 사용해서 버퍼 하나(이하 버퍼 A)를 할당했고 이번 프레임에 사용하고 더 필요가 없어서 바로 Custom Heap에다 해제를 요청했다.
당연히 물리적으로는 즉시 해제되지 않는다. 인접한 block이 병합 가능하면 병합해서 더 큰 block으로 아니면 그 사이즈 그대로 할당 가능한 block으로서 할당 가능한 리스트에 담겨진다.
그런데 해제 직후에(이번 프레임이 지나기 전에) Custom Heap으로부터 거의 같은 사이즈(이거나 조금 더 작은)의 버퍼를 할당하면 어떻게 될까.
방금 해제한 버퍼A는 할당 가능 리스트에 있고 사이즈도 비슷하므로 이 버퍼A 그대로, 또는 메모리가 겹치는 다른 버퍼로서 할당요청에 대해 새로운 블럭으로 리턴될 수 있다. 이건 버그가 아니고 정상적인 작동 방식이다.
문제는 아직 프레임이 끝나지 않았으므로 GPU에서 이 버퍼 A에 접근중일 수 있다는 것이다.
이 버퍼A가 Vertex Buffer이거나 Texture 라면 렌더링 결과물이 조금 이상하게 나오는 정도로 끝나겠지만 Index Buffer이거나 Descriptor라면 GPU내부에서 크래시 할 수 있다.
오래 전에 이 상황에 대해서도 고민을 했었고 그래서 Custom Heap에서는 해제된 블럭에 대해서도 중첩렌더링의 프레임 수만큼 지연시켜서 해제(할당 가능한 리스트에 삽입)한다. 그래서 이 쪽은 문제가 없다…라고 생각했다. 일단 Vertex Buffer와 Index Buffer와 Texture에 대해선 확실히 문제가 없음을 확인했다. 제대로 구현했고 검증했다.
또한 PC에서는 대부분의 경우 UploadBuffer를 통해서 GPU메모리에 써넣기를 수행한다. UploadBuffer에 먼저 써넣고 Copy API를 사용해서 GPU메모리로 전송한다. 이는 GPU타임라인에서 이루어지고 Resource Barrier가 삽입되므로 이런 류의 쓰기로는 문제될 것이 없다.
그럼 CPU에서 막바로 쓰기가 가능한 GPU리소스는 뭐가 있나.
1) Vertex Buffer(System Memory에 할당한 경우)
2) Index Buffer(System Memory에 할당한 경우)
3) Constant Buffer(System Memory에 할당한 경우)
4) Hit Group Shader Table(System Memory에 할당한 경우)
5) Descriptor(System Memory주소/GPU Memory주소 같이나옴)
1,2) Vertex Buffer/Index Buffer는 Custom Heap을 사용하지만 위에서 언급했듯이 완전하게 검증했다.
3) Constant Buffer는 처음부터 1회성으로 사용한다. 매 렌더링 마다 Constant Buffer Pool에서 할당하고 얘네들은 중첩프레임 수 만큼 경과되면 reset된다.
4) 이건 1년인지 2년쯤 전인지 실제로 버그를 겪었다. 이제는 Hit Group Shader Table은 중첩 프레임 수만큼 리소스를 들고 있고 각 프레임마다 전용의 메모리를 사용하므로 문제가 없다.
5) Descriptor는 CPU측 메모리 주소와 GPU측 메모리 주소로 이루어지는데 물론 CPU에서 써넣을 때는 CPU측 주소에 써넣지만 추측컨데 내부적으로 하나의 GPU메모리를 위치에 대해 CPU(CPU측 MMU)주소 GPU(GPU측 MMU주소)주소를 가지는 형태일 것이다. 그래서 CPU측 Descriptor 주소에 써넣으면 GPU측 Descriptor에 반영이 된다.
사실 Descriptor야 말로 GPU에서 접근중에 CPU에서 써넣으면 아주 큰 일 날 수 있는 리소스이다. Descriptor는 렌더링 에셋이 아니고 리소스를 가리키는 포인터와 리소스 속성이 기록된 데이터이기 때문이다.
앞서 Vertex Buffer, Index Buffer, Texture, Descriptor에 대해 Custom Heap을 적용했다고 적었었다.
또한 Vertex Buffer, Index Buffer, Texture는 모두 검증했다고 했다.
게다가 Texture는 CPU에서 직접 쓰기가 안된다. 이건 GPU접근중일때 CPU에서 쓰기가 아예 안된다(GPU Upload Heaps사용하는 경우는 가능). 그러니까 Vertex Buffer, Index Buffer, Texture는 문제될 것이 없다.
그런데 Descriptor에 대한 Custom Heap은? 이거 한 프레임 내에 해제-할당이 연속 될 때 문제가 될 수 있는 상황에 대해서 고민을 해봤던가?
어찌보면 가장 치명적인 리소스인데? 고민한 기억이 없는데…
코드를 뒤져보니 역시나… 가변 사이즈의 Descriptor를 할당해주는 Descriptor Heap(Custom Heap)과 1개씩 할당해주는 Descriptor Pool(고정 사이즈 Pool기반)에는 지연 해제 기능을 구현하지 않았네. 얘네들도 DefReleaseManager를 사용하는지라 물리적인 해제는 중첩 프레임 수만큼 지연되어서 처리되지만 해제 후 즉시 할당될 수 있는 문제에 대해서는 대응하지 못하고 있었다.왜 그 생각을 못했을까.
아니 실제로 문제가 쉽게 드러나진 않았기 때문이다.
Descriptor Pool은 많이 쓰긴 하는데 삼각형 매시 렌더링 쪽에서 주로 사용하는지라 한 프레임 안에 텍스처가 마구 교체되지 않는 한은 증상이 나오기 힘들었다.
RayTracing에서도 삼각형 매시는 부서졌다가 다시 만들어지는 일이 없으므로 한 프레임 안에 하나의 geometry대해 Descriptor 내용이 변경될 일이 없었다.
하지만 Voxel지형은 다르다. Voxel편집이 가능하기 때문에 한 프레임 안에서도 Voxel을 구성하는 리소스가 아예 제거되고 새로 할당될 수 있다.
복셀 오브젝트의 모양이 바뀌면 Vertex Buffer와 Index Buffer의 내용이 변경되는 정도가 아니고 아예 Vertex Buffer와 Index Buffer로 치환된다. 따라서 이를 가리키는 Descriptor의 내용도 변경된다.
그런데 Custom Heap에서 할당받은 Descriptor는 아직 렌더링에 사용중인 Descriptor인 것이다. 여기다 새로 가리킬 리소스의 정보를 써넣으니 이 순간 Descriptor에 접근중인 GPU는 잘못된 내용을 읽게 되고 크래시 하는 것이다.
실제로 최근 GPU크래시는 복셀지형을 편집할 때마다 발생했다.
이게 최근의 DispatchRay실행 중 GPU크래시의 원인이라고 99% 정도 믿고 있다. 물론 재현이 맘대로 안되니 확신할 수는 없다.
그런데 사실 이 문제가 있는것은 예전부터 알고는 있었다. 아니 정확히는 원인은 모르고 있었고 현상은 봐왔는데 그냥 대충 넘어갔던 것.
현재 Raytracing은 2가지 모드를 지원한다.
1) 최초 GBuffer구성 단계부터 월드에다 RayTracing을 수행. 반사와 굴절을 적용하면 최소 2회의 TraceRay()가 실행되는 방식.
2) Raster방식으로 GBuffer를 만들고 GBuffer위에서 반사와 굴절에 대해서만 TraceRay()를 호출하는 방식. 이것은 1회 반사/1회 굴절이라면 1회의 TraceRay()호출만 필요하고 당연히 이쪽이 빠르다.
그래서 2의 방식을 사용하는데 디버깅 및 검증 용으로 1번 방식도 유지는 하고 있다. 그런데 가끔 1번 방식으로 테스트를 하다보면 복셀을 편집할 때 깜빡임이 발생했다. 처음엔 그냥 대수롭지 않게 생각했다. DXR버전 이전 D3D12 Raster버전에서는 라이트맵 갱신 타이밍이 한 프레임 늦기 때문에 실제로 약간의 깜빡임이 발생할 수 있었다. 이후에 DXR버전 작업을 시작했으므로 깜빡임이 이상하지 않다고 넘어갔을지도 모르겠다. 그런데 생각해보면 DXR버전에선 라이트맵을 안쓰는데 깜빡임이 발생할 이유가 없다.
RayTracing중 Ray가 geometry에 충돌하면 Hit Group Shader Table에서 geometry가 가리키는 shader record를 찾는다. shader record에는 hit shader와 shader에 공급되어야 할 Vertex, Texture 좌표, Texture Resource정보가 descriptor 형태로 담겨있다. descriptor내용이 손상되었으니 vertex, texture 좌표, texture를 제대로 읽을 수가 없다. 그래서 해당 geometry의 렌더링이 제대로 되지 않고 다음 프레임에서는 멀쩡히 렌더링 되므로 깜빡이는 것으로 보인다.
Raster방식으로 GBuffer를 먼저 그릴때는 화면이 멀쩡히 구성이 된 상태에서 반사와 굴절만 Raytracing으로 처리하므로 Descriptor의 정보가 손상된 이유로 발생하는 깜빡임이 거의 보이지 않았다.
처음부터 Raytracing을 수행하는 경우는 까만 화면에 처음 씬을 그리는 것이니 깜빡임이 확 체감될 수 밖에 없었다.
대부분의 경우는 이렇게 깜빡이는 정도로 끝나지만 낮은 확률로 GPU내부 크래시로 연결되는 것이다.
크래시 상황을 정확히 재현하진 못했지만 pure RayTracing모드에서 깜빡임은 늘 발생하므로 이것을 잡으면 크래시까지 같이 잡힌다고 판단했다.
해서 Descriptor-Custom Heap에 지연해제 기능을 집어넣었다.
그리고 깜빡임은 사라졌다. 아마도 GPU크래시 원인도 해결됐으리라 믿는다.
약 일주일 후에 이어서 추가…
이후 방송에서 테스트 중 또 gpu크래시가 발생했다.
앞선 수정에서 잠재적 버그를 제거한 것은 맞다. 그런데 지난주 방송중 발생한 gpu크래시의 원인은 아니었다.
결론만 놓고 얘기하면 물 복셀을 추가하면서 새로 생긴 버그가 맞다.
우선 DRED로그를 분석한 결과 Ray Tracing과정에서 크래시하는 것이 확실하다는 결론을 내렸다. 물론 Ray Tracing 진행중에 위에서 언급한 리소스가 변경이 발생하면 문제가 될 수 있다. 1차적으로는 이쪽을 의심했으므로 코드를 샅샅이 뒤졌고 또한 아예 리소스 해제 자체를 수행하지 않는 상태로 테스트를 해봤지만 버그 증상이 나타났다.
리소스 전달체계의 손상 문제가 아니라면 Ray Tracing shader의 문제일 가능성이 높다.
자..요 근래 뭘 수정했더라. 물 복셀을 추가했지. 물 복셀을 추가함에 따라 shader도 고쳤지. shader코드에서 어디가 제일 많이 바뀌었을까? any hit shader다.
물복셀은 삼각형 평면으로 이루어진 물 매시와 다르다. 서로 다른 복셀이 인접할 경우 경계 부분에 면 데이터가 존재한다. 이는 BLAS에도 반영되고 Ray Tracing때 이 경계면에 ray가 충돌하게 된다.
성능상의 이유로 BLAS를 구성하는 대부분의 geometry에 대해서는 oapque(불투명) 속성을 부여하게 된다. 이렇게 하면 언제나 ray의 출발점 기준으로 가장 가까운 매시의 충돌에 대해서만 closest hit shader가 호출된다.
그런데 물 복셀의 경우 항상 closest hit shader만 호출되면 복셀간의 경계면에 ray가 무조건 충돌하고 그것으로 추적이 종료된다. 이로 인해 앞쪽 물 복셀은-투명하다고 생각되는 물인데도 불구하고-뒷쪽 물 복셀이나 뒷쪽 매시를 가리게 된다. 가리지 않게 하려면 closest hit shader에서 물 속성인 것을 인지하고 그 위치에서 ray를 추가로 발사해야한다.
이렇게 하면 성능이 크게 저하된다.
따라서 ray가 매시에 충돌했을때 물 속성이라면 무시할 방법이 필요하다. 그래서 존재하는게 any hit shader다. BLAS를 구성하는 geometry에 대해 non oapque속성을 부여하면 이후 ray tracing에서 ray가 임의의 매시에 충돌할때마다 any hit shader가 호출된다.
any hit shader가 호출되면 여기서 해당 매시가 물 매시인지를 확인하고 물인 경우 이번 충돌을 무시하고 계속 ray를 진행시킨다.
물론 물 복셀이 들어가기 전에도 삼각형 평면으로 구성된 물 매시가 존재했다. 또한 유리창, 캐릭터의 고글 아이템 등 투명/반투명 재질들이 이미 있었으므로 any hit shader는 제대로 준비되어있었다.
그런데 이전에는 명시적으로 지정한 삼각형 매시에 대해서만 non opaue속성을 지정했고 이는 삼각형 매시에 대해서만 any hit shader가 호출되었음을 의미한다.
최근 물 복셀을 추가함에 따라 복셀 매시에 대해서도 non opaque속성을 지정하게 되었고 따라서 복셀 매시와의 충돌에 대해서도 any hit shader가 호출된다.
이 부분이 문제였다.
any hit shader나 closest hit shader나 ray가 매시에 충돌하면 shader record에 저장힌 local root paramter를 전달받는다. 여기에는 충돌한 매시와 대응되는 원래의 버텍스 버퍼(위치,텍스처 좌표등()와 맵핑된 텍스처등이 포함된다.
삼각형 매시에 대한 shader record나 복셀 매시에 대한 shader record나 안의 포맷은 같다. 차이점이 있다면 복셀 매시의 shader record에서는 텍스처 좌표를 담고 있는 버퍼의 디스크립터가 유효하지 않다.
복셀 매시에서는 별도의 텍스처 좌표가 필요없기 때문이다. 이 버퍼의 디스크립터가 유효하지 않으므로 shader안에서 이 디스크립터를 통해 전달될 structured buffer에 접근해서는 안된다.
그런데 무성의하게 복붙으로 작성한 any hit shader의 앞단에서 삼각형 베이스의 매시인지, 복셀 매시인지 확인도 하기 전에 언제나 텍스처 좌표 버퍼를 읽고 있었다.
변명을 하자면 정말 진지하게 물 복셀을 넣으려던건 아니었다. 가능성 여부를 타진하기 위한 시험적인 코드였다. 복셀 매시에 충돌했을 경우 geometry데이터를 가져오는 코드를 대충 closest hit shader를 복붙에서 (매우 성의없게) 만들었었다. 그리고 그냥 잊고 있었다.
결국 local root paramter로 전달된 텍스처 좌표 버퍼를 읽는 3줄의 코드의 위치를 바꾸는 것으로 이 버그는 간단히 처리되었다.
처리는 싱거웠지만 원인을 찾는 과정은 꽤나 길고 지루했다. 이 과정에서 코드를 다시 한번 면밀히 살펴볼 수 있었고 잠재적인 버그들을 처리했다.
버그 잡고 코드는 튼튼해졌으니 결론적으론 해피 엔딩.
앞의 리소스 관리 체계에 대한 설명, 디버깅, 코드 수정 그리고 any hit shader의 디버깅까지에 대한 모든 과정을 ppt로 정리해서 유튜브 방송을 진행했다.