Godot 렌더러의 CPU 측 코드 최적화
요약
본 글은 Godot 렌더러의 CPU 측 코드 최적화 과정과 방법론을 공유합니다. 성능 병목 현상을 식별하고 해결하기 위한 체계적인 접근법(식별-이해-조사-재측정-반복)을 제시하며, 특히 프로파일링 도구 사용의 중요성을 강조합니다.
핵심 포인트
- CPU 최적화는 렌더러 작업 시 필수적이며, GPU와 균형을 맞춰야 합니다.
- 2D 배치 처리는 CPU 성능 향상에 유리하고, 3D에서는 오클루전 컬링이 주로 사용됩니다.
- 성능 병목 현상은 프로파일러를 통해 식별하는 것이 가장 중요합니다.
- Godot은 내장 및 외부(Tracy, Superluminal) 프로파일러를 지원하여 개발을 돕습니다.
Godot의 렌더러가 어떻게 최적화되는지 과정에 대해 약간의 통찰력을 드리고 싶습니다. 외부에서 볼 때, 렌더링 최적화는 마치 블랙 매직처럼 보일 수 있습니다. 여기서는 올해 우리가 수행한 몇 가지 최적화 사례를 보여드림으로써, 렌더링 코드를 최적화하는 것이 생각만큼 무섭지 않다는 것을 강조하고자 합니다.
병목 현상 (Bottlenecks)
이 글에서는 CPU 코드 최적화에만 초점을 맞출 것입니다. 렌더러를 작업할 때는 CPU와 GPU 모두에 대해 코드를 최적화하는 데 매우 신중해야 합니다. 왜냐하면 최종 성능은 두 프로세서 중 더 느린 쪽의 성능에 의해 결정되기 때문입니다. 아무리 많은 CPU 최적화를 하더라도 비효율적인 셰이더(shaders)로부터 당신을 구원할 수는 없습니다.
렌더러를 작성하면서 우리는 항상 어느 한쪽을 이롭게 하기 위해 트레이드오프(tradeoffs)를 합니다. 예를 들어, 2D에서의 배치 처리(batching)는 GPU 성능에 약간의 손해를 입히지만, CPU 성능은 크게 향상시킵니다. 2D 게임이 GPU 병목 현상을 겪기 전에 CPU 병목 현상을 겪는 경향이 있기 때문에, 대부분의 경우 배치는 순이익(net positive)이 됩니다. 반대로, 3D 게임은 더 자주 GPU 병목 현상을 겪는 경향이 있으므로, Godot에서는 GPU의 부하를 줄이기 위해 오클루전 컬링(occlusion culling)을 CPU에서 수행합니다.
CPU와 GPU 작업 사이의 최종 균형에 관계없이, 엔진을 최적화하는 것은 항상 유익합니다. 왜냐하면 엔진에 대한 모든 최적화는 게임 개발자가 사용할 수 있는 CPU 및 GPU 공간을 더 많이 남겨주고, 전력 제한 플랫폼(power-constrained platforms)에서는 배터리 전력을 절약해주기 때문입니다.
방법론 (Methodology)
CPU 성능을 최적화하는 과정은 대략 다음과 같습니다:
- 성능 병목 현상/핫스팟 식별하기
- 병목 현상이 그곳에 있는 이유 이해하기
- 해결책 조사하기
- 성능 재측정하기
- 반복하기
성능 병목 현상 식별하기
이것이 성능 최적화에서 가장 어려운 부분입니다. 게임이나 애플리케이션이 제대로 작동하지 않을 때, 정확히 왜 그런지 추적하기 어려울 수 있습니다. 종종 성능 저하가 어디서 오는지 알게 되면, 성능이 나쁜 이유를 즉시 이해하게 됩니다. 이는 실수로 비용이 많이 드는 API(expensive API)를 핫 루프(hot loop)에서 호출했거나, 세상에 나올 일이 없었던 디버그 코드를 남겨두었을 때 자주 발생하는 경우입니다.
엔진 개발자로서 저희에게는 광범위한 잠재적 게임들을 위해 최적화해야 하는 추가적인 과제가 있습니다. 저희는 단 하나의 목표가 있는 것이 아니라, 수천 개의 움직이는 목표를 가지고 있으며, 사람들이 버그 리포트(bug report)를 할 때만 심각한 문제가 있다는 것을 알게 됩니다. 그렇다고 하더라도, 기존 데모와 오픈 소스 게임을 테스트하는 것만으로도 많은 개선 사항들을 식별하고 만들 수 있습니다.
CPU 병목 현상을 식별하는 가장 좋은 도구는 CPU 프로파일러(profiler)입니다. 종류가 매우 다양합니다. Godot은 에디터에 두 가지를 내장하고 있습니다 (GDScript 코드를 프로파일링하는 것과 렌더러만을 전용으로 프로파일링하는 것). 하지만 엔진 코드 자체를 작업할 때는 외부 프로파일러를 사용해야 합니다. Godot은 트레이싱 프로파일링(tracing profiling)을 위해 Tracy으로 빌드하는 지원을 하지만, 외부 샘플링 프로파일러(sampling profiler)도 사용할 수 있습니다.
제가 선택한 도구는 외부 샘플링 프로파일러인 Superluminal입니다. 이 도구는 실행 중인 Godot 인스턴스에 연결되어 엔진을 실행하는 동안 프로파일링 데이터를 기록합니다. 그런 다음 어떤 코드가 언제 실행되는지에 대한 매우 좋은 시각화를 제공합니다. 또한 최근에는 Linux 지원도 추가되었습니다! 이 기사 아래의 예제들은 Superluminal을 사용할 것입니다.
병목 현상 이해하기
앞서 언급했듯이, 때로는 병목 현상을 이해하기가 쉽습니다. 특히 성능 저하가 설계상의 문제라기보다는 버그 때문일 경우 더욱 그렇습니다. 종종 병목 현상을 이해하려면 주변 코드를 이해해야 합니다.
궁극적으로 성능 최적화는 “필요하지 않은 것을 하지 않는 것”으로 귀결됩니다. 코드를 이해하고 시스템을 이해하는 것은 무엇이 필요한지, 그리고 무엇이 불필요한지를 파악하는 데 매우 도움이 됩니다.
해결책 조사하기
일반적으로 병목 현상을 이해하면 해결책은 명확해 보입니다. 하지만 때로는 정확히 무엇이 필요한지 알아내기 전에 몇 가지 다른 것을 시도해야 할 수도 있습니다. 예를 들어, 계산량이 많은 무거운 루프를 보고 처음에는 반복(iteration)당 계산을 줄이는 것이 해결책이라고 생각할 수 있습니다. 그러나 문제는 메모리 대역폭(memory bandwidth)에서 오는 것일 수 있으므로, 데이터를 재구성하는 것이 더 나은 성능 향상을 가져올 수도 있습니다.
궁극적으로 여러분은 병목 현상의 영향을 직접적으로 줄이는 변경을 하게 될 것입니다.
성능 재측정하기
항상 다시 측정해야 합니다. 컴퓨터는 매우 복잡하고, 게임 엔진은 더욱 복잡합니다. 때로는 더 빨라질 것이라고 생각한 것이 실제로는 그렇지 않을 수 있습니다. 최적화는 종종 직관에 반합니다. 예를 들어, for 루프에 분기(branch)를 추가하여 일부 계산을 건너뛰는 것은 본능적으로 항상 더 빠를 것 같지만, 많은 경우 컴파일러가 루프를 벡터화(vectorizing)하는 것을 막아 오히려 성능 저하를 초래할 수 있습니다. 또는 더 흔한 예로, 무언가를 두 번 계산하지 않기 위해 값을 캐시(cache)할 수 있지만, 해당 코드 경로의 병목 현상이 메모리 읽기라면, 값을 캐싱하는 것이 코드를 느리게 만들 것이며 차라리 작업을 두 번 하는 편이 나았을 것입니다.
절대적인 C++ 컴파일러 전문가가 아니라면, 자신의 코드 변경이 성능에 미치는 정확한 결과를 예측하기는 어려울 것입니다. 따라서 항상 재측정하고 결과를 확인하는 것이 더 좋습니다.
사례 연구 #1: Polygon2D
사례 연구 #1: Polygon2D
이 PR은 Aurélien Condomines와 나눈 대화에서 나왔습니다. 그는 Heidi’s Legacy: Mountains Calling을 작업하고 있는데, Godot의 애니메이션 코드가 매우 느려서 게임에 콘텐츠를 추가하는 데 제한이 있다고 언급했습니다. 그는 애니메이션 캐릭터가 20개 정도만 있어도 게임 성능이 떨어지기 시작한다는 것을 발견했습니다.
저는 그 수치에 놀랐고 뭔가 잘못되었다고 느꼈습니다. 저희의 애니메이션 코드는 20개 정도의 애니메이션 캐릭터만으로는 병목 현상을 일으키지 않아야 했기에, 제가 직접 조사하기로 결정했습니다. 그래서 저는 그에게 화면에 여러 개의 애니메이션 스프라이트를 배치한 최소 프로젝트를 만들어 달라고 요청하여 프로파일링을 할 수 있게 했습니다.
첫 번째 단서는 그가 AnimationPlayer를 사용하여 Sprite3D 위에 표시되는 Polygon2D의 정점(vertex)들을 애니메이션화하고 있었으며, 이를 통해 3D 월드에서 보기 좋은 2D 애니메이션을 구현하려 했다는 것입니다.
이것만으로도 다소 독특하게 들리지만, 심각한 문제를 일으킬 만한 것은 아닙니다. 따라서 우리의 다음 단계는 Superluminal을 실행하여 무슨 일이 일어나고 있는지 확인하는 것입니다.
저는 데모가 실행되는 몇 초간의 영상을 캡처하고 프레임에 확대했습니다.

