콘텐츠로 이동
Search

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

런타임 이미지

python:3.11-slim 기반, torch 2.13.0+cu130, rfdetr 1.9.4

모델

rf-detr-seg-nano (instance segmentation)

오케스트레이션

k3s, 네임스페이스 tenant-wc, 학습 Job 파드

진단 과정

1. GPU 를 실제로 쓰고 있는가

nvidia-smi 의 프로세스 목록에 C(compute) 타입으로 5843MiB 를 잡은 python 이 있다. CUDA 컨텍스트가 살아 있다는 뜻이므로 CPU 학습이 아니다.

같은 목록의 Xorg(125MiB) 와 gnome-shell(18MiB) 은 뒤에서 중요해진다 — 이 데스크톱 프로세스들이 상시로 3~4% 의 GPU-Util 바닥을 만든다.

NOTE

GPU-Util 스냅샷 한 장은 "그 순간 커널이 안 돌았다"는 것 이상을 말해 주지 않는다. 반드시 샘플링해야 한다.

2. 샘플링 — 두 국면의 발견

nvidia-smi --query-gpu=utilization.gpu,utilization.memory,power.draw --format=csv -l 1

국면

관측

해석

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())
"
cores    : 20
cpu busy : 8.0%
iowait   : 0.1%
loadavg  : 2.08 1.79 1.66 2/3534 3750

20코어 중 1.6코어, iowait 거의 0. 어디도 포화가 아니다. 처리량 한계가 아니라 뭔가가 직렬화되어 있다는 뜻이다.

이 시점에서 첫 가설(데이터 로더 부족 / CPU 증강 바운드)이 기각된다. CPU 증강이 병목이면 4~5코어가 붙어 있어야 한다.

NOTE

컨테이너 안이라도 /proc/stat 과 /proc/loadavg 는 cgroup 이 아니라 노드 전체 값이다.

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))
"
files        : 49
total        : 247.2 MiB
newest write : 5306.9 s ago

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 로 스택 확보

컨테이너 안에서 직접 실행하면 실패한다:

Error: Failed to copy Py_Version symbol
Caused by:
    0: Permission denied (os error 13)

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

--target=main 이 PID 네임스페이스를 공유하므로 트레이너가 그대로 PID 1 로 보인다. 노드 셸이 있다면 호스트에서 sudo py-spy dump --pid <호스트 PID> 가 더 간단하다 — 호스트 PID 는 nvidia-smi 프로세스 목록에 이미 나와 있다.

결과:

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 기본 템플릿:

eval_interval: 25   # 이전: 1

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

eval_ema_only: true 는 use_ema: true 를 요구한다. 플랫폼 검증기는 키별 검사라 이 교차 규칙을 잡지 못하고, rfdetr 이 설정 단계에서 거절한다.

3. 배포 범위

서버만 재배포하면 된다. 학습 런타임 이미지는 그대로다 — 트레이너가 model.train(**overrides) 로 전달하므로 화이트리스트만 통과하면 새 키가 rfdetr 에 도달한다. 프론트엔드에도 하드코딩된 파라미터 목록이 없다(백엔드 YAML 을 그대로 렌더링).

검증

현장에서 eval_interval: 40 으로 문제 해소 확인.

배운 것

  • GPU-Util 스냅샷 한 장은 증거가 아니다. 1Hz 샘플링과 전력 을 함께 봐야 유휴와 부하가 갈린다. 데스크톱이 떠 있는 노드는 3~4% 바닥이 상시로 깔린다.
  • 어디도 포화가 아니면 직렬화를 의심한다. CPU·I/O·GPU 가 모두 한가한데 느리면 처리량 문제가 아니다.
  • 추측 대신 스택을 본다. 두 번의 가설(데이터 로더, PTX JIT)이 모두 틀렸고 매번 측정이 잘라 냈다. py-spy 한 번이 그 앞의 모든 추론보다 정확했다.
  • 캐시는 크기가 아니라 mtime 으로 판단한다. 상한에 걸린 캐시는 크기가 고정된 채 내용만 회전한다.
  • 세그멘테이션 모델에서 매 에폭 검증은 함정이다. 마스크 지표 계산이 학습보다 비쌀 수 있다.

재사용 가능한 진단 순서

  1. nvidia-smi 프로세스 목록 — C 타입 프로세스가 있는가 (GPU 사용 여부)
  2. nvidia-smi --query-gpu=utilization.gpu,power.draw --format=csv -l 1 — 유휴 비율과 부하 구간
  3. /proc/stat — CPU busy / iowait (CPU·I/O 바운드 배제)
  4. /proc/<pid>/stat 순회 — 어느 프로세스인가 (워커 vs 메인)
  5. /proc/1/task/*/stat — 어느 스레드인가
  6. py-spy dump — 어느 함수인가 (kubectl debug --profile=general 또는 노드에서 직접)

See also

lastmod 2026-09-03created 2026-09-03author Zer0 sitemap