vLLM & PagedAttention
Tung Bui Thanh (TungDaDev)13 mins read

Chạy LLM inference, người thắng chưa chắc là người có GPU mạnh nhất. Người thắng là người quản lý từng byte VRAM khéo nhất.
Nếu bạn từng thử self-host một model lớn như Llama-3-70B, Qwen-2.5-32B hay DeepSeek trên cụm GPU nội bộ của công ty, nhiều khả năng bạn đã đụng phải bức tường quen thuộc: CUDA Out of Memory (OOM).
Chuyện nghe rất vô lý:
- Bạn mua 4 card NVIDIA A100, mỗi card 80GB VRAM, tổng cộng 320GB.
- Model 70B sau khi quantization chỉ chiếm khoảng 35 - 40GB VRAM tĩnh.
- Vậy chắc phục vụ được cả trăm kỹ sư cùng lúc? Chưa chắc. Nếu engine giữ chỗ trước cho mỗi request theo độ dài context tối đa, một request có thể chiếm cả chục GB dù chỉ dùng vài phần trăm, và chỉ một nhóm nhỏ request đồng thời là GPU đã báo hết bộ nhớ.
Tại sao? Phần bộ nhớ ngốn nhiều và khó đoán nhất khi suy luận LLM không phải trọng số model, mà là KV Cache (Key-Value Cache): nó phình theo độ dài từng request, và nếu quản lý kiểu cấp phát liền một khối thì bị phân mảnh, lãng phí rất nhiều.
Đây cũng là lý do vLLM cùng PagedAttention (từ nhóm nghiên cứu ở UC Berkeley, paper ở hội nghị SOSP 2023) làm thay đổi hẳn cách người ta làm LLM serving. Theo bài công bố của nhóm tác giả, throughput cao hơn tới 14-24 lần so với HuggingFace Transformers và tới 3.5 lần so với HuggingFace TGI; trong paper, so với các hệ thống serving mạnh hơn như FasterTransformer và Orca thì mức tăng là 2-4 lần ở cùng độ trễ.
Mình sẽ đi từ cách Attention tính toán, KV Cache to cỡ nào, PagedAttention quản lý nó ra sao, rồi dựng thử một inference server bằng Docker.
# gốc rễ vấn đề: kv cache phình to
Muốn hiểu vì sao vLLM tạo ra khác biệt lớn như vậy, trước hết cần biết Transformer sinh từ theo kiểu autoregressive như thế nào.
Mỗi lần sinh một token mới, model phải tính mức độ liên quan giữa token đó với toàn bộ các token đứng trước bằng phép Self-Attention:
Attention(Q, K, V) = softmax( (Q * K^T) / sqrt(d_k) ) * VNếu không lưu đệm gì cả, để sinh token thứ 1000 bạn phải tính lại Key (K) và Value (V) của 999 token trước đó. Để khỏi tính đi tính lại, người ta dùng KV Caching: lưu hết vector K và V của các token đã qua vào VRAM.
Cái giá là bộ nhớ. Mỗi token cần lưu K và V ở mọi layer, nên dung lượng KV Cache cho một token là:
bytes/token = 2 (K và V) * số layer * số KV head * head_dim * số byte mỗi phần tửLấy Llama-3-70B (80 layer, 8 KV head nhờ Grouped-Query Attention, head_dim 128) với KV Cache ở FP16 (2 byte): 2 _ 80 _ 8 _ 128 _ 2 = 327,680 byte, khoảng 320 KB mỗi token. Một request 8k token là khoảng 2.5 GB, còn nếu giữ chỗ trước cho trọn context 128k của Llama 3.1 thì một request chiếm tới 40 GB. Model cũ không có GQA còn tốn hơn: paper vLLM tính với OPT-13B là khoảng 800 KB mỗi token.
Số trên là cho Llama-3-70B, KV Cache FP16.
# bộ nhớ thay đổi liên tục, không đoán trước được
Không ai biết trước câu trả lời sẽ dài bao nhiêu. Có câu dừng ở 50 tokens, có câu kéo tới 4,000 tokens. Nghĩa là KV Cache của mỗi request lớn dần theo từng token sinh ra, và lúc request bắt đầu thì engine không biết cuối cùng nó cần bao nhiêu chỗ.
# lãng phí vì phân mảnh bộ nhớ memory fragmentation
Các hệ thống serving trước vLLM (paper lấy ví dụ FasterTransformer, Orca) lưu KV Cache của mỗi request trong một vùng nhớ liền mạch, và bị lãng phí theo mấy kiểu:
- Internal Fragmentation (phân mảnh trong): Vì không biết request dài bao nhiêu, hệ thống phải cấp phát trước một vùng nhớ liên tục (contiguous memory) cho độ dài tối đa, ví dụ 4,096 tokens. Request chỉ dùng hết 200 tokens thì khoảng 95% vùng nhớ đó bỏ không. Kể cả request rồi sẽ dùng hết thì phần chưa sinh tới vẫn bị giữ chỗ suốt thời gian chạy, request khác không dùng được (paper gọi là reservation).
- External Fragmentation (phân mảnh ngoài): Request vào ra lộn xộn làm bộ nhớ GPU bị xé thành nhiều lỗ trống nhỏ. Tổng dung lượng trống còn nhiều, nhưng không còn khoảng liên tục nào đủ lớn cho request mới.
NOTE
Số liệu từ nhóm vLLM (UC Berkeley): ở các hệ thống cũ, 60% - 80% bộ nhớ dành cho KV Cache bị lãng phí vì phân mảnh và giữ chỗ trước. Lưu ý đây là phần bộ nhớ của KV Cache, không phải toàn bộ VRAM.
# pagedattention: mượn bài của hệ điều hành
Nhìn vào bài toán này, các nhà nghiên cứu nhận ra một điểm rất thú vị: nó giống hệt chuyện phân mảnh RAM mà hệ điều hành đã giải quyết từ thập niên 60 bằng Virtual Memory và Paging.
Vậy tại sao cứ phải bắt KV Cache nằm trên một dải ô nhớ vật lý liên tục?
# pagedAttention hoạt động thế nào
- Chia KV Cache thành các block (trang nhớ): Thay vì cấp một dải ô nhớ khổng lồ, vLLM chia KV Cache thành các khối kích thước cố định (thường 16 hoặc 32 tokens mỗi block).
- Không cần liên tục về mặt vật lý: Block 0 có thể nằm đầu VRAM, Block 1 nằm cuối, Block 2 nằm giữa, không sao cả.
- Quản lý bằng Block Table: Giống Page Table của hệ điều hành, mỗi request có một bảng map từ block logic (block thứ mấy của request) sang block vật lý trong VRAM. Kernel attention của vLLM đọc bảng này để lấy K, V từ các block nằm rải rác, thay vì đọc một dải liền.
- Cần tới đâu cấp tới đó (On-Demand Allocation): Token thật sự được sinh ra và lấp đầy block hiện tại thì hệ thống mới xin thêm block vật lý mới. Phần lãng phí chỉ còn nằm ở block cuối chưa đầy của mỗi request, theo nhóm tác giả là dưới 4%.
Đổi lại, kernel attention phải đi tra bảng và đọc bộ nhớ không liền nhau, nên bản thân kernel chậm hơn một chút so với kernel đọc vùng nhớ liền (paper đo overhead khoảng 20-26% ở riêng kernel attention). Cái lợi là batch lớn hơn hẳn nên throughput tổng vẫn tăng mạnh.
TIP
Lợi ích thực tế của PagedAttention là cùng một GPU nhét được nhiều request hơn vào một batch. Throughput tăng bao nhiêu thì tuỳ model, độ dài prompt/output và engine đem ra so, nên muốn biết con số cho hệ thống của mình thì đo bằng chính workload của mình.
# hai tính năng quan trọng của vllm
# copyOnWrite & shared prefix caching
Có block table rồi thì chia sẻ bộ nhớ giữa các chuỗi trở nên dễ, y như nhiều process cùng map một trang nhớ. vLLM dùng việc này theo hai kiểu.
- Copy-on-Write (trong paper): khi một prompt sinh nhiều output cùng lúc (parallel sampling, beam search), các chuỗi dùng chung block của phần prompt, mỗi block có bộ đếm tham chiếu. Chuỗi nào cần ghi token mới vào một block đang dùng chung thì vLLM mới copy block đó ra bản riêng.
- Automatic Prefix Caching: với Multi-turn Chat hay RAG, System Prompt hoặc đoạn tài liệu tham khảo thường giống hệt nhau qua nhiều request. vLLM băm (hash) mỗi block theo nội dung token của nó cộng với toàn bộ phần prefix đứng trước; request mới có prefix trùng thì trỏ luôn vào block đã có, khỏi tính lại phần prefill đó. Chỉ những block đã đầy mới được cache, nên block dùng chung không bao giờ bị ghi thêm. Lợi ích lớn nhất ở đây là giảm thời gian ra token đầu tiên (TTFT) với prompt dài, ngoài chuyện tiết kiệm bộ nhớ. Engine V1 của vLLM bật tính năng này mặc định.
# continuous batching liên tục
Batching kiểu cũ (Static Batching) bị cảnh "con sâu làm rầu nồi canh": một batch 4 request, 3 cái xong ở token 50, còn 1 cái dài 1000 tokens, thế là cả batch phải đứng chờ cái cuối xong mới nhả GPU.
- Continuous Batching trong vLLM: request nào xong ở bước nào thì vLLM nhét ngay request mới trong hàng đợi vào chỗ trống ở bước kế tiếp (iteration-level scheduling). GPU ít phải chạy không tải hơn hẳn. Ý tưởng này không phải của vLLM mà từ paper Orca (OSDI 2022); PagedAttention làm nó hiệu quả hơn vì request mới chỉ cần vài block trống, không cần một vùng nhớ liền đủ cho độ dài tối đa.
# triển khai vllm cho production với docker
vLLM có sẵn server tương thích OpenAI API (/v1/chat/completions, /v1/completions...), nên các thư viện như LangChain, Spring AI, n8n cắm vào vLLM được bằng cách đổi base URL như đang gọi OpenAI. Tương thích ở đây là phần lớn tham số thông dụng, không phải mọi tính năng của OpenAI, nên tính năng nào hơi lạ thì xem docs vLLM trước.
# file docker-compose.yml cho gpu
services:
vllm-server:
image: vllm/vllm-openai:v0.31.0 # pin version, đừng dùng latest
container_name: vllm-llama3
environment:
- HF_TOKEN=hf_your_token_here
ports:
- '8000:8000'
volumes:
- ~/.cache/huggingface:/root/.cache/huggingface
ipc: host
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
command: >
--model meta-llama/Meta-Llama-3-8B-Instruct
--tensor-parallel-size 1
--gpu-memory-utilization 0.90
--max-model-len 8192Nhiều hướng dẫn cũ còn thêm --swap-space 4 và --disable-log-requests. Cả hai đã bị bỏ ở các bản vLLM gần đây (swap KV Cache ra CPU không còn dùng ở engine V1, còn log request đã tắt mặc định, muốn bật thì dùng --enable-log-requests), để lại thì các bản mới sẽ báo lỗi tham số không hợp lệ. Đó cũng là lý do file trên pin hẳn tag v0.31.0 (bản phát hành ngày 05/10/2026) thay vì latest: hôm nay chạy được, tuần sau image latest đổi phiên bản, bỏ một tham số nào đó là container khởi động lỗi mà bạn không hề sửa gì. Muốn nâng cấp thì đổi tag có chủ đích và đọc release notes trước. Model meta-llama/Meta-Llama-3-8B-Instruct là model gated trên Hugging Face, phải chấp nhận license trên trang model rồi mới tải được bằng token.
Mấy tham số cần để ý:
--gpu-memory-utilization 0.90: tỷ lệ VRAM mà instance vLLM này được dùng, gồm trọng số model, bộ nhớ tạm khi tính toán (activation) và KV Cache. Lúc khởi động, vLLM chạy thử một lượt để đo, trừ trọng số và activation ra, phần còn lại trong 90% đó cấp hết cho KV Cache ngay từ đầu. Vì vậynvidia-smiluôn báo gần đầy dù chưa có request nào, chuyện đó là bình thường. Phần 10% còn lại là khoảng dự phòng cho CUDA context và process khác trên cùng GPU. Giá trị mặc định khoảng 0.9 (tuỳ phiên bản).--max-model-len 8192: giới hạn context length tối đa (prompt + output) cho mỗi request. Không đặt thì vLLM lấy theo config của model, với model context 128k thì có thể không khởi động được vì KV Cache không đủ chỗ cho dù chỉ một request dài tối đa. Đặt sát nhu cầu thực tế để khởi động chắc chắn và chặn request quá dài.--tensor-parallel-size: có 2 hoặc 4 GPU (ví dụ 2x RTX 4090 hoặc 4x A100) thì đặt giá trị tương ứng để chia đều model qua các card. Số này phải chia hết số attention head của model, và các card nên nối nhau bằng NVLink, vì mỗi layer đều phải trao đổi dữ liệu giữa các GPU.
# tổng kết
Mình thích vLLM ở chỗ nó không phát minh gì quá cao siêu: lấy đúng Paging của hệ điều hành, áp vào KV Cache, và thế là batch được nhiều request hơn hẳn trên cùng phần cứng.
Còn chuyện có nên tự host thay vì gọi API của các nhà cung cấp lớn hay không thì phải tính: tiền GPU (mua hoặc thuê), công vận hành, chất lượng model mở so với model đóng, đặt cạnh lượng request thực tế. Lưu lượng đều và lớn, hoặc dữ liệu không được phép gửi ra ngoài, thì tự host với vLLM khá đáng. Lưu lượng lèo tèo thì GPU nằm chơi phần lớn thời gian, gọi API thường rẻ hơn. Muốn đọc sâu hơn thì paper gốc là "Efficient Memory Management for Large Language Model Serving with PagedAttention" (SOSP 2023).
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.