본문 바로가기

CPU, GPU 병목 현상 해결

stat unit으로 Game·Render·RHI·GPU 시간을 분리하고 Insights와 profilegpu로 원인을 좁힙니다.

앞의 세 글에서 측정 도구, 메모리, 렌더링 비용을 차례로 살펴봤습니다. 이제 실제로 프레임이 느린 장면을 만났을 때, 그 원인이 게임 스레드(Game Thread), 렌더 스레드(Render Thread), RHI 스레드(RHI Thread), GPU 가운데 어디에 가까운지 가려내고 그에 맞게 대응하는 일만 남았습니다. 프레임 시간은 여러 작업 가운데 가장 늦게 끝나는 쪽의 영향을 받으므로, FPS 숫자 하나만 보고 원인을 정할 수는 없습니다.

이 글에서는 측정한 신호에서 다음에 조사할 곳을 고르는 방법을 정리하고, CPU 쪽의 게임 스레드와 렌더 스레드, 그리고 GPU 쪽의 병목을 각각 어떻게 줄이는지 살펴봅니다. 한 병목을 줄였을 때 병목이 다른 곳으로 옮겨 가는 과정도 숫자로 따라가 봅니다.


신호에서 조사할 곳 고르기

먼저 프로파일링 도구 사용법에서 본 stat unit으로 긴 프레임을 분류하고, 그 작업의 타임라인을 조사합니다.

UNREAL C++ · CH9-4

큰 프레임 시간은 원인 확정이 아닌 조사 신호입니다

Frame과 함께 증가하는 Game·Draw/RHIT·GPU 축으로 다음 조사를 선택합니다. 실행 계산과 대기를 구분하고, 평균 프레임과 드문 스파이크를 각각 확인합니다.

프레임 시간에서 조사할 작업으로 좁히기
신호다음 확인수정 전에 구별할 것
Game 증가Timing Insights에서 Tick·AI·물리 요청·애니메이션 호출을 찾습니다.계산 시간과 다른 작업을 기다린 시간을 구별합니다.
Draw·RHIT 증가렌더·RHI 타임라인과 제출·상태 변경 통계를 확인합니다.CPU 명령 준비·제출과 GPU 셰이더 실행 비용은 다른 축입니다.
GPU 증가GPU 패스에서 그림자·투명·BasePass·후처리 시간을 비교합니다.Shader Complexity의 색은 실제 GPU 밀리초가 아닙니다.
Frame만 길거나 순간 끊김프레임 제한·VSync·동기화·로딩과 메모리 추적을 함께 확인합니다.프레임 평균과 드문 스파이크를 나눕니다. Frame만으로 특정 함수의 원인을 확정하지 않습니다.
Game 증가
다음 확인: Timing Insights에서 Tick·AI·물리 요청·애니메이션 호출을 찾습니다.
수정 전에 구별할 것: 계산 시간과 다른 작업을 기다린 시간을 구별합니다.
Draw·RHIT 증가
다음 확인: 렌더·RHI 타임라인과 제출·상태 변경 통계를 확인합니다.
수정 전에 구별할 것: CPU 명령 준비·제출과 GPU 셰이더 실행 비용은 다른 축입니다.
GPU 증가
다음 확인: GPU 패스에서 그림자·투명·BasePass·후처리 시간을 비교합니다.
수정 전에 구별할 것: Shader Complexity의 색은 실제 GPU 밀리초가 아닙니다.
Frame만 길거나 순간 끊김
다음 확인: 프레임 제한·VSync·동기화·로딩과 메모리 추적을 함께 확인합니다.
수정 전에 구별할 것: 프레임 평균과 드문 스파이크를 나눕니다. Frame만으로 특정 함수의 원인을 확정하지 않습니다.

같은 맵·카메라·해상도·빌드에서 여러 프레임을 비교합니다. Frame은 각 작업 시간을 단순히 더한 값으로 읽지 않습니다. 이 그림은 측정 수치나 특정 함수의 원인을 확정한 결과가 아닙니다.

