개발일지/소프트 렌더러

소프트웨어 렌더러 만들기 - 11 (메모리 누수 해결 및 성능 최적화)

hwi.middle 2024. 7. 17. 12:23

여기까지의 작업내용: https://github.com/hwi-middle/HimchanSoftwareRenderer/tree/f26aa3b245df0417381fa1a5e0fe323d778c1ccb

 

GitHub - hwi-middle/HimchanSoftwareRenderer: C++로 구현한 소프트웨어 렌더러입니다.

C++로 구현한 소프트웨어 렌더러입니다. Contribute to hwi-middle/HimchanSoftwareRenderer development by creating an account on GitHub.

github.com

끔찍한 성능

렌더러를 만들면서 만난 복병이 있다면 성능이다. 처음에는 GPU 쓰는 것도 아니니까 성능은 그럭저럭 나와도 괜찮다고 생각하면서 넘어갔는데, 더블 버퍼링 만들고 나니 성능이 확 떨어졌다. 창 크기를 크게 키우면 아예 20fps 정도로 떨어지기까지 했다.

성능 측정 (병목 탐지)

일단 Bottleneck이 어딘지 찾아보기 위해 적당히 break point를 걸었다. 여기는 계속 루프를 돌기 때문에 disable 해놓고 디버거 켜고 화면 크기 쫙 키운 다음에 enable 했다.

그렇게 확인하게 된 CPU Usage. 솔직히 처음 써본다. 그래서 MSDN을 뒤져보니 대충 감을 잡을 수 있었다. Total CPU는 자기가 호출한 함수까지 포함한 성능이고 Self CPU는 순수하게 자기 자신의 성능인 것이다.

 

그리하여 이 데이터를 살펴보니, 대충 FillBuffer, Color::ToColor32, Math::Clamp, Color32::Color32(생성자)가 말썽인 걸 알 수 있었다.

void WinRenderer::FillBuffer()
{
	for (uint32 i = 0; i < Width * Height; i++)
	{
		ScreenBuffer[i] = Color::White.ToColor32();
	}
}

헉, 이 함수를 보자마자 원인을 알 수 있었다. 매번 ToColor32를 호출해서 새로운 Color32 객체를 만드니 성능이 안나올 수 밖에. FillBuffer가 느릴 수 밖에 없는 이유였다. 이 코드는 연쇄적으로 Color::ToColor32, Math::Clamp, Color32::Color32 이 3가지가 CPU Usage를 많이 차지하도록 한다. Math::Clamp가 의아할 수 있는데, Color::ToColor32에서 Math::Clamp를 4번 (rgba 채널별로 한 번씩) 호출하도록 되어있다.

 

성능 개선

그러면 대안을 제시해보겠다.

// Solution1
void WinRenderer::FillBuffer()
{
	Color32 ClearColor = Color::White.ToColor32();
	std::fill(ScreenBuffer, ScreenBuffer + Width * Height, ClearColor);
}
// Solution2
void WinRenderer::FillBuffer()
{
	memset(ScreenBuffer, 255, Width * Height * sizeof(Color32));
}
// Solution3
void WinRenderer::FillBuffer()
{
	Color32 ClearColor = Color::White.ToColor32();
	Color32* dest = ScreenBuffer;
	for(int i = 0; i < Width * Height; i++)
	{
		*dest++ = ClearColor;
	}
}

각각을 솔루션1, 솔루션2, 솔루션 3이라고 해보자. 성능 측정은 간단하게 fps로 측정해보도록 하겠다.

 

  • 실험조건: 디버깅 없이 실행하여 QHD 모니터에서 창 최대화 후 대략적인 fps 측정
  • 솔루션1: 144fps
  • 솔루션2: 400fps
  • 솔루션3: 140fps
  • 실험결과: 솔루션2가 가장 우수한 성능을 냈다. 솔루션1과 솔루션3은 사실상 동일한 성능을 내었다. 해상도를 바꿔가며 실행했을 때에도 동일한 결과를 보였다.
void WinRenderer::FillBuffer()
{
	memset(ScreenBuffer, 255, Width * Height * sizeof(Color32));
}

그리하여 채택된 새로운 FillBuffer 함수 되시겠다. 사실 솔루션2라고 아무렇지도 않은척 적어뒀지만 이 코드는 적고 나서 굉장히 뿌듯했다. memset은 바이트 단위로 값을 설정하는데...

어... 어라?

