CH3. Model Serving System — 정리

limes22·2026년 8월 15일

Hands-On LLM Serving and Optimization 3장 학습 정리


0장. 용어 사전 (모르는 단어로 설명하지 않기 위해)

용어
token모델이 다루는 최소 단위. 대략 한글 1~2자, 영어 3~4자
tokenizer사람 글 ↔ token 번호 변환기. CPU 작업
forward pass입력을 모델에 한 번 통과시켜 다음 token 확률을 구하는 연산. GPU 작업
prefill입력 프롬프트 전체를 한 번에 forward 시키는 첫 단계. token이 많아 연산량이 큼 → compute-bound
decodeprefill 이후 token을 1개씩 이어 만드는 단계. 연산량은 작지만 매 스텝 모델 가중치 전체를 읽어야 함 → memory-bound
KV cache이미 계산한 중간값(Key, Value)을 저장해 두는 메모리. 다음 token 만들 때 앞부분을 재계산하지 않게 해줌
batching여러 요청을 묶어 forward pass를 한 번에 실행
streaming생성이 끝나기 전에 만들어진 token부터 클라이언트로 흘려보내기
sequence서빙 시스템이 추적하는 요청 단위 상태 객체. (ID, prompt, output, EventQueue)
TTFTTime To First Token. 첫 글자가 화면에 뜰 때까지의 시간
ITL / TPOTInter-Token Latency / Time Per Output Token. 첫 글자 이후 한 글자당 간격
throughput단위 시간당 처리한 token 또는 요청 수 (TPS / RPS)
IPCInter-Process Communication. 서로 다른 OS 프로세스 간 데이터 전달
GIL파이썬이 한 프로세스 안에서 한 번에 한 스레드만 파이썬 코드를 실행하게 막는 잠금장치
SLO / SLA서비스 수준 목표 / 협약. "p99 TTFT 1초 이내" 같은 약속

1장. 컴포넌트 6개가 하는 일

세 줄 요약
1. 위쪽 4개(API server, LLM engine, Workload manager, Model executor)는 CPU 일만 한다.
2. 아래 2개(Model worker, Model manager)만 GPU 일을 하고, 유일하게 진짜 별도 프로세스다.
3. 이 경계선이 3장 전체의 핵심이다 — CPU 잡일이 GPU를 놀게 만들지 않도록 그은 선.

컴포넌트역할어디서 도나
API serverHTTP 요청 파싱, 응답 반환, 스트리밍 엔드포인트메인 프로세스
LLM engine지휘자. 나머지를 초기화하고 흐름을 조율메인 프로세스
Workload manager요청 큐잉 + "누구를 언제 묶어 보낼지" 결정 = 스케줄링 지점메인 프로세스
Model executorworker 프로세스를 띄우고 IPC로 작업 전달메인 프로세스
Model worker실제 forward pass 실행별도 프로세스 (GPU)
Model manager모델·tokenizer 로드와 캐싱worker 프로세스 안

성격이 다른 두 이야기입니다.

  • 컴포넌트 분리 = 코드 설계 (같은 프로세스 안의 객체들)
  • 프로세스 분리 = OS 레벨 격리 (Model worker 하나뿐)

2장. 요청이 흐르는 경로 (원문 그림 3-3)

세 줄 요약
1. 요청 2건이 prompt 5개로 쪼개졌다가 다시 요청 2건으로 합쳐진다.
2. 그 흩어짐과 합쳐짐을 담당하는 게 Workload manager의 자료구조 3개다.
3. 프로세스 경계를 넘는 지점은 딱 한 곳(multiprocessing.Queue)이다.

2-1. Workload manager가 들고 있는 자료구조 3개

자료구조내용왜 필요한가
incoming_queue[sequence (ID, prompt)]아직 배치에 안 들어간 대기열
active_sequence[sequence (ID, prompt)]지금 GPU에서 돌고 있는 것들
sequence_map{ID: (promptA, GenOutput)}생성 결과를 원래 웹 요청으로 되돌리기 위한 지도

