LabHub

블로그

LLM 서빙 최적화 완전 가이드: KV Cache, PagedAttention, 양자화의 모든 것

한국어English日本語

이 글은 LLM 서빙 최적화 기술 전체를 한 번에 훑는 지도입니다. 각 기술이 원래 어떤 문제를 풀려고 나왔는지, 그리고 그 문제가 실제 배포에서 어떤 증상으로 나타나는지를 함께 봅니다.

vLLM 하나만 더 깊이 파고들고 싶다면 이 블로그의 vLLM 내부 구조 시리즈를 먼저 보시길 권합니다. 그쪽은 스케줄러, 선점, 접두사 캐싱, 길이 한도를 공식 문서와 소스 기준으로 하나씩 확인해 정리한 글입니다. 이 글에 나오는 vLLM 설정값 중 버전에 따라 달라진 것들은 아래 절에서 따로 짚었습니다.

LLM 추론의 두 단계: Prefill과 Decode

LLM이 텍스트를 생성하는 과정은 근본적으로 다른 두 단계로 나뉜다. 이 차이를 이해하지 않으면 최적화가 불가능하다.

Phase 1: PREFILL (입력 처리)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
입력: "프랑스의 수도는 어디인가요?"
       └─ 15개 토큰 동시 처리

동작:
  - 모든 입력 토큰을 병렬로 처리 (행렬 곱셈!)
  - 각 토큰의 Q, K, V 계산
  - KV Cache 생성 (재사용할 K, V 저장)
  - 첫 번째 출력 토큰 생성

특성:
  - GPU 연산: COMPUTE-BOUND (행렬 × 행렬)
  - GPU 활용률: HIGH  - 대기 시간 결정 요인: TTFT (Time To First Token)

Phase 2: DECODE (토큰 생성)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
생성: "파리""는""입니다""."...

동작:
  - 한 번에 토큰 1개씩 순차 생성
  - 새 토큰의 Q 계산 후 캐시된 K, V와 어텐션
  - 모든 모델 가중치를 토큰마다 읽어야 함

특성:
  - GPU 연산: MEMORY-BOUND (행렬 × 벡터)
  - GPU 활용률: LOW (종종 5~20%!)
  - 처리량 결정 요인: TBT (Time Between Tokens)

이것이 LLM 서빙 최적화가 어려운 근본 이유:
두 단계가 서로 다른 병목을 가짐!

실제 수치로 확인해 보자:

import torch
import time
from transformers import AutoModelForCausalLM, AutoTokenizer

def measure_llm_phases():
    model_name = "meta-llama/Llama-3.2-1B"
    model = AutoModelForCausalLM.from_pretrained(
        model_name, torch_dtype=torch.float16, device_map="cuda"
    )
    tokenizer = AutoTokenizer.from_pretrained(model_name)

    prompt = "Explain quantum computing in simple terms:"
    inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
    input_len = inputs["input_ids"].shape[1]

    # Prefill 시간 측정
    torch.cuda.synchronize()
    t_start = time.perf_counter()
    with torch.no_grad():
        outputs = model(**inputs)  # 입력 처리만
    torch.cuda.synchronize()
    t_prefill = time.perf_counter() - t_start

    # Decode 시간 측정 (토큰 하나씩)
    t_start = time.perf_counter()
    with torch.no_grad():
        generated = model.generate(
            inputs["input_ids"],
            max_new_tokens=50,
            do_sample=False
        )
    torch.cuda.synchronize()
    t_total = time.perf_counter() - t_start

    t_decode = t_total - t_prefill
    num_new_tokens = generated.shape[1] - input_len

    print(f"입력 토큰 수: {input_len}")
    print(f"Prefill 시간: {t_prefill*1000:.1f}ms (TTFT)")
    print(f"생성 토큰 수: {num_new_tokens}")
    print(f"Decode 시간: {t_decode*1000:.1f}ms")
    print(f"토큰당 시간: {t_decode/num_new_tokens*1000:.1f}ms/token")
    # Llama-1B on H100 기준:
    # Prefill: ~5ms (입력 길이와 선형)
    # Decode: ~3ms/token (모델 크기와 비례, 배치에 의존)

이 측정에서 정말로 확인해야 할 것은 절대 숫자가 아니라 두 단계의 성질 차이입니다. 위 코드를 입력 길이만 바꿔 가며 몇 번 돌려 보면 프리필 시간은 입력 토큰 수에 대략 비례해 늘어나는 반면, 토큰당 디코드 시간은 거의 변하지 않습니다. 프리필은 행렬끼리 곱하는 연산이라 GPU의 연산 유닛이 포화되는 구간이고, 디코드는 토큰 하나를 만들자고 모델 가중치 전체를 메모리에서 한 번씩 읽어 오는 구간이기 때문입니다.

여기서 배칭이 왜 그렇게 큰 효과를 내는지가 따라 나옵니다. 디코드 단계에서 가중치를 읽어 오는 비용은 배치에 몇 개의 요청이 들어 있든 똑같습니다. 한 번 읽어 온 가중치로 요청 1개를 처리하나 64개를 처리하나 메모리에서 퍼 오는 양은 같다는 뜻입니다. 그래서 배치를 키우면 요청당 비용이 거의 그대로 나뉘어 떨어집니다. 배치가 커질수록 디코드는 메모리 병목에서 연산 병목 쪽으로 조금씩 이동하고, 그 지점에 도달하면 배치를 더 키워도 처리량이 늘지 않습니다.

문제는 배치를 키우는 데 한계를 만드는 것이 GPU의 연산 능력이 아니라 KV 캐시 메모리라는 점입니다. 아래에서 볼 모든 기술은 결국 이 한 문장에서 파생됩니다.


KV Cache: 어텐션의 메모리 딜레마

KV Cache 없으면 어떻게 되나?

KV Cache 없는 자기회귀 생성:

Step 1: 입력 ["안녕", "하세요"]"저는" 생성
  - "안녕": Q1,K1,V1 계산
  - "하세요": Q2,K2,V2 계산
  - "저는": Q3,K3,V3 계산
  - 어텐션: Q3 × [K1,K2,K3]^T
  연산량: 3² = 9 번의 내적

