728x90

1. VM에 올린 순간 토큰 생성이 20분의 1로 떨어진다면

맥 미니나 맥 스튜디오를 CI 러너나 추론 워커로 굴려본 사람이라면 익숙한 상황이다. 베어메탈에서 잘 돌던 llama.cpp를 macOS 게스트 VM에 그대로 올렸는데, 체감이 아니라 측정치가 처참하게 떨어진다. GPU offload 옵션(-ngl)도 똑같이 줬고, Metal 백엔드도 정상적으로 잡혔다고 로그에 찍히는데 왜 이러지 싶은 그 상황이다.

이번에 GeekNews에 올라온 trycua/Lume 쪽 리서치 릴리스가 바로 그 원인을 짚었다. 요약하면 이렇다. Apple Virtualization.framework가 게스트에 제공하는 가상 GPU가 Metal 기능을 실제보다 보수적으로 보고하고, llama.cpp는 그 보고를 그대로 믿고 느린 커널을 고른다. 기능 응답값만 게스트 프로세스 한정으로 바꿔줬더니 M1 Ultra 기준 최대 16.36배가 빨라졌다.

수치를 먼저 보자. 테스트 환경은 48코어 GPU M1 Ultra(호스트 macOS 26.6.1), 게스트는 Tahoe Cua 이미지 macOS 26.5.2 / 8 vCPU / 16GiB, Lume 0.5.1, llama.cpp b10167이다.

모델 / 작업기본 게스트Unlocked 게스트배수
TinyLlama 1.1B — pp512431.86 tok/s4,786.70 tok/s11.08x
TinyLlama 1.1B — tg12812.63 tok/s206.60 tok/s16.36x
Gemma 4 12B — pp51271.66 tok/s515.76 tok/s7.20x
Gemma 4 12B — tg1283.41 tok/s49.67 tok/s14.54x
Muse Glimmer 30B — tg1282.37 tok/s21.08 tok/s8.87x

여기서 중요한 건 배수가 아니라 비교 대상이다. 이건 "llama.cpp가 빨라졌다"가 아니라 "VM 안에서 원래 냈어야 할 성능을 되찾았다"는 얘기다. HN 댓글에서도 제목이 오해를 부른다는 지적이 여러 개 달렸고, 저자도 인정했다. 베어메탈 성능은 그대로다.

2. supportsFamily: 한 줄이 커널 디스패치를 통째로 바꾸는 구조

Metal 앱은 컴파일 타임에 GPU 성능을 가정하지 않는다. 런타임에 MTLDevice에 물어보고 그 답에 맞는 코드 경로를 고른다. Apple 공식 가이드 자체가 GPU family 테이블과 런타임 장치 질의를 쓰라고 안내한다.

// 대략 이런 형태의 질의가 백엔드 초기화 시점에 돌아간다
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();

BOOL hasFamily9 = [dev supportsFamily:MTLGPUFamilyApple9];  // 1009
NSUInteger tgMem = dev.maxThreadgroupMemoryLength;          // bytes

// hasFamily9 == NO  -> SIMD-group matrix / bfloat 경로 비활성
// tgMem == 32768    -> 타일 크기를 작게 잡는 fallback 커널 선택

기본 Tahoe VM의 가상 장치는 대략 Apple family 5, 최대 threadgroup 메모리 32KB, SIMD-group matrix 미지원으로 보고한다. 이 답을 받은 llama.cpp Metal 백엔드는 논리적으로 완벽하게 옳은 선택을 한다. "이 GPU는 SIMD-group matrix가 없고 bfloat도 없고 공유 메모리도 32KB뿐이니, 안전한 스칼라 경로로 가자." 버그가 아니라 정직한 대응이다.

문제는 그 답이 사실과 다르다는 것. 실제 연산은 반가상화(paravirtualization) 경로를 타고 호스트의 물리 M1 Ultra GPU에서 돈다. 하드웨어는 family 9급인데 명함에는 family 5라고 적혀 있는 셈이다.