sequence_map이 존재하는 이유가 바로 아래 (6)번 화살표입니다. Request 1이 prompt A·B로 쪼개져 서로 다른 배치에서 처리되더라도, 결과를 다시 "Request 1의 응답"으로 조립해야 하기 때문입니다.

2-2. 여섯 단계

[그림 1] 배칭과 ID 매핑 — 여섯 단계

  Request 1                                    ┌─ incoming_queue
  [prompt A, B] ─┐                             ├─ active_sequence
                 │(1)   ┌──────────────────┐   └─ sequence_map
                 ├─────▶│    API server    │
  Request 2      │      └────────┬─────────┘
  [prompt C,D,E]─┘               │
                                 ▼
                        ┌──────────────────┐  (2) 요청 추가
                        │    LLM engine    │◀────────┐
                        └────────┬─────────┘         │
                          (4)    │  ▲ (5) 결과        │
                                 ▼  │            ┌────┴──────────────┐
                        ┌──────────────────┐     │ Workload manager  │
                        │ Model executor / │     │ 실행 순서 결정     │
                        │      worker      │     │ 상태·결과 추적     │
                        └──────────────────┘     └───────────────────┘
                                 │                    (3) 다음 배치
                                 │                     [(ID, prompt)]
                                 ▼ (6) 생성 텍스트를 원래 요청으로 재매핑
                   Request 1 [GenText A, B]  /  Request 2 [GenText C, D, E]
  1. API server가 HTTP 요청을 파싱해 prompt 단위로 쪼갬
  2. LLM engine이 Workload manager에 요청 등록
  3. Workload manager가 다음 배치 구성원 결정 ← 배칭 전략이 들어가는 유일한 지점
  4. Model executor가 IPC로 worker에게 배치 전달, worker가 GPU에서 생성
  5. 결과 [(ID, generated text)] 반환
  6. sequence_map으로 원래 요청에 재매핑 → 클라이언트

2-3. 프로세스 경계

[그림 2] 프로세스 경계 = 크래시 방화벽

메인 프로세스 (CPU)                    │  워커 프로세스 (GPU)
Client → API server                    │
           ↓                           │
        LLM engine                     │
           ↓                           │
     Workload manager  ← 큐에 쌓임      │
           ↓  (배치 확정)                │
      Model executor ═══ Queue ═══════▶ Model worker
           ↑                           │   └ forward pass
Client ← ────────────── Queue ◀════════┘

3장. 배칭 — 왜 GPU는 Batch로 강해지는가

세 줄 요약
1. Transformer의 핵심 연산은 행렬곱이고, 행렬곱은 batch 차원과 궁합이 좋다.
2. 요청을 하나씩 처리하면 GPU가 매번 준비/실행/종료를 반복하며 대부분 논다.
3. 실측에서 batch 4개 묶음이 순차 처리 대비 총 시간 -62.5%, GPU 활용률 +64%p.

3-1. 원리

[그림 3] 단일 Request vs 다중 Request

Batch = 1        X(Seq, Hidden) × W(Hidden, Hidden) = Y(Seq, Hidden)
Batch = N   X(Batch, Seq, Hidden) × W(Hidden, Hidden) = Y(Batch, Seq, Hidden)
                     └ 가중치 W는 딱 한 번만 VRAM에서 읽는다 ┘

decode 단계는 memory-bound입니다. token 1개를 만들려고 모델 가중치 전체를 VRAM에서 읽어옵니다. batch 크기를 4로 늘려도 가중치는 여전히 한 번만 읽습니다. 즉 읽기 비용을 4개 요청이 나눠 갖습니다. 이게 배칭이 공짜에 가까운 이득을 주는 이유입니다.

[그림 4] 비효율 vs 효율

하나씩(순차):  Req A →GPU→ Req B →GPU→ Req C →GPU→ Req D →GPU
               GPU가 매번 준비/실행/종료 반복, 자원 대부분 유휴 → 낮은 Utilization

묶어서(Batch): [A,B,C,D] ──▶ GPU 한 번
               병렬 자원 최대 활용 → 높은 Utilization

