4GB GPU에서도 8B 모델 학습이 가능한 Soup의 주장을 RTX 5060 Ti로 검증하다
요약
본 기사는 Layer Streaming 기술을 활용한 Soup CLI 도구를 분석하며, 4GB GPU 환경에서도 8B 모델 파인튜닝이 가능하다는 주장을 검증했습니다. 필자는 RTX 5060 Ti를 이용해 메모리 제한 작동 여부와 스트리밍 모델과 일반 모델의 출력 비트 단위 일치성을 확인했으며, 두 결과 모두 성공적으로 입증되었습니다.
핵심 포인트
- Soup CLI는 Layer Streaming으로 VRAM 사용량을 혁신적으로 줄임.
- 4GB GPU에서도 8B 모델 파인튜닝이 가능함을 실험적으로 검증함.
- 스트리밍 모델과 일반 모델의 출력은 비트 단위로 완벽하게 일치함.
- Layer Streaming 구조상 state_dict()에는 실제 가중치가 아닌 플레이스홀더가 포함됨.
이게 뭔가
해외 AI 트렌드 100일 챌린지 Day 005.
이번에 다룰 것은 Soup(Apache-2.0)입니다.
'단일 YAML + 1 명령어만으로 LLM을 파인튜닝할 수 있는' CLI 도구이며, 핵심 기능은 Layer Streaming입니다.
이것은 고정된 베이스 모델의 가중치를 VRAM에 올리지 않고, 호스트 RAM에서 디코더 레이어를 한 층씩 GPU로 스트리밍하여, 4GB GPU에서도 8B 모델의 파인튜닝을 할 수 있다는 주장입니다.
원문 README의 벤치마크 수치(RTX 3050 Laptop 4GB, 119.6 tok/s, 3.32GB peak)에는 개발자 본인이 다음과 같은 주석을 달았습니다.
measured on v0.72.2, before the v0.73.0 correctness repair that cost -4.8% at 32B; neither has
been re-run on a 4 GB card since - re-measurement pending in issue #361
즉, 개발자 본인이 '현재 버전으로는 아직 4GB 카드에서 재검증하지 않았다'고 명확히 밝힌 상태입니다.
이번에는 이 재검증 대기 주장을, 필자가 가진 RTX 5060 Ti(16GB)를 인위적으로 4GB로 제한하여 추적 실험을 진행했습니다.
검증 방법: Soup 공식 Colab 노트북을 더 강력한 GPU에서 실행하기
Soup 리포지토리에는 notebooks/proof-4gb.ipynb라는 '주장을 직접 검증해보세요'라는 노트북이 공개되어 있습니다.
Colab의 무료 T4(16GB)를 torch.cuda.set_per_process_memory_fraction으로 4GB로 제한하고,
GPU의 bf16 지원 여부
- 4GB 제한이 실제로 작동하는지
- Layer Streaming한 모델과 일반 모델의 포워드 출력이 완벽하게 일치하는지
- 실제로 Llama-3.1-8B를 4GB 예산 내에서 학습할 수 있는지
순서대로 확인하는 구성입니다.
이번에는 이 구성을 그대로 따르되, Colab T4보다 강력한 RTX 5060 Ti(16GB, Blackwell) 위에서 실행했습니다.
python3 -m venv ~/soup-venv && source ~/soup-venv/bin/activate
pip install 'huggingface-hub<1.32.0' 'soup-cli[train]==0.75.0' # 노트북이 검증된 것으로 지정하는 버전
결과 1: bit-exact성 확인은 성립했다
bf16 IN HARDWARE True
capped at 4.00 GB (fraction 0.234 of this card)
refused, as it should be: CUDA out of memory. Tried to allocate 4.29 GiB...
...
4GB 메모리 제한은 실제로 작동했으며, 이를 초과하는 할당은 올바르게 거부되었습니다.
그리고 핵심인 'Layer Streaming으로 분할 실행한 모델과, 평소처럼 VRAM에 전부 로드한 모델의 출력이 부동 소수점 비트 단위로 완벽하게 일치한다'는 주장(torch.equal)
도 실제로 그와 같은 결과가 나왔습니다.
최대 절대 오차는 0.0이었습니다.
속임수가 아니라 정말 비트 단위로 동일한 계산을 수행하고 있음이 확인되었습니다.
(덧붙이자면: 필자가 처음에 작성했던 비교 코드는 여기서 한 번 실패했습니다.
Streaming용 모델의 state_dict()
를 단순하게 일반 모델에 load_state_dict
하려 했더니, 베이스 레이어의 가중치가 실체가 없는 meta tensor였기 때문에 오류가 발생한 것입니다.
Layer Streaming은 포워드 시 레이어별로 가중치를 동적으로 로드하는 설계이므로, state_dict()
에는 '실제 데이터'가 아니라 '플레이스홀더'가 들어있다는 것을 깨달았습니다.
이는 이쪽 검증 코드의 오류이며, Soup 자체의 결함은 아닙니다.)
결과 2: 8B 모델의 4GB 학습은 정말로 작동했다
드디어 본론입니다.
4GB로 제한된 프로세스 내에서 Llama-3.1-8B-Instruct (NousResearch의 비게이트 배포 버전)를 NF4 양자화 + LoRA + Layer Streaming으로 실제로 학습시켰습니다.
base: NousResearch/Meta-Llama-3.1-8B-Instruct
training:
quantization: 4bit
...
Soup가 실행 전에 제시하는 사전 진단 패널이 흥미로워 함께 첨부합니다.
base store 5.70 GB across 32 layers (pinned)
VRAM buffers 2 x 113 MB + 1 x 1051 MB large-layer slot = 1276 MB
peak VRAM ~1.97 GB at batch 1 x seq 256 (logits 0.46 GB)
...
그리고 실제 측정 결과:
steps: 7 loss: 4.533 -> 4.073
peak VRAM allocated by this process: 1.78 GB
budget this process was capped to: 4.00 GB
실측 피크 VRAM은 1.78GB였습니다.
4GB 예산을 크게 밑돌았으며, Soup 자체의 사전 예측(1.97GB)과도 가까운 수치였습니다.
8B 모델 (bf16 기준 16GB 초과)이 정말로 4GB 미만의 실 메모리로 학습될 수 있음을 확인했습니다.
loss 역시 7 스텝 만에 4.533에서 4.073까지 순조롭게 하락하여, 학습이 실제로 기능하고 있음도 확인할 수 있었습니다.
개발사 측의 '4GB GPU로 8B 모델을 학습할 수 있다'는 주장은 계산량 및 메모리 사용 면에서 이 RTX 5060 Ti에서도 재현 가능했다고 할 수 있습니다.
결과3: 하지만, 저장된 LoRA 어댑터 파일이 비어 있었다
여기서 예상치 못한 일이 발생했습니다.
학습 후에 생성된 out-8b/adapter_model.safetensors 파일을 safetensors 라이브러리로 직접 열어본 결과, 텐서가 0개(파일 크기 40바이트, 헤더만 존재)였습니다.
from safetensors import safe_open
with safe_open('out-8b/adapter_model.safetensors', framework='pt') as f:
print(len(list(f.keys()))) # -> 0
학습 중에 저장된 checkpoint-7/adapter_model.safetensors도 마찬가지로 0개였습니다.
반면 checkpoint-7/optimizer.pt는 13.7MB였으며, 로그에 나왔던 LoRA applied: 3,407,872 trainable (약 340만 파라미터)라는 옵티마이저 상태량 규모와 일치했습니다.
즉, 학습 자체(그래디언트 계산 및 loss 하락)는 확실히 일어나고 있지만, 학습된 LoRA 가중치가 파일로 올바르게 저장되지 않은 것입니다.
8B 모델에서의 재현은 시간이 걸리기 때문에, 같은 경로를 빠른 SmolLM2-135M-Instruct로 재현(debug_empty_adapter.py)하여, 학습 직후의 모델을 세 가지 방법으로 살펴본 결과 원인을 파악할 수 있었습니다.
model.state_dict() -> ...layers.0.self_attn.q_proj.lora_A.default.weight (inner 없음, 값은 비제로)
model.named_parameters() -> ...layers.0.inner.self_attn.q_proj.lora_A.default.weight (inner 있음)
peft.get_peft_model_state_dict(model) -> 0 keys
Soup는 Layer Streaming을 위해 래핑한 각 디코더 레이어(StreamedDecoderLayer)의 state_dict()를 독자적으로 덮어쓰면서, 저장 시 키에서 래퍼 고유의 inner. 접두사를 제거하고 있습니다 (소스 코드 주석에 따르면 '일반적인 LoRA 어댑터와 구별할 수 없는 형태로 저장하기 위한 의도적 설계').
하지만 이 덮어쓰기는 state_dict()에만 적용되었을 뿐, PyTorch 표준의 named_parameters()
은inner.
이 붙은 채로.
Hugging Face Trainer.save_model()
→ PeftModel.save_pretrained()
가 내부적으로 호출하는 peft.get_peft_model_state_dict()는 named_parameters()
베이스를 기반으로 LoRA 키를 특정한 다음 state_dict()
와 비교하는 구현이 되어 있기 때문에, 키 이름이 일치하지 않아 '해당 파라미터 없음'으로 판단되어 오류도 경고도 없이 빈 dict을 반환하게 됩니다.
이것이 그대로 adapter_model.safetensors
에 쓰여져 0 텐서 파일이 되어 있었습니다.
학습 자체(그래디언트 계산・loss 하락)가 정상이었던 것은, 이 불일치가 포워드/백워드 패스에는 전혀 영향을 미치지 않았기 때문입니다.
135M 모델에서도 8B 모델에서도 같은 현상이 재현되었으므로, 모델 종류에 의존하지 않는 Layer Streaming 기능 자체에 기인하는 결함으로 보고 있습니다.
우회책도 확인했습니다.
trainer.save_model()을 거치지 않고, model.state_dict() (Soup 고유의 덮어쓰기 버전. 키는 정확하고 값도 실제로 학습된 것)
에서 `
AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기