NCCL 트러블슈팅 회고: MLOps 엔지니어가 바톤을 넘겨받기까지

· 8 분 소요


TL;DR

  • 문제: K8s 위 분산 학습에서 GPU worker 4개 이상 사용 시 NCCL all_reduce에서 CUDA error: illegal memory access 발생. 1~2개에서는 정상
  • 원인: 사용자 코드가 아닌 NCCL 인프라 자체의 문제. gloo 백엔드로 전환하니 동일 코드가 정상 동작
  • 교훈: “코드 먼저 의심”은 무리 없는 판단이었지만, ML 엔지니어의 체계적인 코드 레벨 검증이 선행되었기에 인프라 문제라는 결론의 신뢰도가 확보됐다. ML ↔ MLOps 경계에 걸친 문제일수록 협업의 순서와 역할 분담이 중요하다


문제 상황

환경

Kubernetes 위에서 Ray TorchTrainer 기반의 DDP(Distributed Data Parallel) 분산 학습을 운영하고 있다. 구성은 다음과 같다.

항목
노드 구성 8노드 x 1GPU
통신 백엔드 NCCL 2.26.2+cuda12.2
통신 방식 Socket/TCP
오케스트레이션 Kubernetes

증상

GPU worker를 1~2개로 설정하면 학습이 정상적으로 돌아간다. 그런데 4개 이상(4, 8)으로 늘리면 dist.all_reduce() 호출 시점에서 다음 에러가 발생한다.

CUDA error: an illegal memory access was encountered (Cuda failure 700)

에러 발생 지점은 NCCL collective operation — dist.all_reduce()다.

초기 판단의 갈림길

“worker 1~2에서는 되고 4에서 안 된다”는 정보를 받았을 때, 두 가지 방향의 의심이 동시에 떠올랐다.

  • 인프라: 똑같은 코드가 worker 수에 따라 다르게 동작한다? → 네트워크, NCCL, 리소스 문제
  • 코드: 분산 학습 GPU 수 분기 로직에 따라 동작이 달라질 수 있다? → collective op 호출 패턴, 텐서 shape 불일치

그런데 에러 메시지가 CUDA error: illegal memory access다. 이걸 보는 순간, 코드 레벨 문제에 더 무게를 실었다. 결과적으로 ML 엔지니어에게 GPU 개수나 분산 학습 worker 설정에 따라 학습이 다르게 동작하는 부분이 있는지 분석을 요청하게 됐다.


디버깅 타임라인

ML 엔지니어의 코드 레벨 디버깅부터 시작해, 최종적으로 인프라 문제를 확정하기까지 6단계를 거쳤다. 각 단계를 가설-실험-결과 형태로 정리한다.

시도 1: NCCL P2P 비활성화 [ML]

  • 가설: GPU 간 P2P direct memory access가 실패하고 있다
  • 실험: NCCL_P2P_DISABLE=1 환경변수 설정
  • 결과: 8노드 x 1GPU 구조에서는 노드당 GPU가 하나뿐이라 P2P 자체가 사용되지 않는다. 무관. 되돌림

시도 2: collective op 호출 횟수 불일치 수정 [ML]

  • 가설: 모델의 detection head에서 num_pos > 0인 if 브랜치만 reduce_mean(내부적으로 all_reduce)을 2회 호출하고, else 브랜치는 0회 호출한다. 프로세스 간 collective op 호출 횟수 불일치 시 hang이나 crash가 발생할 수 있다
  • 실험: else 브랜치에 dummy reduce_mean 2회 추가
  • 결과: batch_size=8이면 num_pos=0이 사실상 발생하지 않아 else 브랜치가 트리거되지 않았다. 가설 자체는 이론적으로 유효하나, 이 환경에서는 실제 원인이 아니었다. 되돌림

시도 3: Dockerfile 반영 [ML]

  • 가설: 시도 2의 코드 수정이 컨테이너 이미지에 반영되지 않았을 수 있다
  • 실험: Dockerfile에 해당 파일의 COPY 라인 추가
  • 결과: 시도 2의 코드 변경 자체를 되돌렸으므로 불필요. 되돌림

시도 4: CUDA 에러 위치 정확히 잡기 [ML]

  • 가설: Ray worker 프로세스에서 CUDA 에러가 비동기적으로 보고되어, 실제 에러 발생 위치가 부정확하게 표시되고 있다
  • 실험: CUDA_LAUNCH_BLOCKING=1 환경변수를 설정해 CUDA 커널을 동기 실행으로 전환
  • 결과: 에러가 dist.all_reduce() 자체(ProcessGroupNCCL.cpp:3356)에서 발생한다는 것을 정확히 확인. 디버깅 전용 설정이므로 되돌림