참고: 이 데모 장면의 모든 테스트는 Ryzen 5 9600X CPU로 진행되었습니다.
스크린샷만으로는 완전히 명확하지 않으므로, 우리가 시간을 어디에 쓰고 있는지 분석해 드리겠습니다:
- 4.6 ms: AnimationMixer. 제가 보기에는 느리지만, 이 장면에는 모든 정점이 애니메이션되고 있기 때문에 많은 수의 애니메이션 트랙이 존재하여 완전히 예상 밖의 일은 아닙니다.
- 15.7 ms:
Polygon2D::_notification(). 이에 대해서는 아래에서 더 자세히 말씀드리겠습니다. - 11.7 ms: 장면 그리기(Drawing the scene). 이 중 3.3 ms는 정점 배열(vertex arrays) 생성에, 그리고 5 ms는 정점 배열 해제(freeing vertex arrays)에 사용됩니다. 따라서 실제로 장면을 렌더링하는 데는 약 3.4 ms만 소요됩니다.
이러한 높은 수준의 개요만으로도 두 가지 문제가 발생하고 있음을 즉시 알 수 있습니다:
- Polygon2D가 예상치 못한 동작을 하고 있습니다.
- 무엇이든 간에, 매 프레임마다 버텍스 배열(vertex arrays)이 생성되고 해제되는 결과를 낳고 있습니다.
그래서 자세히 살펴보고 Polygon2D에서 시간이 어디에 쓰이는지 확인해 봅시다. 우리는 타임라인을 확대할 수 있는데, 모양은 다음과 같습니다:

또는 아래의 콜 그래프(call graph)를 사용하여 더 상세한 개요를 얻을 수도 있습니다:

콜 그래프는 선택된 함수 내부의 함수 호출들을 비용이 많이 드는 순서로 정렬합니다. 가장 많은 시간이 새 서피스(surface)를 추가하고 이전 서피스를 해제하는 데 쓰인다는 것을 볼 수 있습니다. 이는 우리가 이미 렌더링 코드에서 본 것과 일치합니다. 이제 우리는 Polygon2D 클래스가 렌더링 시간에도 성능 저하의 원인이라는 것을 알게 되었습니다.
이제 '소스 및 디스어셈블리(Source and Disassembly)' 패널을 살펴보고 정확히 어떤 코드 라인이 책임자인지 볼 수 있습니다:

이 코드는 Polygon2D의 내부 메시(mesh)를 구축하는 역할을 합니다. 이 프로파일은 이 코드가 매 프레임 실행되며 매우 느리다는 것을 알려주고 있습니다. 모든 것을 종합해 보면, 버텍스(vertices)가 매 프레임 CPU에서 변경되기 때문에 메시가 매 프레임 재구축되는 것은 놀라운 일이 아닙니다!
우리의 프로파일 결과에 따르면 이것이 정말 느린 과정이라는 것을 알게 되었습니다. 그래서 질문은, 우리가 무언가를 할 수 있느냐는 것입니다?
물론입니다!
Godot은 버텍스 데이터를 수동으로 업데이트할 수 있는 저수준 API(low-level API)를 노출합니다. 이는 메시의 버텍스 개수가 동일하게 유지될 것이라고 알고 있을 때 매우 유용할 수 있습니다. 오래된 메시를 해제하고, 새 메시를 할당한 다음, 그 새 메시로 버텍스 데이터를 업로드하는 대신, 기존 메시에 단순히 버텍스 데이터를 업로드할 수 있습니다.
이것이 제가 PR(pull request)에서 하게 된 것입니다. PR의 대부분 작업은 어떤 경우에 메시를 업데이트하는 것이 적합한지, 그리고 어떤 경우에 메시가 재구성되어야 하는지를 식별하는 것이었습니다.
이 최적화는 애니메이션 Polygon2D의 성능을 약 3배 향상시켜, Aurélien이 사용하던 기법을 완전히 실현 가능하게 만들었습니다.
제 PR 적용 후 단일 프레임을 살펴보면, 초당 35 ms에서 겨우 13 ms로 줄어든 것을 알 수 있습니다:

