상세 컨텐츠

본문 제목

FT_Newton: 최적화를 통해 FPS 방어하기 - (11)

Computer Graphics/ALEngine

by Banjosh 2025. 2. 4. 01:10

본문

 저번 글까지 기본적인 기능은 완성되었지만, 매우 느린 물리 엔진이라는 사실을 확인할 수 있었다.

이번 글에서는 그 느렸던 물리 엔진의 FPS 방어 과정을 적어보려 한다.

 

 물리 엔진의 기본 기능이 확보된 후, 게임 엔진 개발 팀원들에게 받은 미션은 100 x 100의 박스를 생성하고 FPS 60 방어선을 지키는 것이었다. 2 x 2 크기의 박스 충돌에서도 느렸던 내 물리 엔진을 어떻게 최적화할지 고민한 끝에 다음 과정을 거치기로 했다.

 

1. 현재 상태 파악

2. Visual Studio Profiler를 이용한 분석 및 문제 가정

3. 문제 가정에 따른 해결 방법 모색

4. 해결 방법 적용 후 다시 테스트

 


 

1. 현재 상태 파악

 

  최적화를 진행하기 위해 일단 고정된 테스트 박스의 개수를 정한 뒤 현재 FPS를 기록했다. 이후 Visual Studio의 Profiler를 통해 속도가 느려지는 구간을 확인하고, 문제가 되는 원인을 가정한 뒤 해결 방법을 모색하고 이를 직접 해결한 후 다시 결과를 확인하는 과정을 반복했다.

우선 현재 상태를 기록해보았는데, 지난 글까지 debug 모드로 빌드하여 컴파일러 최적화 없이 물리 엔진이 돌아갔다는 사실을 깨달았다. 이를 감안하고 7 x 7 x 7 (343개)의 박스를 생성한 결과를 살펴보자.

 

FPS 테스트 결과

 아래는 RenderDoc을 통해 실행한 결과 영상이다. 왼쪽 상단에 FPS가 기록된 것을 볼 수 있다. 확실히 release 모드로 빌드해서 그런지 이전 글보다는 훨씬 빨라진 것을 확인할 수 있었지만, 400개도 안 되는 박스 평균 FPS 60으로 처리하는 것은 우리가 설정한 목표에 훨씬 못 미치는 수준이었다.

(참고로 목표였던 10000개의 박스 테스트는 Vulkan 설계 상 GPU 메모리 최적화가 안 되어 불가능했기 때문에, 렌더링 엔진과 합쳐진 후 진행해볼 예정이다.)

 

2. Visual Studio Profiler를 이용한 분석 및 문제 가정

Call Tree 분석

Call Tree

 

 위 사진은 Visual Studio Profiler로 CPU 사용량을 Call Tree로 확인한 결과이다. Call Tree는 프로그램의 함수 호출 계층 구조를 보여주는 것으로, 불꽃 모양이 가장 큰 getEpaResult()findCollisionPoints() 함수의 사용량이 꽤 높은 것을 볼 수 있다.

참고로 함수 이름 바로 오른쪽은 Total CPU 사용량이고, 그 오른쪽은 Self CPU 사용량이다.

  • Total CPU 사용량: 해당 함수와 자식 함수를 포함한 전체 CPU 사용량
  • Self CPU 사용량: 자식 함수 호출을 제외하고 해당 함수 내부에서만 사용된 CPU 사용량

getEpaResult() 분석 

getEpaResult

runPhysics() 내부에서 getEpaResult()의 Total CPU 사용량이 32.20%로 가장 높았는데, 하위 계층에서 호출된 함수들을 보면 allocator라는 단어가 붙은 함수들이 다수 보였다.

 

 

findCollisionPoints() 분석

fildCollisionPoints

findCollisionPoints()의 Total CPU 사용량은 21.39%였으며, 하위 함수 중 가장 높은 7.66%의 CPU 사용량을 보이는 함수도 역시 allocator가 붙어 있었다. 그 외에도 allocator가 붙은 함수가 다수 확인되었다.

 

 

Self CPU 사용량 분석

Self CPU

