Một câu hỏi: nếu LLM dự đoán token tiếp theo dựa trên toàn bộ context trước đó, và mỗi câu trả lời cần sinh 500 tokens, thì mỗi token mới phải chạy lại tính toán với toàn bộ 1000+ tokens (input + đã sinh) trước nó. Tổng cộng: 500 × 1000 = 500,000 lượt attention. Nghe đã thấy chậm.

Thực tế nếu naive như vậy, ChatGPT sẽ không tồn tại. Cái cứu nó là KV cache, một kỹ thuật đơn giản về ý tưởng nhưng phức tạp về implementation. Và khi KV cache trở thành bottleneck mới (memory), vLLM đẻ ra PagedAttention để giải.

Bài này giải thích hai kỹ thuật quan trọng nhất của LLM inference modern, không công thức ma trận, chỉ mental model.

Dành cho: dev đã hiểu attention (bài 9-11) và muốn hiểu tại sao vLLM nhanh hơn naive PyTorch 10-100 lần.

Mental model: attention tốn gì

Lúc model sinh token thứ N+1, mỗi attention head làm việc này:

Q (query)  = vector của token thứ N+1 (token "hiện tại")
K (keys)   = matrix của TẤT CẢ tokens từ 1 đến N
V (values) = matrix của TẤT CẢ tokens từ 1 đến N

attention(Q, K, V) = softmax(Q · K^T / sqrt(d)) · V

Khi sinh token thứ N+2, model lại tính:

Q' = vector của token N+2
K' = matrix của tokens 1 đến N+1   <-- chỉ khác cũ ở token N+1!
V' = matrix của tokens 1 đến N+1   <-- chỉ khác cũ ở token N+1!

Quan sát quan trọng: K và V của các token 1 đến N không đổi. Chúng được tính lại y hệt như lần trước. Lãng phí khủng khiếp.

Lý do K và V không đổi: cả hai đều là projection từ embedding tĩnh của token + position. Token 5 luôn có cùng K và V dù model đang sinh token 10 hay token 100.

(Riêng Q thay đổi vì chính nó là token mới đang xử lý.)

Phần 1: KV cache, ý tưởng đơn giản

Giải pháp: cache K và V của các token đã tính. Mỗi lần sinh token mới, chỉ tính K và V cho token mới, append vào cache, rồi đọc cache cho phần cũ.

Lần 1 (sinh token N+1):
  tính K, V cho tokens 1..N (chuyện một lần)
  lưu vào KV cache

Lần 2 (sinh token N+2):
  tính K, V chỉ cho token N+1
  append vào KV cache
  đọc KV cache toàn bộ để làm attention

Lần 3 (sinh token N+3):
  tính K, V chỉ cho token N+2
  append vào KV cache
  ...

Mỗi lần sinh token mới chỉ cần tính K, V cho 1 token mới thay vì N tokens. Speedup tỉ lệ với độ dài câu trả lời.

Code conceptual:

# Naive (không cache)
def generate_naive(input_tokens, max_new=500):
    tokens = input_tokens.copy()
    for _ in range(max_new):
        # mỗi lần chạy lại từ đầu
        logits = model.forward(tokens)
        next_token = sample(logits[-1])
        tokens.append(next_token)
    return tokens

# Với KV cache
def generate_with_kv_cache(input_tokens, max_new=500):
    # Pass đầu (prefill): tính K, V cho toàn bộ input
    logits, kv_cache = model.forward(input_tokens, use_cache=True)
    next_token = sample(logits[-1])
    tokens = input_tokens + [next_token]

    # Các pass sau (decode): chỉ tính cho token mới nhất
    for _ in range(max_new - 1):
        logits, kv_cache = model.forward(
            [next_token], past_key_values=kv_cache, use_cache=True
        )
        next_token = sample(logits[-1])
        tokens.append(next_token)
    return tokens

Speedup thực tế trên Llama-3-8B: với cache khoảng 30-50 tokens/s, không cache khoảng 2-3 tokens/s. Khác biệt 10-20 lần.

Phần 2: Hai phase của inference

KV cache chia inference thành hai phase rõ rệt:

Phase 1: Prefill. Xử lý toàn bộ input prompt một lần. Tính K, V cho tất cả input tokens. Output: token đầu tiên + KV cache đầy đủ.

Đặc trưng: compute-bound (GPU làm việc đầy đủ). Tốc độ giới hạn bởi FLOPs.

Phase 2: Decode. Sinh từng token một, append vào KV cache, đọc cache cũ. Mỗi token sinh ra mới chỉ cần 1 forward pass mini.

Đặc trưng: memory-bandwidth-bound. GPU phải đọc toàn bộ KV cache mỗi step. Tốc độ giới hạn bởi memory bandwidth, không phải FLOPs.

Time(prefill) ~ proportional to len(input)
Time(decode)  ~ proportional to len(output) × len(context_so_far)

Đây là lý do:

  • Câu trả lời ngắn (50 tokens) cảm thấy nhanh
  • Câu trả lời dài (2000 tokens) chậm dần về cuối
  • Context dài (8k tokens) prefill mất 2-3 giây trước khi nhả token đầu

Phần 3: Vấn đề mới sau khi có KV cache, memory

KV cache cứu tốc độ nhưng tốn memory kinh khủng.

Công thức:

KV cache size = 2 (K và V) × num_layers × num_heads × head_dim
              × seq_len × batch_size × precision_bytes

Áp dụng cho Llama-3-8B:

  • 32 layers, 8 KV heads (GQA), head_dim 128
  • 1 sequence, 8192 context, FP16

Tính: 2 × 32 × 8 × 128 × 8192 × 1 × 2 = 1 GB per sequence.

Llama-3-70B với 64 layers, 8 KV heads, head_dim 128, 32k context: KV cache 5 GB per sequence. Nếu batch 32 sequences: 160 GB chỉ riêng KV cache. Lớn hơn cả model weights (140 GB FP16).

KV cache nhanh chóng trở thành bottleneck. Không phải compute, không phải model size, mà KV cache.

Phần 4: PagedAttention, áp dụng virtual memory cho GPU

vLLM team nhận ra: KV cache có chung pattern với memory của process trong OS. Mỗi request cần một “vùng nhớ” để lưu KV cache. Naive cách: allocate liên tục một block size = max_seq_len cho mỗi request.

Vấn đề: max_seq_len thường lớn (4k, 8k), nhưng request thực tế chỉ dùng vài trăm tokens. Lãng phí gọi là internal fragmentation.

Ngoài ra, request có thể đến và đi không đồng đều. Khi một request finish, vùng nhớ của nó free, nhưng vùng nhớ tiếp theo có thể không phù hợp size. Gọi là external fragmentation.

Hai loại fragmentation này khiến memory utilization của KV cache chỉ 20-40% trong engine truyền thống.

Giải pháp PagedAttention: chia KV cache thành các block kích thước cố định (16 hoặc 32 tokens). Một request cần KV cache cho 500 tokens sẽ chiếm khoảng 500/16 = 32 block. Các block không cần liên tục trong memory, lưu qua block table như virtual memory.

Naive: 1 request <-> 1 contiguous block 8192 tokens (dù chỉ dùng 500)

PagedAttention: 1 request <-> page table -> [block 5, block 12, block 33, ...]
  mỗi block 16 tokens, chỉ allocate khi cần

Lợi ích:

  • Memory utilization >95%
  • Multiple request share block table dễ dàng
  • Cùng prefix (system prompt) có thể share block, không duplicate KV cache

Throughput tăng 2-4 lần so với engine trước nó. Đây là phần khiến vLLM nổi tiếng.

Throughput Llama-3-8B trên A100:
  HF Transformers naive:  ~50 tok/s
  HF Transformers w/ KV cache:  ~150 tok/s
  TGI v1:  ~600 tok/s
  vLLM (PagedAttention):  ~2000 tok/s

Phần 5: Continuous batching, người bạn của PagedAttention

KV cache + PagedAttention chỉ giải quyết memory. Nhưng vẫn còn vấn đề: làm sao chạy nhiều request đồng thời trên cùng GPU?

Naive batching (static): gom N request, đợi tất cả prefill xong, decode song song, đợi tất cả request dài nhất xong rồi return. Vấn đề: request ngắn phải đợi request dài, GPU idle nhiều.

Continuous batching (vLLM, TGI): trên mỗi decode step, GPU làm việc với một batch của các request đang active. Khi một request finish, ngay lập tức nhận một request mới vào batch.

Time -->
Req 1: |prefill|decode|decode|decode|decode|decode|finish|
Req 2:                |prefill|decode|decode|finish|
Req 3:                |prefill|decode|decode|decode|decode|...
Req 4:                                            |prefill|decode|...

Không có thời gian chết. GPU luôn ở 90-95% utilization.

Continuous batching cần PagedAttention vì kích thước batch thay đổi liên tục, không gọn gàng theo memory layout truyền thống.

Phần 6: Speculative decoding, tăng tốc decode

Một kỹ thuật khác nhắm vào phase decode: speculative decoding (Google, 2023).

Ý tưởng: dùng một draft model nhỏ (1B params) sinh nhanh nhiều token, rồi target model lớn (70B) verify trong một pass. Pass verify rất nhanh vì có thể verify nhiều token song song (parallel scoring).

Draft model sinh:  "def fib(n): return"
Target model verify nhanh tất cả 5 token cùng lúc
  - "def" ok
  - " fib" ok
  - "(n)" ok
  - ":" ok
  - " return" ok
Accept 5 tokens trong 1 pass thay vì 5 pass.

Speedup thực tế 2-3 lần với draft model tốt. vLLM có hỗ trợ. Khi nào dùng:

  • Có cặp model “cùng họ” (Llama-3-8B làm draft cho Llama-3-70B)
  • Throughput quan trọng hơn first-token latency

Phần 7: Prefix caching, free win

Nếu các request chia sẻ cùng system prompt dài (vd RAG với 2000 token prompt cố định), prefix caching cho phép reuse KV cache của prefix giữa các request.

# vLLM
vllm serve <model> --enable-prefix-caching

Lợi ích:

  • Prefill chỉ tính phần đuôi của prompt (phần khác giữa các request)
  • Tiết kiệm 30-70% prefill time với system prompt dài

Đây là free win nếu bạn có system prompt cố định. Bật mặc định.

Phần 8: Pitfall thực tế

Pitfall 1: Đo throughput không tính prefill.

Một dev tôi quen quảng cáo engine của họ “1000 tok/s”. Hoá ra số đó là decode rate, không tính prefill time. Khi gửi prompt 2000 tokens, prefill mất 1.5s, output 200 tokens, total 1.5s + 0.2s = 1.7s. Effective throughput ~120 tok/s, không phải 1000.

Always đo end-to-end. First-token latency + total time chia tổng output tokens. Số này mới phản ánh user experience.

Pitfall 2: Disable KV cache cho mục đích sai.

Hugging Face Transformers có flag use_cache=False. Một số người disable vì đọc trên forum nói “cache làm sai output trong batch”. Chuyện đó là bug từ 2022, fix lâu rồi. Không có lý do disable KV cache trong production.

Disable KV cache chỉ làm sense trong:

  • Training (forward pass dùng cho backward, không cần cache)
  • Debug specific issue

Bình thường: bật.

Pitfall 3: Context length quá dài, memory blow up.

--max-model-len 128000 trên Llama-3.1 nghe oai nhưng:

  • 1 request 128k context chiếm vài GB KV cache
  • VRAM 80GB chỉ serve được vài request concurrent
  • Throughput tụt thê thảm

Đặt theo use case thực. Chat thông thường: 4k-8k đủ. RAG: 8k-16k. Document analysis: tăng theo nhu cầu.

Pitfall 4: Quên prefix caching khi có system prompt dài.

System prompt 3000 token, 100 request/s, mỗi request prefill 3000 token cho cùng prompt. Tốn 300,000 token/s prefill cho phần lặp.

Bật prefix caching: chỉ prefill phần unique. Throughput tăng 3-5 lần.

Cheatsheet

Khái niệmÝ nghĩaKhi nào quan trọng
KV cacheLưu K, V của token đã xử lýLuôn luôn (mặc định)
Prefill phaseXử lý toàn bộ input prompt 1 lầnFirst-token latency
Decode phaseSinh từng token, đọc KV cacheThroughput long output
PagedAttentionChia KV cache thành blockMulti-user serving
Continuous batchingĐổi batch sau mỗi stepMulti-user throughput
Speculative decodingDraft model sinh, target verifySingle request speedup
Prefix cachingReuse KV cache của prefix chungSystem prompt dài + nhiều request

Heuristic memorize:

  • KV cache: nhanh hơn 10x, tốn memory tỉ lệ với context × batch
  • PagedAttention: cho phép batch lớn hơn 2-4 lần với cùng VRAM
  • Continuous batching: GPU utilization 95% vs 60% (static batching)
  • Prefix caching: free 30-70% prefill nếu có shared prompt
  • Speculative decoding: 2-3x decode speedup, cần draft model

Lời kết

KV cache và PagedAttention là hai trong những kỹ thuật quan trọng nhất khiến LLM inference khả thi ở quy mô production. Hiểu chúng giúp bạn:

  • Debug khi inference chậm bất thường (thường là KV cache bị disable hoặc context quá dài)
  • Tính được VRAM cần cho deployment trước khi bỏ tiền mua GPU
  • Đọc paper inference mới (FlashAttention, RingAttention, Medusa…) không bị shock

Hands-on cho bạn:

  1. Load Llama-3-8B với HF Transformers, sinh 500 token với use_cache=Trueuse_cache=False. Đo thời gian. Cảm nhận khác biệt.
  2. Cài vLLM, serve cùng model với batch size 1 và batch size 16 (locust gửi 16 concurrent request). Đo throughput, xem PagedAttention + continuous batching làm gì.
  3. Tự tính KV cache size cho model bạn quan tâm dùng công thức ở Phần 3. So sánh với VRAM card bạn có. Tính max batch size khả thi.
  4. Bật --enable-prefix-caching trong vLLM với system prompt 2000 tokens. So sánh throughput trước và sau.

Bài tiếp theo: RAG: retrieval-augmented generation từ vector DB tới prompt. Chuyển từ inference engine sang pattern application. RAG là cách dev thường tiếp xúc LLM thực tế nhất, nhưng hiểu sai nó dễ gây disaster: chậm, sai, hoặc cả hai.