비유하자면 이렇다. 리눅스에서 /proc/cpuinfo의 flags에 avx512가 안 보이면 런타임 디스패치를 쓰는 라이브러리는 조용히 SSE 경로로 내려간다. CPU는 멀쩡히 AVX-512를 지원하는데 하이퍼바이저가 CPUID 마스킹을 해버린 경우와 정확히 같은 구조다. 아무 에러도 안 나고, 그냥 느리다. 이게 이런 문제의 가장 악질적인 부분이다.

호환성 계층이 바꾸는 값은 딱 두 개다.

기능기본 게스트테스트 프로필
supportsFamily:1009falsetrue
SIMD-group matrixoffon
SIMD-group reductionoffon
bfloat16offon
최대 threadgroup 메모리32KB64KB

Common / Mac / Metal family 값과 working-set-size는 건드리지 않는다. 이유가 있는데, 뒤에서 MLX 얘기할 때 나온다.

3. 게스트 프로세스 한정 dylib 주입 — 적용법과 실제 함정

구현 방식은 의외로 얌전하다. 물리 GPU 할당도, VFIO passthrough도, 커널 패치도 아니다. DYLD_INSERT_LIBRARIES로 게스트 프로세스에 작은 dylib를 끼워 넣어 특정 기능 질의의 반환값만 바꾼다. 실제 작업 제출은 여전히 Virtualization.framework 그래픽 경로를 탄다.

적용 절차

# 1) 게스트 이미지 쪽 소스에서 dylib 빌드 + 검증
cd libs/lume/metal-capability-shim
./Scripts/build.sh
./Scripts/verify.sh

# 2) 호스트에서 VM 정지 후 feature level 해제
lume stop my-vm
defaults write com.apple.gpusw.ParavirtualizedGraphics \
  ForceUnrestrictedDeviceFeatureLevel -bool true
lume run my-vm

# 3) 해당 프로세스에만 환경변수로 적용
lume ssh my-vm \
  DYLD_INSERT_LIBRARIES=/path/to/LumeMetalCapabilities-arm64.dylib \
  LUME_METAL_APPLE_FAMILY_MAX=1009 \
  /path/to/metal-capabilities 1009

2단계 호스트 설정과 3단계 게스트 주입이 둘 다 필요하다는 점이 포인트다. 호스트 쪽 ForceUnrestrictedDeviceFeatureLevel만 켜고 dylib를 안 넣으면 아무 일도 안 일어나고, 반대도 마찬가지다. 롤백은 환경변수 제거 후 프로세스 재시작 + VM 정지 후 defaults delete로 원복하면 된다.

적용됐는지 확인하는 가장 빠른 방법

배수 자랑을 믿지 말고 stderr를 봐라. llama.cpp Metal 백엔드는 초기화 시점에 자기가 뭘 봤는지 다 찍는다.

$ llama-bench -m tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf \
    -p 512 -n 128 -r 10 -t 8 -ngl -1 -o json 2> init.log

$ grep -Ei "family|simdgroup|bfloat|threadgroup" init.log
# 기본 게스트 (느린 경로)
ggml_metal_init: GPU family: MTLGPUFamilyApple5  (1005)
ggml_metal_init: simdgroup reduction   = false
ggml_metal_init: simdgroup matrix mul. = false
ggml_metal_init: has bfloat            = false

# unlocked 게스트
ggml_metal_init: GPU family: MTLGPUFamilyApple9  (1009)
ggml_metal_init: simdgroup reduction   = true
ggml_metal_init: simdgroup matrix mul. = true
ggml_metal_init: has bfloat            = true

원문에서도 Muse Glimmer 30B 측정 때 기본 게스트 stderr에서 Apple family 5와 비활성 SIMD/bfloat 경로를, unlocked 쪽에서 family 9와 활성 경로를 확인했다고 명시한다. 이 로그 한 줄이 모든 벤치마크보다 신뢰도가 높다. 나는 VM 위 GPU 워크로드 이슈를 받으면 처리량 숫자보다 초기화 로그부터 본다.

흔한 함정

