Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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 서빙 최적화가 어려운 근본 이유:

두 단계가 서로 다른 병목을 가짐!

실제 수치로 확인해 보자:

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 (모델 크기와 비례, 배치에 의존)

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: 이전 계산 결과 재사용

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 메모리 관리가 핵심 문제

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: FREE │

│ Block 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

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

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

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

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 │ 실용성 │

├──────────┼──────────┼──────────┼──────────┼──────────┤

│ FP16 │ 140 GB │ 기준선 │ 80.9% │ 8×H100 │

│ BF16 │ 140 GB │ +5% │ 80.9% │ 8×H100 │

│ INT8 │ 70 GB │ +10% │ 80.2% │ 2×H100 │

│ GPTQ-4b │ 36 GB │ +30% │ 79.8% │ 1×H100 │

│ AWQ-4b │ 36 GB │ +35% │ 79.5% │ 1×H100 │

│ GGUF-Q4 │ 38 GB │ CPU 가능 │ 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를 이용한 추측 디코딩 구현 (개념적):

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 스타일 텐서 병렬 어텐션:

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: GPU0→GPU1→GPU2→GPU3

마이크로 배치 2: GPU0→GPU1→GPU2→GPU3

...

파이프라인 버블 (비효율):

│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 서버 설정:

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)

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

현재 단락 (1/562)

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

작성 글자: 0원문 글자: 18,106작성 단락: 0/562