여기까지가 “에러 위치 특정” 단계다. 에러가 사용자의 텐서 연산이 아니라 NCCL의 collective operation 내부에서 발생한다는 것이 확인됐다.

시도 5: all_reduce 자체를 우회 [ML] ← 터닝포인트

  • 가설: all_reduce 자체가 문제라면, forward에서 all_reduce를 제거하고 로컬 평균만 사용하면 forward는 통과할 것이다
  • 실험: reduce_mean 호출을 제거하고 로컬 avg_factor만 사용하도록 수정
  • 결과: forward는 통과했지만, backward의 DDP gradient sync에서 동일한 CUDA error: illegal memory access 발생. 코드에서 all_reduce를 아예 호출하지 않아도 NCCL이 내부적으로 수행하는 gradient sync에서 같은 에러가 난다 — 코드 문제가 아니라 NCCL 인프라 자체의 문제임이 확정됐다. 되돌림

이 실험이 결정적이었다. 사용자 코드에서 명시적으로 호출하는 all_reduce뿐 아니라, DDP가 암묵적으로 수행하는 gradient sync까지 실패한다는 건, 문제가 NCCL 통신 계층에 있다는 뜻이다. ML 코드를 깊이 이해하는 사람이 아니면 설계할 수 없는 실험이었다.

시도 6: gloo 백엔드 전환 [MLOps]

  • 가설: NCCL이 아닌 다른 통신 백엔드를 사용하면 동일 코드가 정상 동작할 것이다
  • 실험: TorchConfig(backend='gloo')로 전환
  • 결과: 동일 코드가 정상 동작한다. 문제 해결.


근본 원인과 해결

원인 분석

NCCL이 all_reduce를 수행하면서 GPU 메모리에 결과를 기록하려 할 때, NCCL이 내부적으로 할당하고 관리하는 버퍼의 주소가 유효하지 않게 되는 것이 직접적인 원인이다. 사용자 코드가 NCCL 내부를 직접 건드리지는 않으므로, NCCL 또는 그 하부 인프라의 문제다.

NCCL은 프로세스 수에 따라 통신 알고리즘과 버퍼 크기를 동적으로 결정한다. 2개일 때는 단순 send/recv로 끝날 것이, 4개 이상이면 ring 토폴로지를 구성하면서 더 복잡한 내부 로직을 타게 된다. 이것이 “1~2개는 되고 4개 이상이면 안 된다”는 증상의 배경이다.

가능성 높은 원인은 세 가지다.

  1. NCCL 2.26.2 버전 자체의 버그: 매우 최신 버전이라 아직 검증이 충분하지 않았을 수 있다
  2. K8s pod 간 네트워크 문제: bootstrap recv에 약 10초가 소요되는 비정상적 지연이 관찰됐다
  3. CUDA driver와 NCCL 빌드 버전 간 호환성 불일치: driver는 CUDA 13.0을 지원하지만 NCCL은 12.2로 빌드되어 있다

현재 상태

gloo 백엔드로 분산 학습이 정상 동작 중이다. 디버깅 과정에서 추가했던 모든 workaround(환경변수, 코드 변경, Dockerfile 수정)는 전부 되돌렸다.

다만 gloo는 CPU 기반이라, SyncBN(Synchronized Batch Normalization)을 재활성화했을 때 BN layer마다 GPU-CPU 전송 오버헤드가 발생한다. 학습 속도 모니터링이 필요한 상태이며, NCCL 자체의 근본적인 해결은 아직 남아 있다.


회고: 초기 대응

“코드 먼저 의심”은 틀리지 않았다

결과적으로 코드 문제가 아니었다. 그러면 처음부터 인프라를 봤어야 했나? 돌이켜 보면, 아니라고 생각한다.

CUDA error: illegal memory access가 떴을 때 코드 문제를 먼저 의심하는 건 자연스러운 대응이다.

  • 가장 흔한 원인이 ML 코드 레벨에서의 텐서 misuse다 — 해제된 텐서 접근, device mismatch, out-of-bounds
  • 분산 학습 코드에서는 GPU 수에 따라 분기하는 로직에서 collective op 호출 횟수 불일치, 텐서 shape 불일치가 있을 수 있다
  • 스택 트레이스를 깊이 까보기 전까지는 내 코드 문제인지 라이브러리 문제인지 구분이 안 된다

