RF-DETR:Troubleshooting:SlowTraining
RF-DETR 학습이 GPU 를 잡고 있는데도 비정상적으로 느린 현상의 원인 규명과 해결 기록.
결론부터: 느린 것은 학습이 아니라 에폭마다 도는 검증 지표 계산이었다. torchmetrics 의 MeanAveragePrecision.update() 가 예측 마스크를 CPU 에서 하나씩 RLE 로 인코딩하는 동안 GPU 는 벽시계의 85~90% 를 놀았다. 기본 하이퍼파라미터의 eval_interval: 1 이 그 비용을 매 에폭 물게 하고 있었다.
증상
- RF-DETR 학습이 체감상 비정상적으로 느림
-
nvidia-smi의 GPU-Util 이 0% 로 보여 CPU 로 도는 것이 의심됨
+-----------------------------------------+------------------------+----------------------+
| 0 NVIDIA GB10 On | 0000000F:01:00.0 Off | N/A |
| N/A 44C P0 10W / N/A | Not Supported | 0% Default |
+-----------------------------------------+------------------------+----------------------+
| 0 N/A N/A 2817 G /usr/lib/xorg/Xorg 125MiB |
| 0 N/A N/A 3348 G /usr/bin/gnome-shell 18MiB |
| 0 N/A N/A 2727029 C python 5843MiB |
+-----------------------------------------------------------------------------------------+
환경
| 항목 | 값 |
| 노드 | DGX Spark (NVIDIA GB10, aarch64, 20 cores) |
| 드라이버 / CUDA | 580.173.02 / 13.0 |
| 런타임 이미지 | |
| 모델 | |
| 오케스트레이션 | k3s, 네임스페이스 |
진단 과정
1. GPU 를 실제로 쓰고 있는가
nvidia-smi 의 프로세스 목록에 C(compute) 타입으로 5843MiB 를 잡은 python 이 있다. CUDA 컨텍스트가 살아 있다는 뜻이므로 CPU 학습이 아니다.
같은 목록의 Xorg(125MiB) 와 gnome-shell(18MiB) 은 뒤에서 중요해진다 — 이 데스크톱 프로세스들이 상시로 3~4% 의 GPU-Util 바닥을 만든다.
| NOTE |
| GPU-Util 스냅샷 한 장은 "그 순간 커널이 안 돌았다"는 것 이상을 말해 주지 않는다. 반드시 샘플링해야 한다. |
2. 샘플링 — 두 국면의 발견
| 국면 | 관측 | 해석 |
| A (대부분) | 0~4% / 10.5W, 약 7초에 한 번 72~92% 스파이크 (그때도 13W) | 학습이 GPU 에 일을 7초에 한 번만 올린다. 3~4% 바닥은 데스크톱 프로세스 |
| B (약 30초 구간) | 61~89% 연속 / 35~38W | 이 하드웨어의 실제 학습 속도 |
GPU 유휴가 벽시계의 85~90%. 병목은 GPU 앞단이다.
전력이 결정적인 단서였다. 10.5W 는 유휴, 38W 는 실제 부하다. 국면 A 의 "92%" 스파이크조차 13W 였다는 점에서 그 순간에도 큰 일을 하지 않았다.
3. CPU / I/O 바운드인가
컨테이너에 procps 가 없어 top 이 안 된다. /proc 를 직접 읽는다.
kubectl exec -n tenant-<코드> <파드> -- python -c "
import time, os
def snap():
v = list(map(int, open('/proc/stat').readline().split()[1:]))
return sum(v), v[3] + v[4], v[4]
t0, i0, w0 = snap(); time.sleep(3); t1, i1, w1 = snap()
dt = t1 - t0
print('cores :', os.cpu_count())
print('cpu busy : %.1f%%' % (100 * (dt - (i1 - i0)) / dt))
print('iowait : %.1f%%' % (100 * (w1 - w0) / dt))
print('loadavg :', open('/proc/loadavg').read().strip())
"
20코어 중 1.6코어, iowait 거의 0. 어디도 포화가 아니다. 처리량 한계가 아니라 뭔가가 직렬화되어 있다는 뜻이다.
이 시점에서 첫 가설(데이터 로더 부족 / CPU 증강 바운드)이 기각된다. CPU 증강이 병목이면 4~5코어가 붙어 있어야 한다.
| NOTE |
| 컨테이너 안이라도 |
4. 어느 프로세스가 CPU 를 먹는가
kubectl exec -n tenant-<코드> <파드> -- python -c "
import os
for pid in sorted((p for p in os.listdir('/proc') if p.isdigit()), key=int):
try:
st = open('/proc/%s/stat' % pid).read().rsplit(') ', 1)[1].split()
print(pid, open('/proc/%s/comm' % pid).read().strip(),
'cpu_s=%.1f' % ((int(st[11]) + int(st[12])) / os.sysconf('SC_CLK_TCK')),
'threads=' + st[17])
except Exception: pass
"
1 python cpu_s=5124.7 threads=35
136-139 pt_data_worker 각 ~136s (학습 로더 4개)
145-148 pt_data_worker 각 ~30s (검증 로더 4개)
워커 8개는 정상 동작 중이고 합쳐서 664 CPU-초, 메인 프로세스 혼자 5124 CPU-초 — 약 7.7배. num_workers 는 제대로 적용되었고 데이터 로딩은 병목이 아니다.
정상적인 GPU 학습이면 메인 프로세스는 GPU 를 기다리느라 CPU 를 거의 쓰지 않는다. 반대 상황이다.
5. 오답 — PTX JIT 컴파일 가설
GB10 은 Blackwell sm_121 이다. 휠에 해당 아키텍처의 SASS 가 없으면 드라이버가 커널마다 PTX 에서 JIT 컴파일하고, 그것은 프로세스 안의 단일 스레드 CPU 작업이다 — 관측된 패턴과 잘 맞았다.
kubectl exec -n tenant-<코드> <파드> -- python -c "
import torch
print('torch :', torch.__version__, 'cuda', torch.version.cuda)
print('device :', torch.cuda.get_device_capability())
print('archs :', torch.cuda.get_arch_list())
"
torch : 2.13.0+cu130 cuda 13.0
device : (12, 1)
archs : ['sm_80', 'sm_90', 'sm_100', 'sm_110', 'sm_120']
sm_121 이 없다. 그러나 이것만으로는 증거가 되지 않는다 — CUDA 는 같은 major 안에서 마이너 상위 호환을 보장하므로 sm_120 cubin 은 12.1 장치에서 로드되는 것이 정상이다.
직접 증거는 JIT 캐시의 갱신 여부다. 크기만 보면 안 된다: 상한에 걸려 돌려막기를 하면 크기는 그대로인 채 내용만 바뀐다.
kubectl exec -n tenant-<코드> <파드> -- python -c "
import os, time
newest = 0; n = 0; total = 0
for root, _, files in os.walk('/root/.nv/ComputeCache'):
for f in files:
try: st = os.stat(os.path.join(root, f))
except OSError: continue
n += 1; total += st.st_size; newest = max(newest, st.st_mtime)
print('files :', n)
print('total : %.1f MiB' % (total / 2**20))
print('newest write : %.1f s ago' % (time.time() - newest))
"
88분째 정지. 248MB 는 초기에 한 번 컴파일된 것이고 현재의 CPU 소모와 무관하다. 가설 기각.
6. 어느 스레드인가
kubectl exec -n tenant-<코드> <파드> -- python -c "
import os
d = '/proc/1/task'
rows = []
for t in os.listdir(d):
try:
st = open('%s/%s/stat' % (d, t)).read().rsplit(') ', 1)[1].split()
rows.append(((int(st[11]) + int(st[12])) / os.sysconf('SC_CLK_TCK'),
t, open('%s/%s/comm' % (d, t)).read().strip()))
except Exception: pass
rows.sort(reverse=True)
for cpu, t, comm in rows[:12]: print('%9.1f %-8s %s' % (cpu, t, comm))
"
5078.6 1 python
137.5 366 pt_autograd_0
29.3 140 pt_data_pin
4.8 149 pt_data_pin
2.3 135 cuda-EvtHandlr
메인 스레드가 5124.7 중 5078.6 (99%). 관측 구간 5307초 대비 96% 로 쉬지 않고 돌고 있다.
다만 이 결과만으로는 "PTX JIT" 과 "파이썬 핫루프"를 가르지 못한다 — 드라이버 JIT 도 호출 스레드에서 인라인으로 돌기 때문이다. 스택을 직접 봐야 한다.
7. py-spy 로 스택 확보
컨테이너 안에서 직접 실행하면 실패한다:
Kubernetes 기본 capability 집합에 SYS_PTRACE 가 없다. kubectl debug 의 general 프로파일이 이를 붙여 준다.
kubectl debug -n tenant-<코드> <파드> --target=main --profile=general \
--image=python:3.11-slim -- sh -c "pip install -q py-spy && py-spy dump --pid 1 --nonblocking"
# 출력은 임시 컨테이너의 로그로 간다
kubectl logs -n tenant-<코드> <파드> -c debugger-xxxxx
| NOTE |
| |
결과:
Thread 1 (active): "MainThread"
_get_safe_item_values (torchmetrics/detection/helpers.py:644)
update (torchmetrics/detection/mean_ap.py:533)
wrapped_func (torchmetrics/metric.py:549)
on_validation_batch_end (rfdetr/training/callbacks/coco_eval.py:447)
_call_callback_hooks (pytorch_lightning/trainer/call.py:228)
_evaluation_step (pytorch_lightning/loops/evaluation_loop.py:455)
run (pytorch_lightning/loops/evaluation_loop.py:146)
on_advance_end (pytorch_lightning/loops/training_epoch_loop.py:406)
run (pytorch_lightning/loops/training_epoch_loop.py:154)
...
train (rfdetr/detr.py:998)
train (rfdetr_detect.py:467)
원인
마스크 RLE 인코딩
torchmetrics 의 MeanAveragePrecision.update() 안 _get_safe_item_values 가 예측 마스크를 하나씩 CPU 에서 RLE 로 인코딩한다. rf-detr-seg-* 는 쿼리마다 마스크가 나오므로 배치당 수백 장이 대상이다.
rfdetr 이 backend="faster_coco_eval" 을 켜 두지만(rfdetr/training/callbacks/coco_eval.py:197), 그 백엔드는 compute() 단계만 바꾼다. 병목인 update() 는 torchmetrics 구현 그대로다.
비용을 두 번 낸다
기본 템플릿이 use_ema: True 인데 rfdetr 의 eval_ema_only 기본값이 False 다. 이 조합에서 on_validation_batch_end 는 검증 배치마다 EMA 모델로 독립적인 forward 를 한 번 더 돌리고 지표도 다시 업데이트한다 (coco_eval.py:463). 검증 비용이 정확히 2배가 된다.
관측된 두 국면의 정체
- 국면 B (38W, 30초) = 실제 학습 에폭
- 국면 A (10.5W, 길게) = 검증
기본값이 eval_interval: 1 이었으므로 이 교대가 매 에폭 반복되었다.
해결
커밋 30d5069 perf(training): Cut the RF-DETR validation cost
1. 기본 검증 주기
src/geo_mlops/training/hyperparams.py 의 RF-DETR 기본 템플릿:
rfdetr 이 이 값을 Lightning 의 check_val_every_n_epoch 로 그대로 넘기므로(rfdetr/training/trainer.py:721), 해당 에폭이 아니면 지표 계산뿐 아니라 검증 루프 자체가 통째로 생략된다. 마지막 에폭은 rfdetr 이 알아서 다시 검증한다.
2. 검증 비용 스위치 두 개 개방
rfdetr TrainConfig 필드지만 플랫폼 화이트리스트에 없어 위저드가 거부하던 값들이다.
eval_ema_only: false # true = EMA 모델로만 검증, 중복 forward 제거 (비용 절반)
log_per_class_metrics: true # false = 클래스별 AP 계산·로깅 생략
템플릿에서는 rfdetr 기본값 그대로 두었다. 켜면 측정 대상이 바뀌기 때문이다 — eval_ema_only 는 지표가 EMA 모델 기준만 남고, log_per_class_metrics: false 는 클래스별 AP 가 사라진다. 성능을 위해 조용히 바꿀 성질이 아니라고 판단해 노출만 하고 대가를 주석에 명시했다.
| WARNING |
| |
3. 배포 범위
서버만 재배포하면 된다. 학습 런타임 이미지는 그대로다 — 트레이너가 model.train(**overrides) 로 전달하므로 화이트리스트만 통과하면 새 키가 rfdetr 에 도달한다. 프론트엔드에도 하드코딩된 파라미터 목록이 없다(백엔드 YAML 을 그대로 렌더링).
검증
현장에서 eval_interval: 40 으로 문제 해소 확인.
배운 것
- GPU-Util 스냅샷 한 장은 증거가 아니다. 1Hz 샘플링과 전력 을 함께 봐야 유휴와 부하가 갈린다. 데스크톱이 떠 있는 노드는 3~4% 바닥이 상시로 깔린다.
- 어디도 포화가 아니면 직렬화를 의심한다. CPU·I/O·GPU 가 모두 한가한데 느리면 처리량 문제가 아니다.
- 추측 대신 스택을 본다. 두 번의 가설(데이터 로더, PTX JIT)이 모두 틀렸고 매번 측정이 잘라 냈다. py-spy 한 번이 그 앞의 모든 추론보다 정확했다.
- 캐시는 크기가 아니라 mtime 으로 판단한다. 상한에 걸린 캐시는 크기가 고정된 채 내용만 회전한다.
- 세그멘테이션 모델에서 매 에폭 검증은 함정이다. 마스크 지표 계산이 학습보다 비쌀 수 있다.
재사용 가능한 진단 순서
-
nvidia-smi프로세스 목록 —C타입 프로세스가 있는가 (GPU 사용 여부) -
nvidia-smi --query-gpu=utilization.gpu,power.draw --format=csv -l 1— 유휴 비율과 부하 구간 -
/proc/stat— CPU busy / iowait (CPU·I/O 바운드 배제) -
/proc/<pid>/stat순회 — 어느 프로세스인가 (워커 vs 메인) -
/proc/1/task/*/stat— 어느 스레드인가 -
py-spy dump— 어느 함수인가 (kubectl debug --profile=general또는 노드에서 직접)