위 그림의 표는 Frame과 함께 늘어나는 축마다 다음에 확인할 것과, 고치기 전에 구별할 것을 정리합니다. Game이 늘었다면 타이밍 인사이트에서 Tick, AI, 물리 요청, 애니메이션 호출을 찾되, 계산에 걸린 시간과 다른 작업을 기다린 시간을 구별합니다. Draw나 RHIT가 늘었다면 렌더, RHI 타임라인과 제출, 상태 변경 통계를 보되, CPU가 명령을 준비하고 제출하는 비용과 GPU가 셰이더를 실행하는 비용은 다른 축이라는 점을 기억합니다. GPU가 늘었다면 GPU 패스에서 그림자, 반투명, BasePass, 후처리의 시간을 비교하는데, Shader Complexity의 색은 실제 GPU 밀리초가 아닙니다.

마지막 행은 Frame만 길거나 순간적으로 끊기는 경우입니다. 이때는 프레임 제한, VSync, 동기화, 로딩과 메모리 추적을 함께 확인하고, 평균 프레임과 드문 스파이크(spike)를 나눠 봅니다. Frame만으로 특정 함수가 원인이라고 정할 수는 없습니다. 그림 아래의 설명처럼 같은 맵, 카메라, 해상도, 빌드에서 여러 프레임을 비교하고, Frame을 각 작업 시간의 단순한 합으로 읽지 않습니다. 이 그림은 측정 수치나 특정 함수가 원인이라고 확정한 결과가 아니라, 조사 순서를 고르는 표입니다.

고친 뒤에도 같은 병목일까

같은 조건에서 기준선이 Frame 26ms, Game 24ms, Draw 9ms, GPU 14ms였는데, 게임 쪽 작업을 고친 뒤 Frame 17ms, Game 14ms, Draw 9ms, GPU 16ms가 되었다고 합시다. Game은 10ms 줄었지만, 이제는 GPU가 Frame에 가장 가까운 후보가 되었습니다. 그러니 예전 원인만 계속 줄이기보다 새로 드러난 GPU 패스와 동기화 구간을 조사해야 합니다. 또 Frame 17ms는 1000/17 ≈ 58.82FPS이므로, 나아지긴 했어도 60FPS의 예산을 아직 넘습니다.

이처럼 한 병목을 줄이면 병목이 다른 작업으로 옮겨 가는 경우가 많습니다. 그래서 고친 뒤에는 같은 장면에서 Frame, Game, Draw, RHIT, GPU를 다시 기록하고, 얼마나 나아졌는지와 함께 병목이 어디로 옮겨 갔는지도 확인합니다.


게임 스레드의 병목 줄이기

CPU 병목은 게임 로직이나 렌더링 명령을 준비하는 과정에서 CPU를 지나치게 쓸 때 생깁니다. 게임 스레드는 게임플레이 로직의 대부분을 처리하므로, 여기서 병목이 생기면 게임 갱신이 느려져 프레임이 떨어집니다.

액터와 컴포넌트의 갱신

AActor::Tick()과 UActorComponent::TickComponent()는 매 프레임 실행되므로 무거운 로직을 넣지 않습니다. 틱이 필요 없는 클래스는 생성자에서 PrimaryActorTick.bCanEverTick이나 PrimaryComponentTick.bCanEverTick을 false로 두어 아예 등록하지 않고, 이미 등록된 틱은 SetActorTickEnabled 같은 함수로 켜고 끕니다.

틱 그룹(Tick Group)은 물리 계산 전후처럼 틱이 실행될 단계를 정하는 것으로, 중요도의 순위가 아닙니다. 같은 그룹 안에서 액터들의 순서는 정해져 있지 않으므로, 다른 틱에 의존한다면 선행 틱(prerequisite)을 지정합니다. SetActorTickInterval()로 틱 간격을 늘려, 예를 들어 0.1초마다 한 번만 실행되게 할 수도 있는데, 멀리 있는 NPC나 환경 물체에 알맞습니다. 액터 전체가 아니라 필요한 컴포넌트만 틱하게 하고, 필요 없는 컴포넌트는 끕니다.

물리 시뮬레이션

복잡한 물리 시뮬레이션은 CPU를 많이 씁니다. 필요 없는 물리 물체는 Simulate Physics를 끄거나 충돌 프리셋을 No Collision으로 바꿔 물리 바디 수를 줄입니다. 시뮬레이션할 물체에는 UCapsuleComponent, UBoxComponent 같은 단순한 기본 도형 충돌이나 단순 충돌 메시를 먼저 씁니다. 복잡한 메시 충돌을 단순 충돌로 쓰는 Use Complex as Simple은 시뮬레이션 대상의 일반적인 최적화 방법이 아니며, 정적인 쿼리 용도처럼 제한된 경우에만 검토합니다. 엔진 버전에 맞는 물리 설정과 서브스테핑(substepping), 충돌 채널, 물리 바디 수도 함께 조정합니다.