무엇보다, ML 엔지니어의 코드 레벨 디버깅이 선행되지 않았다면 코드 레벨 원인을 배제하기가 오히려 어려웠을 것이다. 여러 시도를 거치면서 코드 문제가 아님을 체계적으로 입증해 줬고, 특히 시도 5(forward에서 all_reduce 제거해도 backward에서 동일 에러)가 결정적이었다.

처음부터 “이건 인프라 문제일 거야”라고 가정하고 NCCL 버전 교체, 네트워크 설정 변경을 시도했다면, 변수가 너무 많아서 오히려 더 오래 걸렸을 수 있다.

아쉬운 점: 병렬 디버깅

초기 대응이 완전히 잘못된 것은 아니었지만, “worker 1~2에서는 되고 4에서는 안 된다”는 말을 들었을 때 인프라 쪽 디버깅도 병렬적으로 진행했으면 어땠을까.

ML 엔지니어가 코드를 보는 동안, MLOps 쪽에서는 이런 것들을 확인해 뒀을 수 있었다:

  • NCCL 버전 히스토리와 최근 변경 이력
  • 클러스터에서 4+ GPU 분산 학습이 정상 동작한 이력이 있는지
  • pod 간 네트워크 상태, bootstrap 지연 등

그랬다면 시도 5에서 “코드 아님”이 확정됐을 때, 바로 다음 스텝으로 넘어갈 수 있었을 것이다.

이직 직후 온보딩 기간이라 회사 인프라의 히스토리를 충분히 파악하지 못한 상태였기 때문에 이번에는 어려웠다. 하지만 추후에 비슷한 문제가 발생한다면, ML 엔지니어가 코드를 디버깅하는 동안 MLOps 쪽에서도 병렬적으로 인프라 조사를 진행해야 한다.

CUDA 에러에 대한 직관 확장

평소에 나는 CUDA error: illegal memory access를 CPU 프로그래밍의 null pointer exception과 비슷한 층위에서 이해하고 있었다.

  • CPU: 프로세스가 유효하지 않은 메모리 주소에 접근 → OS가 SIGSEGV 전달
  • GPU: 커널이 유효하지 않은 GPU 메모리 주소에 접근 → CUDA 런타임이 에러 발생

백엔드 개발자가 NPE를 보면 “내 코드 메모리 접근에 뭔가 문제가 있구나”라고 먼저 생각하듯, 이 에러를 보면 “텐서가 해제됐거나, 잘못된 인덱스로 접근했거나, 다른 device의 메모리를 참조했겠지”라고 먼저 생각했던 것이다.

이번 케이스가 특이했던 건, 에러가 사용자 코드가 아니라 NCCL 내부에서 발생했다는 점이다. CPU 비유로 풀면, 내 코드에서 NPE가 난 게 아니라 내가 호출한 시스템 라이브러리(예: libc의 send()) 내부에서 SIGSEGV가 난 것과 비슷하다.

평소의 직관 — “이건 코드 문제일 가능성이 높다” — 은 기본적으로 맞다. 다만, 코드 레벨 원인을 충분히 배제한 뒤에도 에러가 사라지지 않으면, “라이브러리/인프라 내부에서 난 NPE”일 수 있다는 쪽으로 시야를 넓혀야 한다는 걸 배웠다.


회고: ML ↔ MLOps 협업의 경계

바톤이 넘어오기까지

이번 디버깅을 되돌아보면, ML 엔지니어가 코드 문제를 배제해 나가는 과정이 있었고, 그 끝에서 비로소 MLOps 쪽으로 바톤이 넘어왔다.

단계 시도 역할 목적
1단계 시도 1~3 ML 코드 레벨 가설 검증 및 배제
2단계 시도 4 ML CUDA 에러 발생 위치 정확히 특정
3단계 시도 5 ML all_reduce 자체가 문제임을 입증 → “코드 문제 아님” 확정
4단계 시도 6 MLOps 통신 백엔드 전환으로 우회 해결

1~3단계는 코드를 잘 아는 사람이 해야 하는 영역이다. detection head 모듈의 collective op 호출 패턴을 분석하고, forward에서 all_reduce를 제거하는 실험을 설계하려면 ML 코드에 대한 깊은 이해가 필요하다.

4단계는 MLOps 영역의 판단이다. 코드 문제가 아니라는 결론이 확보된 상태에서, 통신 백엔드를 교체하는 것은 인프라 운영자의 의사결정이다.

