BERT Fine-tuning 시 GPU 저활용 문제 분석 및 해결

CUDA Sync 병목, Batch Size 최적화, Linear Scaling Rule

BERT fine-tuning 중 GPU utilization이 10~20%에 머무는 현상의 원인을 분석하고 해결한다. 매 배치마다 발생하는 CUDA synchronization 병목, batch size와 GPU 효율의 관계, batch size 변경 시 반드시 따라야 하는 learning rate 조정 원칙까지 정리한다.

Deep Learning
Engineering
PyTorch
저자

Kwangmin Kim

공개

2026년 04월 09일

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()을 호출하면 이 비동기 흐름이 깨진다.

loss.item()           # GPU가 loss 계산을 끝낼 때까지 CPU가 대기 (sync)
(...).sum().item()    # 한 번 더 대기 (sync)

.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를 늘리면 두 가지 효과가 생긴다:

  1. 배치 수 감소 → epoch당 sync 횟수 감소
  2. 배치당 연산량 증가 → 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}\]

# 수정 전
trainer.train(..., batch_size=16, learning_rate=2e-5)

# 수정 후
trainer.train(..., batch_size=64, learning_rate=8e-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)

Subscribe

Enjoy this blog? Get notified of new posts by email: