왜 첫 요청만 유독 느린가

임베딩 서버를 띄우고 헬스체크가 통과했는데도, 실제 사용자의 첫 요청에서 수백 ms에서 수 초까지 지연이 튀는 경험은 흔하다. 프로세스가 살아 있다는 것과 추론이 준비됐다는 것은 다르기 때문이다. 첫 요청은 모델 가중치의 지연 로딩, 토크나이저 초기화, CUDA 컨텍스트 생성, cuBLAS/cuDNN 커널 선택과 오토튜닝, 메모리 풀 확보를 한꺼번에 떠안는다. 이 비용은 요청당이 아니라 프로세스당 한 번 발생하지만, 하필 실제 트래픽의 첫 타자가 그 비용을 다 낸다.

지연이 몰리는 구간

특히 GPU 환경에서는 첫 커널 실행 시 JIT 컴파일과 알고리즘 탐색이 일어난다. 입력 시퀀스 길이나 배치 크기가 바뀌면 새로운 shape에 대해 이 과정이 다시 발생한다. 즉 "한 번 워밍하면 끝"이 아니라 "실제 트래픽이 쓰는 shape들을 미리 태워야" 한다.

워밍의 핵심 원칙

워밍은 단순히 아무 문장이나 한 번 넣는 게 아니다. 세 가지를 맞춰야 효과가 있다. 첫째, 실제 서비스가 사용하는 배치 크기. 둘째, 대표적인 시퀀스 길이 구간(짧은 질의와 긴 문서). 셋째, 실제 추론과 동일한 dtype·디바이스 경로. 이 조건이 어긋나면 워밍은 했는데 첫 실사용에서 다시 튀는 상황이 생긴다.

애플리케이션 레벨 워밍 코드

FastAPI 기반 임베딩 서버라면 lifespan에서 대표 shape들을 미리 추론시켜 커널을 태운다.

from contextlib import asynccontextmanager
from fastapi import FastAPI
import torch

WARMUP_SHAPES = [(1, 16), (8, 32), (8, 128), (32, 256)]

def warmup(model, tokenizer, device):
    model.eval()
    with torch.inference_mode():
        for batch, seq_len in WARMUP_SHAPES:
            text = ["샘플 문장입니다 " * (seq_len // 4)] * batch
            enc = tokenizer(text, padding="max_length",
                            truncation=True, max_length=seq_len,
                            return_tensors="pt").to(device)
            model(**enc)
    if device.type == "cuda":
        torch.cuda.synchronize()

@asynccontextmanager
async def lifespan(app: FastAPI):
    warmup(app.state.model, app.state.tokenizer, app.state.device)
    yield

app = FastAPI(lifespan=lifespan)

torch.cuda.synchronize()가 중요하다. CUDA 호출은 비동기라 이 호출 없이는 커널이 실제로 끝나기 전에 워밍이 종료된 것으로 착각할 수 있다.

레디니스 프로브와 분리하기

워밍이 끝나기 전에 로드밸런서가 트래픽을 보내면 워밍의 의미가 없다. 쿠버네티스에서는 liveness와 readiness를 분리하고, 워밍 완료 후에만 readiness가 통과하도록 상태 플래그를 둔다.

readinessProbe:
  httpGet:
    path: /ready      # 워밍 완료 시 200
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3
livenessProbe:
  httpGet:
    path: /healthz    # 프로세스 생존만 확인
    port: 8080
  periodSeconds: 10
startupProbe:
  httpGet:
    path: /ready
    port: 8080
  failureThreshold: 30
  periodSeconds: 5

/ready는 워밍 완료 플래그가 참일 때만 200을 반환하고, /healthz는 이벤트 루프 응답 여부만 본다. startupProbe로 초기 워밍에 넉넉한 시간을 주면, 워밍이 긴 대형 모델에서도 liveness가 조기에 재시작을 유발하지 않는다.

동적 shape 문제와 대응

워밍한 shape 외의 입력이 들어오면 커널 재탐색이 다시 발생한다. 이를 줄이는 현실적인 방법은 두 가지다.

방법효과비용
시퀀스 길이 버킷팅재컴파일 shape 수 제한패딩으로 약간의 연산 낭비
배치 크기 고정/패딩배치 관련 오토튜닝 억제지연·처리량 트레이드오프

입력을 미리 정한 길이 구간(예: 32/64/128/256)으로 반올림해 패딩하면, 실제 등장하는 shape 집합이 유한해지고 그 집합만 워밍하면 된다.

주의할 점

워밍은 만능이 아니다. 몇 가지를 놓치면 오히려 문제가 된다.

  • 메모리: 워밍 중 큰 배치를 태우면 그 시점의 피크 메모리가 확보된다. 실제 최대 배치보다 큰 shape로 워밍하면 OOM을 앞당길 수 있으니 상한을 실제 운영값에 맞춘다.
  • 스케일아웃 지연: 오토스케일로 새 파드가 뜰 때마다 워밍 시간만큼 가용 지연이 생긴다. HPA의 목표치와 initialDelaySeconds를 함께 조정한다.
  • 모델 리로드: 무중단 배포로 가중치를 교체하면 새 프로세스에서 워밍을 다시 해야 한다. 배포 파이프라인에 워밍 완료 확인 단계를 넣는다.
  • 측정: 워밍 효과는 첫 요청 지연(cold p99)과 정상 p99를 나눠서 봐야 드러난다. 평균만 보면 워밍의 개선이 묻힌다.

결국 워밍의 목적은 "프로세스당 한 번 내야 할 초기화 비용을, 사용자가 아니라 배포 절차가 대신 내게 하는 것"이다. 실제 트래픽의 shape를 관측해 워밍 대상을 주기적으로 갱신하는 것까지 포함해야 지속적으로 효과를 낸다.