마지막으로 Self CPU 사용량을 내림차순으로 확인해 보았는데, ucrtbase.dll 함수들이 1위와 3위를 차지한 것을 볼 수 있었다. 이 함수들은 Universal C Runtime Library 내부의 익명 함수로, Profiler에서 정확한 함수 이름을 찾지 못했거나 최적화가 적용된 상태에서 호출이 감춰진 경우 이런 식으로 표시된다.

하지만 allocator 호출이 많았던 것을 고려하면, 이 함수들이 메모리 할당 관련 시스템 호출일 가능성이 크다고 판단했다. 결국 CPU 사용량의 대부분이 메모리 할당 함수라는 가정을 하게 되었고, 이를 해결하기 위한 방법을 찾아보았다.

 


 

3. 문제 가정에 따른 해결 방법 모색

 

 잦은 메모리 할당에 의한 지연을 해결하는 방법은 생각보다 쉽게 찾을 수 있었다. 그 방법은 바로 메모리 풀(memory pool) 을 사용하는 것이었는데, 이는 다음과 같은 장점이 있다:

  1. 메모리 할당을 크게 해서 시스템 호출(system call) 최소화
  2. 할당된 메모리가 주소상 가깝게 모여 있어 캐시 적중률 증가

 물론 메모리 풀에도 단점이 존재한다. 메모리 풀은 보통 정적 배열에 특화되어 있고, 동적 할당 자료구조를 쓰고 싶다면, 편리하게 쓰던 std::vector 와 같은 자료구조 대신 연결 리스트를 이용해야한다.

 

 하지만 이런 단점에도 불구하고, 장점이 매우 크기 때문에 메모리 풀을 구현하여 물리 엔진에 적용하기로 했다. (직접 메모리 풀을 구현한 내용은 다음 글에서 다룰 예정이다.)

 


 

4. 해결 방법 적용 후 다시 테스트

 

메모리 풀 구현 후 물리 엔진을 다시 RenderDoc을 통해 실행해보았다. 그 결과는 다음과 같다.

 

FPS 테스트 결과

 

평균 FPS가 210으로 상당히 빨라진 것을 확인할 수 있었다. 메모리 최적화를 처음 해본 결과 이렇게 확연히 빨라진 것을 보고 매우 놀랐다. Profiler 결과도 다시 확인해보자.

 

Call Tree 결과

Call Tree

 Call Tree를 보면 이전에 CPU 사용량이 높았던 getEpaResult()findCollisionPoints()의 CPU 사용량이 확 줄어든 것을 볼 수 있다. 물론 Total CPU 사용량은 비율로 표시되기 때문에 실감이 덜 날 수 있지만, 실제로는 크게 감소했다.

 

 getEpaResult() 와 findCollisionPoints() 분석

 

 아까 보이던 allocator들이 전부 사라진 것을 볼 수 있고, 새로 구현한 생긴 BlockAllocator와 StackAllocator들은 CPU 사용량이 얼마 되지 않는 것을 확인 할 수 있다.

 

Self CPU 결과

Self CPU

 Self CPU 사용량 1, 3위를 차지했던 ucrtbase.dll 함수가 사라진 것을 볼 수 있었다. 이는 메모리 할당 함수라는 가정이 맞았음을 증명한다.

 

마무리

 원래 메모리 최적화 후에도 FPS 방어가 되지 않으면 다른 원인을 찾아야겠다고 생각했지만, 너무 빨라져서 최적화는 여기서 마무리하기로 했다. (게임 엔진에서 해야 할 작업이 많기 때문에, 얼른 끝내고 다른 작업을 도와야 한다.)

 최적화를 끝냈지만, 너무 찾기 쉬운 원인이었고 해결 방법도 어렵지 않아서 다행이었다. 만약 메모리 최적화 후에도 FPS 방어가 안 됐다면 다음 문제를 찾는 것이 쉽지 않았을 것 같다. 지금은 일단 최적화의 대표적인 사례인 메모리 최적화를 해봤다는 점에서 만족하기로 했다.


 다음 글에서는 아까 말했듯이 메모리 풀 구현에 대한 내용을 작성할 예정이다.

관련글 더보기