코드 문제 배제가 왜 ML 엔지니어의 영역이어야 했는가

이 문제를 처음 만났을 때, 해당 ML 코드를 초기부터 작성한 엔지니어와 함께 디버깅한 것은 아니었다. 만약 원래 코드를 작성하고 다른 환경에서 실행해 본 경험이 있는 엔지니어였다면, “우리 환경에서는 GPU 8개로 잘 돌아간다”는 정보를 바로 제공해 줬을 수도 있고, 그랬다면 처음부터 인프라를 의심했을 것이다.

하지만 그런 정보가 없는 상태에서는, 코드 레벨 원인을 배제하는 과정이 반드시 필요했다. 그리고 그 배제는 ML 코드를 읽고 이해할 수 있는 사람이 해야 했다. MLOps 엔지니어가 detection head의 분기 로직을 분석하고 collective op 호출 패턴의 일관성을 검증하기는 어렵다.

인프라를 먼저 의심해야 하는 신호

이번 경험을 포함해, 인프라 문제를 우선적으로 의심해 봐야 하는 패턴을 정리하면 다음과 같다.

1. 동일 코드가 환경에 따라 다르게 동작할 때

같은 코드, 같은 데이터인데 클러스터 A에서는 되고 B에서는 안 된다면 인프라부터 본다. 이번 케이스도 사후적으로 보면 이 패턴에 해당하지만, 초기에는 “다른 환경에서의 비교 데이터”가 없었기 때문에 코드 레벨 원인을 먼저 배제했다.

2. 에러가 비결정적(non-deterministic)일 때

같은 코드를 같은 조건으로 돌렸는데 어떤 때는 되고 어떤 때는 안 되면, 코드 버그보다는 네트워크 불안정, 메모리 단편화, 타이밍 이슈 같은 인프라 쪽 가능성이 높다. 코드 버그는 보통 일관적으로 재현된다.

3. 에러 메시지가 시스템 레벨 리소스를 가리킬 때

/dev/shm 부족, Connection refused, timeout, OOM killed, SIGKILL — 이런 에러는 표면적으로도 리소스나 네트워크 설정 문제를 가리킨다. 이번 케이스의 CUDA error: illegal memory access는 오히려 코드 버그처럼 보이는 메시지여서 판단이 어려웠다.

4. 인프라 변경 직후 발생할 때

K8s 클러스터 업그레이드, GPU 드라이버 업데이트, CNI 플러그인 교체, NCCL 버전 변경 직후에 문제가 생겼다면 그 변경을 먼저 의심한다.

5. 스케일 의존적 실패일 때

“GPU 1~2개는 되고 4개 이상이면 안 된다”는 패턴은 사실 코드와 인프라 양쪽 다 의심할 수 있는 경계 케이스다. 코드 쪽이면 collective op 불일치, 인프라 쪽이면 네트워크 대역폭 포화나 NCCL 내부 토폴로지 변경 같은 것이 원인일 수 있다.

다만, 스케일이 특정 임계점을 넘을 때 갑자기 실패하는 것(2 → 4로 갈 때 불연속적으로 깨지는 것)은, 코드의 점진적인 버그보다는 인프라의 비선형적 동작 변화를 시사하는 경우가 많다. 이런 경계에 있는 케이스는 경험이 쌓여 이루는 직관을 믿어 봐야 한다. 그리고 그 경험을 쌓는 과정에서 ML 엔지니어와의 협업이 필수적이다.


정리

이번 트러블슈팅의 핵심 교훈을 세 가지로 정리한다.

  • 디버깅 순서: CUDA error: illegal memory access를 보고 코드를 먼저 의심한 것은 틀리지 않았다. 코드 문제를 체계적으로 배제했기 때문에 인프라 문제라는 결론의 신뢰도가 높아졌다.

  • 병렬 디버깅: ML 엔지니어가 코드를 디버깅하는 동안 MLOps 엔지니어도 인프라 쪽 조사를 병렬로 진행했다면, 결론에 더 빨리 도달할 수 있었다. 다음에는 그렇게 하자.

  • 협업: ML ↔ MLOps 경계에 걸친 문제는 한쪽만으로 풀 수 없다. ML 엔지니어의 코드 레벨 검증이 “코드 문제 아님”을 확정해 주었고, 그 바톤을 받아 MLOps 엔지니어가 인프라 우회를 결정했다. 이 릴레이가 제대로 작동한 것에 감사하고, 앞으로도 이런 협업의 구조를 의식적으로 만들어 가야 한다.




hit count

댓글남기기