AI와 길 찾기

플레이어에게서 멀리 떨어진 AI는 갱신 빈도를 줄이거나 아예 멈춥니다. 행동 트리의 서비스(Service)와 데코레이터(Decorator)로 AI 로직이 조건에 맞을 때만 실행되게 할 수 있습니다. 길 찾기는 내비게이션 메시의 생성 비용을 줄이고, RecastNavMesh 설정에서 셀 크기 같은 값을 조절합니다. 일부 AI 시스템은 여러 CPU 코어에 작업을 나눠 처리하기도 합니다.

애니메이션

스켈레탈 메시에 LOD를 설정하면 멀리 있을 때 더 적은 본과 낮은 복잡도로 애니메이션을 계산합니다. 애니메이션 블루프린트의 이벤트 그래프와 애님 그래프에서는 복잡한 로직을 줄이고, 애니메이션 갱신 비용이 큰 노드가 무엇인지 프로파일링으로 확인합니다. 시야 밖에 있거나 화면에서 아주 작은 캐릭터는 애니메이션 갱신을 멈추거나 줄이는 애니메이션 컬링을 씁니다. 포즈 에셋(Pose Asset)과 애님 몽타주(Anim Montage)도 블렌딩을 효율적으로 하는 데 쓸 수 있습니다.

그 밖의 기법

총알, 이펙트, 임시 액터처럼 자주 만들어지고 사라지는 물체는 메모리 관리와 최적화 기법에서 본 오브젝트 풀링으로 SpawnActor와 DestroyActor의 CPU 부담을 줄입니다. 프로파일링으로 자주 실행되는 경로(hot path)가 확인된 블루프린트 로직은 C++로 옮기는 것을 검토합니다. 다만 단순히 C++로 바꾸는 것보다 호출 빈도, 자료구조, UObject에 접근하는 방식이 더 큰 영향을 줄 때가 많습니다.


렌더 스레드의 병목 줄이기

렌더 스레드는 게임 월드를 그리기 위한 명령을 준비해 그래픽 API로 보내는 일을 합니다. 여기서 병목이 생겼다면 드로 콜이 많거나 렌더링 상태가 자주 바뀔 가능성이 큽니다.

드로 제출 비용부터 줄입니다. 같은 메시를 여러 번 배치한다면 UInstancedStaticMeshComponent나 식생(foliage) 시스템의 인스턴싱으로 제출 비용을 줄일 수 있는데, UFoliageType은 식생의 설정 객체이며, 효과는 머티리얼 섹션, 인스턴스 컬링, 플랫폼 제약에 따라 달라집니다. 가까운 스태틱 메시 여러 개는 렌더링 최적화에서 본 액터 병합으로 하나의 메시로 합칠 수 있지만, 머티리얼이 많거나 오클루전과 LOD의 단위가 커지면 이득이 줄어듭니다. 큰 오픈 월드에서는 HLOD가 멀리 있는 여러 액터를 하나의 메시와 머티리얼로 묶어 드로 콜을 줄이고, 텍스처 아틀라스는 텍스처를 바꿔 끼우는 비용과 셰이더 변경 비용을 줄입니다.

컬링도 다듬습니다. 오클루전 컬링은 시야에서 가려진 물체를 그리지 않게 하며, 언리얼은 기본적으로 하드웨어 오클루전 쿼리를 씁니다. 정적인 장면에서는 Precomputed Visibility Volume으로 미리 계산한 가시성 데이터를 써서 실행 중의 컬링을 효율적으로 만들 수 있고, 식생과 인스턴스 메시는 컬링 설정을 조정해 불필요한 렌더링을 줄입니다.

동적 광원은 드로 콜과 셰이더 복잡도를 늘리므로, 지금 쓰는 조명 방식에서 영향 범위와 그림자 비용을 비교합니다. 미리 계산한 조명과 달리 루멘 전역 조명에서는 Static 광원의 미리 계산된 기여가 꺼진다는 점도 앞 글과 같습니다. 그림자는 Cast Shadows 끄기, 그림자 맵 해상도 낮추기, 캐스케이드 수 조절 같은 앞 글의 기법을 적용합니다.