Step 2: ["안녕","하세요","저는"]"AI" 생성
  - 모든 토큰 처음부터 다시 계산!
  - "안녕": Q1,K1,V1 다시 계산 (낭비!)
  - "하세요": Q2,K2,V2 다시 계산 (낭비!)
  - "저는": Q3,K3,V3 다시 계산 (낭비!)
  연산량: 4² = 16 번의 내적

N번째 토큰: O(N²) 연산
전체 생성 (L 토큰): O(L³) 연산
100 토큰 생성: 1백만 번의 내적 연산
1000 토큰 생성: 10억 번의 내적 연산

KV Cache: 이전 계산 결과 재사용

import torch
import torch.nn as nn
import math

class AttentionWithKVCache(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.d_k = d_model // n_heads
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        self.W_o = nn.Linear(d_model, d_model)

        # KV Cache 저장소
        self.k_cache = None  # (batch, n_heads, seq_len, d_k)
        self.v_cache = None  # (batch, n_heads, seq_len, d_k)

    def forward(self, x, use_cache=True):
        batch, seq, d = x.shape

        # 새 Q, K, V 계산
        Q = self.W_q(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)
        K = self.W_k(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)
        V = self.W_v(x).view(batch, seq, self.n_heads, self.d_k).transpose(1, 2)

        if use_cache and self.k_cache is not None:
            # 캐시에 새 K, V 추가
            K = torch.cat([self.k_cache, K], dim=2)  # (batch, heads, past+new, d_k)
            V = torch.cat([self.v_cache, V], dim=2)

        if use_cache:
            self.k_cache = K.detach()  # 캐시 업데이트
            self.v_cache = V.detach()

        # Q는 현재 토큰만, K/V는 전체 시퀀스
        scale = math.sqrt(self.d_k)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / scale
        weights = torch.softmax(scores, dim=-1)
        output = torch.matmul(weights, V)

        return output.transpose(1, 2).contiguous().view(batch, seq, d)


# KV Cache 크기 계산
def calculate_kv_cache_size(
    seq_len, n_layers, n_heads, head_dim, batch_size, dtype_bytes=2
):
    """
    KV Cache 메모리 사용량 (bytes)
    2: K와 V 두 개
    """
    size = (2 * n_layers * n_heads * head_dim * seq_len * batch_size * dtype_bytes)
    return size


# Llama 3.1 70B 기준:
size = calculate_kv_cache_size(
    seq_len=4096,
    n_layers=80,
    n_heads=8,           # GQA로 줄어든 KV heads
    head_dim=128,
    batch_size=1,
    dtype_bytes=2        # FP16
)
print(f"KV Cache (Llama 70B, seq=4096, batch=1): {size/1e9:.1f} GB")
# 결과: ~6.7 GB per 요청!
# 배치 32: ~214 GB → H100 80GB에 들어가지 않음!
# → KV Cache 메모리 관리가 핵심 문제

이 계산식에서 실무적으로 가장 중요한 인자는 n_heads입니다. 위 예시에 8이 들어간 것은 Llama 3.1 70B가 GQA(Grouped Query Attention)를 쓰기 때문입니다. 쿼리 헤드는 64개지만 키와 값 헤드는 8개만 두고 8개의 쿼리 헤드가 하나의 KV 헤드를 공유하는 구조입니다. 만약 이 모델이 옛날 방식대로 헤드마다 별도의 K와 V를 가졌다면 같은 식에 64가 들어가고, KV 캐시는 요청당 6.7GB가 아니라 54GB가 됩니다. 최근 모델들이 거의 예외 없이 GQA나 MQA를 채택한 이유가 이것입니다. 서빙 시점의 메모리 예산이 곧 동시 처리 가능한 요청 수이고, 그것이 곧 단가이기 때문입니다.

배포를 준비할 때는 이 식을 거꾸로 쓰는 편이 실용적입니다. 먼저 GPU 총 메모리에서 모델 가중치가 차지하는 몫을 빼서 KV 캐시에 남는 용량을 구합니다. 그 용량을 위 식의 요청당 캐시 크기로 나누면 동시에 살아 있을 수 있는 요청 수의 상한이 나옵니다. 이 값이 목표 동시 접속 수보다 작다면 아무리 스케줄러를 잘 튜닝해도 그 수치는 나오지 않습니다. 그때는 양자화로 가중치를 줄이거나, 최대 길이를 줄이거나, GPU를 늘리는 것 외에 방법이 없습니다. 순서를 바꿔 말하면, 튜닝으로 풀 수 있는 문제와 하드웨어를 더 써야 풀리는 문제는 이 나눗셈 한 번으로 구분됩니다.

한 가지 더. 캐시 크기는 시퀀스 길이에 정확히 비례합니다. 최대 길이를 32768로 열어 둔 서비스에서 실제 요청의 중앙값이 1200 토큰이라면, 최악을 가정한 용량 계획은 실제 필요량의 스무 배가 넘게 잡힙니다. 최대 길이를 실제 워크로드에 맞게 줄이는 것이 대개 가장 값싼 최적화인 이유입니다.

KV Cache 메모리 단편화 문제

전통적인 KV Cache 할당 방식:

요청이 도착하면 max_seq_len만큼 미리 할당:
┌───────────────────────────────────────────────────────┐
Request 1: seq_len=100  (max=512 할당)│ ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░         │
[사용: 100] [낭비: 412 슬롯 = 80%!]├───────────────────────────────────────────────────────┤
Request 2: seq_len=50   (max=512 할당)│ ██████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░         │
[사용: 50]  [낭비: 462 슬롯 = 90%!]├───────────────────────────────────────────────────────┤
Request 3: seq_len=300  (max=512 할당)│ █████████████████████████████████████░░░░░░░░         │
[사용: 300] [낭비: 212 슬롯 = 41%!]└───────────────────────────────────────────────────────┘

총 할당: 3 × 512 = 1536 슬롯
총 사용: 450 슬롯
낭비: 1086 슬롯 = 70%!

+ 내부 단편화: 할당은 되었으나 사용 안 됨
+ 외부 단편화: 작은 요청들 사이의 빈 공간

PagedAttention (vLLM): 가상 메모리가 LLM을 구하다

핵심 아이디어: OS 가상 메모리의 LLM 적용

Kwon et al. (UC Berkeley, 2023)의 통찰: "OS가 수십 년 전에 메모리 단편화 문제를 해결했잖아. 그 방법을 KV Cache에 적용하자!"

OS 가상 메모리의 교훈:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
가상 주소 → 페이지 테이블 → 물리 주소
프로세스는 연속된 가상 주소를 봄
실제 물리 메모리는 불연속할 수 있음
→ 단편화 없음, 효율적 메모리 사용

PagedAttention 아이디어:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
가상 KV 슬롯 → 블록 테이블 → 물리 블록
시퀀스는 연속된 가상 슬롯을 봄
실제 GPU 메모리의 블록은 불연속할 수 있음
→ 단편화 없음, 효율적 GPU 메모리 사용
PagedAttention 메모리 레이아웃:

물리 메모리를 고정 크기 블록으로 분할 (기본: 16 토큰):
┌─────────────────────────────────────────────────────────┐
│                  물리 KV Cache 블록                     │
Block 0: [tok0~15]   Block 1: [tok16~31]Block 2: [tok32~47]  Block 3: [tok48~63]Block 4: [tok64~79]  Block 5: FREEBlock 6: FREE        Block 7: FREE└─────────────────────────────────────────────────────────┘

블록 테이블 (페이지 테이블과 동일한 역할):
┌──────────┬──────────────────────────────────────────────┐
│ 요청 ID  │ 가상 블록 → 물리 블록 매핑                   │
├──────────┼──────────────────────────────────────────────┤
Req 1    │ virt[0]→phys[0], virt[1]→phys[2] (토큰 0~15: 블록0, 토큰 32~47: 블록2)├──────────┼──────────────────────────────────────────────┤
Req 2    │ virt[0]→phys[1], virt[1]→phys[3] (토큰 0~15: 블록1, 토큰 16~31: 블록3)└──────────┴──────────────────────────────────────────────┘

핵심:
- 블록은 필요할 때 ON-DEMAND로 할당!
- 시퀀스 길이가 늘어날수록 블록 추가
- 단편화 < 1 블록 = 최대 16 토큰 (사실상 없음)
- 블록 공유 가능! (프롬프트 공유 시)
# vLLM을 이용한 PagedAttention 서빙:
from vllm import LLM, SamplingParams
import time

def benchmark_vllm():
    # 모델 로드 (PagedAttention 자동 사용)
    llm = LLM(
        model="meta-llama/Llama-3.2-8B-Instruct",
        gpu_memory_utilization=0.9,    # GPU 메모리의 90% 사용
        max_model_len=8192,            # 최대 시퀀스 길이
        block_size=16,                 # PagedAttention 블록 크기
        max_num_seqs=256,              # 최대 동시 요청 수
    )

    # 다양한 길이의 요청 생성
    prompts = [
        "짧은 질문: 파이썬이란?",
        "중간 길이 질문: " + "딥러닝의 역사에 대해 자세히 설명해주세요. " * 5,
        "긴 질문: " + "트랜스포머 아키텍처를 처음부터 구현하는 방법은? " * 10,
    ] * 20  # 60개 요청

    params = SamplingParams(
        temperature=0.0,    # 그리디 디코딩
        max_tokens=100
    )

    start = time.perf_counter()
    outputs = llm.generate(prompts, params)
    elapsed = time.perf_counter() - start

    total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
    print(f"요청 수: {len(prompts)}")
    print(f"총 생성 토큰: {total_tokens}")
    print(f"처리 시간: {elapsed:.1f}s")
    print(f"처리량: {total_tokens/elapsed:.0f} tokens/s")


# vLLM의 메모리 효율:
# 전통 방식: GPU 메모리의 20-40%만 실제 KV Cache에 사용
# PagedAttention: GPU 메모리의 >95% 실제 KV Cache에 사용
# 결과: 동일 GPU에서 2-3배 더 많은 요청 처리 가능!

Prefix Caching: 공통 프롬프트 공유

PagedAttention의 보너스: 여러 요청이 같은 프롬프트를 공유하면 블록을 재사용할 수 있다!

# vLLM Prefix Caching 설정:
llm = LLM(
    model="meta-llama/Llama-3.2-8B-Instruct",
    enable_prefix_caching=True,   # 공통 프리픽스 KV 캐시 공유
)

# System 프롬프트가 같은 여러 요청:
system_prompt = "당신은 도움이 되는 AI 어시스턴트입니다. " * 50  # 긴 시스템 프롬프트

requests = [
    system_prompt + "질문1: 파이썬이란?",
    system_prompt + "질문2: 자바스크립트란?",
    system_prompt + "질문3: Go 언어란?",
]

# system_prompt 부분의 KV Cache가 3개 요청에서 공유됨!
# system_prompt 처리: 1번만 실행 (3번이 아님)
# Prefill 비용: 3 → 1 (3배 절약!)

연속 배칭(Continuous Batching): 처리량 극대화

정적 배칭의 문제

정적 배칭 (Static Batching):

GPU가 처리하는 배치:
Step 1:  [Req1: 진행중] [Req2: 진행중] [Req3: 진행중]
Step 2:  [Req1: 진행중] [Req2: 완료!] [Req3: 진행중]
Step 3:  [Req1: 진행중] [     대기    ] [Req3: 진행중]GPU 낭비!
Step 4:  [Req1: 완료!] [     대기    ] [Req3: 진행중]GPU 낭비!
Step 5:  [     대기    ] [     대기    ] [Req3: 완료!]GPU 낭비!

새 요청은 배치 전체가 완료될 때까지 대기
GPU 낭비율: 50% 이상

연속 배칭: 매 스텝마다 동적 스케줄링

연속 배칭 (Continuous Batching / Iteration-level Scheduling):

GPU가 처리하는 배치 (매 토큰 생성 후 재조정):
Step 1:  [Req1] [Req2] [Req3]
Step 2:  [Req1] [Req2] [Req3]
Step 3:  [Req1] [Req4] [Req3]Req2 완료 즉시 Req4 삽입!
Step 4:  [Req5] [Req4] [Req3]Req1 완료 즉시 Req5 삽입!
Step 5:  [Req5] [Req4] [Req6]Req3 완료 즉시 Req6 삽입!

GPU는 항상 최대 활용!
처리량 향상: 정적 배칭 대비 2-4
# vLLM의 연속 배칭 내부 구조 (간략화):
from vllm.engine.async_llm_engine import AsyncLLMEngine
from vllm.engine.arg_utils import AsyncEngineArgs
import asyncio

async def serve_with_continuous_batching():
    engine = AsyncLLMEngine.from_engine_args(
        AsyncEngineArgs(
            model="meta-llama/Llama-3.2-8B-Instruct",
            max_num_seqs=256,          # 최대 동시 시퀀스
            max_num_batched_tokens=8192,  # 배치당 최대 토큰
        )
    )

    # 비동기로 여러 요청 동시 처리
    async def generate(prompt, request_id):
        async for output in engine.generate(prompt, request_id=request_id):
            if output.finished:
                return output.outputs[0].text

    # 요청들이 비동기로 처리됨 (연속 배칭 자동 적용)
    results = await asyncio.gather(
        generate("질문1: ...", "req_1"),
        generate("질문2: ...", "req_2"),
        generate("질문3: ...", "req_3"),
    )
    return results

양자화(Quantization): 정밀도를 트레이드오프하여 속도와 메모리 절약

왜 양자화인가?

LLM 메모리 사용량 (FP16 기준):
  Llama 3.1 8B:   16 GB
  Llama 3.1 70B:  140 GB
  Llama 3.1 405B: 810 GB

일반 GPU 메모리:
  RTX 4090:  24 GB  → 8B 모델도 빡빡
  A100 80GB:         → 70B 불가
  H100 80GB:         → 70B 불가 (단일 GPU)
  H100 8×80GB:       → 70B 가능, 405B 불가

양자화로 메모리 절약:
  FP16 (16비트): 기준선
  INT8  (8비트):  50% 절약, ~1% 성능 손실
  INT4  (4비트):  75% 절약, ~2-3% 성능 손실
  INT3  (3비트):  81% 절약, 성능 저하 주의
  INT2  (2비트):  88% 절약, 보통 사용 불가 수준

Post-Training Quantization (PTQ): INT8

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

# INT8 양자화 (LLM.int8() - Dettmers et al., 2022):
config_int8 = BitsAndBytesConfig(
    load_in_8bit=True,
    # 선택적: 특정 레이어는 FP16 유지 (정확도 중요한 레이어)
    llm_int8_skip_modules=["lm_head"],
)

model_int8 = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-3.1-70B-Instruct",
    quantization_config=config_int8,
    device_map="auto",    # 자동으로 GPU에 분산
)