3-2. 실측 (Qwen2.5-0.5B, vLLM, RTX 4090 24GB, prompt 4개, 동일 sampling params)

처리 방식총 처리 시간평균 TTFT평균 ITLGPU UtilOutput Tokens
순차 (하나씩 4번)24.8 s1.85 s38.5 ms22 %512
Batch (한 번에 4개)9.3 s1.64 s21.7 ms86 %512
개선 효과↓62.5%↓11.4%↓43.6%↑64%p

여기서 반드시 짚고 넘어갈 것: 이 표에서 TTFT까지 좋아진 건 "배칭하면 TTFT도 개선된다"는 일반 법칙이 아닙니다. 이 실험은 prompt 4개를 동시에 던진 상황이라, 순차 방식에서는 2·3·4번째 요청이 앞 요청이 끝날 때까지 기다리면서 평균 TTFT가 나빠진 것입니다.

일반적으로는 반대입니다 — batch를 키우면 throughput↑, TTFT↑(나빠짐), ITL↑(나빠짐)이 동시에 일어납니다. 모으려고 기다리는 시간이 붙기 때문입니다. 절대 수치는 환경에 따라 달라지니, 원리(batch → GPU 활용도↑ → throughput↑)만 가져가고 숫자는 직접 측정하세요.

3-3. 정적 배칭 vs continuous batching (원문 그림 3-4)

[그림 5] 두 배칭 방식

정적:   [A====][B==][C======][D===]  ← D가 끝날 때까지 슬롯 4칸 잠김
        ░░░░░░░░░░ 낭비된 GPU 시간 ░░░░

연속:   [A====][E======]
        [B==][F====][G===]           ← 끝나는 즉시 새 요청 투입
        [C======][H==]

원문 그림 3-4에서 달라지는 건 딱 두 가지입니다.

  1. sequence 구성이 늘어남: (ID, prompt)(ID, prompt, output, EventQueue)
    • output: 지금까지 만든 token 누적
    • EventQueue: 클라이언트로 token을 흘려보낼 통로 = 스트리밍의 출구
  2. 배치 처리 스레드라는 루프가 생김

핵심 문장 한 줄:

배치를 실행해도 prompt마다 새 token은 딱 하나씩 나온다.
그래서 매 스텝 output += token, prompt += token으로 sequence를 갱신하고 루프를 다시 돈다.

이 "매 token마다 도는 루프"가 곧 iteration-level scheduling이고, 여기서 스트리밍이 부산물로 저절로 성립합니다.


4장. 실습 코드 — 엔드포인트 4개 = 대조군 4개

세 줄 요약
1. 같은 모델(facebook/opt-125m)을 4가지 방식으로 서빙해 나란히 비교하게 만든 구조다.
2. /generate_stream일부러 KV cache를 안 쓴다 — 반면교사용.
3. /generate_vllm이 정답지, 나머지는 그 정답이 왜 필요한지 보여주는 과정.

ch03/single_model_llm_serving/
├── llm/
│   ├── llm.py                # LLMEngine
│   ├── model_executor.py     # IPC + 프로세스 관리
│   ├── model_manager.py      # 모델/토크나이저 로드
│   ├── model_worker.py       # 별도 프로세스, forward pass
│   └── workload_manager.py   # 큐잉/배칭 상태
├── main.py                   # FastAPI
├── requirements.txt          # vllm==0.9.0.1, torch==2.7.0
└── tests/
    ├── test_api.py           # FastAPI 엔드포인트
    ├── test_vllm.py          # vLLM 경로
    └── test_stream.sh        # curl 기반 스트리밍 수동 테스트
계층 구조
main.py (FastAPI)
└─ LLMEngine                     # 오케스트레이션
   ├─ WorkloadManager            # 큐잉/배칭 상태 관리
   └─ ModelExecutor              # 별도 프로세스와 IPC
      └─ ModelWorker (별도 process)   # 실제 forward pass
         └─ ModelManager             # 모델/토크나이저 로드
