2026-10-14 업데이트: 이 작업은 워커 VM 안과 Proxmox 호스트 양쪽에서 같이 진행했는데, 처음 글은 VM 쪽 시야로만 쓰여 있었습니다. 호스트에서 확인한 내용을 반영해 일부 수치와 설명을 바로잡고 보탰습니다.
지난 글에서 Proxmox에서 Arrow Lake(Core Ultra 5 235H) iGPU를 SR-IOV로 쪼개 openSUSE MicroOS 게스트에 붙였고, 그다음 글에서는 그 드라이버를 OBS로 자동 빌드하는 파이프라인까지 만들었습니다.
그럼 이제 그 VF(가상 GPU)로 뭘 하느냐? 제일 먼저 떠오른 게 Immich의 머신러닝(얼굴 인식, CLIP 검색, OCR) 이었습니다. CPU로 돌리던 걸 iGPU로 넘기면 CPU도 아끼고 빠르지 않을까? 하는 단순한 생각이었는데… GPU HANG부터 시작해서 꽤 긴 여정이 됐습니다. ㅎㅎ
🧭 환경
- Proxmox 호스트 5대, 그 위에 RKE2 워커 VM (agent1~5)
- agent1~3은 각자 호스트 iGPU의 VF를 하나씩 받음 (agent3 = pve3의 VF3)
- Immich
v3.0.2, 머신러닝 이미지만v3.0.2-openvino로 교체해서 테스트 - 실제로 쓰는 모델: CLIP
ViT-SO400M-16-SigLIP2-256__webli, 얼굴buffalo_l, OCRKOREAN__PP-OCRv5_mobile
🚨 1막: CL_OUT_OF_RESOURCES 와 GPU HANG
OpenVINO 자체는 GPU를 잘 인식했습니다. XMX 확장까지 보이고 GPU_HW_MATMUL도 잡힙니다. 그런데 CLIP 모델을 GPU에 올려 추론을 시키면 바로 이렇게 됩니다.
CL_OUT_OF_RESOURCES게스트 dmesg에는 이런 게 찍혀 있었습니다.
GPU HANG: ecode 12:10:00000000, in python3 [2007335]
... context resetGuC가 컨텍스트를 강제로 리셋한 겁니다. FP32로 바꿔도, 엔진을 바꿔도 똑같았고, 게스트 쪽 sysfs 타임아웃을 아무리 늘려도 소용없었습니다. 메모리 부족도 아니었고(clinfo 기준 25GiB), 클럭도 800~2200MHz로 정상이었습니다.
🛠️ 원인: VF는 호스트(PF)가 정한 규칙을 따른다
SR-IOV에서는 GPU 시간을 VF끼리 나눠 쓰기 때문에, 각 VF가 얼마나 오래 GPU를 붙잡을 수 있는지를 호스트(PF)가 정합니다. 호스트에서 확인해보니 VF3의 정책이 이랬습니다.
# 호스트(pve3). GT가 둘이라 gt0(렌더/컴퓨트)과 gt1(미디어)에 각각 있음
/sys/class/drm/card1/prelim_iov/vf3/gt0/exec_quantum_ms = 8
/sys/class/drm/card1/prelim_iov/vf3/gt0/preempt_timeout_us = 250000 # 250msPF 자신과 나란히 놓고 보면 차이가 확실합니다.
| exec_quantum_ms | preempt_timeout_us | |
|---|---|---|
| PF (호스트 자신) | 0 (무제한) | 0 (무제한) |
| 각 VF (기본값) | 8 | 250000 (250ms) |
| 각 VF (지금) | 8 | 2000000 (2초) |
게스트 안에서 타임아웃을 아무리 늘려도, 결국 호스트가 “250ms 안에 자리 안 비키면 리셋”이라고 정해두면 끝입니다. 게스트는 이 값을 읽을 수도 없어서 dmesg에는 “GPU HANG”만 찍힙니다. 그래서 호스트 쪽 preempt_timeout_us를 2초로 올렸습니다. 0(무제한)으로 두지 않은 건 일부러입니다. VF 하나가 GPU를 영원히 붙잡으면 같은 iGPU를 쓰는 다른 VF와 호스트까지 같이 멈추니까요. (card 번호는 호스트마다 다를 수 있습니다.)
echo 2000000 > /sys/class/drm/card1/prelim_iov/vf3/gt0/preempt_timeout_us
echo 2000000 > /sys/class/drm/card1/prelim_iov/vf3/gt1/preempt_timeout_us결과는 바로 나왔습니다. HANG 0건, PF adverse event 0건. 이후로 몇 번의 부하 테스트에서도 한 번도 다시 나오지 않았습니다.
나중에 OpenVINO로 직접 프로파일링해보니 커널 길이로는 250ms도 전혀 모자라지 않았습니다.
네이티브 OpenVINO, FP16
SO400M visual 64 ms/장, 최장 primitive 0.49 ms
buffalo det 최장 primitive 2.62 ms (Convolution)
최장 커널 2.62 ms 기준
원래 250 ms → 여유 95배
지금 2000 ms → 여유 760배95배 여유가 있는데 HANG이 났다는 건 커널 하나가 길어서가 아니라는 뜻입니다. 호스트에서 i915 요청 트레이스를 걸어보니, Immich가 쓰는 ONNX Runtime의 OpenVINO EP 경로에서는 요청 하나가 엔진을 약 1초 동안 붙잡고 있었습니다. (당시 모듈에 low-level tracepoint가 없어서 요청의 add→retire 간격으로 잰 상한값입니다.) ORT EP가 여러 커널을 선점할 수 없는 긴 덩어리로 묶어서 제출하고, 그 덩어리가 250ms 안에 자리를 못 비킨 겁니다. 그래서 2초는 근본 수정이 아니라 효과 좋은 우회책이고, Immich ML이 ORT를 쓰는 한 유지해야 합니다.
재부팅해도 유지되게: sysfs.conf
sysfs 값은 재부팅하면 날아갑니다. 처음엔 cron @reboot로 걸었다가, /etc/sysfs.conf(sysfsutils)가 원래 이런 용도라는 걸 떠올리고 그쪽으로 옮겼습니다. 5대 모두에 넣었습니다. 같은 CPU인데도 카드 번호가 pve1~4는 card1, pve5는 card0이라서, 경로는 PCI 경로로 쓰고 card0과 card1을 둘 다 적었습니다. sysfsutils는 없는 경로를 만나면 에러만 찍고 다음 줄로 넘어가서 무해합니다.
# /etc/sysfs.conf (VF 7개 × gt0/gt1 × card0/card1, VF3 부분만)
devices/pci0000:00/0000:00:02.0/sriov_numvfs = 7
devices/pci0000:00/0000:00:02.0/drm/card1/prelim_iov/vf3/gt0/preempt_timeout_us = 2000000
devices/pci0000:00/0000:00:02.0/drm/card1/prelim_iov/vf3/gt1/preempt_timeout_us = 2000000
devices/pci0000:00/0000:00:02.0/drm/card0/prelim_iov/vf3/gt0/preempt_timeout_us = 2000000
devices/pci0000:00/0000:00:02.0/drm/card0/prelim_iov/vf3/gt1/preempt_timeout_us = 2000000🐢 2막: HANG은 없어졌는데… 왜 이렇게 느리지?
HANG은 없어졌는데 agent3(pve3)에서 측정한 숫자가 영 시원찮았습니다. CPU 모드와 비교하면 빠르긴 한데 기대치보다 수십 배 느렸고, 로그에는 Unexpected memirq status 0x0이 수백 줄씩 쏟아졌습니다.
| 작업 (pve3) | GPU | CPU (2.5코어 제한) |
|---|---|---|
| 얼굴 인식 | 0.28s | 1.20s |
| 얼굴 검출 | 0.51s | 1.44s |
| CLIP | 0.19~0.58s | 0.76s |
이것저것 의심하다가 찾은 원인은 GPU가 아니었습니다. 먼저 보인 건 pve3 호스트가 91°C에서 1.2~1.4GHz로 쓰로틀링되고 있었다는 겁니다. Immich ML은 GPU로 추론을 넘겨도 전처리/후처리에서 CPU를 꽤 씁니다. CPU가 쓰로틀링되면 GPU가 놀아도 전체가 느려집니다.
그런데 더 큰 몫은 ONNX Runtime의 OpenVINO EP 경로였습니다. 같은 VF에서 비교하면 차이가 황당할 정도입니다.
| 경로 | 모델 | 장당 시간 |
|---|---|---|
| 네이티브 OpenVINO | SO400M (큰 모델) | 64 ms |
| ORT + OpenVINO EP | ViT-B-32 (수십 배 작은 모델) | 190~580 ms |
수십 배 큰 모델을 네이티브로 돌리는 쪽이 3~9배 빠릅니다. 남은 용의자는 EP의 서브그래프 분할(경계마다 CPU EP로 왕복), 동적 shape 컴파일, 추론마다 일어나는 호스트↔디바이스 복사입니다. 쓰로틀링은 여기에 얹힌 추가 손실이었습니다.
그래서 쓰로틀링이 없는 agent2(pve2) 에서 같은 조건(큰 이미지 20장, 동시성 3)으로 다시 쟀습니다.
| 항목 (agent2) | GPU (OpenVINO) | CPU |
|---|---|---|
| 처리량 | 1.17 img/s | 0.19 img/s |
| 이미지당 파드 CPU 사용 | 1.8 core-s | 13.0 core-s |
| 전체 실행 동안 노드 추가 CPU | 159 core-s | 478 core-s |
| GPU HANG | 0 | – |
처리량 약 6배, 이미지당 파드 CPU는 약 1/7. 다만 1/7은 파드 기준입니다. 파드 cgroup 밖에서 도는 커널 스레드와 드라이버 몫까지 들어가는 노드 기준(159 vs 478 core-s)으로는 약 1/3입니다. 그래도 넘어갈 가치는 충분합니다. 그리고 게스트의 memirq 로그 스팸은 결과에 아무 영향 없는 단순 노이즈였습니다.
재미있는 건 호스트 dmesg에도 같은 메시지가 초당 10~12건씩 쏟아지고 있었는데, 출처가 전혀 달랐다는 겁니다. 아무도 안 쓰는 VF7이었습니다. 호스트 i915가 게스트에 안 준 VF를 기본값(sriov_drivers_autoprobe=1)대로 전부 바인딩해서 VF마다 렌더 노드가 생겼고, VM 콘솔을 vga: virtio-gl로 두면 QEMU의 egl-headless가 렌더 노드를 지정하지 않고 처음 열리는 i915 노드를 잡습니다. 그게 VF7이었던 거죠. QEMU가 fd를 쥐고 있는 동안 VF7은 잠들지 못하고 memirq 핸들러를 계속 돌립니다. 헤드리스 k8s 노드에 GL 콘솔이 필요할 리 없으니 모든 VM을 vga: serial0으로 바꿔서 정리했습니다. (설정은 QEMU 프로세스가 새로 뜰 때 적용됩니다. 게스트 안 reboot이나 qm reset으로는 안 바뀝니다.)
🧠 3막: 메모리는 어디에 잡히나?
iGPU는 별도 VRAM이 없어서 시스템 메모리를 GEM 객체로 씁니다. 처음엔 “이게 파드 메모리로 안 잡혀서 limit을 우회하면 어쩌지?”가 걱정이었는데, 측정해보니 GEM 메모리도 파드의 memcg에 shmem/unevictable로 정상적으로 잡혔습니다. 즉 쿠버네티스 limit이 GPU 모드에서도 제대로 동작합니다.
대신 양이 좀 됩니다.
- CPU 모드: 회수 불가 메모리 약 6.3GiB
- GPU 모드: 약 7~9GiB (그중 GEM이 약 5.2GiB)
그래서 request를 6000Mi → 9000Mi로, limit을 15000Mi로 잡았습니다. 참고로 이 숫자도 처음엔 한가할 때 측정한 2.1GiB를 피크로 착각할 뻔했는데, 지난 30일 동안 ML이 실제로 한 일이 거의 없어서(최대 0.11코어) 그 값은 “놀 때” 값이었습니다. request/limit은 실제 부하를 걸어서 잰 피크로 정해야 합니다.
호스트에서 보면 이야기가 하나 더 있습니다. VF를 패스스루 받은 VM은 메모리 전체가 호스트에 고정(pin)됩니다. 디바이스가 DMA로 게스트 메모리 어디든 접근해야 하니 VFIO가 VM 메모리를 통째로 잠급니다. GPU 부하를 거는 13분 동안 호스트에서 389번 샘플링해 보니 kvm 프로세스의 VmLck가 20423 MiB에서 1KiB도 움직이지 않았습니다. GPU 버퍼는 이미 고정된 게스트 RAM 안에서 나오니까요.
호스트 GPU를 쓰든 안 쓰든 변화 없음. VM 할당량만큼 고정, 그게 전부
게스트 여기서 변함. GPU 버퍼가 게스트 RAM(shmem)을 씀
파드 memcg에 shmem/unevictable로 잡힘 → limit이 제대로 동작대신 고정된 메모리는 호스트 스왑도 virtio-balloon도 손대지 못합니다. VM에 준 만큼이 그대로 호스트 비용입니다. 실제로 pve3가 부하 중에 서서히 느려지다 완전히 얼어붙은 적이 있는데, 그 노드는 스왑이 0이었습니다. 부트 디스크 끝에 스왑 파티션(8GiB × 2)을 만들어 해결했습니다. 스왑이 생겨도 VM 몫은 못 내보내지만 OSD, MDS, 페이지 캐시는 밀어낼 수 있어서 숨 쉴 틈이 생깁니다.
🚀 배포
최종 설정은 이렇습니다. Fleet(GitOps)으로 배포했습니다.
- 이미지:
ghcr.io/immich-app/immich-machine-learning:v3.0.2-openvino - replicas 2, nodeAffinity로 agent1, agent2에만 배치 (쓰로틀링 있던 agent3는 제외)
- request
9000Mi/ limit15000Mi, CPU limit2500m MACHINE_LEARNING_OPENVINO_PRECISION=FP16,MACHINE_LEARNING_MODEL_TTL=600- 모델 캐시
/cache는 CephFS 공유 볼륨 → OpenVINO 컴파일 캐시가 공유돼서 두 번째부터 모델 로드가 빠름
운영 스모크 테스트에서 첫 요청은 모델 로드 때문에 약 20초, 그다음부터는 큰 이미지 한 장에 약 1초 정도였습니다. agent1 GPU도 따로 검증했는데 OpenVINO 단독으로 이미지당 52ms, 최장 커널 0.61ms로 깔끔했습니다.
🌡️ 덤: pve3 쓰로틀링 잡기
쓰로틀링 때문에 빠진 agent3가 아쉬워서, pve3 호스트의 전력 제한을 조정하면서 다시 테스트했습니다.
- PL1 23W / PL2 35W, 최대 클럭 3.0GHz로 제한
- OpenVINO 단독 루프(GPU만): 약 9.1 img/s, GPU 사용률 98%
- Immich 실제 작업 혼합(동시성 3): 약 1.0 img/s, 파드 CPU 약 1.95코어, GPU 57%
- 온도 77.9°C, 쓰로틀링 0회, HANG 0회
GPU만 돌리면 초당 9장인데 실제 Immich 작업은 초당 1장 남짓이라는 건, 병목이 GPU가 아니라 CPU와 ORT 경로 쪽이라는 뜻입니다. 그리고 처음엔 “미니 PC 쿨링의 한계”라고 생각했는데, 아니었습니다. 나중에 분해해 보니 pve3의 CPU 팬이 아예 안 돌고 있었습니다. 팬을 Noctua로 바꾼 뒤에는 원래 설계 전력인 28W 풀로드에서도 72.7°C로 평탄하게 버팁니다. 위의 23W/35W/3.0GHz는 팬이 없던 시절의 임시 설정이었고, 그 값을 찾은 과정은 발열 글에 따로 정리했습니다.
📝 정리
- SR-IOV VF에서 GPU HANG이 나면 게스트가 아니라 호스트(PF)의 VF 스케줄링 정책(
preempt_timeout_us,exec_quantum_ms)을 먼저 보세요. - sysfs 값 영속화는 cron 말고
/etc/sysfs.conf. - “GPU가 느리다” 싶으면 CPU 쓰로틀링과 ORT EP 경로부터 의심. 같은 VF에서 네이티브 OpenVINO와 비교해 보면 어디서 시간이 새는지 보입니다.
- iGPU의 GEM 메모리는 파드 memcg에 잡힙니다. request/limit은 GPU 메모리까지 포함해서, 실제 부하에서 잰 값으로.
- 결과: 같은 작업에 처리량 6배, 파드 CPU는 1/7(노드 기준 1/3). 지난번에 만든 SR-IOV 세팅이 드디어 밥값을 하기 시작했습니다.
- 250ms가 커널 길이에는 95배 여유였는데도 HANG이 났다면, 커널이 아니라 제출 단위(ORT EP의 덩어리)를 의심합니다.
- VFIO 패스스루 VM은 메모리 전체가 호스트에 고정됩니다. VM 할당량이 곧 호스트 비용이고, 호스트 스왑 0은 위험합니다.
- 헤드리스 VM이면
vga: serial0. GL 콘솔은 호스트에 붙은 VF를 집어 쓸 수 있습니다.

답글 남기기