(1) hardened runtime 바이너리에는 주입이 안 먹는다. 이게 제일 많이 밟는다. Homebrew로 깔았거나 서명·공증된 배포 바이너리라면 dyld가 조용히 무시하거나 이런 걸 뱉는다.

dyld[41283]: tried: '/path/to/LumeMetalCapabilities-arm64.dylib'
  (code signature in <...> not valid for use in process:
   library load disallowed by system policy)

혹은 아예 아무 로그 없이 family 5 그대로 뜬다. 그래서 원문도 "hardened 또는 플랫폼 보호 실행 파일은 라이브러리 주입을 거부할 수 있다"고 못 박아 뒀다. 직접 빌드한 llama.cpp 바이너리로 검증하는 게 안전하다.

(2) 아키텍처 불일치. arm64 dylib를 Rosetta로 도는 x86_64 프로세스에 꽂으면 이렇게 나온다.

dyld[52011]: Library not loaded: LumeMetalCapabilities-arm64.dylib
  Reason: no suitable image found. Did find:
    ...arm64.dylib: mach-o, but wrong architecture

(3) 전역으로 걸지 마라. ~/.zshrc나 로그인 세션 전체에 DYLD_INSERT_LIBRARIES를 박으면 게스트에서 도는 모든 Metal 앱이 거짓 기능표를 받는다. 원문 권고대로 장기 실행 추론 서버는 작업별 LaunchAgent를 만들어 그 환경에만 지정하는 게 맞다.

(4) MLX는 이 계층으로 얻을 게 없다. 오히려 깨질 수 있다. MLX-LM 0.31.3 / MLX 0.32.0 조합은 기본 VM에서 이미 1,656 tok/s(pp512)를 냈고 적용 후 1,665 tok/s로 사실상 동일했다(1.005배). 더 중요한 건 제거 실험 결과다. MTLGPUFamilyMetal3까지 보고하게 만들면 MLX가 반가상화 장치에 없는 residency set을 요청해버린다. 그래서 릴리스 버전은 변경 대상을 Apple family enum으로 한정하고 Metal 3 값은 손대지 않는다. 기능 플래그를 "일단 다 켜면 되겠지"로 접근하면 안 되는 이유가 여기 다 들어 있다.

(5) 버전 의존성. 사설이고 버전에 민감한 게스트 Metal 동작에 기대는 방식이다. Apple이 macOS 마이너 업데이트 하나로 바꿔버릴 수 있다. 프로덕션 이미지에 이걸 넣는다면 게스트 OS 버전을 핀 고정하고, 부팅 후 family 값을 확인하는 헬스체크를 붙여야 한다. 값이 1005로 나오면 알람이 울리게.

4. 16배를 어떻게 읽어야 하나 + 진단 체크리스트

배수만 보면 오해한다. 호스트 대비로 보면 그림이 다르다. TinyLlama 기준 프롬프트 처리는 베어메탈의 98.25%까지 따라붙었지만 토큰 생성은 72.06%에 그친다. Gemma 4 12B는 각각 99.59% / 94.82%. 즉 기능 플래그를 고쳐도 VM 성능 격차 자체가 사라지진 않는다.

이건 워크로드 성격상 자연스럽다. 프롬프트 처리(prefill)는 compute-bound 배치 연산이라 커널 품질이 지배적이고, 토큰 생성(decode)은 메모리 대역폭과 레이턴시에 민감해서 반가상화 경로의 오버헤드가 그대로 드러난다. 모델이 클수록 배수가 줄어드는 것(TinyLlama 16.36배 → Muse Glimmer 30B 8.87배)도 같은 맥락으로 보인다.

측정 신뢰도 면에서 원문이 잘한 부분도 짚고 넘어가자. 예비 측정 중 호스트에 다른 계산 작업이 돌던 걸 발견하고 그 결과를 폐기하고 재실행했으며, 채택한 세 환경 결과는 경합 없는 같은 시간대에 수집했다. 각 측정 전후 게스트 메모리 98% 여유 / swap 0 / compressor 0을 기록했다. 다만 Muse Glimmer 측정 때는 호스트를 간헐적 CPU 활동이 있는 다른 VM과 공유했다는 조건도 그대로 남겼다. 이 정도 기록이 없는 벤치마크는 그냥 안 믿는 게 낫다.

