728x90

도입: 시뮬레이터가 아니라 "진짜 iOS"가 뜬다

모바일 앱 CI 파이프라인 붙여본 사람은 다 아는 통증이 있다. Xcode Simulator는 x86/arm64 macOS 바이너리를 돌리는 물건이라 진짜 iOS 커널이 아니다. 그래서 코드사이닝, 키체인, 백그라운드 태스크, 디바이스 특성 타는 SDK(결제, 지문/FaceID, DRM)는 시뮬레이터에서 재현이 안 된다. 결국 랙에 실기기를 꽂아놓고 USB 허브와 씨름하게 된다. 나도 사무실 구석에 아이폰 8대 물린 맥미니를 몇 년 굴려봤는데, 케이블 접촉 불량과 배터리 스웰링이 진짜 장애 원인 1, 2위였다.

vphone-cli가 HN 상위에 오르면서 9천 스타를 넘긴 이유가 이거다. Apple이 macOS에 넣어둔 Virtualization.framework — 원래 macOS/Linux 게스트 부팅용으로 공개된 그 프레임워크 — 안에 Apple의 PCC(Private Cloud Compute) 리서치 VM 인프라 경로가 있고, 이걸 타고 실제 iPhone IPSW를 게스트로 부팅시킨다. 시뮬레이터가 아니라 iOS 커널이 뜨고, SSH가 붙고, VNC로 화면이 나온다.

brew install zqxwce/tap/vphone-cli

# 다운로드 → 패치 → DFU 복원 → CFW 설치 → 첫 부팅까지 한 방
vphone-cli vm create myphone -V jb
vphone-cli vm launch myphone

핵심: PCC 리서치 VM이라는 뒷문과 "패치 개수"의 의미

동작을 거칠게 비유하면 이렇다. Apple은 PCC 보안 검증을 위해 연구자에게 "제한된 Apple OS를 VM으로 띄워 뜯어보라"는 경로를 열어뒀다. 이때 하이퍼바이저 쪽에 PV=3 같은 사적(private) entitlement가 필요하고, 이건 원래 Apple이 서명한 바이너리만 쓸 수 있다. vphone-cli는 이 문을 열기 위해 호스트에서 SIP/AMFI를 완화하고, 서명 안 된 자기 바이너리에 그 entitlement를 얹는다. 즉 게스트가 아니라 호스트의 보안 정책을 먼저 깎는 구조다. 이 지점이 실무 도입의 핵심 판단 포인트다.

그 다음이 게스트 쪽. 그냥 iPhone IPSW를 던진다고 부팅되지 않는다. iBoot → 커널 → AMFI/SSV(서명된 시스템 볼륨)/Img4/TXM으로 이어지는 부트체인 검증이 VM 환경을 거부한다. 그래서 도구가 IPSW를 받아 부트체인 바이너리를 패치하고, DFU 모드로 띄운 뒤 SHSH를 받아 복원하고, CFW(커스텀 펌웨어)를 호스트 마운트로 밀어 넣는다. README의 수동 절차를 보면 파이프라인이 그대로 드러난다.

vphone-cli vm new myphone                          # 1. 빈 번들
vphone-cli fw prepare myphone --iphone-version 26.1 # 2. IPSW 다운로드 + 병합
vphone-cli fw patch myphone --variant jb            # 3. 부트체인 패치
vphone-cli vm launch myphone --dfu                  # 4. DFU 부팅(백그라운드)
vphone-cli restore myphone --get-shsh                # 5. SHSH 획득
vphone-cli restore myphone                           #    DFU 복원
vphone-cli vm stop myphone
vphone-cli cfw install myphone --variant jb          # 6. CFW 설치 (sudo 요구)
vphone-cli vm launch myphone                         # 7. 첫 부팅

재밌는 건 --variant다. 보안 우회 강도를 5단계로 나눠놨고, README에 패치 개수와 CFW 단계 수가 명시돼 있다.

  • less — 4패치 / 2단계. 패치리스에 가깝고 iOS 완화 기능 대부분 유지
  • regular — 42패치 / 10단계. AMFI/SSV/Img4/TXM 우회
  • dev — 53패치 / 12단계. TXM entitlement·디버그 우회 추가
  • jb — 113패치 / 14단계. 풀 탈옥, 첫 부팅 시 Sileo·TrollStore 자동 설치
  • exp — 141패치 / 18단계. jb 상위집합 + 안티-VM-디텍션 리서치 패치

