🤖 AI✍️ Khoa📅 19/04/2026☕ 13 phút đọc

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.