엔드포인트실행 경로캐싱/배칭
/basic_generateModelExecutor → HF model.generate() 1개 시퀀스HF 내부 KV cache
/generateWorkloadManager 큐 → 최대 4개 배치 → model.generate()HF 내부 KV cache
/generate_stream별도 스레드 loop → token 1개씩 forward캐시 없음
/generate_vllmvllm.LLM 직접 호출PagedAttention + continuous batching

5장. 의도된 설계 포인트 5가지

세 줄 요약
1. 스트리밍 경로의 비효율은 버그가 아니라 교보재다.
2. worker가 1개뿐이라 배치와 스트리밍이 자원을 두고 직렬로 싸운다 — 이것도 현실 재현.
3. 배칭은 고정 크기 4의 정적 배칭이라 vLLM과 정확히 반대 축에 있다.

① 모델이 사실상 두 벌 로드된다 (llm.py:20,23)

LLMEngine.__init__
   ├─ (a) 별도 프로세스의 transformers 모델   ← 손수 만든 배칭
   └─ (b) 메인 프로세스의 vllm.LLM 엔진        ← 프레임워크
        같은 opt-125m을 나란히 → "직접 vs 프레임워크" 체험

② 스트리밍 경로가 KV cache를 안 쓴다 (model_worker.py:88-93)

매 token마다 use_cache=False로 프롬프트 전체를 처음부터 다시 forward합니다. workload_manager.py:86에서 sequence.prompt += token으로 이어붙이니 프롬프트가 계속 길어집니다.

[그림 6] KV cache 있을 때 / 없을 때 (조작 포인트: 생성 길이 n을 늘려보기)

있음:  prefill 1회, 이후 매 스텝 새 token 1개만 계산
       스텝 비용:  ■  ■  ■  ■  ■        (거의 일정)

없음:  매 스텝 전체를 재계산
       스텝 비용:  ■  ■■  ■■■  ■■■■  ■■■■■   (계속 커짐)

원문의 "O(n²)"은 절반만 맞습니다.
FFN 기준 — 스텝당 O(n), 총합 O(n²)
attention까지 포함 — 스텝당 O(n²), 총합 O(n³)
짧은 데모에선 안 보이지만 긴 컨텍스트에서 폭발하는 건 후자입니다.

ModelWorker.__init__stream_states 딕셔너리(past_key_values용)가 선언만 되고 안 쓰입니다(model_worker.py:23) — 증분 디코딩으로 확장할 자리를 비워둔 흔적.

③ worker 프로세스는 1개, 큐를 공유 (model_executor.py:17-20)

task_queue / result_queue 한 쌍과 worker 1개만 띄우고, /generate(배치)와 /generate_stream(스트리밍)이 같은 worker에 순차적으로 작업을 던집니다. → 실제 단일 모델 서버의 리소스 경합을 그대로 재현.

④ 고정 크기 폴링 배칭 (workload_manager.py:23, batch_size=4)

get_next_batch()가 큐에서 최대 4개까지 채워 반환하고, generate()의 while 루프가 _is_batch_finished를 기다립니다. → 활성 배치가 전원 끝나야 다음 배치로 넘어가는 정적 배칭.

⑤ 프로세스 격리

mp.Process로 띄운 worker, multiprocessing.Queue로만 통신. 모델 크래시가 API 프로세스를 죽이지 않음 (README의 "Reliability" 포인트).

사소한 코드 관찰

  • main.py:16multiprocessing.Lock()은 단일 asyncio 프로세스 내 지연 초기화 방지용이라 threading.Lock으로 충분
  • model_worker.py:118-119의 "Waiting for debugger to attach..." 로그는 debugpy 연결 코드 없이 남은 죽은 문구
  • vllm==0.9.0.1, torch==2.7.0 고정 — 재현 시 이 버전 준수

6장. vLLM 튜닝 노브 — 내부 원리를 알아야 만지는 것들