이 숫자를 "많을수록 좋다"로 읽으면 안 된다. 패치를 많이 할수록 부팅한 iOS는 실기기와 멀어진다. 앱 동작 검증이 목적이면 오히려 lessregular가 진실에 가깝고, 내부를 뜯어보거나 파일시스템에 자유롭게 접근해야 하면 jb가 편하다. 안티-VM-디텍션이 붙은 exp는 이름 그대로 리서치용이다.

실무 관점: 도입 전에 계산해야 할 비용과 함정

1) 호스트 보안을 깎는다 = CI 러너로 쓰기 곤란하다

README가 제시하는 두 가지 길 중 A안은 SIP를 완전히 끄고 AMFI까지 부트아그로 무력화한다.

# 복구 모드 터미널
csrutil disable
csrutil allow-research-guests enable

# macOS 재부팅 후
sudo nvram boot-args="amfi_get_out_of_my_way=1 -v"

이 상태 머신을 사내 CI 러너로 쓰겠다는 건, 서명 검증이 꺼진 맥에 사내 코드사이닝 인증서와 App Store Connect API 키를 올려놓겠다는 뜻이다. 보안팀이 있는 조직이라면 그대로는 못 통과한다. B안(csrutil enable --without debug + vphone-amfidont로 해당 바이너리만 허용)이 그나마 낫지만, "debug 제한만 뺀 SIP"도 완전한 SIP는 아니다. 현실적인 타협은 격리 VLAN에 물린 전용 맥, 시크릿 없이, 결과물만 아티팩트로 뽑아내는 구조다.

2) 중첩 가상화 불가 — 클라우드 맥 대부분 탈락

이게 제일 크다. 실무에서 맥 인프라는 보통 클라우드 맥(호스팅 업체의 가상 맥)으로 굴리는데, 그런 환경에서는 아예 뜨지 않는다.

Virtualization is not available on this hardware

README FAQ가 명확히 짚는다. 호스트 맥 자체가 VM이면 PV=3 게스트 부팅을 중첩할 수 없다. 즉 비중첩 물리 Apple Silicon 맥 + macOS 15(Sequoia) 이상이 최소 조건이다. 베어메탈 맥미니를 새로 사거나, 물리 맥을 제공하는 업체를 찾아야 한다. 도입 검토서 첫 줄에 이 문장을 박아두는 게 좋다.

3) 실제로 만나는 에러들

가장 흔한 첫 관문은 이거다. 빌드도 됐고 실행했는데 그냥 죽는다.

$ ./vphone-cli --help
zsh: killed     ./vphone-cli

커널이 SIGKILL을 쐈다는 뜻이고, 원인은 십중팔구 AMFI다. amfi_get_out_of_my_way=1 부트아그가 안 먹었거나(=SIP가 완전히 꺼져 있지 않았거나), vphone-amfidont로 allowlist를 안 걸었거나. 특히 A안에서 csrutil disable 없이 nvram만 건드리면 부트아그 자체가 무시된다. nvram boot-args로 값이 남아 있는지, csrutil status로 SIP 상태가 어떤지 둘 다 확인해야 한다.

다음으로 삽질 유발 1위는 cfw install이 시스템 바이너리 재서명 중에 멈추면서 메모리가 무한정 올라가는 현상이다. README에 원인이 꽤 상세히 적혀 있는데, ldid-procursus 2.1.5-procursus7까지의 버그다. bytes(uint64_t)__builtin_clzll(0)을 제로 가드 없이 호출해 UB가 되고, 그 결과 길이가 0으로 잡히면서 unsigned 루프 카운터가 언더플로 → 1바이트씩 무한히 버퍼에 쓰며 종료하지 않는다. entitlements plist에 정수 값 0이 들어 있는 Apple 시스템 바이너리를 만나면 터진다. 대응은 이렇다.

sudo kill -9 <hung ldid pid>      # 이미 걸렸으면 먼저 죽이고
brew install --HEAD ldid-procursus
brew link --overwrite ldid-procursus

업스트림에서 고쳐졌지만 아직 태그된 릴리스에 안 들어가서, Homebrew stable을 쓰면 그대로 밟는다. 나 같으면 사내 프로비저닝 스크립트에 이 --HEAD 설치를 아예 박아두겠다.

