본문 바로가기

AI Algorithms (AI 기술과 알고리즘)

메모리 터질 때 쓰는 치트키? DeepSpeed로 GroundingDINO 돌려봄

GroundingDINO 미세조정 분투기: 끈질긴 OOM과 타입 에러 해결의 전 과정

서문: 거대 모델 미세조정, 이상과 현실의 간극

GroundingDINO와 같이 강력한 비전-언어 모델을 특정 도메인에 맞게 최적화하는 가장 확실한 방법은 전체 파라미터를 미세조정(Full Fine-tuning)하는 것입니다. 이 접근법은 모델이 가진 잠재력을 최대한 끌어내어 LoRA와 같은 경량화 튜닝 기법을 뛰어넘는 성능을 기대하게 합니다.
하지만 이론적인 이상과 현실의 간극은 컸습니다. 이 과정은 단순히 준비된 스크립트를 실행하는 것으로 끝나지 않았습니다. 오히려 제한된 하드웨어 환경에서 발생하는 메모리 부족(OOM), 데이터 타입 충돌 등 예상치 못한 기술적 난관과의 끈질긴 싸움이었습니다.
이 블로그 게시물은 수많은 실패와 디버깅, 그리고 전략 수정을 통해 마침내 학습에 성공하기까지의 전 과정을 체계적으로 기록한 문서입니다. 비슷한 도전에 직면한 다른 엔지니어들에게 실질적인 가이드가 되는 것을 목표로, 제가 겪었던 시행착오와 해결책을 투명하게 공유하고자 합니다.
이제, 제가 마주했던 첫 번째 거대한 장벽, 메모리 부족 문제와의 사투부터 시작해 보겠습니다.
--------------------------------------------------------------------------------

1장: 첫 번째 관문 - OOM(Out of Memory)과의 사투

이 장의 목표는 명확했습니다. 멀티 GPU 환경에서 DeepSpeed를 활용하여 GroundingDINO 모델의 전체 파라미터 학습을 시작하는 것이었죠. 하지만 모델의 거대한 크기 때문에, 학습은커녕 초기화 단계에서부터 메모리 부족(OOM) 문제에 부딪혔습니다. 이 장에서는 OOM을 해결하기 위해 시도했던 단계별 접근법과 문제의 본질을 파악한 뒤 내렸던 전략적 전환 과정을 상세히 다룹니다.

1.1. 시도 1: DeepSpeed ZeRO Stage 2의 실패

가장 먼저 시도한 것은 두 개의 GPU를 활용한 분산 학습이었습니다. accelerate 라이브러리와 DeepSpeed의 ZeRO Stage 2 최적화를 적용하여 다음과 같은 명령어를 실행했습니다.

accelerate launch --num_processes 2 --use_deepspeed --deepspeed_config_file ds_config.json loco_grounding_dino_full_finetune.py --category screw_bag --epochs 20 --batch_size 1 --learning_rate 1e-5

--num_processes 2 옵션을 통해 멀티 GPU 학습을 시도했지만, 결과는 처참한 실패였습니다. 터미널에는 다음과 같은 에러 로그가 출력되었습니다.

[rank1]: torch.distributed.DistBackendError: NCCL error in: /pytorch/torch/csrc/distributed/c10d/ProcessGroupNCCL.cpp:3690, unhandled cuda error...
[rank1]: ncclUnhandledCudaError: Call to CUDA function failed.
[rank1]: Last error:
[rank1]: Cuda failure 2 'out of memory'

에러의 핵심은 Cuda failure 2 'out of memory'였습니다. 스택 트레이스는 dist.broadcast 함수를 가리키고 있었습니다. 모델 파라미터를 주 GPU(rank 0)에서 다른 GPU(rank 1)로 복사하는 과정에서 메모리 스파이크가 발생해 VRAM을 초과했다는 명백한 증거였습니다. 이 실패는 ZeRO Stage 2 설정만으로는 모델 파라미터 전체를 단일 GPU에 잠시라도 올리는 것조차 버겁다는 사실을 명확히 보여주었습니다.

1.2. 시도 2: 더 강력한 최적화, ZeRO Stage 3

