왜 GPU가 놀고 있는가

임베딩 파이프라인을 처음 돌리면 대부분 GPU 사용률이 20~40%에 머문다. 모델 연산 자체는 빠른데, 텍스트 토크나이징·데이터 로딩·직렬화가 CPU에서 순차적으로 일어나면서 GPU가 다음 배치를 기다리는 구간이 길기 때문이다. 처리량 병목은 대개 모델이 아니라 배치를 채우고 옮기는 과정에 있다. 따라서 최적화의 목표는 "GPU가 쉬지 않도록 배치를 끊김 없이 공급"하는 것이다.

배치 크기와 시퀀스 길이

고정 배치 크기로 모든 문서를 처리하면 짧은 문장이 많은 배치에서 패딩 낭비가 커진다. 길이가 뒤죽박죽인 입력을 그대로 넣으면 가장 긴 시퀀스에 맞춰 패딩이 붙고, 그만큼 무의미한 연산이 늘어난다. 길이순 정렬 후 동적 배치를 구성하면 패딩 토큰을 크게 줄일 수 있다.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3", device="cuda")
model.max_seq_length = 512

# 길이순 정렬로 패딩 낭비 최소화 (내부적으로 원래 순서 복원됨)
emb = model.encode(
    texts,
    batch_size=256,
    convert_to_numpy=True,
    normalize_embeddings=True,
    show_progress_bar=True,
)

배치 크기는 무작정 키우지 말고 VRAM이 OOM 직전까지 올린 뒤 한 단계 낮춘 값을 쓴다. 긴 시퀀스가 섞이면 순간 메모리가 튀므로 여유를 둬야 한다.

토크나이징과 데이터 로딩 분리

CPU 토크나이징을 GPU 추론과 겹치게 만들면 대기 시간이 사라진다. DataLoader의 워커로 토크나이징을 미리 돌리고, 결과를 큐로 흘려 GPU가 항상 다음 배치를 잡을 수 있게 한다.

from torch.utils.data import DataLoader

loader = DataLoader(
    dataset,
    batch_size=256,
    num_workers=8,          # CPU 코어에 맞춰 조정
    pin_memory=True,        # host→device 복사 가속
    persistent_workers=True,
    prefetch_factor=4,
)

for batch in loader:
    ids = batch["input_ids"].to("cuda", non_blocking=True)
    with torch.inference_mode():
        out = model(ids)

pin_memory와 non_blocking=True를 함께 써야 복사와 연산이 실제로 겹친다. 둘 중 하나만 켜면 효과가 없다.

혼합 정밀도와 컴파일

FP32 그대로 돌리면 텐서 코어를 제대로 활용하지 못한다. FP16/BF16 추론으로 전환하면 메모리가 절반으로 줄고 처리량이 오른다. 최신 GPU라면 BF16이 수치 안정성 면에서 안전하다.

import torch

with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
    with torch.inference_mode():
        emb = model.encode(texts, batch_size=384)

torch.compile은 반복 추론에서 커널 오버헤드를 줄여주지만, 입력 시퀀스 길이가 계속 바뀌면 재컴파일이 잦아 오히려 느려진다. 길이 버킷팅과 함께 쓰는 것이 전제다.

최적화 기법 비교

기법주 효과주의점
길이순 정렬 배치패딩 연산 감소순서 복원 필요
DataLoader 프리페치GPU 대기 제거워커 과다 시 CPU 경합
BF16 추론메모리·속도 개선일부 구형 GPU 미지원
torch.compile커널 오버헤드 감소가변 길이 시 재컴파일

모니터링과 튜닝 순서

추측으로 파라미터를 바꾸지 말고 먼저 관측한다. nvidia-smi dmon으로 실사용률을 보고, GPU가 100%에 못 미치면 데이터 공급이 병목이라는 뜻이다.

# 1초 간격으로 SM 사용률·메모리·전력 관측
nvidia-smi dmon -s um -d 1

# 워커별 CPU 점유 확인 (토크나이징 병목 진단)
pidstat -t 1

튜닝은 데이터 로딩 → 배치 구성 → 정밀도 → 컴파일 순으로 하나씩 바꾸며 처리량(docs/sec)을 측정한다. 여러 개를 동시에 바꾸면 무엇이 효과를 냈는지 알 수 없다.

주의점

배치가 커질수록 실패 시 재처리 비용도 커진다. 수백만 건을 한 잡으로 돌리기보다 샤드 단위로 나눠 체크포인트를 남기고, 실패한 샤드만 재실행하는 구조가 안전하다. 또 임베딩 정규화 여부, 모델 버전, max_seq_length는 반드시 메타데이터로 기록해야 한다. 이 값이 바뀌면 벡터 공간이 달라져 기존 인덱스와 섞이면 검색 품질이 조용히 무너진다. 처리량 최적화는 결과의 재현성을 보장한 위에서만 의미가 있다.