LLM Serving, Hardware Bottleneck và MLOps
1. Tại sao Serving LLM khác với Serving API thường?
Bạn đã quen Deploy microservice lên Kubernetes. Scale up thêm pod khi CPU cao, boom — xong. Với LLM, CPU không phải bottleneck. VRAM (GPU Memory) mới là tài nguyên giới hạn, và cách nó bị tiêu thụ hoàn toàn khác với RAM thông thường:
Traditional API server:
Request đến → Process → Return → RAM freed
100 concurrent requests → tăng replicas → linear scale
LLM Inference server:
Request đến → Load model weights (fixed, ~16GB for 8B model)
→ Allocate KV Cache (per-request, grows với context length)
→ Generate tokens (sequential, memory-intensive)
→ Return → KV Cache freed
Problem: Model weights chiếm phần lớn VRAM cố định
KV Cache của concurrent requests cạnh tranh phần còn lại
Tổng VRAM = Model Size + Σ(KV Cache per active request)
2. Hardware Deep Dive — GPU Memory Hierarchy
Hiểu hardware là hiểu tại sao các thiết kế quyết định như vậy:
A100 SXM GPU Memory Hierarchy:
─────────────────────────────────────────────────────────────────
L1 Cache (per SM): 192 KB | Bandwidth: ~20 TB/s | Latency: ~5 cycles
L2 Cache (shared): 40 MB | Bandwidth: ~5-7 TB/s | Latency: ~30 cycles
HBM2e (VRAM): 80 GB | Bandwidth: 2 TB/s | Latency: ~hundreds ns
─────────────────────────────────────────────────────────────────
Compare: RTX 4090 (consumer):
GDDR6X: 24 GB | Bandwidth: 1 TB/s | Latency: similar
─────────────────────────────────────────────────────────────────
FP16 FLOPS (compute capacity):
A100 SXM: 312 TFLOPS
RTX 4090: 82 TFLOPS (consumer, much cheaper but 1/4 the compute)
Điều này dẫn đến 2 bottleneck khác nhau tùy theo phase inference:
2.1 Prefill Phase — Compute-Bound
Prefill: Process toàn bộ input prompt một lúc
Input: [t₁, t₂, ... t₁₀₀₀] (1000 tokens)
Operation: Matrix multiplication
Q = X × W_Q (shape: [1000, 4096] × [4096, 4096])
K = X × W_K
V = X × W_V
→ Parallel operations across all 1000 tokens
GPU behavior: Tensor Cores fully utilized
Bottleneck: FLOPS (compute capacity)
Throughput metric: tokens/second for prefill
Ví dụ: Llama 3 8B trên A100
Prefill 1000 tokens: ~50ms
→ Đây là lý do Time-to-First-Token (TTFT) tăng với prompt dài
2.2 Decode Phase — Memory-Bound
Decode: Sinh từng token một (auto-regressive)
Mỗi bước: Input là MỘT token mới
Operation: Matrix × Vector (thay vì Matrix × Matrix)
Q_new = x_new × W_Q (shape: [1, 4096] × [4096, 4096])
→ Chỉ 1 row được compute
→ Tensor Cores idle vì batch quá nhỏ
→ Load toàn bộ model weights (16GB) + KV Cache từ HBM vào L2/L1
→ Bandwidth: 2 TB/s, nhưng data cần load per step rất lớn
Roofline Analysis:
Arithmetic Intensity = FLOPS / Bytes_moved
Decode: ~10 GFLOPS / 16 GB = 0.6 FLOPS/byte
A100 Compute/BW Ratio: 312 TFLOPS / 2 TB/s = 156 FLOPS/byte
0.6 << 156 → Memory-Bound, hoàn toàn
Throughput metric: Time Per Output Token (TPOT)
Optimization target: Tăng bandwidth utilization, không phải FLOPS
Practical implication: Nếu sếp hỏi tại sao GPU utilization chỉ 30% nhưng response vẫn chậm — đó là vì GPU đang đợi memory, không thiếu compute. Giải pháp là batching (gộp requests), không phải tăng GPU mạnh hơn.
3. vLLM & PagedAttention — Cuộc cách mạng trong Serving
3.1 Vấn đề Memory Fragmentation
Trước vLLM, các framework naive (Hugging Face, early Triton) allocate KV Cache theo cách:
GPU VRAM (80GB):
Model weights: [══════════════════════] 16 GB
Request 1 (max 2048 tokens): [════════] 4 GB (pre-allocated!)
Request 2 (max 2048 tokens): [════════] 4 GB (pre-allocated!)
Request 3 (max 2048 tokens): [════════] 4 GB (pre-allocated!)
Wasted (internal fragmentation):
Request 1 chỉ dùng 512 tokens thực → 75% wasted!
Request 2 dùng 1024 tokens → 50% wasted!
Total wasted: ~50-80% VRAM!
Không ai biết trước request nào sẽ dài bao nhiêu khi bắt đầu, nên framework phải pre-allocate worst-case. Kết quả: throughput thấp thảm.
3.2 PagedAttention — Ý tưởng từ OS Virtual Memory
Nhóm Berkeley nhận ra đây giống hệt bài toán External Memory Fragmentation trong OS, đã được giải quyết từ những năm 1960 bằng Paging:
OS Virtual Memory:
Physical RAM chia thành Pages nhỏ (4KB)
Process không cần RAM contiguous
Page Table map virtual address → physical page
PagedAttention (vLLM):
GPU VRAM chia thành KV Blocks nhỏ (mỗi block = 16 tokens of KV Cache)
Mỗi sequence không cần KV Cache contiguous
Block Table map logical token position → physical VRAM block
flowchart TB
subgraph B["Trước PagedAttention (fragmented)"]
BW["Weights + Req1 + Req2 + Wasted space"]
end
subgraph A["Sau PagedAttention (dynamic blocks)"]
BL["Weights + B1..B9 + Free blocks"]
T1["Req1 -> [B1, B3, B5]"]
T2["Req2 -> [B2, B4, B6, B7, B8]"]
end
B --> M["Fragmentation ~80%"]
A --> N["Fragmentation ~4%, Throughput +2-4x"]Trước PagedAttention (pre-allocated, fragmented):
┌──────────────────────────────────────────────────────┐
│ Weights │ Req1 [████░░░░] │ Req2 [████████] │ Wasted │
└──────────────────────────────────────────────────────┘
used unused fully used wasted
Sau PagedAttention (dynamic, non-contiguous):
┌──────────────────────────────────────────────────────┐
│ Weights │B1│B2│B3│B4│B5│B6│B7│B8│B9│ FREE │ │
└──────────────────────────────────────────────────────┘
Block Table:
Req1: [B1, B3, B5] (chỉ 3 blocks vì 48 tokens)
Req2: [B2, B4, B6, B7, B8] (5 blocks vì 80 tokens)
Remaining [B9, ...]: available cho requests mới
→ Fragmentation giảm từ ~80% xuống ~4%
→ Throughput tăng 2-4× với cùng hardware
3.3 Continuous Batching — Maximize GPU utilization
Naive static batching: nhóm N requests, xử lý hết, mới lấy batch mới. Problem: requests kết thúc lúc khác nhau → GPU idle chờ batch hoàn thành.
Static Batching:
Batch: [Req1 (50 tokens), Req2 (200 tokens), Req3 (30 tokens)]
After 30 tokens: Req3 done → GPU slots IDLE
After 50 tokens: Req1 done → More slots IDLE
After 200 tokens: Req2 done → Entire batch complete
GPU utilization: ~30-50% on average
Continuous Batching (vLLM):
Ngay khi 1 request trong batch complete → nhét request mới vào ngay
Timeline:
t=0: [Req1, Req2, Req3] → batch size 3
t=30: Req3 done → [Req1, Req2, Req4_NEW] → vẫn size 3
t=50: Req1 done → [Req2, Req4, Req5_NEW] → vẫn size 3
GPU utilization: ~70-90%
3.4 Speculative Decoding — Trick thông minh
Observation: decode phase chậm vì sequential (1 token/step, memory-bound). Nhưng nếu ta dùng model nhỏ (draft model) để đoán K token tiếp theo, rồi verify bằng model lớn (target model) trong 1 forward pass — effectively xử lý K tokens 1 lúc?
Draft Model (nhỏ, nhanh): đoán [token1, token2, ..., tokenK]
Target Model (lớn, chậm): verify K tokens cùng lúc trong 1 pass
Nếu target agree với tất cả K tokens → accept K tokens, chỉ tốn 1 "step"
Nếu target reject tại vị trí j → accept [t1..t_{j-1}], resample t_j từ target
Throughput tăng ~2-3× khi draft model có nhiều tokens phù hợp (e.g., code, formulaic text)
LLaMA 3 dùng speculative decoding với Llama 3 8B làm draft cho Llama 3 70B.
4. Quantization — Giảm Model Size mà Không Phá Vỡ Não
4.1 Tại sao Quantize?
Full precision (FP32): 32 bits/weight → Llama 3 70B = 280 GB (4× A100!)
Half precision (FP16): 16 bits/weight → Llama 3 70B = 140 GB (2× A100)
INT8 quantization: 8 bits/weight → Llama 3 70B = 70 GB (1× A100)
INT4 quantization: 4 bits/weight → Llama 3 70B = 35 GB (3× RTX 4090!)
4.2 Post-Training Quantization (PTQ)
Không cần re-train. Lấy model đã train xong → apply quantization → serve.
GPTQ (Generalized PTQ for Transformers): OBC (Optimal Brain Compression) algorithm — minimize quantization error layer by layer:
Cho mỗi layer weight matrix W:
1. Chọn column đầu tiên, quantize: W_q = quantize(W[:,0])
2. Tính error: E = W[:,0] - W_q[:,0]
3. Compensate error sang các columns khác:
W[:,1:] += E × H^{-1}[:,1:] (H = Hessian matrix)
4. Tiếp tục với column kế
Kết quả: error được phân phối đều through remaining weights
→ Model INT4 GPTQ thường chỉ kém model FP16 gốc ~1-2% trên benchmarks
AWQ (Activation-Aware Weight Quantization): Observation từ tác giả: không phải tất cả weights đều quan trọng như nhau. Weights tương ứng với salient activations (những channel hay được activate mạnh) cực kỳ nhạy với quantization error.
AWQ:
1. Chạy model với calibration dataset, record activation magnitudes
2. Tìm salient channels (top 1% activation magnitude)
3. Scale salient channels lên trước khi quantize:
W_scaled = W × scale (scale = sqrt(activation_magnitude))
Q_scaled = quantize(W_scaled)
W_q = Q_scaled / scale (undo scale)
4. Salient weights bây giờ có range nhỏ hơn → quantization error nhỏ hơn
AWQ thường tốt hơn GPTQ ~0.5-1% perplexity, và inference nhanh hơn vì không cần GPTQ's slow dequantization.
4.3 GGUF Format — Cho CPU/Edge Inference
GGUF (GPT-Generated Unified Format) là format của llama.cpp. Được thiết kế cho CPU inference:
flowchart TB H["Header (magic/version/metadata)"] --> KV["Key-Value metadata"] KV --> TD["Tensor data (quantized weights)"] TD --> L0["Layer 0: Q4_K_M"] TD --> L1["Layer 1: Q4_K_M"] TD --> LN["..."]
GGUF file layout:
┌─────────────────────────────────────────┐
│ Header (magic, version, metadata) │
├─────────────────────────────────────────┤
│ Key-Value metadata │
│ (model architecture, tokenizer info...) │
├─────────────────────────────────────────┤
│ Tensor data (ALL quantized weights) │
│ [Layer 0 weights: Q4_K_M format] │
│ [Layer 1 weights: Q4_K_M format] │
│ ... │
└─────────────────────────────────────────┘
Quantization variants (theo chất lượng):
Q2_K: ~2.6 bits/weight → Nhỏ nhất, chất lượng thấp
Q4_0: ~4.5 bits/weight → OK, nhanh
Q4_K_M: ~4.8 bits/weight → Best quality/size ratio (recommend)
Q5_K_M: ~5.7 bits/weight → Gần như FP16 quality
Q8_0: ~8.5 bits/weight → Gần FP16, nặng hơn nhiều
Llama.cpp trick: Mix quantization per layer
Embedding và output layers (nhạy cảm nhất): giữ Q8 hoặc FP16
Middle transformer layers: Q4_K_M
→ Compromise tốt giữa size và quality
4.4 Chọn Format nào?
Use case Recommended format
───────────────────────────────────────────────────────
Production GPU server (A100) AWQ (INT4) hoặc FP16
Consumer GPU (RTX 4090) AWQ INT4 hoặc GPTQ INT4
CPU inference (MacBook M) GGUF Q4_K_M
Edge devices (Raspberry Pi) GGUF Q2_K
Fine-tuning (LoRA) FP16 hoặc BF16 (không quantize base)
5. Fine-tuning — Khi nào và Cách nào
5.1 RAG vs Fine-tuning — Câu hỏi muôn thuở
Misconception phổ biến: "Fine-tune cho model học data của công ty."
Thực tế:
Fine-tuning giỏi: Học phong cách, format, tone, domain-specific patterns
→ "Luôn trả lời theo format JSON với fields X, Y, Z"
→ "Dùng ngôn ngữ technical, avoid hedging language"
→ "Code output luôn dùng Python type hints"
Fine-tuning kém: Inject factual knowledge mới
→ Knowledge được "nhúng" vào weights → khó update, khó kiểm soát
→ Model hay hallucinate "gần đúng" thay vì admit ignorance
→ Catastrophic forgetting: fine-tune nhiều có thể phá vỡ general knowledge
RAG giỏi: Mọi use case cần factual grounding từ data của bạn
→ Update knowledge base bất cứ lúc nào
→ Traceable (citation rõ ràng)
→ Không tốn tiền re-train
Decision framework:
Data mới, cần update thường xuyên → RAG
Format output phức tạp, khó prompt-engineer → Fine-tune
Domain jargon rất đặc thù → Fine-tune (hoặc cả hai)
Budget ít → RAG (fine-tune tốn GPU-hours, infra phức tạp)
5.2 LoRA — Parameter-Efficient Fine-Tuning
Full fine-tuning model 70B cần 140GB VRAM minimum để load model + gradients + optimizer states (~4-8× model size). Không khả thi với GPU thông thường.
LoRA (Low-Rank Adaptation) giải quyết bằng insight toán học:
Full fine-tuning:
Update ΔW cho mỗi weight matrix W (shape: d × d)
→ Số params cần train = d² (ví dụ: 4096² = 16.7M per layer)
LoRA:
Thay vì update ΔW trực tiếp, approximate bằng low-rank decomposition:
ΔW = A × B
với A: (d × r), B: (r × d), r << d (rank, thường r=4, 8, 16, hay 64)
Số params cần train = 2 × d × r (ví dụ: 2 × 4096 × 16 = 131K per layer)
Reduction: 131K / 16.7M ≈ 0.8% của original params!
Inference: W_effective = W + A × B (merge vào weights sau training)
→ Zero inference overhead sau khi merge
QLoRA: Còn tiết kiệm hơn — load base model trong NF4 (4-bit) định dạng (frozen), chỉ train LoRA adapters trong BF16. Fine-tune 65B model trên 1 GPU 48GB A6000 — thứ trước đây cần cụm 8 A100.
5.3 LoRA Training Pipeline thực tế
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model
# QLoRA config
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # Load base model in 4-bit
bnb_4bit_compute_dtype=torch.bfloat16, # Compute in BF16
bnb_4bit_quant_type="nf4", # NormalFloat4 quantization
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8B",
quantization_config=bnb_config,
)
# LoRA config
lora_config = LoraConfig(
r=16, # rank
lora_alpha=32, # scaling (effective_lr = alpha/r × original_lr)
target_modules=["q_proj", "v_proj", "k_proj"], # Which layers to adapt
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Output: trainable params: 6,815,744 || all params: 8,037,679,104 || trainable%: 0.08%
6. Production Observability cho LLM Systems
6.1 Metrics cần monitor
Infrastructure metrics:
gpu_memory_used_gb → Alert nếu > 85% (còn buffer cho spikes)
gpu_utilization_percent → Target 70-90% khi có load
kv_cache_utilization → High = thiếu VRAM, cần reduce max_tokens hay batch size
LLM-specific metrics:
time_to_first_token_ms → User experience metric (prefill latency)
tokens_per_second → Throughput metric (decode speed)
request_queue_depth → Alert nếu queue tăng liên tục
token_budget_exceeded_rate → Requests bị cut off vì quá dài
Business metrics:
cost_per_request_usd → Token usage × price/token
cache_hit_rate → Semantic caching hoặc KV Cache prefix reuse
error_rate_by_stop_reason → "length", "stop", "content_filter"
6.2 Semantic Caching — Tiết kiệm 30-50% API cost
Nếu nhiều user hỏi câu tương tự, tại sao phải gọi LLM N lần?
flowchart TB
Q["User Query"] --> E["Embed"]
E --> S["Search Cache DB (Vector Search)"]
S --> H{"Similarity > threshold?"}
H -->|YES| R1["Return cached response (cost = $0)"]
H -->|NO| L["Call LLM"]
L --> G["Get response"]
G --> C["Store {embedding, response} vào cache"]
C --> R2["Return response"]Semantic Cache Architecture:
User Query → Embed → Search trong Cache DB (Vector Search)
│
├── Cache Hit (similarity > threshold, e.g. 0.95):
│ Return cached response (cost = $0)
│
└── Cache Miss:
Call LLM → Get Response
Store {embedding, response} vào Cache
Return response
Implementation với Redis + pgvector:
Key: embedding vector của query
Value: {response, metadata, created_at, ttl}
Lookup: Cosine similarity > 0.95 → cache hit
TTL: 24h cho factual queries, shorter cho time-sensitive
Caveat: Không phải mọi query đều nên cache. Queries có cá nhân hóa ("email của tôi là gì"), queries về thời gian thực ("giá vàng hôm nay"), hay queries mà response phụ thuộc vào user context — không cache.
6.3 RAGAS trong CI/CD Pipeline
# .github/workflows/rag-eval.yml
name: RAG Quality Gate
on:
push:
paths:
- 'prompts/**'
- 'chunking/**'
- 'embedding_model'
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- name: Run RAGAS Evaluation
run: |
python evaluate.py \
--testset data/qa_testset_100.json \
--metrics context_precision,context_recall,faithfulness,answer_relevance
- name: Check Quality Gate
run: |
python check_metrics.py \
--baseline metrics/baseline.json \
--current metrics/current.json \
--threshold 0.05 # Fail nếu bất kỳ metric nào giảm > 5%
Với 100 test cases, pipeline này chạy trong ~5-10 phút (dùng GPT-3.5 làm evaluator để tiết kiệm cost). Đảm bảo không có regression khi thay đổi bất kỳ component nào trong RAG pipeline.