세 줄 요약
1. 이 옵션들은 서로 독립이 아니다. 하나를 바꾸면 Memory / Scheduling / Communication에 연쇄로 영향이 간다.
2. 그래서 "권장값"을 외우는 게 아니라, 각 옵션이 어느 자원을 건드리는지를 알아야 한다.
3. 목표(throughput / latency / 비용)를 먼저 정하고, 그 목표에 해당하는 지표만 보고 조인다.

6-1. GPU 메모리는 이렇게 나뉜다

[그림 7] GPU Memory 구성 (조작 포인트: --gpu-memory-utilization 을 0.7↔0.95로)

┌────────────────────────────┐
│  Model Weight              │  고정
│  Activation                │  batch 크기에 비례
│  CUDA Graph / Workspace    │  거의 고정
│  KV Cache          ◀───────┤  ★ 남는 만큼 여기로 간다
│  여유 공간                  │
└────────────────────────────┘
     값 ↑ → KV Cache 여유 ↑ (동시 처리 가능 요청 ↑)
     값 ↓ → OOM 위험 ↓, 대신 처리량 ↓

6-2. 옵션 7개

#옵션무엇을 정하나올리면내리면
01--gpu-memory-utilizationGPU 메모리 중 vLLM이 쓰는 비율KV cache 여유↑OOM 위험↓, 처리량↓
02--max-num-seqs동시에 처리할 sequence(요청) 최대 수concurrency↑, throughput↑메모리 사용↓
03--max-num-batched-tokens한 scheduling step에서 처리할 총 token 예산throughput↑, 대신 latency·TTFT 악화 가능GPU 활용도↓, 작은 batch 반복
04--tensor-parallel-size모델을 몇 개 GPU로 쪼갤지큰 모델 실행 가능, 처리량↑통신 비용↓
05--kv-cache-dtypeKV cache 데이터 타입 (auto / fp16 / fp8 / int8)메모리 절감↑, 품질 손실 가능품질 안전
06--enable-prefix-caching같은 prefix 재사용으로 prefill 생략prefill 연산↓, TTFT↓
07--enable-chunked-prefill긴 prompt의 prefill을 조각내서 점진 수행compute peak↓, 다른 요청 간섭↓

6-3. 각 옵션이 건드리는 자원

옵션제어 대상
--gpu-memory-utilizationMemory (용량)
--max-num-seqsMemory + Scheduling (동시성)
--max-num-batched-tokensScheduling + Memory (token budget)
--tensor-parallel-sizeCompute + Communication (모델 병렬)
--kv-cache-dtypeMemory + Quality
--enable-prefix-cachingCompute + Latency (prefill 재사용)
--enable-chunked-prefillCompute + Memory + Interference

6-4. 실전 팁

  • --gpu-memory-utilization: 보통 0.7~0.95 사이. 너무 낮으면 No available cache blocks 에러가 납니다. 그때는 0.05씩 올려가며 맞춥니다.
  • --max-num-seqs: 값이 커질수록 KV cache 요구량이 선형으로 늘어납니다. (예시: 16 → 약 32 GB, 64 → 약 128 GB, 128 → 약 256 GB — 모델·컨텍스트 길이에 따라 달라짐)
  • --tensor-parallel-size: GPU를 늘려도 성능이 선형으로 늘지 않습니다. All-Reduce / Reduce-Scatter 같은 GPU 간 통신이 병목이 되면 오히려 손해입니다.
  • --enable-prefix-caching: 공통 System Prompt나 긴 지시문을 반복 사용하는 서비스에서 효과가 매우 큽니다.
  • --enable-chunked-prefill: 누군가 8K 프롬프트를 던졌을 때 다른 사람 화면이 얼어붙지 않게 하는 옵션.

측정 지표 체크리스트: TTFT, ITL, TPS, RPS, GPU Utilization, KV Cache Hit Rate, OOM 발생 여부, Queue Time, Network I/O


7장. 멀티 모델 서빙 아키텍처

