D3D12엔진개발 – Multi-Thread 렌더링, Wait줄이기

미리 말해둔다.
D3D12 렌더러 작업을 시작하기 전, 그러니까 지금 D3D12렌더러의 원형이었던 D3D11렌더러는 Single-Thread기반이었다.
게임엔진 자체는 여기저기서 멀티스레드를 사용하고 있지만 렌더링-그래픽 API사용 계층은 싱글 스레드로만 동작했다.

이전 포스트에서도 언급했듯이 그럼에도 불구하고 GPU 점유율 90%이상의 괜찮은 성능을 보여주었다. 그런데 D3D12렌더러를 만들면서 이전의 작동방식은 GPU를 펑펑 놀게 하는 결과를 가져왔다.

그래서 별로 내키지는 않지만 렌더러를 멀티스레드 체계로 바꿨다. 정말 예전 코드는 10%도 안남았다고 생각한다. 왕창 다시 만들었다.

하여간 멀티스레드 렌더링 얘기는 전에도 포스팅했으니 그 얘기 하고 싶은건 아니다.
멀티스레드 렌더링을 도입하면서 Wait를줄이기 위해 굉장히 노력했다.

물론 스레드 동기화 때문에라도 Wait가 필요하다. WaitForSingleObject() 라든가 WaitForMultipleObjects(), 그 외 SRWLock이나 Spinlock등이다.

여기서 말하는 Wait는 이런 스레드 동기화 Wait가 아니다

CommandQueue에 대해 Fence걸고 Wait하는것을 말한다.
이것은 Command List객체를 무제한 할당해서 사용할 수 없기 때문에 Command List를 재활용하기 위해서 앞서 Execute하면서 제출한 Command List의 처리가 모두 완료되기를 기다리는 것이다.

기본적으로 내가 만든 렌더러에서 멀티스레드는 다음과 같이 처리한다.
예를 들어 100개의 오브젝트를 렌더링 한다고 하면…

Thread 0 – (0-24) Objects
Thread 1 – (25-49) Objects
Thread 2 – (50-74) Objects
Thread 3 – (75-99) Objects

이렇게 균등분할해서 거의 동시에 스레드가 작업을 시작한다.

그런데 Z-Sort가 필요할때가 있다. 알파블랜딩이 들어가면 Z-Sort가 필요하다.

이 경우 큐에서 Z-Sort를 미리 해둔다. 큐의 앞에서부터 마지막까지 스레드 순서에 따라 분배하기 때문에 Thread 0에 카메라로부터 가장 먼 오브젝트들이 배당된다. Thread 3에는 가장 가까운 오브젝트들이 배당된다.
따라서 4개의 스레드가 동시에 Command List를 만드는건 상관없지만 Execute를 동시에 하는 것은(정확히는 순서 없이 Execute하는 것은) 문제가 있다.

그래서 thread 1은 thread 0 이 Execute하기를 기다리고 thread 2는 thread 1이 Execute하기를 기다리고..이런식으로 가장 마지막 스레드가 마지막에 Execute를 호출한다.
메인스레드는 그 마지막 스레드를 wait한다.

물론 잘 작동한다.

여기서 약간 시간이 지체되는건 어쩔수 없다. 안타깝지만.
그런데  Shadow Map을 만들기 위해 Shadow Caster 오브젝트를 렌더링하고 있을때도 이런식으로 처리하고 있었다. Shadow Caster를 그릴땐 Z-Sort가 전혀 필요없다. 그러니까 모든 스레드가 최대한 빨리 Execute해도 된다.

그래서 Z-Sort가 필요한 구간을 빼고 나머지 멀티스레드 렌더링 구간은 앞의 스레드를 대기하지 않고 바로 Execute하도록 수정했다.

GPUView를 보니 그래도 부족하다.

현재 Shadow Map은 카메라와의 거리에 따라 각각 다른 Shadow Map영역을 사용하는 Cascade Shadow Map으로 처리하고 있다. Cascade 단게 0,1,2,3으로 매트릭스 상태가 바뀔때마다 스레드 그룹들이 대기하고 있는 것이다.

첨부한 이미지를 보면 Shadow Caster 스레드 4개씩 두개의 그룹이 약간의 시간차를 두고 작업을 하고 있는것을 볼수 있다. 이것은 카메라에 거리에 따라  2단계의 ShadowMap을 만들어내고 있기 때문이다. 언급했듯 Shadow Map의 Cascade 레벨이 바뀔때 스레드 그룹이 대기하기 때문에 이런 현상이 발생한다.

그러고보니 엔진 내부에서 Shadow Map을 그리기 위한 상태를 싱글스레드용으로 한개만 유지하고 있었다. 우선 이 코드부터 고쳤다. Shadow Map렌더링을 위한 상태를 Cascade단계별로 분리했다.

그리고 각Cascade레벨 단위로 스레드 그룹이 렌더링을 하고 wait를 하지 않도록 했다. 최종적으로 Shadow Caster렌더링에 투입된 모든 스레드의 작업이 끝나기를 기다리는 것은 마지막 Shadow Map단계를 처리하고 난 다음이다.

첨부한 이미지를 보면 빨간박스가 Shadow Caster오브젝트들을 렌더링하는 스레드이다.

파란 박스는 최종적으로 화면에 보이는 오브젝트들을 렌더링하는 스레드이다. 이 경우는 Z-Sort가 필요하기 때문에 스레드 순서대로 Execute를 호출하는 것을 확인할 수 있다.

 

2번에 걸쳐 Shadow Caster오브젝트들을 렌더링하는 경우.(Before)
Shadow Map생성  Command작성에 걸린 시간 0.79ms. 
shadowmap_wait_per_cascade

 

wait없이 2단계의 Shadow Map생성을 연이어 처리하는 경우.(After)
Shadow Map생성  Command작성에 걸린 시간 0.25ms 
shadowmap_no_wait_per_cascade

Command List를 만드는데 걸린 시간ㅇ 1/3정도로 줄어
결과적으로 350프레임 나오던것이 370프레임 정도로 올라갔다.

전체 프레임으로 보면 10%도 안되는 성능향상이지만 이 얘길 왜 자랑스럽게 늘어놓는가 하면…
D3D12에서의 최적화란것은 죄 이런식이기 때문이다.

일단 포팅 후 이런식으로 Wait를 줄여나간다.

그럼 오늘의 최적화는 여기까지…


답글 남기기

댓글을 게시하려면 다음의 방법 중 하나를 사용하여 로그인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중