렌더 스레드에서는 프리미티브와 컴포넌트의 수, 드로 제출, 머티리얼 전환, 동적 상태 변경을 확인합니다. 머티리얼 셰이더의 실행 비용은 주로 GPU 쪽에서 Shader Complexity와 profilegpu로 따로 봅니다.


GPU의 병목 줄이기

GPU 병목은 GPU가 픽셀 처리, 정점 처리, 셰이더 계산 같은 실제 렌더링 작업에 오래 걸릴 때 생기며, 진단에는 profilegpu가 가장 쓸모 있습니다.

가장 먼저 볼 것은 픽셀 복잡도입니다. 반투명이나 마스크드 머티리얼을 줄이고 파티클의 오버드로를 다듬으며, Shader Complexity 모드로 오버드로가 심한 곳을 찾습니다. 머티리얼 에디터의 Stats 탭으로 셰이더 명령 수를 확인해 필요 없는 연산과 텍스처 샘플링을 지우고, 프로젝트 설정의 Rendering 항목에서 모션 블러, 블룸, 앰비언트 오클루전처럼 필요 없는 렌더링 기능을 끄거나 품질을 낮춥니다.

폴리곤 수는 앞 글에서 본 LOD로 먼 물체부터 줄이고, 에셋을 만드는 단계에서 폴리곤을 줄이고 노멀 맵으로 디테일을 표현합니다.

해상도도 조절할 수 있습니다. 기본 해상도를 낮추거나 r.ScreenPercentage 콘솔 변수로 렌더링 해상도를 줄이면 GPU 부하가 크게 내려가지만 화면 품질이 떨어질 수 있습니다. 낮은 해상도로 그린 뒤 높은 해상도로 키우는 업스케일링 기술을 쓰면 GPU 부담을 줄이면서 품질을 어느 정도 지킬 수 있는데, 언리얼 엔진 5에는 TSR(Temporal Super Resolution)이 내장되어 있고, FSR(FidelityFX Super Resolution)이나 DLSS(Deep Learning Super Sampling)는 플러그인으로 쓸 수 있습니다.

그림자는 r.Shadow.MaxResolution, r.Shadow.TexelsPerPixel, 캐스케이드 수 같은 설정으로 비용을 낮추고, 실시간 레이 트레이싱 그림자 대신 라이트맵이나 캡슐 섀도를 검토합니다. 후처리는 포스트 프로세스 볼륨에서 효과마다 품질을 조정하며, 직접 만든 복잡한 후처리 셰이더는 성능을 꼼꼼히 확인합니다. 파티클은 지나치게 많은 수, 복잡한 머티리얼, 심한 오버드로를 피하고 LOD와 컬링을 씁니다.


더 깊이: 멀티스레딩과 데이터 지향 설계

언리얼 엔진은 기본적으로 멀티스레딩 구조를 쓰지만, 특정 계산을 FAsyncTask나 Async로 다른 스레드에 나눠 게임 스레드의 부담을 줄일 수도 있습니다. 이때 UObject를 만들거나 바꾸는 일, 액터와 컴포넌트에 접근하는 일은 게임 스레드에서만 해야 하므로, 작업이 끝난 뒤 필요한 변경은 게임 스레드로 다시 넘겨야 합니다.

데이터 지향 설계(Data-Oriented Design, DOD)는 비슷한 데이터를 메모리에 연속으로 두고 함께 처리해, CPU의 캐시 미스(cache miss)를 줄이는 설계 방식입니다. 연속된 데이터가 캐시에 유리한 이유는 컴퓨터구조 교재의 메모리 계층과 주기억장치에서 공간 지역성으로 설명합니다. 언리얼의 매스(Mass) 프레임워크가 이런 ECS(Entity Component System) 비슷한 방식을 제공합니다.

프로젝트 설정의 Engine 아래 Rendering이나 Physics 항목에서 전반적인 품질 설정을 조정할 수 있고, 개발 초기부터 에셋 제작 지침을 정해 최적화된 모델, 텍스처, 머티리얼을 만들도록 하면 나중에 고칠 일이 줄어듭니다.