세 줄 요약
1. 단일 모델 서빙 인스턴스를 부품으로 취급하고, 그 위에 라우팅 계층을 얹은 구조다.
2. 핵심 부품은 셋 — 서비스 그룹(A), 라우팅 로직(B), 프로비저닝 서비스(C).
3. 라우팅 맵이 이 구조의 심장이다. 모델이 뜨고 내려갈 때마다 갱신된다.

[그림 8] 멀티 모델 서빙 (조작 포인트: 모델 수와 인스턴스 수를 각각 늘려보기)

                                          ┌─────────────────────────┐
                          Model A request │ Single model instance   │
                        ┌────────────────▶│ ─ Model A service group │
                        │                 └─────────────────────────┘
  ┌────────────┐  (B)   │                 ┌─────────────────────────┐
  │            │  ┌───────────────┐       │ Single model instance   │
  │ Customer   │─▶│ Model service │──────▶│ ─ Model B service group │  (A)
  │   app      │  │ API + routing │       └─────────────────────────┘
  │            │  └───────┬───────┘       ┌─────────────────────────┐
  └─────┬──────┘          │         ┌────▶│ Single model instance   │
        │                 ▼         │     │ ─ Model C service group │
        │        ┌─────────────────┐│     └─────────────────────────┘
        │        │  Routing map    ││                  ▲
        │        │  A → group 1    ││                  │
        │        │  B → group 2    ││                  │ 모델별 서비스 그룹
        │        │  C → group 3    ││                  │ 프로비저닝
        │        └────────▲────────┘│                  │
        │                 │ update  │                  │
        │  provision      │         │                  │
        └────────────────▶┌─────────┴──────┐───────────┘
                          │ Model provision│  (C)
                          │    service     │
                          └────────────────┘
부품하는 일
(A) Model X service group모델 하나당 단일 모델 서빙 인스턴스 여러 개. 3~6장에서 만든 게 여기 한 칸에 들어감
(B) Model service API + routing logic예측 요청을 받아 라우팅 맵을 보고 해당 그룹으로 전달
Routing map모델 ↔ 서비스 그룹 대응표. stateful하고, 여기가 틀리면 전부 틀림
(C) Model provision service모델 배포 요청을 받아 서비스 그룹을 띄우고 라우팅 맵을 갱신

여기서 놓치기 쉬운 점: 이 그림은 "모델마다 전용 그룹"을 가정합니다. 즉 가장 비싸고 가장 안전한 배치입니다. 실무에서 비용이 문제가 되면 이 전제를 깨야 하고, 그 깨는 방법이 Q3의 답입니다.


마지막 3문제 답변

Q1. 왜 별도 프로세스/컴포넌트로 분리하는가? GPU와 CPU를 격리하는 이유는?

컴포넌트 분리와 프로세스 분리는 이유가 다릅니다. 섞어서 답하면 핵심을 놓칩니다.

컴포넌트 분리 (같은 프로세스 안)

정책이 바뀌는 지점을 한 곳에 몰아두기 위해서입니다. 배칭 전략을 정적 → continuous로 바꾸려면 WorkloadManager만 고치면 되고, API 스펙이나 worker 코드는 안 건드립니다. 부수 효과로 GPU 없이 큐 로직만 단위 테스트할 수 있게 됩니다.

프로세스 분리 (worker만) — 이유 3개

1. GIL 회피.
forward pass 중에는 파이썬 인터프리터가 물려 있습니다. 같은 프로세스에 있으면 GPU가 도는 동안 asyncio 이벤트 루프가 멈춰서, 다른 요청의 HTTP 응답과 스트리밍 청크 전송이 전부 지연됩니다. TTFT가 무너지는 전형적 원인.

2. 크래시 격리 — 이게 가장 큽니다.
CUDA OOM이나 illegal memory access가 나면 CUDA context가 복구 불가능합니다. 파이썬 예외를 잡아도 그 프로세스의 GPU는 사실상 죽은 상태라, 유일한 복구 수단이 프로세스 재시작입니다. worker만 죽이고 다시 띄우면 API 프로세스는 살아남아 503을 반환하고 헬스체크에 응답할 수 있습니다.