# INT8의 핵심 혁신 (LLM.int8()):
# 문제: activation에 이상값(outlier)이 있으면 양자화 품질 저하
# 해결: 이상값이 있는 열은 FP16으로 유지, 나머지만 INT8
# → "혼합 정밀도 분해(Mixed-precision decomposition)"

4비트 양자화: NF4와 GPTQ

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

# NF4 양자화 (QLoRA에서 도입, Dettmers et al., 2023):
config_4bit = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,   # 계산은 FP16으로
    bnb_4bit_quant_type="nf4",              # NormalFloat4 형식
    bnb_4bit_use_double_quant=True,         # 양자화 스케일도 양자화!
)

# NF4란?
# 신경망 가중치는 대략 정규분포를 따름
# 정규분포에 최적화된 4비트 코드북
# 각 코드가 정규분포 상의 동일 확률 구간을 커버
# → 일반 INT4보다 1-2% 정확도 향상

# Double quantization:
# 가중치 양자화 스케일 (FP32): 4096 파라미터당 1개
# 이 스케일 자체도 INT8로 양자화
# → 추가 0.5비트 절약


# GPTQ (Frantar et al., 2022):
# 레이어별 최적화 기반 양자화
# Hessian 행렬을 이용해 양자화 오차 최소화
from auto_gptq import AutoGPTQForCausalLM

