Tuần này DeepSeek open-source inference engine của họ và lên top Hacker News với gần 1400 điểm. Đọc thread thảo luận, mình thấy nhiều anh em dev vẫn nghĩ chạy LLM nhanh chủ yếu là nhờ GPU mạnh. Thực tế, chênh lệch giữa một script transformers gọi model.generate() và một inference engine như vLLM hay SGLang có thể lên tới 10–20 lần throughput trên cùng một con GPU. Phần chênh lệch đó đến từ cách quản lý memory và scheduling, chứ phần cứng không đổi. Bài này mình sẽ giải thích 3 kỹ thuật cốt lõi mà engine nào cũng dùng: continuous batching, PagedAttention và prefix caching. Sau đó mình hướng dẫn cách đo và tune chúng với vLLM để tự kiểm chứng.
1. Vấn đề gốc: decode bị giới hạn bởi memory bandwidth, không phải compute
Một request LLM có 2 phase:
- Prefill: xử lý toàn bộ prompt một lần. Phase này nặng về compute, GPU chạy gần full công suất.
- Decode: sinh từng token một. Mỗi bước phải đọc toàn bộ weights của model từ VRAM chỉ để tính ra 1 token.
Với model 7B ở FP16 (~14GB weights) trên GPU có bandwidth ~1TB/s, mỗi bước decode tốn tối thiểu khoảng 14ms chỉ để đọc weights. Nếu batch chỉ có 1 request thì gần như toàn bộ compute bị bỏ phí. Nhưng nếu batch 64 request cùng lúc, weights vẫn chỉ đọc một lần cho mỗi bước, tức là gần như được thêm 63 token miễn phí.
Vì vậy bài toán của inference engine là nhồi được càng nhiều request vào batch càng tốt. Có 2 thứ cản trở việc này: cách batching truyền thống và KV cache.
2. Continuous batching và PagedAttention
Static batching (kiểu cũ) gom N request, chạy tới khi request dài nhất xong rồi mới nhận batch mới. Request trả lời 10 token phải ngồi chờ request trả lời 2000 token, slot trong GPU bị bỏ trống.
Continuous batching (còn gọi là iteration-level scheduling) thì ra quyết định ở mỗi bước decode: request nào xong thì rút ra ngay, request mới trong queue được chèn vào ngay bước sau.
flowchart LR
A[Client requests] --> B[Waiting queue]
B --> C{Scheduler moi step}
C -->|con KV block trong| D[Running batch]
C -->|het block| E[Preempt hoac cho]
D --> F[GPU forward pass]
F --> G{Request xong?}
G -->|Yes| H[Tra ve client, giai phong block]
G -->|No| C
H --> C
Nhưng muốn chèn request mới vào thì phải có chỗ chứa KV cache, tức là key/value của attention cho mọi token đã xử lý. KV cache chiếm rất nhiều VRAM: với Llama-3-8B, mỗi token tốn khoảng 128KB, nên một context 8K token đã ngốn ~1GB.
Các hệ thống cũ cấp phát KV cache liên tục theo max_seq_len cho từng request, dẫn tới lãng phí 60–80% do fragmentation. PagedAttention (paper vLLM 2023) áp dụng ý tưởng virtual memory của OS: chia KV cache thành các block cố định (mặc định 16 token/block) và dùng một block table để map logical sang physical. Request cần thêm bao nhiêu thì cấp thêm block đó, lãng phí chỉ còn dưới 4%. Nhờ vậy batch size thực tế tăng gấp nhiều lần.
3. Prefix caching: tái sử dụng KV cache giữa các request
Đây là kỹ thuật mà mình thấy dev Việt ít khai thác nhất, dù lợi ích rất lớn cho các ứng dụng thực tế. Chatbot, RAG hay agent đều có system prompt dài giống nhau ở đầu mỗi request. Prefix caching hash từng block KV theo nội dung token, nên request sau có cùng prefix sẽ bỏ qua prefill cho phần đó.
sequenceDiagram
participant R1 as Request 1
participant E as Engine
participant KV as KV Block Pool
R1->>E: system prompt 2000 tok + cau hoi A
E->>KV: Prefill 2000 tok, luu block theo hash
participant R2 as Request 2
R2->>E: system prompt 2000 tok + cau hoi B
E->>KV: Lookup hash, hit 125 block
E->>E: Chi prefill phan cau hoi B
SGLang làm tương tự bằng RadixAttention (radix tree), còn vLLM V1 dùng hash-based block cache và bật mặc định. Điều kiện để cache hit là prefix phải giống hệt từng token. Lỗi kinh điển là nhét timestamp hoặc user ID vào đầu system prompt, làm mọi request đều miss cache. Phần động hãy luôn đặt ở cuối.
4. Thực hành: chạy, đo và tune với vLLM
Khởi động server (vLLM 0.8+ với V1 engine, GPU 24GB):
pip install "vllm>=0.8"
vllm serve Qwen/Qwen2.5-7B-Instruct \
--gpu-memory-utilization 0.90 \
--max-model-len 8192 \
--max-num-seqs 128 \
--enable-prefix-caching \
--port 8000
Ba tham số quan trọng nhất:
-
--gpu-memory-utilization: phần VRAM dành cho weights và KV cache. Càng cao thì càng nhiều block, nhưng dễ OOM nếu GPU còn chạy thứ khác. -
--max-model-len: giảm xuống nếu không cần context dài, vì mỗi request tốn ít block hơn và batch được nhiều hơn. -
--max-num-seqs: số request tối đa trong một batch.
Script benchmark dưới đây đo TTFT (time to first token) và throughput, chạy 2 lần: system prompt chung (cache hit) và system prompt có prefix ngẫu nhiên (cache miss):
import asyncio, time, uuid, statistics
from openai import AsyncOpenAI
client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="x")
SYSTEM = "Bạn là trợ lý pháp lý. " + "Quy định nội bộ... " * 400 # ~2000 tokens
async def one(i, shared):
sys_prompt = SYSTEM if shared else f"[{uuid.uuid4()}] " + SYSTEM
t0 = time.perf_counter(); ttft = None; n = 0
stream = await client.chat.completions.create(
model="Qwen/Qwen2.5-7B-Instruct",
messages=[{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"Câu hỏi số {i}: tóm tắt điều 3"}],
max_tokens=200, stream=True)
async for chunk in stream:
if ttft is None:
ttft = time.perf_counter() - t0
n += 1
return ttft, n
async def bench(shared, concurrency=64):
t0 = time.perf_counter()
res = await asyncio.gather(*[one(i, shared) for i in range(concurrency)])
total = time.perf_counter() - t0
ttfts = [r[0] for r in res]
print(f"shared={shared} p50 TTFT={statistics.median(ttfts)*1000:.0f}ms "
f"throughput={sum(r[1] for r in res)/total:.0f} tok/s")
asyncio.run(bench(shared=False))
asyncio.run(bench(shared=True))
Trên RTX 4090 mình thường thấy TTFT p50 giảm từ khoảng 1.5–2s xuống còn vài trăm ms khi prefix được share. Con số cụ thể tùy model và độ dài prompt, nên bạn hãy tự chạy để có số liệu thật của mình.
Cuối cùng, theo dõi engine qua endpoint Prometheus có sẵn:
watch -n 1 'curl -s localhost:8000/metrics | grep -E \
"num_requests_(running|waiting)|kv_cache_usage|prefix_cache_(hits|queries)" \
| grep -v "^#"'
Cách đọc số liệu:
-
num_requests_waitingtăng liên tục trong khikv_cache_usagegần 1.0: hết block. Hãy giảm--max-model-len, dùng model quantized (AWQ/FP8) hoặc thêm GPU. -
num_requests_runningthấp, cache usage thấp mà vẫn chậm: client gửi chưa đủ concurrency, engine đang bị bỏ đói. - Tỉ lệ
prefix_cache_hits / prefix_cache_queriesthấp dù dùng chung system prompt: kiểm tra xem có phần động nào nằm ở đầu prompt không.
Kết luận
Inference engine nhanh là nhờ giữ cho GPU luôn bận với một batch lớn, và cả 3 kỹ thuật trên đều phục vụ mục tiêu đó. Một số việc bạn có thể làm ngay:
-
Đừng serve production bằng
model.generate()của transformers. Hãy dùng vLLM, SGLang hoặc TGI, vì chênh lệch throughput tính bằng lần. - Thiết kế prompt cache-friendly: phần tĩnh (system prompt, tool definitions, few-shot examples) đặt đầu, phần động (thời gian, user info, câu hỏi) đặt cuối.
-
Đặt
--max-model-lenvừa đủ dùng, đừng để mặc định 32K hay 128K nếu app chỉ cần 8K. - Benchmark với concurrency thật: đo TTFT và throughput ở 1, 16, 64 request đồng thời, đừng chỉ đo 1 request rồi kết luận.
-
Gắn
/metricsvào Grafana từ ngày đầu, vìkv_cache_usagevànum_requests_waitinglà hai chỉ số cho biết khi nào cần scale.
Khi đã hiểu 3 khái niệm này, việc đọc source của DeepSeek inference engine hay bất kỳ engine mới nào sẽ dễ hơn nhiều, vì phần lớn chúng chỉ là các biến thể tối ưu hơn của cùng những ý tưởng đó.
Top comments (0)