문제 해결을 위해 ds_config.json 파일을 수정하여 더 강력한 최적화 기법인 ZeRO Stage 3로 전환했습니다. 이 방식은 모델 파라미터를 여러 GPU와 CPU 메모리에 조각내어 분산 저장하는 파라미터 분할(Parameter Sharding)과 CPU 오프로딩(CPU Offloading)을 모두 사용합니다. 이론적으로 메모리 사용량을 극적으로 줄일 수 있는 가장 강력한 방법이었습니다.
하지만 기대와 달리, ZeRO Stage 3를 적용했음에도 불구하고 여전히 동일한 Cuda failure 2 'out of memory' 에러가 발생했습니다. 이 시점에서 이 문제가 정말 끈질긴 OOM 이슈이며, 가장 공격적인 최적화 기법조차 초기 통신 단계에서 실패하고 있다는 것을 깨달았습니다.
실패 원인은 더 깊은 곳에 있었습니다. 모델 파라미터를 GPU 간에 분배하는 과정에서 사용되는 통신 버퍼(Bucket) 자체가 이미 한정된 VRAM을 초과하고 있었던 것입니다. 문제 해결을 위해 버킷 사이즈를 5MB에서 1MB까지 줄이는 "초-경량 설정(Hyper-Aggressive Config)"까지 시도했지만, 이마저도 실패로 돌아갔습니다.

1.3. 전략적 전환: 멀티 GPU를 버리고 단일 GPU로

반복되는 OOM의 근본 원인은 명확해졌습니다. 24GB VRAM을 갖춘 소비자 등급의 하드웨어 환경에서는, 거대한 모델 데이터를 두 GPU 간에 주고받는 통신 대역폭(NCCL Broadcast) 자체가 주된 메모리 병목 현상이었던 것입니다. 멀티 GPU의 이점보다 통신 비용이 압도적으로 큰 상황이었죠.
여기서 저는 중대한 전략 변경을 결정했습니다. 멀티 GPU를 포기하고, 단일 GPU에서 학습을 진행하기로 한 것입니다.
이 전략은 몇 가지 명확한 이유로 매력적이었습니다. 첫째, GPU 간 통신 비용이 완전히 '0'이 되므로 더 이상 NCCL 브로드캐스트 에러에 시달릴 필요가 없었습니다. VRAM을 오롯이 모델과 데이터, 그래디언트 계산에만 집중할 수 있게 된 것이죠. 둘째, 단일 GPU 환경에서도 DeepSpeed의 강력한 CPU 오프로드 기능은 여전히 유효하여, GPU 메모리가 부족할 경우 CPU RAM을 활용해 메모리 효율을 극대화할 수 있었습니다. 마지막으로, 배치 사이즈가 1로 줄어드는 데 따른 학습 불안정성을 막기 위해, 그래디언트 누적(Gradient Accumulation)을 4로 설정하여 실질적으로 배치 사이즈 4와 동일한 학습 효과를 내도록 보완했습니다.
이 전략적 전환은 끈질기게 저를 괴롭혔던 OOM 문제를 해결하는 결정적인 전환점이었습니다. 하지만 메모리의 산을 넘자마자, 또 다른 복병이 기다리고 있었습니다.
--------------------------------------------------------------------------------

2장: 두 번째 관문 - 끝나지 않는 타입 충돌(Mixed Precision)

OOM 문제를 해결하고 한숨 돌리는 것도 잠시, 곧바로 데이터 타입 불일치라는 새로운 복병을 만났습니다. 메모리 효율을 위해 혼합 정밀도(Mixed Precision) 학습을 시도했지만, 이로 인해 모델 내부의 여러 연산에서 타입 충돌이 발생하기 시작했습니다. 이 장에서는 FP16, BF16을 오가며 겪었던 시행착오와 최종 해결책을 다룹니다.

2.1. 시도 1: FP16과 FP32의 충돌

단일 GPU와 DeepSpeed CPU 오프로드 조합으로 전환한 후, DeepSpeed 설정 파일의 fp16 모드를 활성화했습니다. 그러자 다음과 같은 에러가 발생했습니다.

RuntimeError: Input type (float) and bias type (c10::Half) should be the same