model_gptq = AutoGPTQForCausalLM.from_quantized(
    "TheBloke/Llama-2-70B-GPTQ",    # 이미 양자화된 모델
    device="cuda:0",
    use_triton=True,                  # Triton 커널 사용 (더 빠름)
)
# GPTQ는 PTQ 중 정확도가 가장 높은 편

AWQ: 활성화를 고려한 가중치 양자화

# AWQ (Lin et al., 2023) 핵심 통찰:
# 모든 가중치가 동등하게 중요하지 않다!
# 1%의 "핵심(salient)" 가중치가 큰 활성화를 생성함
# 이것들을 INT4로 양자화하면 품질이 급격히 저하

# 해결책:
# 1. 활성화 통계를 사용해 핵심 채널 찾기
# 2. 해당 채널의 가중치 스케일 조정 (per-channel scaling)
# 3. 모든 가중치를 INT4로 양자화하되, 스케일로 오차 보상

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model_path = "meta-llama/Llama-3.1-8B-Instruct"
quant_path = "llama-3.1-8b-awq"

# 양자화 실행 (calibration 데이터 필요):
model = AutoAWQForCausalLM.from_pretrained(model_path)
tokenizer = AutoTokenizer.from_pretrained(model_path)

quant_config = {
    "zero_point": True,
    "q_group_size": 128,
    "w_bit": 4,           # 4비트
    "version": "GEMM"
}

model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized(quant_path)


# AWQ vs GPTQ 비교:
# AWQ:  빠른 추론 (Triton/CUDA 커널 최적화)
#       메모리: FP16의 25%
#       정확도: GPTQ와 유사하거나 약간 낮음
# GPTQ: 더 높은 정확도 (Hessian 기반)
#       추론 속도: AWQ와 유사
#       메모리: AWQ와 동일

양자화 성능 비교표

Llama 3.1 70B 양자화 비교 (H100 단일 GPU):

┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ 형식     │ 메모리   │ 처리량   │ MMLU     │ 실용성   │
├──────────┼──────────┼──────────┼──────────┼──────────┤
FP16140 GB   │ 기준선   │ 80.9%8×H100BF16140 GB+5%80.9%8×H100INT870 GB+10%80.2%2×H100GPTQ-4b  │ 36 GB+30%79.8%1×H100AWQ-4b   │ 36 GB+35%79.5%1×H100GGUF-Q438 GBCPU 가능 │ 79.1%CPU/GPU└──────────┴──────────┴──────────┴──────────┴──────────┘

추측 디코딩(Speculative Decoding): 무료 점심은 있다

아이디어: 작은 모델이 초안을 쓰고 큰 모델이 검증

표준 디코딩:
  70B 모델이 1 토큰 생성 = 1 forward pass = 10ms
  100 토큰 = 1000ms = 1
추측 디코딩 (Leviathan et al., 2023):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1: 초안 모델(7B)K개 토큰 빠르게 생성
         "파리" "는" "프랑스의" "수도" "입니다"
         5개 토큰, 5ms (7B 모델)

Step 2: 대형 모델(70B)K+1개 토큰 한 번에 검증!
        ["파리" "는" "프랑스의" "수도" "입니다"] → 병렬 처리!
        10ms (70B 모델이지만 1번의 forward pass)

Step 3: 검증 결과:
        "파리""는""프랑스의""수도""입니다"4개 토큰 수락, 1개 토큰 거부

Step 4: 거부된 위치 이후 대형 모델이 재생성
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
결과: 10ms로 4개 토큰 생성 (표준: 40ms)
속도 향상: 2.5-4 (수락률에 따라 다름)
품질 손실: ZERO (대형 모델이 최종 검증)
# PyTorch를 이용한 추측 디코딩 구현 (개념적):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

def speculative_decode(
    target_model,    # 큰 모델 (70B)
    draft_model,     # 작은 모델 (7B)
    input_ids,
    max_new_tokens=100,
    K=5,             # 초안 토큰 수
    temperature=1.0
):
    """
    추측 디코딩 구현
    K: 초안 모델이 한 번에 생성할 토큰 수
    """
    generated = input_ids.clone()

    while generated.shape[1] < input_ids.shape[1] + max_new_tokens:
        # Phase 1: 초안 모델로 K개 토큰 생성
        draft_ids = []
        draft_probs = []

        draft_input = generated.clone()
        for _ in range(K):
            with torch.no_grad():
                draft_out = draft_model(draft_input)
                logits = draft_out.logits[:, -1, :] / temperature
                probs = torch.softmax(logits, dim=-1)
                next_token = torch.multinomial(probs, 1)
                draft_ids.append(next_token)
                draft_probs.append(probs[0, next_token[0, 0]])
                draft_input = torch.cat([draft_input, next_token], dim=1)

        # Phase 2: 대형 모델로 K+1개 위치 동시 검증
        candidate = torch.cat([generated] + draft_ids, dim=1)
        with torch.no_grad():
            target_out = target_model(candidate)
            # 각 위치에서의 확률
            target_logits = target_out.logits[:, len(generated[0])-1:-1, :]
            target_probs = torch.softmax(target_logits / temperature, dim=-1)

        # Phase 3: 수락/거부 결정
        accepted = 0
        for i, (draft_id, draft_prob, tgt_prob_dist) in enumerate(
            zip(draft_ids, draft_probs,
                target_probs[0])
        ):
            token_id = draft_id[0, 0].item()
            tgt_prob = tgt_prob_dist[token_id].item()

            # 수락 확률: min(1, p_target / p_draft)
            accept_prob = min(1.0, tgt_prob / max(draft_prob.item(), 1e-8))
            if torch.rand(1).item() < accept_prob:
                generated = torch.cat([generated, draft_id], dim=1)
                accepted += 1
            else:
                # 거부: 대형 모델의 확률로 재샘플링
                adjusted = torch.clamp(tgt_prob_dist - draft_probs[i], min=0)
                adjusted = adjusted / adjusted.sum()
                next_token = torch.multinomial(adjusted.unsqueeze(0), 1)
                generated = torch.cat([generated, next_token], dim=1)
                break

        if accepted == K:
            # 모두 수락: 대형 모델의 다음 토큰도 추가
            last_logits = target_out.logits[:, -1, :] / temperature
            last_probs = torch.softmax(last_logits, dim=-1)
            next_token = torch.multinomial(last_probs, 1)
            generated = torch.cat([generated, next_token], dim=1)

    return generated

텐서 병렬성과 파이프라인 병렬성

Tensor Parallelism: 레이어를 GPU에 분산

텐서 병렬성 (Tensor Parallelism, Shoeybi et al., 2019):

70B 모델, 8 GPU, 64 Attention Heads:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GPU 0: Head 0~7   (W_q: [d_model, 8*d_k]1/8)
GPU 1: Head 8~15
GPU 2: Head 16~23
GPU 3: Head 24~31
GPU 4: Head 32~39
GPU 5: Head 40~47
GPU 6: Head 48~55
GPU 7: Head 56~63

GPU가 독립적으로 해당 헤드 계산
마지막에 All-Reduce (GPU 결과 합산)

통신 비용:
- 어텐션 레이어당 1번의 All-Reduce
- FFN 레이어당 1번의 All-Reduce
- NVLink (H100): 900 GB/s (최신)
- PCIe: 64 GB/s (느림)
NVLink 있으면 텐서 병렬성 효율적
# Megatron-LM 스타일 텐서 병렬 어텐션:
import torch
import torch.distributed as dist

def tensor_parallel_attention(x, W_q, W_k, W_v, W_o,
                               n_heads, rank, world_size):
    """
    rank: 현재 GPU의 번호 (0 ~ world_size-1)
    world_size: 총 GPU 수
    """
    local_heads = n_heads // world_size
    local_head_dim = W_q.shape[1] // world_size

    # 각 GPU는 자신의 헤드만 계산
    # W_q는 이미 샤딩되어 있음 (각 GPU에 1/world_size 크기)
    Q_local = x @ W_q    # (batch, seq, local_heads * d_k)
    K_local = x @ W_k
    V_local = x @ W_v

    # 로컬 어텐션 계산
    Q = Q_local.view(*Q_local.shape[:-1], local_heads, local_head_dim)
    K = K_local.view(*K_local.shape[:-1], local_heads, local_head_dim)
    V = V_local.view(*V_local.shape[:-1], local_heads, local_head_dim)

    # 어텐션 계산 (FlashAttention 사용 시 더 빠름)
    attn_out = compute_attention(Q, K, V)

    # 출력 프로젝션 (W_o도 샤딩)
    out_local = attn_out.view(*attn_out.shape[:-2], -1) @ W_o

    # All-Reduce: 모든 GPU의 결과 합산
    dist.all_reduce(out_local, op=dist.ReduceOp.SUM)

    return out_local


def compute_attention(Q, K, V):
    """간단한 스케일드 내적 어텐션"""
    scale = Q.shape[-1] ** -0.5
    scores = torch.matmul(Q, K.transpose(-2, -1)) * scale
    weights = torch.softmax(scores, dim=-1)
    return torch.matmul(weights, V)