3. 확장 경로.
CUDA context는 프로세스 단위입니다. 나중에 tensor parallel로 가면 rank당 1프로세스가 자연스러운 형태고, IPC 구조를 미리 갖춰뒀으면 그대로 늘리면 됩니다.

CPU/GPU 격리의 본질

GPU를 놀리지 않는 것입니다. GPU는 시간당 비용이 압도적으로 비싼 자원이고, tokenize · HTTP 파싱 · JSON 직렬화 · detokenize는 전부 CPU 일입니다. 이걸 GPU 스텝 사이에 끼워 넣으면 GPU bubble(연산 없이 대기하는 구간)이 생깁니다. 5장 실측표의 "순차 방식 GPU Util 22%"가 정확히 이 상태입니다.

분리하면 GPU가 배치 N을 돌리는 동안 CPU가 배치 N+1을 준비하는 오버랩이 가능해집니다.

단, 공짜는 아닙니다. multiprocessing.Queue는 pickle 직렬화를 타서 텐서를 주고받으면 비쌉니다. vLLM이 ZMQ + shared memory를 쓰는 이유가 이겁니다. 이 데모는 그 비용을 감수하고 구조만 보여주는 쪽을 택했습니다.


Q2. 배칭과 스트리밍은 각각 뭘 해결하고, 어떻게 공존하는가?

해결하는 문제가 정반대다

해결 대상원리
batching처리량 (throughput)decode는 memory-bound. batch를 키워도 가중치는 한 번만 읽음 → 읽기 비용을 N개가 분담
streaming체감 지연 (TTFT)총 완료 시간은 그대로여도, 첫 token부터 흘리면 체감이 완전히 달라짐

충돌 지점

배칭은 모으려고 기다립니다 → 큐 대기가 TTFT를 늘립니다. 스트리밍은 즉시성을 원합니다.
정적 배칭에선 여기에 더해, 짧은 요청이 같은 배치의 긴 요청이 끝날 때까지 슬롯에 묶여 tail latency가 터집니다.

공존의 답: 배치 단위를 "요청"이 아니라 "iteration"으로 바꾼다

이게 continuous batching (iteration-level scheduling) 입니다. 매 decode 스텝마다 스케줄러가 다시 판단합니다.

  • 끝난 sequence는 즉시 배치에서 빼고 대기 요청을 그 자리에 투입
  • 모든 활성 sequence는 매 스텝마다 자기 token을 1개씩 받음

여기서 스트리밍이 부산물로 저절로 성립합니다. 원문 그림 3-4의 그 문장 그대로 — "배치를 실행해도 prompt마다 새 token은 딱 하나씩 나온다." 그 하나를 EventQueue로 흘려보내면 그게 스트리밍입니다. 배칭과 스트리밍이 같은 루프에서 나온다는 게 이 구조의 핵심입니다.

여기에 두 가지가 더 붙습니다.

  • PagedAttention — 길이가 제각각인 KV cache를 고정 크기 블록으로 쪼개 관리. 서로 다른 길이의 sequence를 한 배치에 자유롭게 넣고 뺄 수 있게 하는 물리적 전제조건.
  • chunked prefill (--enable-chunked-prefill) — 긴 프롬프트의 prefill을 조각내서, 진행 중인 decode 스트림이 그동안 멈추지 않게 함.

이 데모 코드는 공존을 못 한다

worker 1개 + 공유 큐 + 정적 배칭 + 배치 밖 별도 스트리밍 경로 — 이건 공존이 아니라 번갈아 하기입니다. 5장 설계 포인트 ③④가 그 사실을 정확히 짚고 있고, /generate_vllm을 옆에 둔 이유가 이것입니다.

측정할 땐 지표를 반드시 쪼개세요. batch 크기를 키우면 throughput↑, TTFT↑, ITL↑이 동시에 일어납니다. 한 숫자로는 절대 안 보입니다.


Q3. 멀티 모델 서빙의 리소스 관리와 트레이드오프

병목은 VRAM 하나입니다. 본질적으로 캐시 관리 문제(OS 페이지 캐시와 같은 구조)이고, Model provision service가 그 캐시 관리자 역할을 합니다.