원인은 명확했습니다. DeepSpeed가 모델의 가중치(bias)는 FP16(c10::Half)으로 변환했지만, 모델에 입력되는 데이터(Input)는 FP32(float) 타입으로 유지되어 타입 충돌이 발생한 것입니다.
여기서부터 길고 고된 디버깅이 시작되었습니다. 제 첫 번째 가설은 단순한 입력 불일치였습니다. 입력 데이터를 모델의 dtype에 맞게 변환하려 했지만, Hugging Face 모델 객체의 .dtype 속성이 DeepSpeed에 의해 변경된 실제 파라미터 타입을 정확히 반영하지 못해 실패했습니다. 두 번째 시도는 accelerator.autocast()를 사용하는 것이었습니다. accelerate 라이브러리가 제공하는 자동 타입 변환 컨텍스트 매니저를 사용했지만, 이 역시 근본적인 해결책이 되지 못했습니다. 문제가 모델의 순전파(forward pass) 과정 깊숙한 곳에 있다는 것이 분명해졌습니다.

2.2. 시도 2: 대안으로서의 BF16, 그러나...

FP16의 불안정성을 해결하기 위해 BF16(BFloat16)으로의 전환을 시도했습니다. BF16은 FP32와 동일한 숫자 표현 범위를 가져 오버플로우나 타입 충돌 문제에 더 강건하며, 사용 중인 RTX 3090 GPU가 이를 지원한다는 장점이 있었습니다.
ds_config.json 파일과 학습 코드를 BF16에 맞게 수정했지만, 결과는 또다시 실패였습니다.

RuntimeError: mat1 and mat2 must have the same dtype, but got Float and BFloat16

이번에도 모델 내부의 행렬 곱셈 연산에서 FP32(Float)와 BF16(BFloat16)이 충돌했습니다. autocast를 사용하고, 모델 로딩 시 torch_dtype을 지정하는 등 여러 시도에도 불구하고 반복되는 이 에러는 한 가지 사실을 증명했습니다. GroundingDINO처럼 복잡한 다중 컴포넌트 아키텍처에서는, 상위 레벨의 도구만으로는 모델 내부 깊은 곳에서 일어나는 모든 연산의 타입을 완벽하게 통제하기 어렵다는 것이었습니다.

2.3. 또 다른 전략적 전환: 혼합 정밀도를 포기하다

새벽 3시가 가까워 오는 시간, 계속되는 타입 충돌과의 사투 끝에 결단을 내려야 했습니다. 그날 밤의 로그에 담긴 또 다른 제 자아의 심정은 명확했습니다. "저도 지쳤습니다, 이제 확실하게 가겠습니다." 결국 가장 확실하고 근본적인 해결책을 선택하기로 했습니다. 바로 혼합 정밀도 사용을 완전히 포기하고, 순수 FP32(Full Precision) 모드로 전환하는 것이었습니다.
이 결정은 혼합 정밀도가 주는 메모리 효율이라는 이점을 포기하는 것이었지만, 계산된 트레이드오프였습니다. 1장에서 확립한 '단일 GPU + DeepSpeed CPU 오프로드' 조합 덕분에, FP32의 더 큰 메모리 사용량(약 12~14GB 예상)을 감당할 수 있을 것이라는 확신이 있었습니다. 결국 전 이론적인 메모리 효율성을 쫓는 대신, 보장된 안정성을 택했던 거죠.
결국 ds_config.json에서 mixed precision 관련 설정을 모두 비활성화하고, 코드에서도 관련 옵션을 제거했습니다. 이로써 타입 에러가 발생할 가능성을 원천적으로 차단했고, 마침내 학습이 가능한 환경을 구축하는 데 성공했습니다.
--------------------------------------------------------------------------------

3장: 마지막 허들 - 코드 레벨 버그 수정

메모리와 타입이라는 거대한 두 산을 넘자, 비로소 코드의 세부적인 로직 오류들이 수면 위로 드러나기 시작했습니다. 복잡한 프레임워크 설정 뒤에 가려져 있던 작은 실수들이 하나둘씩 나타났습니다. 이 장에서는 학습 성공 직전에 마주쳤던 다양한 코드 버그와 그 디버깅 과정을 기록합니다.

3.1. 기본 설정 및 로직 오류

가장 먼저 마주친 것은 기본적인 코드 설정 오류였습니다.

  • NameError: name 'args' is not defined: 커맨드라인 인자를 파싱하는 parser.parse_args() 코드를 누락하여 발생한 간단한 실수였습니다. 해당 라인을 추가하여 즉시 해결했습니다.
  • UnboundLocalError: cannot access local variable 'model': 체크포인트 저장 및 로드 기능을 추가하는 과정에서 실수로 모델 초기화 코드를 삭제하여 발생한 문제입니다. 코드를 원상 복구하여 해결했습니다.