Pipeline Parallelism: 레이어를 GPU에 순서대로 배치

파이프라인 병렬성 (Pipeline Parallelism):

70B 모델, 80 레이어, 4 GPU:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GPU 0: 레이어  0~19 (임베딩 +20 트랜스포머)
GPU 1: 레이어 20~39
GPU 2: 레이어 40~59
GPU 3: 레이어 60~79 + LM 헤드

순서:
  마이크로 배치 1: GPU0GPU1GPU2GPU3
  마이크로 배치 2: GPU0GPU1GPU2GPU3
  ...

파이프라인 버블 (비효율):
GPU0 [mb1]GPU0 [mb2]GPU0 [mb3]GPU0 [mb4]GPU0 대기
  │    대기   │GPU1 [mb1]GPU1 [mb2]GPU1 [mb3]GPU1 대기
  │    대기   │    대기   │GPU2 [mb1]GPU2 [mb2]GPU2 대기
  │    대기   │    대기   │    대기   │GPU3 [mb1]GPU3 대기

마이크로 배칭으로 파이프라인 버블 감소:
버블 비율 = (p-1)/(m+p-1)
p: 파이프라인 스테이지 수, m: 마이크로 배치 수
m을 크게 할수록 효율 향상

vLLM vs TGI vs TensorRT-LLM: 주요 프레임워크 비교

LLM 서빙 프레임워크 비교 (2025년 기준):

┌────────────────┬─────────────────────────────────────────────────────┐
│ 프레임워크     │ vLLM                                                │
├────────────────┼─────────────────────────────────────────────────────┤
│ 개발처         │ UC Berkeley / vLLM team                             │
│ 핵심 기술      │ PagedAttention, Continuous Batching│ 양자화 지원    │ AWQ, GPTQ, INT8, FP8│ 최대 처리량    │ ★★★★☆ (높음)TTFT 지연      │ ★★★☆☆ (중간)│ 사용 편의성    │ ★★★★★ (매우 쉬움)│ 커스터마이즈   │ ★★★☆☆ (중간)│ 라이선스       │ Apache 2.0│ 특징           │ Python 친화적, 가장 활발한 커뮤니티                 │
└────────────────┴─────────────────────────────────────────────────────┘

┌────────────────┬─────────────────────────────────────────────────────┐
│ 프레임워크     │ TGI (Text Generation Inference)├────────────────┼─────────────────────────────────────────────────────┤
│ 개발처         │ Hugging Face│ 핵심 기술      │ Continuous Batching, Flash Attention│ 양자화 지원    │ GPTQ, AWQ, Bitsandbytes│ 최대 처리량    │ ★★★☆☆ (중간)TTFT 지연      │ ★★★☆☆ (중간)│ 사용 편의성    │ ★★★★☆ (쉬움)│ 커스터마이즈   │ ★★★★☆ (높음)│ 라이선스       │ HFOIL (상업용 주의)│ 특징           │ HF 생태계 통합, Docker 기반 배포                    │
└────────────────┴─────────────────────────────────────────────────────┘

┌────────────────┬─────────────────────────────────────────────────────┐
│ 프레임워크     │ TensorRT-LLM├────────────────┼─────────────────────────────────────────────────────┤
│ 개발처         │ NVIDIA│ 핵심 기술      │ TensorRT 최적화, In-flight Batching│ 양자화 지원    │ INT8, INT4, FP8, SmoothQuant, AWQ│ 최대 처리량    │ ★★★★★ (가장 높음, NVIDIA GPU 전용)TTFT 지연      │ ★★★★★ (최저)│ 사용 편의성    │ ★★☆☆☆ (복잡, C++ 기반)│ 커스터마이즈   │ ★★☆☆☆ (어려움)│ 라이선스       │ Apache 2.0│ 특징           │ NVIDIA GPU 최적화, 기업용 프로덕션                  │
└────────────────┴─────────────────────────────────────────────────────┘

┌────────────────┬─────────────────────────────────────────────────────┐
│ 프레임워크     │ llama.cpp / Ollama├────────────────┼─────────────────────────────────────────────────────┤
│ 개발처         │ ggerganov / Ollama Inc.                             
│ 핵심 기술      │ GGUF 양자화, CPU/GPU 하이브리드                     │
│ 양자화 지원    │ Q2~Q8 (GGUF 형식)│ 최대 처리량    │ ★★☆☆☆ (낮음, CPU 사용 시)TTFT 지연      │ ★★☆☆☆ (높음)│ 사용 편의성    │ ★★★★★ (매우 쉬움)│ 커스터마이즈   │ ★★☆☆☆ (제한적)│ 라이선스       │ MIT│ 특징           │ CPU 실행 가능, 개인/개발 환경                       │
└────────────────┴─────────────────────────────────────────────────────┘

어떤 프레임워크를 선택해야 하는가?

선택 가이드:

프로덕션 서빙, 높은 처리량이 필요:
vLLM (Python) 또는 TensorRT-LLM (NVIDIA GPU 전용)

HuggingFace 생태계와 통합 필요:
TGI

개인 실험, 로컬 실행:
Ollama (가장 쉬움)

CPU에서 실행해야 함:
  → llama.cpp 또는 Ollama

최고 성능, NVIDIA GPU 전용:
TensorRT-LLM + Triton Inference Server

실전: 프로덕션 LLM 서빙 스택

프로덕션 LLM 서빙 아키텍처:

클라이언트
로드 밸런서 (Nginx / AWS ALB)
API 게이트웨이 (FastAPI / Kong)
  │ 레이트 리미팅, 인증, 로깅
라우터 (모델 선택, 요청 큐)
  ├─→ vLLM 서버 1 (8B 모델, 빠른 응답용)
  ├─→ vLLM 서버 2 (70B 모델, 고품질용)
  └─→ vLLM 서버 3 (특수 도메인 모델)
      모니터링 (Prometheus + Grafana)
      지표: TTFT, TBT, throughput, GPU 사용률
# 프로덕션 vLLM 서버 설정:
import subprocess

