이 글은 Vulkan을 학습하는 개발자의 관점에서, MoltenVK 사용 시 프로그램 종료 시점에 나타나는 ‘GPU memory still allocated’ 로그의 원인을 사실 위주로 정리한 기록입니다.
문제 상황
MoltenVK를 사용하는 Vulkan 애플리케이션을 종료할 때 아래와 같은 메시지가 출력되는 현상을 발견했습니다. 이것이 실제 메모리 누수인지 확인해볼 필요가 있었습니다.
[Vulkan] Destroyed VkPhysicalDevice for GPU Apple M4 Pro with 29 MB of GPU memory still allocated.
가설 1: Vulkan 리소스 정리 순서 문제
첫 번째로, Vulkan 명세를 위반한 리소스 파괴 순서 때문에 메모리 누수가 발생했을 수 있다고 가정했습니다.
검증: Vulkan 명세 확인
Vulkan 명세에 따르면, 안전한 종료를 위해서는 특정 순서에 따라 객체를 파괴해야 합니다.
| 단계 | 객체 계층 | 파괴 순서 | 근거 |
|---|---|---|---|
| 0 | 동기화 | vkDeviceWaitIdle | 모든 GPU 작업의 종료를 보장합니다. |
| 1 | 프레임 | Framebuffer → ImageView → Swapchain | 부모-자식 규칙을 따릅니다. |
| 2 | 파이프라인 | Pipeline → PipelineLayout → RenderPass/Shader | 의존성 관계에 따라 파괴합니다. |
| 3 | 디스크립터 | Set → Pool → Layout | 풀을 파괴하면 셋이 암묵적으로 해제됩니다. |
| 4 | 커맨드 | CommandPool | 풀을 파괴하면 커맨드 버퍼가 암묵적으로 해제됩니다. |
| 5 | 리소스 | Buffer/Image → Memory | 자식 객체를 먼저 파괴합니다. |
| 6 | 메모리 | VMA Allocator | 모든 리소스가 해제된 후 파괴합니다. |
| 7 | 동기화 객체 | Fence, Semaphore | VkDevice 파괴 전에 파괴해야 합니다. |
| 8 | 상위 객체 | Device → Surface → Instance | 모든 자식 객체가 파괴된 후 파괴합니다. |
제 프로젝트의 shutdown 함수는 vkDeviceWaitIdle 호출을 시작으로, Vulkan 명세가 권장하는 순서(자식 객체 파괴 후 부모 객체 파괴)를 잘 따르고 있음을 확인했습니다. Validation Layer에서도 관련 오류가 발생하지 않았습니다.
가설 1 결론: 기각
리소스 정리 순서는 문제가 아니라고 결론 내렸습니다.
가설 2: CommandBuffer 명시적 해제 누락
Vulkan 명세상 vkDestroyCommandPool이 풀에서 할당된 모든 커맨드 버퍼를 자동으로 해제하지만, MoltenVK에서는 vkFreeCommandBuffers를 명시적으로 호출하는 것을 권장하는 경우가 있습니다. 이것이 원인일 수 있다고 생각했습니다.
검증: 명시적 해제 구현
destroyCommandPool 함수를 수정하여 vkFreeCommandBuffers를 명시적으로 호출하도록 변경했습니다.
void Application::destroyCommandPool() {
if (commandPool_ != VK_NULL_HANDLE) {
// MoltenVK 권장: 명시적 해제
if (!commandBuffers_.empty()) {
vkFreeCommandBuffers(device_.device(), commandPool_,
static_cast<uint32_t>(commandBuffers_.size()),
commandBuffers_.data());
commandBuffers_.clear();
}
vkDestroyCommandPool(device_.device(), commandPool_, nullptr);
commandPool_ = VK_NULL_HANDLE;
}
}
하지만 코드를 수정하고 실행해도 “GPU memory still allocated” 메시지는 사라지지 않았습니다.
가설 2 결론: 기각
CommandBuffer의 명시적 해제 여부는 이 문제와 관련이 없었습니다.
가설 3: Objective-C Autorelease Pool 문제
macOS 환경에서는 Metal 객체들이 @autoreleasepool에 의해 관리되는데, 이 풀이 제때 해제되지 않아 메모리가 남아있을 수 있다는 가설을 세웠습니다.
검증: Autorelease Pool 적용
메인 루프의 각 프레임 시작과 끝에 @autoreleasepool을 생성하고 해제하는 코드를 추가했습니다.
// main loop
while (!window_.shouldClose()) {
#ifdef __APPLE__
void* pool = objc_autoreleasePoolPush();
#endif
window_.pollEvents();
drawFrame();
#ifdef __APPLE__
objc_autoreleasePoolPop(pool);
#endif
}
이 변경 후에도 종료 시점의 메시지는 동일하게 발생했습니다.
가설 3 결론: 기각
Objective-C의 Autorelease Pool 문제도 아니었습니다.
가설 4: CAMetalLayer의 Drawable 보유 문제 (최종 가설)
남은 가능성은 MoltenVK의 버그가 아니라, MoltenVK가 의존하는 macOS 그래픽 스택(Core Animation)이 마지막 프레임을 표시하기 위해 드로어블을 잠시 보유하는 구조적 특성이라고 판단했습니다.
이론적 배경
-
VkImage와CAMetalDrawable의 관계
MoltenVK에서 스왑체인을 생성하면 각VkImage는CAMetalLayer가 반환한CAMetalDrawable.texture를 래핑합니다. 즉, Vulkan 스왑체인 이미지는 Metal 드로어블을 직접 참조할 뿐, 실제 리소스의 소유자는 아닙니다. -
드로어블 풀과 표시 타이밍
Apple의 Metal Best Practices Guide – Drawables에 따르면,
CAMetalLayer는 제한된 수의 드로어블을 재사용 가능한 풀(limited and reusable resource pool) 로 관리합니다.
시스템이 아직 드로어블을 재활용하지 못한 경우, 새 드로어블을 요청하는 시점에 일시적으로 대기(block) 가 발생할 수 있습니다.
또한 드로어블의 실제 표시(present)는 커맨드 버퍼 실행이 완료된 후 이루어집니다.
이러한 구조는 macOS가 트리플 버퍼링(triple buffering) 을 구현하는 일반적인 방식으로, Core Animation이 몇 개의 프레임을 미리 보유하여 부드러운 화면 전환을 보장합니다.
즉, macOS는 프레임 전환의 일관성을 위해 마지막 몇 개의 드로어블을 잠시 유지(retain) 하므로, 애플리케이션이 리소스를 모두 해제하더라도 일부 드로어블이 GPU 메모리를 계속 점유할 수 있습니다.
MoltenVK 관점
MoltenVK은 CAMetalDrawable.texture를 단순히 감싼 VkImage를 노출합니다. Core Animation이 드로어블을 retain하는 동안 Metal 드라이버는 해당 텍스처를 “사용 중”으로 간주하고, MoltenVK은 디바이스 파괴 시점에 그 메모리를 아직 할당된 것으로 보고합니다. 실제로 MoltenVK 로그에는 다음과 같은 정보 메시지가 남습니다.
[mvk-info] Destroyed VkPhysicalDevice for GPU Apple M4 Pro with 29 MB of GPU memory still allocated.
MoltenVK 이슈 #2547에서도 비슷한 현상이 논의되며, 메모리 누수의 가능성보다는 타이밍의 문제라고 보고 있습니다. Instruments에서 Metal 객체 누수가 관찰되지 않고 있다는 점이 이러한 주장을 더 강화합니다.
검증
1. 수학적 검증
해상도 2560 × 1440, 포맷 RGBA8(4 bytes/pixel)일 때 한 프레임은 약 14.06 MiB이고 두 프레임은 약 28.1 MiB입니다. 로그의 29 MB와 거의 일치합니다.
2. 포맷 변경 실험
VkSurfaceFormatKHR chooseSurfaceFormat(const std::vector<VkSurfaceFormatKHR>& formats) {
for (const auto& f : formats) {
if ((f.format == VK_FORMAT_R16G16B16A16_SFLOAT) &&
f.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) {
return f;
}
}
return formats.empty()
? VkSurfaceFormatKHR{VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR}
: formats[0];
}
[Vulkan] Created 3 swapchain images with size (2560, 1440) and contents scale 2.0
[Swapchain] Created: format=97, extent=2560x1440, images=3
[Vulkan] Destroyed VkDevice on GPU Apple M4 Pro with 2 Vulkan extensions enabled.
[Vulkan] Destroyed VkPhysicalDevice for GPU Apple M4 Pro with 57 MB of GPU memory still allocated.
스왑체인 포맷을 RGBA16F(8 bytes/pixel)로 바꾸면 예상 크기는 약 56.2 MiB입니다. 실제 로그에서도 57 MB가 남았다고 보고되어 예측과 정확히 맞아떨어졌습니다.
가설 4 결론: 채택
남아 있는 메모리는 Core Animation이 retain 중인 스왑체인 드로어블이며, macOS의 정상적인 컴포지션 타이밍 차이에서 비롯된 정보성 메시지라고 결론 내렸습니다.
최종 결론
MoltenVK에서 출력되는 “GPU memory still allocated” 메시지는 다음과 같이 정리할 수 있습니다.
-
실제 메모리 누수가 아닙니다. 이것은 MoltenVK가 제공하는 정보성 로그이며, macOS의 디스플레이 컴포지터(
CAMetalLayer)가 정상적으로 동작하면서 마지막 프레임들을 보유하고 있음을 보여주는 것입니다. 프로세스가 종료되면 이 메모리는 운영체제에 의해 완전히 회수됩니다. 실제 프로젝트에서 MVK 로그를 출력하도록 설정하고 확인을 해보면[mvk-info] Destroyed VkPhysicalDevice for GPU Apple M4 Pro with 29 MB of GPU memory still allocated.와 같은 메시지가 나오는데, 이 메시지는 위에서 나온 Vulkan의 로그와 일치하는 것을 알 수 있습니다. 즉, Vulkan의 로그는 MoltenVK에서 제공하는 정보성 로그와 동일합니다. -
애플리케이션의 리소스 정리는 올바릅니다. Vulkan Validation Layer에서 오류가 없었고, 리소스 파괴 순서도 명세를 준수하고 있었습니다.
-
보고된 메모리의 정체는 스왑체인 이미지 2장입니다. 실험을 통해 보고된 메모리 크기가 스왑체인 이미지 2장의 크기와 정확히 일치함을 입증했습니다.
따라서 이 메시지는 실질적인 문제가 아니며, 무시하거나 로그 레벨을 조정하여 숨겨도 괜찮습니다.
권장 조치 (선택사항)
이 정보성 로그를 보지 않으려면, 환경 변수를 설정하여 MoltenVK의 로그 레벨을 조정할 수 있습니다.
# .zshrc 또는 .bash_profile 에 추가
export MVK_CONFIG_LOG_LEVEL=1 # 에러만 표시
또는 코드 내에서 직접 설정할 수도 있습니다.
#ifdef __APPLE__
setenv("MVK_CONFIG_LOG_LEVEL", "1", 1);
#endif
참고 자료
- Apple Developer Documentation – CAMetalLayer
- Metal Best Practices Guide – Drawables
- Metal Best Practices Guide – Triple Buffering
- KhronosGroup/MoltenVK Issue #2547 – “still allocated” log is informational, not a leak
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.