컬러 채널 하나가 1바이트니까... memset을 통해 255라는 값을 넣으면 모든 바이트가 255값을 가지면서 하얀색 배경으로 초기화할 수 있게 된다! 그렇게 해서 솔루션2가 정상작동 할 수 있는 것이다. 그리고 memset은 원래 빠르니까 성능이 잘 나오는 것도 당연지사. 아, 이 아이디어 참 뿌듯하다!

 

다만 의외였던 지점은 std::fill이 꽤나 느리다는 점이었다. 왠지 모르게 std 네임스페이스에 있으니까 굉장히 빠를거라는 못된 믿음을 갖고 있었는데, 반성해야겠다.

심각한 메모리 누수

성능 측정을 하면서 알게된 건데, 어마어마한 메모리 누수가 있다...! 창을 Resize할 때 마다 메모리가 기하급수적으로 증가한다. 몇번 Resize 드래그 몇 번 했더니 3.4GB를 먹는 미쳐버린 상황.

Resize가 발생할 경우 WM_TIMER 메시지를 통해 Tick을 발생시켜 그리고 있으니 이 부분에 brakpoint를 걸어서 조사해봤다.

분명하게 메모리가 증가하고 있다. 화면을 키우면 당연히 증가할 수 있겠지만 이미 비정상적인 증가(화면을 줄여도 메모리가 해제되지 않음)가 있음을 확인했으므로 한번 살펴본다.

으응...? 사실 이것도 처음 써보는데 다 Unknown이란다. 아무 정보도 안준다.

void WinRenderer::Release()
{
	::DeleteObject(OriginalBitmap);
	::DeleteObject(MemBitmap);
	::ReleaseDC(Handle, MemDC);
	::ReleaseDC(Handle, ScreenDC);
	_CrtDumpMemoryLeaks();
}

_CrtDumpMemoryLeaks() 라는게 있어서 한번 써봤다.

어... 그래, 메모리 누수 있는건 나도 아는데 뭔 말인지 모르겠다. 이걸 가지고 어떻게 분석해야하지...?

메모리 누수 해결

void WinRenderer::Release()
{
	::DeleteObject(OriginalBitmap);
	::DeleteObject(MemBitmap);
	::DeleteDC(MemDC);
	::DeleteDC(ScreenDC);
}

그냥 이리저리 삽질하다가 해결하게 됐는데, ReleaseDC를 사용한게 문제였다. 여기서는 DeleteDC를 사용해야했다. 특히 MemDC가 그렇다. ReleaseDC는 GetDC 같은걸로 가져온걸 놓아주는거고, CreateDC로 만든건 DeleteDC로 아예 지워줘야한다.

특히 MSDN에서는 ReleaseDC는 CreateDC를 호출해서 만든 DC를 해제할 수 없음을 명확히 하고 있다.

게다가, DC가 제대로 해제되지 않으면 0을 반환하기 때문에 if문으로 잘 처리해두었으면 애초에 일어나지 않았을 누수였다. 사실 빠르게 구현한다고 예외처리를 스스로 생각하기에도 매우 게을리했는데, 이 부분을 많이 반성하게 되었다.

아무튼, 이렇게 수정한 후 다시 메모리 사용량을 보니 정상적인 수준으로 돌아왔음을 알 수 있다. 특히, 해상도를 줄이면 다시 메모리 사용량이 줄어드는 것이 관찰되어 완전히 해결되었다고 판단했다.

반성할 점이 많다

성능 저하, 메모리 누수 모두 막을 수 있는 일이었다. 매 프레임마다 생성자를 호출하는 미친 코드도, 예외처리가 하나도 안된 Release 처리도 참 아쉽다. 어쨌든 스스로 이런 것들을 해결했다는게 뿌듯하기도 하지만, 애초에 이런 문제를 일으키면 안됐는데, 하는 아쉬움도 크다.

 

아예 몰랐던 문제라면 배웠다 하고 넘어가겠지만, 너무 당연한 것들을 소홀히했다. 앞으로는 더욱 정신차려서 코딩해야겠다... 이제 이런 부분도 어느정도 해결 됐으니 삼각형 그리는 처리로 넘어가보겠다. 사실 키보드 입력도 받아야하는데 삼각형 그리기가 조금 더 하고싶어서 먼저 구현하려한다.

 

그래도, 앞으로 꾸준히 나아가고 있다. 난 잘하고 있어!