3.2. 모델 출력 구조 디버깅

기본 오류를 해결하자, 모델의 출력과 관련된 더 복잡한 문제가 발생했습니다.

AttributeError: 'GroundingDinoObjectDetectionOutput' object has no attribute 'encoder_last_hidden_state'

이 에러는 모델의 출력 객체에서 encoder_last_hidden_state라는 속성을 찾을 수 없다는 의미입니다. 공식 문서가 불분명하고 DeepSpeed 같은 프레임워크가 모델의 실제 상태를 추상화하는 상황에서, 근본적인 "백투더베이직" 디버깅 전략을 택했습니다. 코드에 직접 print() 구문을 삽입하여 객체의 실체를 확인하는 것이었죠.

# 디버깅 코드 삽입
print(f"DEBUG: Output type: {type(outputs)}")
print(f"DEBUG: Available attributes: {dir(outputs)}")

이 코드를 실행하자 터미널에 모델의 실제 출력 객체가 가진 모든 속성 목록이 출력되었습니다.

DEBUG: Output type: <class 'transformers.models.grounding_dino.modeling_grounding_dino.GroundingDinoObjectDetectionOutput'>
DEBUG: Available attributes: [..., 'encoder_last_hidden_state_text', 'encoder_last_hidden_state_vision', 'encoder_text_hidden_states', 'encoder_vision_hidden_states', ...]

디버깅 출력 결과를 바탕으로, 필요로 하는 속성의 정확한 이름이 encoder_last_hidden_state_vision임을 확인했습니다. 흥미롭게도 encoder_vision_hidden_states 속성도 존재했지만, 이 값은 None을 반환하여 TypeError를 유발했습니다. 복잡한 프레임워크의 스택 트레이스를 의심하기 전에, 이처럼 간단한 print문으로 내부 상태를 직접 확인하는 과정이 문제 해결의 결정적인 열쇠가 되었습니다.

3.3. 손실 함수 차원 불일치 해결

학습이 거의 성공 단계에 이르렀을 때, 손실(loss) 계산 로직에서 두 가지 차원 관련 에러가 발생했습니다.

  1. IndexError: too many indices for tensor of dimension 0: 이 오류는 스칼라 값(차원이 0인 텐서)에 인덱싱을 시도할 때 발생합니다. squeeze() 연산으로 인해 텐서의 배치 차원이 의도치 않게 사라진 것이 원인이었습니다. 텐서가 항상 배치 차원을 유지하도록 코드를 수정하여 해결했습니다.
  2. ValueError: ... target size ... is different to the input size: 손실 함수에 입력되는 예측값 텐서와 정답(target) 텐서의 차원이 불일치하여 발생한 문제입니다. squeeze나 unsqueeze를 사용하여 두 텐서의 차원을 일치시켜 문제를 해결했습니다.

이러한 세밀한 버그들을 모두 잡고 나서야, 비로소 길고 험난했던 디버깅 여정을 끝내고 안정적으로 학습을 시작할 수 있었습니다.
--------------------------------------------------------------------------------

결실: 성공적인 학습과 성능 분석

모든 OOM, 타입 충돌, 코드 버그를 해결한 후, 마침내 다음과 같은 최종 명령어로 학습을 시작할 수 있었습니다.

CUDA_VISIBLE_DEVICES=0 accelerate launch --num_processes 1 --use_deepspeed --deepspeed_config_file ds_config.json loco_grounding_dino_full_finetune.py --category screw_bag --epochs 20 --batch_size 1 --learning_rate 1e-5

터미널에는 그토록 기다렸던 학습 진행 로그가 출력되기 시작했습니다.

DEBUG: Output type: <class 'transformers.models.grounding_dino.modeling_grounding_dino.GroundingDinoObjectDetectionOutput'>

심어둔 디버그 메시지의 등장은 모델의 순전파 과정이 마침내 모든 메모리, 타입, 로직 에러를 극복했다는 최초의 확실한 증거였습니다. 학습이 막 시작되려는 순간이었죠.