비용 계층부터 알아야 판단이 선다

[그림 9] 모델 가중치 위치별 전환 비용 (7B fp16 ≈ 14GB 기준)

디스크(NVMe) ──수십 초──▶ CPU RAM ──1초 안팎──▶ VRAM
                              ↑ 여기가 핵심 트릭
                        host RAM에 warm cache를 두면
                        cold start가 수십 초 → 1초대로 떨어짐

전략 4가지의 위치

전략비용cold start격리
모델당 전용 인스턴스 (그림 8의 기본형)최악없음완벽
시분할 + VRAM 스왑좋음수 초~수십 초중간
LoRA multi-adapter최고거의 없음약함
MIG / MPS 분할중간없음HW 수준

LoRA multi-adapter가 가장 자주 정답입니다. base 모델 가중치는 한 벌만 VRAM에 두고 adapter(수십~수백 MB)만 갈아 끼웁니다. 파인튜닝 변종 여러 개를 서빙하는 케이스라면 다른 전략을 검토할 이유가 별로 없습니다. 단, base 모델이 서로 다르면 못 씁니다.

eviction 정책 설계 시 반드시 챙길 것

  • LRU + refcount — 진행 중인 요청이 있는 모델은 절대 제거 금지
  • thrashing 방어 — 두 모델이 번갈아 요청되면 로딩만 반복하다 끝납니다. 최소 체류 시간(min residency)이나 hysteresis 필요
  • KV cache pool이 진짜 비용입니다 — 모델을 여러 개 올리면 각자 KV 블록 풀을 예약해야 하고 파편화됩니다. 가중치 14GB보다 이쪽이 더 아플 때가 많습니다. (6장 --gpu-memory-utilization 그림에서 KV Cache가 "남는 만큼" 차지하는 구조를 떠올리면 이해가 빠릅니다)
  • 라우팅 맵 일관성 — 모델을 내리는 순간과 라우팅 맵 갱신 사이에 틈이 있으면 이미 내려간 그룹으로 요청이 갑니다. drain → 맵 갱신 → 종료 순서가 강제되어야 합니다

트레이드오프의 실체

  • 비용 최적화는 GPU 유휴 시간을 없애라고 함 → 모델을 촘촘히 얹고 자주 스왑
  • 지연시간 최적화는 cold start를 없애라고 함 → 모델을 상시 pin
  • 확장성은 인스턴스를 독립적으로 늘리라고 함 → 모델 간 자원 공유를 포기

실무 판단 기준: 트래픽 분포로 head/tail을 가른다

  • head 모델(요청률 높음): 상시 pin. 평균 유휴 시간이 로딩 시간보다 짧으면 스왑은 무조건 손해
  • tail 모델(가끔 호출): on-demand 로딩 + host RAM warm cache. scale-to-zero 허용
  • 판단식: 평균 유휴 간격 > 로딩 시간 ÷ 감내 가능한 p99 손실 이면 스왑이 이긴다

그리고 SLO를 모델별로 다르게 거세요. 대화형은 TTFT SLO, 배치 작업은 throughput SLO. 하나의 지표로 전부 관리하려는 순간 가장 비싼 쪽에 맞춰지고 비용이 새어 나갑니다.


부록. 이 장의 한 줄 요약

이 디렉토리는 제대로 된 서빙 시스템에 필요한 모든 구성요소(큐잉 / 배칭 / 프로세스 격리 / 스트리밍)를 최소 구현으로 보여주되, 의도적으로 비효율적인 경로(KV cache 없는 스트리밍, 정적 배칭)를 vLLM 경로와 나란히 두어 왜 프레임워크가 필요한지 체감시키는 교육용 레퍼런스입니다.

참고 링크

profile
GPU 인프라 / MLOps 엔지니어 오수진입니다. 쿠버네티스 기반 GPU 스케줄링(HAMi·KAI·Volcano)과 LLM 서빙 인프라를 다룹니다.

0개의 댓글