VM/컨테이너 위 GPU 워크로드 진단 체크리스트

  1. 기능 질의 결과를 먼저 덤프한다. 처리량 측정은 그다음이다. Metal이면 supportsFamily/maxThreadgroupMemoryLength, CUDA면 compute capability와 SM 수, CPU면 /proc/cpuinfo flags. 런타임 디스패치를 쓰는 라이브러리는 에러 없이 조용히 느려진다.
  2. 베어메탈 기준선을 반드시 같이 잰다. "VM에서 A가 B보다 10배 빠름"은 A가 정상인지 B가 비정상인지 말해주지 않는다. 호스트 대비 비율이 있어야 판단이 선다.
  3. 백엔드 초기화 로그를 CI에 남긴다. 이미지 업데이트 후 커널 경로가 바뀌면 로그 diff로 즉시 잡힌다.
  4. prefill과 decode를 분리해서 본다. 하나로 뭉친 tok/s는 병목을 가린다. 원인이 커널 선택인지 대역폭인지 구분이 안 된다.
  5. 같은 문제를 다른 프런트엔드에서도 확인한다. 이 격차는 Lume만의 문제가 아니다. Tart에 "No GPU passthrough in macOS guest?" 이슈가 열려 있고, UTM에서도 앱이 반가상화 Metal 장치를 감지하고도 소프트웨어 렌더링으로 폴백하는 사례가 보고됐다.
  6. 런타임 스택을 바꿔보는 것도 대안이다. MLX-LM처럼 기본 VM에서 이미 제대로 도는 스택이 있다면 주입 없이 그쪽으로 가는 게 운영상 훨씬 깔끔하다.

왜 Apple이 굳이 보수적인 프로필을 노출하는지는 원문도 답하지 않는다. HN에서 나온 추측은 두 갈래인데, QEMU/KVM이 마이그레이션 호환성 때문에 최소공배수 CPU 모델을 쓰듯 세대 간 이전 가능성을 위한 기준선일 수 있다는 것과, 일부 기능을 안전하게 가상화할 수 없어서라는 것이다. 둘 다 추정이고 공식 문서 확인이 필요하다.

5. 정리

한 줄 요약: llama.cpp가 느린 게 아니라 VM이 GPU 스펙을 거짓말하고 있었고, 그 대답만 바로잡으니 최대 16배가 돌아왔다.

써야 할 사람: macOS 게스트 VM에서 llama.cpp로 텍스트 전용 GGUF를 돌리는데 베어메탈 대비 처리량이 이해 안 되게 낮은 경우. 특히 Lume/Tart 기반 격리 워커나 에이전트 샌드박스를 운영 중이라면 한 번은 재볼 가치가 있다.

쓰지 말아야 할 상황: MLX 스택(효과 없음, Metal 3까지 건드리면 residency set 문제 발생), 서명된 hardened 바이너리, 그리고 게스트 OS 버전을 고정할 수 없는 프로덕션. 현재 검증 범위는 M1 Ultra + 지정된 Tahoe 게스트, llama.cpp 모델 3개, MLX-LM 호환성 확인 1건이 전부다. 다른 칩·게스트·Metal API는 별도 검증이 필요하고, 저자도 호스트 칩/버전/작업/기본·unlocked 결과를 함께 이슈로 제출해달라고 요청하고 있다. 실험적 리서치 릴리스라는 딱지를 잊지 말자.

개인적으로 이 사례에서 제일 오래 남는 건 배수가 아니라 진단 순서다. "느리다"는 티켓을 받으면 프로파일러를 켜기 전에 이 워크로드가 자기 실행 환경을 어떻게 인식하고 있는지부터 덤프하는 습관. Metal이든 CUDA든 AVX든, 런타임 디스패치가 있는 곳엔 항상 같은 함정이 숨어 있다.

참고 자료

728x90

+ Recent posts