0

Why LLM Inference Engines Are Fast: Continuous Batching, PagedAttention and Prefix Caching Explained with vLLM

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 đó.
                                    
                                    ```mermaid
                                    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):
                                                                        
                                                                        ```bash
                                                                        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):
                                                                                  
                                                                                  ```python
                                                                                  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:
                                                                                                                                                                                                          
                                                                                                                                                                                                          ```bash
                                                                                                                                                                                                          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_waiting` tăng liên tục trong khi `kv_cache_usage` gầ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_running` thấ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_queries` thấ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:
                                                                                                                                                                                                              
                                                                                                                                                                                                              1. **Đừ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.
                                                                                                                                                                                                              2. **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.
                                                                                                                                                                                                              3. **Đặt `--max-model-len` vừa đủ dùng**, đừng để mặc định 32K hay 128K nếu app chỉ cần 8K.
                                                                                                                                                                                                              4. **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.
                                                                                                                                                                                                              5. **Gắn `/metrics` vào Grafana từ ngày đầu**, vì `kv_cache_usage` và `num_requests_waiting` là 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 đó.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.