Epoch 1/20 | Loss: 7.4493
▶ I-AUROC: 43.85%
★ Best Model Saved
...
Epoch 15/20 | Loss: 4.1503
▶ I-AUROC: 53.67%
★ Best Model Saved
...
Epoch 19/20 | Loss: 4.1037
▶ I-AUROC: 61.12%
★ Best Model Saved
Epoch 20/20 | Loss: 4.0398
▶ I-AUROC: 65.53%
★ Best Model Saved

Full Fine-tuning의 효과는 기존 LoRA 방식과 비교했을 때 명확하게 나타났습니다.

방법screw_bag I-AUROC개선
v5.2 (Frozen + LoRA)58.59%-
Full Fine-tune (20 Epoch)65.53%+6.94%

결과를 분석해 보면, Loss가 7.44에서 4.03으로 꾸준히 감소했으며, I-AUROC 성능은 65.53%를 달성하여 LoRA 방식 대비 6.94% 의 의미 있는 성능 향상을 확인했습니다. 특히 Epoch 15부터 성능이 53.67%에서 65.53%로 급상승하는 패턴을 보였는데, 이는 학습을 더 연장할 경우 추가적인 성능 향상 가능성이 높다는 긍정적인 신호였습니다.

에필로그: 체크포인트의 함정

이러한 가능성을 확인하기 위해, 20 Epoch에서 학습된 체크포인트를 불러와 50 Epoch까지 학습을 재개(--resume_from_checkpoint)하는 시도를 했습니다.
하지만 재시작 후 첫 Epoch의 성능은 65.53%가 아닌 48.79%로 급락하는 예상치 못한 문제가 발생했습니다.
원인은 모델 가중치는 정상적으로 복원되었으나, Optimizer의 상태(e.g., 모멘텀)가 제대로 복원되지 않은 것으로 추정됩니다. DeepSpeed와 같은 복잡한 분산 학습 환경에서 발생할 수 있는 내부 호환성 문제일 가능성이 높습니다. 이 경험을 통해 얻은 교훈은 명확했습니다. 가장 안전하고 확실한 방법은 처음부터 다시 학습하는 것('Fresh Start')이었습니다.
--------------------------------------------------------------------------------

결론: 험난한 여정에서 얻은 교훈들

멀고도 험난한 여정

GroundingDINO의 전체 파라미터를 미세조정하는 여정은 결코 순탄치 않았습니다. 수많은 OOM, 예측 불가능한 타입 에러, 그리고 사소한 코드 버그들을 해결하고 나서야 마침내 성공적인 학습과 의미 있는 성능 향상을 확인할 수 있었습니다.
이 험난한 과정에서 얻은 핵심적인 기술적 교훈들은 다음과 같습니다.

  1. 메모리 전략의 유연성: 멀티 GPU의 통신 오버헤드가 단일 GPU와 CPU 오프로드 조합보다 클 수 있습니다. 하드웨어와 모델의 특성을 고려하여 상황에 맞는 과감한 전략 전환이 필요합니다.
  2. 혼합 정밀도의 안정성 문제: FP16/BF16은 메모리 효율이 높지만, 모델 아키텍처에 따라 안정성 문제를 일으킬 수 있습니다. 타입 에러가 반복된다면, 가장 확실한 해결책은 안정성을 택해 FP32로 전환하는 것입니다.
  3. print()를 믿어라: 복잡한 프레임워크의 스택 트레이스에 파묻히다 보면 라이브러리 자체를 의심하기 쉽습니다. 하지만 이번의 경험은 겸손한 교훈을 주었습니다. 항상 자신의 코드를 먼저 의심해야 합니다. 간단한 print(dir(output)) 구문이 라이브러리의 추상화에 가려진 모델의 진짜 상태를 보여주었고, 이 과정으로 수많은 디버깅 시간을 절약해 주었습니다.
  4. 체크포인트는 만능이 아니다: 특히 DeepSpeed와 같은 분산 학습 환경에서는 체크포인트 복원이 완벽하게 동작하지 않을 수 있습니다. 학습을 재개한 후에는 반드시 성능이 이전과 동일하게 유지되는지 확인하는 과정이 필요합니다.

이 글이 거대 모델 미세조정이라는 비슷한 문제로 고생하고 있는 다른 개발자들에게 작은 도움이 되기를 바랍니다. 포기하지 않고 끈질기게 문제를 파고들면, 결국 길은 열리게 마련입니다.