이제 렌더링이 프레임 시간의 약 50%를 차지하고, 애니메이션은 약 30%, Polygon2D 업데이트가 약 20%를 차지합니다.
물론 이 코드 조각도 훨씬 더 최적화될 수 있습니다. 첫째로, 모든 정점이 움직일 때마다 전체 메시 데이터를 재업로드하고 있습니다. 잠재적인 최적화 방법은 변경된 영역의 메시만 업데이트하는 것입니다. 이렇게 하면 몇 개의 정점에만 영향을 미치는 애니메이션이 있을 경우, 전체 업로드 비용을 지불할 필요가 없습니다.
하지만 이러한 최적화는 부분 업데이트를 실제로 통해 이득을 볼 수 있는 게임이나 장면에 의해 안내되어야 합니다. 이 게임은 기본적으로 매 프레임 모든 정점을 애니메이션하기 때문에, 부분 업데이트를 수행하는 데 필요한 추적이 순수하게 부정적인 영향을 줄 가능성이 높습니다. 기억하세요, 무언가를 제대로 최적화하려면 테스트할 수 있어야 합니다! 테스트 케이스가 없으면 적절한 최적화를 할 수 없습니다.
결과적으로 제가 진행한 작고 안전한 최적화만으로도 성능 향상을 얻어 제 장치에서 28 FPS에서 83 FPS로 올라갔습니다. 그래서 일단은 충분했습니다. 만약 누군가 정말 관심이 있고 시간이 있다면, 성능을 더 끌어올릴 수 있습니다. 개인적으로는 현재 렌더링 프레임에서 시간을 어디에 쓰고 있는지 가장 궁금합니다. 왜냐하면 그 부분이 프레임 시간의 50%를 차지하기 때문입니다.
사례 연구 #2: SPIRV를 DXIL로 트랜스파일(Transpilation) 최적화
올해 초, Asilkan은 D3D12 백엔드에서 우리의 셰이더 컴파일 파이프라인을 조사했습니다. D3D12로 셰이더를 컴파일하는 현재 방법은 Pedro가 작성한 이 이전 블로그 게시물에 설명되어 있습니다. 간단히 말해 다음과 같습니다:
- 내장 셰이더 컴파일러를 사용하여 GDShader를 GLSL로 컴파일합니다.
- GLSLang을 사용하여 GLSL을 SPIRV로 컴파일합니다.
- Mesa의 NIR 컨버터를 사용하여 SPIRV를 DXIL로 트랜스파일(Transpile)합니다.
- GPU 드라이버에 DXIL을 제출하여 바이너리로 컴파일하게 합니다.
이 과정에서 가장 느린 부분은 트랜스파일 단계입니다. 게다가, Vulkan은 SPIRV를 직접 사용할 수 있기 때문에 Vulkan에서 DXIL로 전환할 때 로딩 시간에 눈에 띄는 차이를 만듭니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Godot Engine News의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기