# vLLM OpenAI 호환 서버 시작:
cmd = [
    "python", "-m", "vllm.entrypoints.openai.api_server",
    "--model", "meta-llama/Llama-3.1-8B-Instruct",
    "--tensor-parallel-size", "2",         # 2 GPU 텐서 병렬
    "--gpu-memory-utilization", "0.9",
    "--max-model-len", "8192",
    "--max-num-seqs", "256",               # 최대 동시 요청
    "--max-num-batched-tokens", "8192",    # 배치당 최대 토큰
    "--quantization", "awq",               # AWQ 양자화
    "--enable-prefix-caching",            # 프리픽스 캐싱 활성화
    "--block-size", "16",                  # PagedAttention 블록 크기
    "--port", "8000",
]

# 클라이언트 측:
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")

response = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[{"role": "user", "content": "파이썬의 GIL이란?"}],
    temperature=0.0,
    max_tokens=500,
)
print(response.choices[0].message.content)

이 글의 vLLM 설정값 중 버전에 따라 달라진 것들

위 예제 코드에 적힌 vLLM 설정값 몇 개는 널리 인용되는 값이지만 최신 소스의 선언 기본값과 다릅니다. 그대로 옮겨 쓰기 전에 짚고 갑니다. 아래 값들은 2026-08-12에 vLLM 공식 문서와 main 브랜치 소스를 직접 확인해 이 블로그의 vLLM 배포 튜닝 편에 정리한 것과 같은 기준입니다.

gpu_memory_utilization은 대부분의 글이 기본값을 0.9로 적지만, 확인 시점의 CacheConfig 선언 기본값은 0.92였습니다. 차이가 0.02뿐이라 사소해 보여도 80GB GPU에서는 1.6GB이고, KV 캐시로 환산하면 동시 요청 몇 개에 해당하는 양입니다. 용량 계획을 세울 때는 인용된 숫자가 아니라 사용 중인 버전에서 직접 확인한 값을 쓰세요.

enable_prefix_caching은 위 예제에서 명시적으로 켜고 있습니다. 확인 시점 기준으로는 선언 기본값이 이미 참이라 따로 켜지 않아도 동작하는 쪽이 기본입니다. 명시해서 나쁠 것은 없지만, 이 옵션을 켰더니 빨라졌다는 식의 비교는 요즘 버전에서는 성립하지 않을 수 있습니다.

max_num_batched_tokensmax_num_seqs에 위 예제는 8192와 256을 주고 있는데, main 브랜치 SchedulerConfig의 클래스 선언 기본값은 각각 2048과 128입니다. 이 값들은 실행 맥락과 환경에 따라 조정되어 적용되므로 선언값과 실제 적용값이 다를 수 있습니다. 기동 로그에서 확인하는 습관을 들이는 편이 안전합니다. 두 값이 각각 무엇을 제한하는지는 컨텍스트 윈도우와 길이 한도 편에 정리해 두었습니다.

block_size는 위 예제가 16을 명시하고 있습니다. 사용 가능한 값이 하드웨어와 어텐션 백엔드에 따라 달라지므로, 기본값을 가정하기보다 기동 로그와 사용 중인 버전의 엔진 인자 문서를 확인하세요.

무엇을 먼저 측정할 것인가

이 글에 나온 기술 목록을 위에서부터 순서대로 적용하는 것은 대부분 시간 낭비입니다. 지금 이 배포의 병목이 어디인지 먼저 보고, 거기에 해당하는 것 하나만 건드리는 편이 훨씬 빠릅니다.

vLLM OpenAI 호환 서버는 /metrics 경로에 Prometheus 형식 지표를 내보냅니다. 서버를 띄운 상태에서 다음 한 줄이면 바로 보입니다.

curl -s http://localhost:8000/metrics | grep -E 'vllm:(kv_cache_usage_perc|num_preemptions|prefix_cache)'

지표 이름과 의미는 공식 문서에서 확인한 것입니다.

vllm:kv_cache_usage_perc는 KV 캐시 사용률이고 1이 100퍼센트입니다. 이 값이 평시에 0.2 근처에서 머문다면 GPU 메모리는 남아도는데 다른 이유로 요청을 못 넣고 있다는 뜻입니다. max_num_seqs가 먼저 걸리고 있을 가능성이 큽니다. 반대로 0.95 근처에 붙어 있으면 캐시가 한계이므로 양자화나 최대 길이 축소를 봐야 합니다.

vllm:num_preemptions는 엔진이 요청을 선점한 누적 횟수입니다. 이 값이 계속 올라가고 있다면 이미 만들어 둔 KV 캐시를 버리고 나중에 다시 계산하는 낭비가 반복되는 중입니다. 처리량이 어느 순간부터 안 오르는 전형적인 원인이고, 진단과 대응 순서는 스케줄러와 선점 편에 따로 정리했습니다.

vllm:prefix_cache_hitsvllm:prefix_cache_queries는 각각 접두사 캐시가 적중한 토큰 수와 조회한 토큰 수입니다. 둘의 비율이 곧 적중률입니다. 시스템 프롬프트를 공유하는 서비스인데 이 비율이 낮다면, 프롬프트 앞쪽에 요청마다 달라지는 값을 넣고 있을 가능성이 높습니다. 타임스탬프나 사용자 이름이 시스템 프롬프트 첫머리에 있으면 그 뒤 전체가 캐시에서 어긋납니다.

지연 시간 쪽은 vllm:time_to_first_token_seconds, vllm:request_time_per_output_token_seconds, vllm:request_queue_time_seconds 세 개를 같이 봅니다. 마지막 값이 크다면 엔진이 느린 것이 아니라 큐에서 기다린 것이므로, 모델이나 커널을 건드리는 대신 동시성 설정과 인스턴스 수를 봐야 합니다. 사용자 체감이 나쁘다는 신고가 들어왔을 때 이 세 개 중 어느 것이 나쁜지에 따라 손댈 곳이 완전히 달라집니다.

부하를 직접 만들어 볼 때는 vLLM에 딸려 오는 벤치마크 CLI를 씁니다.

# 서버를 띄워 둔 상태에서 별도 셸에서 실행
vllm bench serve \
  --model meta-llama/Llama-3.1-8B-Instruct \
  --host localhost --port 8000 \
  --random-input-len 1024 \
  --random-output-len 128 \
  --num-prompts 200

vllm bench 아래에는 serve, latency, throughput 세 가지가 있습니다. 서버에 실제 요청을 흘려 보는 것은 serve이고, 나머지 둘은 서버 없이 엔진만 재는 용도입니다. 여기 적은 플래그는 공식 CLI 문서에서 확인한 것만 골랐습니다. 데이터셋 지정이나 요청 도착률 조절 같은 추가 옵션은 버전마다 이름이 달라졌으므로 vllm bench serve --help로 확인하세요.

