1 문제 상황
mBERT(bert-base-multilingual-uncased, 167M 파라미터)를 RTX 2070 Max-Q(8.6GB VRAM)에서 fine-tuning하던 중 학습 속도가 예상보다 현저히 느렸다.
훈련 중: 49%|████▊ | 137/282 [00:19<00:20, 1.28it/s]
nvidia-smi dmon -s u로 GPU 사용률을 확인한 결과:
# gpu sm mem enc dec
0 18 5 0 0
0 15 5 0 0
0 13 4 0 0
GPU(sm) utilization이 10~20% 수준으로, GPU가 대부분의 시간을 유휴 상태로 보내고 있었다. 정상적인 BERT fine-tuning이라면 70~95% 수준이어야 한다.
2 원인 분석
2.1 구조적 원인: CUDA Synchronization
PyTorch에서 GPU 연산은 기본적으로 비동기(asynchronous)로 실행된다. CPU는 GPU에 연산을 “예약”하고 곧바로 다음 작업을 준비할 수 있다.
그런데 .item()을 호출하면 이 비동기 흐름이 깨진다.
.item()은 GPU 텐서의 값을 Python 숫자로 꺼내는 연산이다. 이 순간 CPU는 GPU가 해당 연산을 완전히 끝낼 때까지 블로킹된다. 이를 CUDA synchronization(동기화) 이라고 한다.
[배치 처리 흐름 — 기존 코드]
GPU: |===forward===|===backward===|
CPU: | |.item() 대기 |.item() 대기 | 다음 배치 준비 |
→ CPU가 GPU를 기다리는 시간이 전체의 대부분을 차지
2.2 왜 seq_len=32에서 특히 심한가
seq_len이 짧을수록 GPU의 배치당 연산량이 적다. 즉, GPU가 한 배치를 끝내는 시간이 매우 짧다.
그런데 .item() 동기화 대기 시간은 seq_len과 무관하게 일정하게 발생한다. 결과적으로:
실제 GPU 연산 시간: ██ (짧음, seq_len=32이므로)
.item() 대기 시간: ████████ (상대적으로 길어짐)
GPU 연산이 짧을수록 sync 오버헤드 비율이 커진다.
실제 진단 결과:
# forward pass 단독 측정 (워밍업 후)
전송: 2.4ms
forward: 633ms ← 첫 번째 (CUDA kernel 컴파일 지연)
forward: ~20ms ← 워밍업 후 실제 속도첫 번째 forward가 633ms로 느렸던 것은 CUDA kernel의 JIT(Just-In-Time) 컴파일 때문이다. 이후에는 ~20ms 수준으로 정상화된다.
2.3 부가 원인: 작은 batch size
batch_size=16일 때:
- 배치 수가 많아진다 → sync 횟수 비례 증가
- 배치당 GPU 작업량이 적다 → GPU 파이프라인이 충분히 채워지지 않음
3 해결 방법
3.1 GPU 텐서로 누적 — .item() 호출 횟수 최소화
핵심 원칙: 값이 필요한 시점(epoch 끝)에만 CPU로 가져온다.
# 기존 코드 — 매 배치마다 2번 sync 발생
total_loss += loss.item() # sync 1
correct += (predicted == labels).sum().item() # sync 2
# 수정 코드 — GPU 텐서로 누적, epoch 끝에 1번만 sync
total_loss = torch.tensor(0.0, device=device)
correct = torch.tensor(0, device=device)
# 배치 루프 내부
total_loss += loss.detach() # GPU에서 누적 (sync 없음)
correct += (predicted == labels).sum() # GPU에서 누적 (sync 없음)
# epoch 끝에 한 번만 CPU로 전송
epoch_loss = total_loss.item() / len(train_loader) # sync 1회
epoch_accuracy = 100 * correct.item() / total # sync 1회.detach()를 사용하는 이유: loss는 연산 그래프에 연결되어 있어 메모리를 계속 보유한다. .detach()로 그래프에서 분리한 뒤 누적해야 메모리 누수가 없다.
3.2 batch_size 증가
batch_size를 늘리면 두 가지 효과가 생긴다:
- 배치 수 감소 → epoch당 sync 횟수 감소
- 배치당 연산량 증가 → GPU 파이프라인이 더 오래, 더 꽉 차게 유지됨
RTX 2070 Max-Q(8GB VRAM), BERT-base, seq_len=32 기준으로 batch_size=64가 적당하다.
실제 처리량 비교:
| 설정 | it/s | batch_size | samples/sec |
|---|---|---|---|
| 기존 (batch=16) | 7.21 | 16 | 115 |
| 수정 후 (batch=64) | 5.14 | 64 | 329 |
it/s는 줄었지만 실제 처리량(samples/sec)은 약 3배 증가했다.
4 Learning Rate 조정: Linear Scaling Rule
4.1 왜 batch size를 바꾸면 learning rate도 바꿔야 하는가
같은 수의 샘플을 처리할 때:
- batch=16: 4번 업데이트 (각 업데이트는 16개 기준 gradient)
- batch=64: 1번 업데이트 (64개 기준 gradient)
batch=64의 gradient는 batch=16보다 4배 더 많은 샘플에서 평균을 낸 것이다. 분산이 작아지고(더 안정적), 방향은 비슷하다.
4번의 작은 스텝과 1번의 큰 스텝이 비슷한 이동 거리를 갖도록 맞추려면 learning rate도 같은 비율로 키워야 한다.
4.2 Linear Scaling Rule (Goyal et al., 2017)
\[lr_{new} = lr_{base} \times \frac{batch\_size_{new}}{batch\_size_{base}}\]
본 프로젝트에 적용하면:
\[lr_{new} = 2 \times 10^{-5} \times \frac{64}{16} = 8 \times 10^{-5}\]
4.3 주의사항
Linear Scaling Rule은 완벽한 공식이 아니라 경험적 출발점이다.
- batch size가 너무 크면(수백~수천) gradient 분산이 지나치게 줄어들어 오히려 일반화 성능이 나빠질 수 있다
- BERT fine-tuning에서는 learning rate가 너무 높으면 사전학습된 가중치가 빠르게 망가질 수 있다 (catastrophic forgetting)
- 이 경우
2e-5 → 8e-5는 완만한 조정 범위이므로 실용적으로 문제없다 - 확신이 없다면
{2e-5, 5e-5, 8e-5}를 비교 실험해보는 것이 좋다
5 요약
| 문제 | 원인 | 해결 |
|---|---|---|
| GPU utilization 10~20% | 매 배치 .item() 2회 → CUDA sync 병목 |
GPU 텐서로 누적, epoch당 1회만 .item() |
| 낮은 처리량 | batch_size=16으로 GPU 파이프라인 미충전 | batch_size=64로 증가 |
| Learning rate 불일치 | batch size 변경 후 lr 미조정 | Linear Scaling Rule 적용: 2e-5 → 8e-5 |
최종 처리량: 1.28 it/s → 5.14 it/s (batch_size 기준 실질 3배 향상)
6 관련 주제
선행 지식
후속 주제
- 트랜스포머 기반 언어 모델의 파인튜닝 (placeholder)