정리

  • 프레임 시간은 가장 늦게 끝나는 작업의 영향을 받으므로, stat unit에서 Frame과 함께 늘어나는 축을 찾아 다음 조사를 고르고, Frame을 작업 시간의 합으로 읽지 않습니다.
  • 평균 프레임과 드문 스파이크는 따로 조사하며, 스파이크는 로딩, GC, 입출력, 대기 같은 경로도 함께 봅니다.
  • 한 병목을 줄이면 병목이 옮겨 갈 수 있으므로, 고친 뒤에는 같은 장면에서 모든 축을 다시 기록합니다.
  • 게임 스레드는 Tick 줄이기, 틱 간격과 선행 틱, 단순한 충돌, AI와 애니메이션의 거리별 갱신, 풀링으로 부담을 줄입니다.
  • 렌더 스레드는 인스턴싱, 병합, HLOD, 텍스처 아틀라스, 컬링으로 드로 제출과 상태 변경을 줄입니다.
  • GPU는 픽셀 복잡도, 폴리곤 수, 해상도와 업스케일링(TSR 등), 그림자와 후처리, 파티클을 조정해 줄입니다.
  • 다른 스레드로 나눈 작업도 UObject와 액터는 게임 스레드에서만 다룹니다.

연습 문제

1. Game을 더 줄이면 60FPS가 될까

앞의 예에서 고친 뒤의 값(Frame 17ms, Game 14ms, Draw 9ms, GPU 16ms)이 그대로일 때, Game을 더 줄이면 60FPS가 저절로 보장된다고 말할 수 있을까요?

해설 보기

말할 수 없습니다. GPU가 16ms로 Frame에 가장 가까운 후보가 되었고 동기화 같은 다른 제한도 남아 있을 수 있으므로, Game만 줄여서는 Frame이 16.67ms 아래로 내려간다는 보장이 없습니다. 같은 구간의 타임라인으로 다음 후보인 GPU 패스와 동기화를 확인하고, 화면 품질도 함께 비교합니다.

2. 한 번 튀는 80ms

Game, Draw, GPU는 모두 짧은데 Frame만 가끔 80ms로 튀는 프레임이 있습니다. 이 값 하나로 AI 함수가 원인이라고 정할 수 있을까요?

해설 보기

정할 수 없습니다. 순간적인 80ms는 에셋 로딩, 가비지 컬렉션, 파일 입출력, 다른 작업의 대기 같은 경로에서도 생길 수 있으므로, 그 프레임의 타임라인과 로딩, 메모리 추적을 함께 확인해야 합니다. 평균 프레임의 개선과 드문 끊김은 따로 기록하고 조사합니다.

3. 틱 순서에 기대는 코드

카메라를 따라가는 액터 A가 매 틱 캐릭터 B의 위치를 읽어 자신을 옮기는데, 가끔 한 프레임 늦게 따라가는 것처럼 보입니다. 두 액터는 같은 틱 그룹에 있습니다. 무엇을 고쳐야 할까요?

해설 보기

같은 틱 그룹 안에서 액터들의 실행 순서는 정해져 있지 않으므로, A가 B보다 먼저 실행되는 프레임에는 B가 아직 움직이기 전의 위치를 읽게 됩니다. 그래서 한 프레임 늦게 따라가는 것처럼 보입니다.

A의 틱에 B를 선행 틱(prerequisite)으로 지정해 B가 먼저 실행되게 하거나, A를 B보다 뒤의 틱 그룹에 둡니다. 틱 그룹은 중요도가 아니라 실행 단계를 정하는 것이므로, 의존 관계가 있으면 선행 관계를 명시합니다.

틱의 그룹과 선행 관계는 Epic의 Actor Ticking 문서에서, 프로파일링의 전체 흐름은 Introduction to Performance Profiling and Configuration 문서에서 확인할 수 있습니다.

이것으로 9장을 마칩니다. 9장에서는 언리얼 인사이트와 stat 명령, GPU 비주얼라이저로 성능을 측정하는 방법, 가비지 컬렉션의 도달 가능성과 메모리를 줄이는 기법, 렌더링 비용을 GPU와 렌더 스레드로 나눠 줄이는 방법, 그리고 측정한 신호에서 병목을 찾아 해결하고 다시 확인하는 과정을 익혔습니다. 다음 장의 플랫폼별 빌드와 패키징부터는 게임을 플랫폼별로 빌드하고 패키징하는 방법, 디버깅과 로그, 핫픽스와 업데이트, 서버 운영을 다룹니다.