측정할 때 흔한 실수 하나만 짚겠습니다. 입력과 출력 길이를 고정한 합성 부하는 스케줄러 입장에서 실제 트래픽보다 훨씬 다루기 쉽습니다. 길이가 제각각인 진짜 트래픽에서 나타나는 선점과 큐 대기가 합성 부하에서는 잘 재현되지 않습니다. 합성 벤치마크 수치는 설정 A와 B의 상대 비교에만 쓰고, 절대 용량 계획은 실제 트래픽 분포로 다시 재는 것이 안전합니다.

실패 사례와 진단 순서

기동은 되는데 최대 길이에서 거부당한다. 모델의 컨텍스트 윈도우가 128k라고 해서 128k 요청이 통과하지는 않습니다. KV 캐시에 담을 수 있는 토큰 수가 max_model_len보다 작으면 vLLM은 기동 단계에서 이를 알려 주고 멈춥니다. 에러 메시지가 해법까지 알려 주는 드문 경우인데, gpu_memory_utilization을 올리거나 max_model_len을 줄이라고 합니다. 실무에서는 후자가 먼저입니다. 대개 실제로 필요한 것보다 길게 열어 둔 상태이기 때문입니다.

처리량이 어느 순간 갑자기 무너진다. 동시 요청을 늘리다 보면 처리량이 완만하게 포화되는 것이 아니라 특정 지점에서 꺾여 내려가는 경우가 있습니다. 선점을 의심하고 vllm:num_preemptions를 봅니다. 값이 오르고 있다면 대응은 역설적입니다. max_num_seqs를 줄이는 쪽입니다. 동시 요청을 줄이면 처리량이 떨어질 것 같지만, 선점이 반복되는 상태는 이미 재계산으로 낭비하고 있는 상태라서 오히려 순처리량이 올라가는 경우가 많습니다.

양자화했더니 오히려 느려졌다. 메모리는 줄었는데 토큰 생성 속도가 안 나오는 경우입니다. 양자화는 가중치를 읽어 오는 양을 줄여 주지만, 연산 직전에 원래 정밀도로 되돌리는 비용이 붙습니다. 배치가 작아 메모리 병목 구간에 있을 때는 이득이 크고, 배치가 커서 이미 연산 병목 구간에 들어와 있으면 역전될 수 있습니다. 커널이 해당 하드웨어에 최적화되어 있는지도 갈립니다. 양자화 형식을 바꾸는 판단은 반드시 실제 배치 크기에서 재고 결정하세요.

추측 디코딩을 켰는데 더 느려졌다. 이 기법의 이득은 초안 모델의 수락률에 전적으로 달려 있습니다. 수락률이 낮으면 초안 생성 비용만 추가되고 버려집니다. 초안 모델이 대상 모델과 계열이 다르거나, 도메인이 특수하거나, 온도를 높게 준 샘플링이면 수락률이 떨어집니다. 그리고 배치가 큰 서버에서는 이득 자체가 줄어듭니다. 남는 연산 여유를 이용하는 기법인데 배치가 이미 그 여유를 다 쓰고 있기 때문입니다.

접두사 캐싱이 안 먹는다. 적중률이 기대보다 낮다면 프롬프트 앞부분에 요청마다 달라지는 내용이 있는지부터 봅니다. 접두사 캐싱은 말 그대로 앞에서부터 일치하는 구간까지만 재사용합니다. 첫 줄에 현재 시각을 넣는 순간 그 뒤로는 전부 다시 계산됩니다. 변하는 값은 프롬프트 뒤쪽으로 미는 것만으로 적중률이 크게 달라집니다.

언제 이 최적화를 쓰지 않나

이 글의 기술 대부분은 동시 요청이 충분히 많은 온라인 서빙을 전제합니다. 그 전제가 깨지면 이득도 같이 사라집니다.

트래픽이 드문드문한 사내 도구라면 연속 배칭도 PagedAttention도 체감되지 않습니다. 배치에 들어갈 요청 자체가 없기 때문입니다. 이런 워크로드에서 실제로 중요한 것은 처리량이 아니라 콜드 스타트와 유휴 시간의 GPU 비용입니다. 상시 띄워 둔 GPU 한 장이 하루 몇백 건을 처리하고 있다면, 서빙 스택을 튜닝하는 것보다 API 제공 모델로 옮기는 편이 총비용에서 유리한 경우가 많습니다.

밤에 한 번 도는 배치 작업이라면 지연 시간 지표는 의미가 없습니다. 이때는 오프라인 추론 인터페이스로 한 번에 다 밀어 넣는 편이 서버를 띄우는 것보다 단순하고 빠릅니다. TTFT를 줄이려고 설정을 만지는 것은 아무도 기다리지 않는 작업의 응답성을 개선하는 일입니다.

품질이 아직 검증되지 않은 단계라면 양자화는 미루세요. 양자화는 품질을 조금 내주고 용량을 얻는 거래인데, 기준선 품질을 모르는 상태에서 이 거래를 하면 나중에 품질 문제가 생겼을 때 원인이 모델인지 양자화인지 프롬프트인지 구분할 수 없게 됩니다. 순서는 항상 품질 기준선 확보가 먼저입니다.

마지막으로, 프레임워크를 바꾸는 것은 대개 마지막 수단입니다. 위 비교표는 프레임워크 사이의 차이를 보여 주지만, 실제 배포에서 나빴던 수치의 대부분은 프레임워크 선택이 아니라 길이 설정, 동시성 설정, 프롬프트 구조에서 나옵니다. 같은 프레임워크 안에서 고칠 수 있는 것을 다 고쳐 본 다음에 옮겨도 늦지 않습니다.

참고 자료

LLM 서빙 최적화는 하드웨어, 알고리즘, 시스템 소프트웨어가 교차하는 복잡한 분야다. PagedAttention이 가상 메모리 개념을 LLM에 적용했듯, 앞으로도 컴퓨터 과학의 오래된 지혜가 새로운 형태로 재발견될 것이다. 각 기술이 풀고자 하는 근본 문제를 이해하면, 새로운 최적화가 나올 때마다 빠르게 습득하고 적용할 수 있다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다