그 밖에 실제로 자주 걸리는 것들:

  • "Press home to continue"에서 멈춤 — VNC(vnc://vm-ip:5901)로 붙어서 우클릭(두 손가락 클릭)이 홈 버튼이다. 하드웨어 버튼이 없으니 당연한데 모르면 5분 날린다.
  • 시스템 앱이 설치되지 않음 — iOS 초기 설정에서 지역을 일본이나 EU로 고르면 안 된다. VM이 만족시킬 수 없는 규제 검사가 추가로 붙는다. 미국 같은 지역을 선택하라는 게 README 권고다. 이거 모르고 "한국 옆이니까 일본" 골랐다가 재설치하는 사람 나온다.
  • 앱이 EXC_GUARD / GUARD_TYPE_MACH_PORT로 크래시vphone-cli fw patch <name> --variant <v> --force-exc-guard로 다시 패치한 뒤 재복원·재설치. iOS 18 베이스에서는 항상 켜야 한다고 명시돼 있다.

4) 그래도 매력적인 지점: 자동화 소켓

실무자 입장에서 제일 눈이 간 건 사실 이 부분이다. VM 번들 안에 vphone.sock 호스트 컨트롤 소켓이 있어서 스크린샷·터치·스와이프·하드웨어 키·클립보드를 프로그래밍으로 조작할 수 있고, 각 액션이 스크린샷을 인라인으로 반환한다. AI 기반 E2E 테스트를 염두에 둔 설계고, 이를 MCP 서버로 감싼 vphone-mcp도 같이 언급돼 있다.

실기기 팜에서 이걸 하려면 WebDriverAgent 띄우고 USB 세션 관리하고 스크린샷 파이프 뚫느라 인프라가 꽤 나온다. VM이면 vm clone이 APFS 클론이라 빠르고 디바이스 아이덴티티도 새로 발급된다. 테스트 케이스마다 깨끗한 기기를 몇 초 만에 찍어내는 그림은 확실히 탐난다.

vphone-cli vm clone myphone myphone-2   # APFS 클론 + 새 디바이스 아이덴티티
vphone-cli vm list --json               # 스크립팅용
vphone-cli vm export myphone --out ./backup/   # zstd, 기본 fast (--max = xz -9)

5) 트레이드오프와 대안

정리하면 이렇다. 얻는 것은 진짜 iOS 커널, 스냅샷/클론, 케이블 없는 팜, 루트 셸(ssh -p 22222 mobile@<vm-ip>, 비밀번호 alpine). 내주는 것은 호스트 보안 정책, 물리 맥 하드웨어 비용, 그리고 법적·정책적 리스크다. IPSW와 iOS는 Apple EULA 대상이고, 이 도구는 부트체인 패치와 탈옥을 포함한다. 회사 제품 테스트 인프라로 상용 도입하겠다면 법무 검토 없이는 못 간다. 개인 리서치나 보안 분석 목적과, 사내 CI 표준화는 완전히 다른 문제다.

대안은 여전히 셋이다. (1) 순수 기능 검증은 Xcode Simulator + XCUITest, (2) 실기기 커버리지는 클라우드 디바이스 팜(BrowserStack, Firebase Test Lab 계열) 또는 자체 실기기 팜, (3) 보안 리서치·펌웨어 분석이면 vphone-cli. 세 번째 칸의 대안이 사실상 없었다는 게 이 프로젝트의 진짜 가치다.

정리

한 줄로: Apple의 PCC 리서치 VM 경로를 이용해 물리 Apple Silicon 맥 위에 실제 iPhone 펌웨어를 부팅시키는 도구이고, 그 대가로 호스트의 SIP/AMFI를 깎는다.

  • 지금 써볼 사람 — iOS 보안 리서처, 펌웨어/커널 분석가, 탈옥 생태계 도구 개발자, "실기기 없이 iOS 내부를 뜯어보고 싶다"는 사람. 여기엔 대체재가 사실상 없다.
  • 관망할 사람 — 앱 CI/CD 담당자. 아이디어는 매력적이지만 SIP off 머신을 파이프라인에 넣는 순간 보안 리스크가 이득을 넘는다. 자동화 소켓 + MCP 조합이 어떻게 성숙하는지 6개월쯤 지켜볼 가치는 충분하다.
  • 쓰면 안 되는 사람 — 클라우드 맥(중첩 VM)만 있는 팀. 하드웨어부터 다시 사야 한다.

테스트된 조합도 README에 표로 정리돼 있는데, 호스트 macOS 26대, 게스트 iPhone 펌웨어 18.6.2부터 27.0 베타대까지 꽤 넓다. 다만 표에 없는 조합은 언제든 깨질 수 있는 성격의 프로젝트로 보이니, 실제 검증 전에 Tested Environments 표부터 확인하는 게 맞다.

참고 자료

728x90
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