SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Prefill và decode: Vì sao token đầu tiên chậm?

Prefill bị giới hạn bởi năng lực tính toán và quyết định time to first token; decode bị giới hạn bởi băng thông bộ nhớ và quyết định token/giây. Đo riêng trên máy bạn.

Prefill và decode trong một đoạn văn

Prefill và decode là một phân biệt giúp giải thích phần lớn các câu hỏi về độ trễ của LLM (large language model) tự host. Prefill đọc toàn bộ prompt trong một lượt và bị giới hạn bởi năng lực tính toán. Decode tạo câu trả lời từng token một và bị giới hạn bởi băng thông bộ nhớ. Thời gian đến token đầu tiên là chỉ số của prefill. Số token mỗi giây là chỉ số của decode.

Cả hai giai đoạn đều chạy trên cùng GPU (graphics processing unit), dùng cùng weights và nằm trong cùng một process, nên việc xem chúng là một workload là điều tự nhiên. Chúng hoạt động như hai chương trình khác nhau cùng chia sẻ một thiết bị. Khi tách riêng hai giai đoạn này, một danh sách dài các kết quả khó hiểu sẽ không còn khó hiểu nữa.

Vì sao prefill bị giới hạn bởi năng lực tính toán?

Prefill đưa toàn bộ prompt qua từng layer đúng một lần. Một prompt gồm 2,000 token tạo ra 2,000 hàng công việc cho mỗi phép nhân ma trận, nên GPU thực hiện nhiều phép tính cho mỗi byte weight được tải. Tỷ lệ giữa số phép tính và số byte được di chuyển này gọi là arithmetic intensity, và prefill có arithmetic intensity cao. Thiết bị chạy gần giới hạn tính toán, còn memory bus vẫn chưa bị sử dụng hết.

Prefill tạo ra 2 thứ: KV cache (các tensor key và value) cho mọi token trong prompt và token đầu ra đầu tiên. Không có dữ liệu nào đến người đọc trước khi lượt xử lý đó hoàn tất. Vì vậy, thời gian prefill và time to first token (TTFT) gần như là cùng một phép đo.

Chi phí prefill tăng theo độ dài prompt. Phần tuyến tính là lượng công việc nhân ma trận ở mỗi layer. Phần bậc hai là attention, trong đó mỗi token attention đến mọi token đứng trước nó; phần này bắt đầu đáng kể khi context dài. Vì vậy, tăng gấp đôi prompt sẽ ít nhất làm TTFT tăng gấp đôi.

Bạn có thể kiểm tra điều này trong một phút. Gửi một prompt 200 token đến server, sau đó gửi một prompt 2,000 token, mỗi lần yêu cầu cùng số lượng token đầu ra. TTFT sẽ tăng rõ rệt. Tốc độ streaming sau token đầu tiên hầu như không thay đổi.

Vì sao decode bị giới hạn bởi băng thông bộ nhớ?

Decode tạo ra một token trong mỗi bước. Để tạo token đó, GPU phải đọc toàn bộ weight của model từ bộ nhớ, dùng mỗi weight cho một vài phép toán rồi loại bỏ nó. Arithmetic intensity gần bằng 1, nên các compute unit dành phần lớn thời gian để chờ.

Decode chậm vì mỗi token cần đọc toàn bộ model từ bộ nhớ, nên memory bus quyết định tốc độ còn các compute unit gần như nhàn rỗi.

Vì vậy, giới hạn tốc độ decode trên một stream đơn là lượng phép tính có thể thực hiện trên lý thuyết. Lấy băng thông bộ nhớ chia cho số byte mà các weight chiếm dụng.

ChartPublished memory bandwidth and the decode ceiling it implies for a 16 GB model
The data behind this chart
[
  {
    "device": "CPU, dual channel DDR5-5600",
    "mem_bandwidth_gb_s": 90,
    "decode_ceiling_tok_s": 6
  },
  {
    "device": "NVIDIA A10G",
    "mem_bandwidth_gb_s": 600,
    "decode_ceiling_tok_s": 38
  },
  {
    "device": "NVIDIA L40S",
    "mem_bandwidth_gb_s": 864,
    "decode_ceiling_tok_s": 54
  },
  {
    "device": "NVIDIA RTX 4090",
    "mem_bandwidth_gb_s": 1008,
    "decode_ceiling_tok_s": 63
  },
  {
    "device": "NVIDIA A100 80GB SXM",
    "mem_bandwidth_gb_s": 2039,
    "decode_ceiling_tok_s": 127
  },
  {
    "device": "NVIDIA H100 SXM",
    "mem_bandwidth_gb_s": 3350,
    "decode_ceiling_tok_s": 209
  }
]

Cột băng thông chứa thông số do từng vendor công bố. Cột giới hạn là thông số đó chia cho 16 GB, tức kích thước của model 8 billion parameter được lưu ở độ chính xác 16 bit. Đây là phép tính, không phải kết quả benchmark. Tốc độ đo được sẽ thấp hơn giới hạn này. Biết mức chênh lệch là bao nhiêu rất hữu ích, vì nó cho biết bạn nên tối ưu serving stack hay nâng cấp hardware.

Đọc 6 hàng theo thứ tự, bạn sẽ thấy ngay quy luật. CPU dùng dual channel DDR5 đạt khoảng 90 GB/s, giới hạn decode của model đó ở gần 6 token mỗi giây. L40S đạt gần 54. H100 SXM có băng thông được công bố là 3350 GB/s, đạt gần 209.

Đây cũng là lý do quantization là đòn bẩy đơn lẻ mạnh nhất để tăng tốc decode. Lưu cùng model ở 8 bit thay vì 16 bit sẽ giảm một nửa số byte phải đọc cho mỗi token, nên giới hạn tốc độ tăng khoảng gấp đôi. Bạn không thêm phép tính nào. Bạn chỉ di chuyển ít dữ liệu bộ nhớ hơn.

Làm cách nào để tự đo từng giai đoạn trên server của tôi?

Ollama trả về phần tách thời gian trong response body. Hãy yêu cầu completion không streaming rồi đọc các bộ đếm.

curl -s http://localhost:11434/api/generate -d '{
  "model": "llama3.2",
  "prompt": "Explain memory bandwidth in two sentences.",
  "stream": false
}' | jq '{prompt_eval_count, prompt_eval_duration, eval_count, eval_duration}'

Dùng tag của model mà bạn đã pull. ollama list sẽ hiển thị tag đó. prompt_eval_countprompt_eval_duration là prefill: số token của prompt và thời gian xử lý prompt. eval_counteval_duration là decode. Thời lượng tính bằng nanosecond, nên tốc độ decode là eval_count / eval_duration * 1e9 và tốc độ prefill là prompt_eval_count / prompt_eval_duration * 1e9. Với cùng một request, tốc độ prefill thường cao hơn nhiều so với tốc độ decode. Phần chênh lệch đó là cơ sở để giải thích mọi nội dung còn lại trong phần này.

Với một OpenAI compatible server như vLLM, curl có thể đo thời điểm nhận được byte đầu tiên.

curl -N -s -o /dev/null \
  -w 'pretransfer %{time_pretransfer}s  first_byte %{time_starttransfer}s\n' \
  http://localhost:8000/v1/completions \
  -H 'Content-Type: application/json' \
  -d '{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "Explain memory bandwidth.", "max_tokens": 128, "stream": true}'

time_starttransfer là thời điểm byte đầu tiên của body đến, nên khi kết hợp với "stream": true, giá trị này bao gồm TTFT và thời gian thiết lập connection. Trừ time_pretransfer để loại bỏ chi phí thiết lập. Chạy 2 lần và giữ kết quả lần thứ 2, vì lần gọi đầu tiên có thể bao gồm thời gian load model khi cache còn lạnh.

vLLM cũng cung cấp phần tách thời gian dưới dạng Prometheus metrics tại /metrics. Chạy curl -s http://localhost:8000/metrics | grep -E 'time_to_first_token|inter_token_latency' để lấy các histogram vllm:time_to_first_token_secondsvllm:inter_token_latency_seconds. Thêm vllm:num_requests_runningvllm:num_requests_waiting để theo dõi độ dài queue, và vllm:kv_cache_usage_perc để theo dõi áp lực lên cache. 5 tên đó là toàn bộ dashboard.

Khi có tải, vllm bench serve --model <name> --num-prompts 200 --request-rate 4 điều khiển server đang chạy và báo cáo thời gian đến token đầu tiên cùng latency của từng output token theo percentile. Đây là cách duy nhất để thấy 2 giai đoạn cạnh tranh tài nguyên với nhau. Trước khi tinh chỉnh, hãy lấy một baseline sạch: phương pháp trong đo số token mỗi giây trên local LLM cho kết quả vẫn còn giá trị sau khi reboot.

Vì sao system prompt dài làm chậm token đầu tiên nhưng không làm giảm tốc độ streaming?

Vì system prompt tạo ra phần việc prefill, không làm thêm việc nào khác. Nó được xử lý một lần, trong cùng lượt với phần còn lại của prompt, trước khi token đầu tiên xuất hiện. Sau lượt đó, nội dung này chỉ còn tồn tại dưới dạng các mục trong KV cache, và quá trình decode đọc chúng cùng với mọi phần khác. Vì vậy, system prompt dài 3,000 token làm tăng TTFT trong mọi request, nhưng gần như không thay đổi số token mỗi giây.

Gần như, không phải hoàn toàn. Các mục KV bổ sung đó vẫn được đọc lại ở mỗi bước decode, nên prompt rất dài sẽ làm tốc độ decode giảm nhẹ. Phần tiếp theo sẽ nói về vấn đề này.

Cách khắc phục là không tính toán lại cùng một prefix. Server có prefix caching sẽ giữ KV cache của một prefix dùng chung và tái sử dụng nó, nên request thứ hai có cùng system prompt sẽ bỏ qua hoàn toàn phần prefill đó. vLLM gọi cơ chế này là automatic prefix caching; hãy kiểm tra vllm serve --help trên phiên bản bạn dùng, vì giá trị mặc định đã thay đổi qua các bản phát hành. KV cache nằm trong GPU này khác với prompt cache mà nhà cung cấp API dùng để tính phí, và sự khác nhau giữa KV cache và prompt cache đáng để đọc trước khi bạn tối ưu một trong hai loại cache này.

Vì sao decode chậm dần khi context đầy?

Có 2 nguyên nhân, đều liên quan đến KV cache.

Nguyên nhân thứ nhất là băng thông. Ở mỗi bước decode, attention đọc key và value của mọi token trước đó. Weights là chi phí cố định trên mỗi token. KV cache thì tăng dần. Bạn có thể tính kích thước của nó từ config.json của model: số byte trên mỗi token bằng 2 nhân với num_hidden_layers, nhân với num_key_value_heads, nhân với chiều head (hidden_size chia cho num_attention_heads), rồi nhân với số byte trên mỗi phần tử. Số 2 ở đầu là một key và một value.

Với một layout phổ biến cho model 8 billion parameter, gồm 32 layer, 8 key và value head theo GQA (grouped query attention), chiều head là 128 và precision 16 bit, phép tính là 2 x 32 x 8 x 128 x 2 = 131,072 byte, tương đương khoảng 128 KiB trên mỗi token. Một cuộc hội thoại 8,000 token vì vậy sử dụng khoảng 1 GB KV cache cho mỗi request.

Nguyên nhân thứ hai là dung lượng. 1 GB đó là phần memory không thể dùng để chứa weights hoặc context của user khác. Server cấp phát KV pool một lần khi khởi động; trên vLLM, cấu hình này nằm ở --gpu-memory-utilization. Khi pool đầy, request mới phải chờ. vllm:num_requests_waiting tăng dần trong khi vllm:kv_cache_usage_perc gần 1 là dấu hiệu chính xác của trạng thái này. Một số stack sẽ preempt request đang chạy rồi tính lại cache sau, thay vì đưa request vào queue. Người dùng sẽ thấy stream bị stall giữa chừng.

Context dài khiến bạn chịu 2 loại chi phí: nhiều prefill hơn lúc bắt đầu và nhiều dữ liệu memory phải đọc hơn trên mỗi token trong phần trả lời còn lại.

Vì sao batching tăng throughput nhưng làm xấu tail latency?

Vì decode bị giới hạn bởi băng thông, các request bổ sung gần như không tốn thêm chi phí ở phía compute. Một lần đọc weights có thể tạo ra một token cho từng sequence trong batch, nên throughput tổng tăng gần tuyến tính theo batch size cho đến khi KV pool hết chỗ hoặc batch đủ lớn để lại bị giới hạn bởi compute. Continuous batching xây dựng lại batch ở mỗi step, nên request đã hoàn tất sẽ rời đi và request đang chờ sẽ tham gia mà không phải đợi các request khác trong batch.

Chi phí thể hiện trong các percentile. Next token của mỗi user hiện phải đợi phần chậm nhất của một step dùng chung, nên p50, tức median, vẫn ở mức chấp nhận được trong khi p99, tức 1 request chậm nhất trong 100 request, bị kéo dài. Người dùng nhận thấy p99 vì đó là khoảng dừng ngay giữa một câu.

Prefill làm tình trạng này rõ hơn. Khi một prompt lớn đến giữa luồng xử lý, nó chiếm device trong một step dài, khiến mọi request đang streaming bị gián đoạn. Chunked prefill loại bỏ phần lớn vấn đề này bằng cách chia prompt dài thành các phần và trộn từng phần vào các batch decode. Tính đến tháng 8 năm 2026, engine vLLM V1 bật tính năng này theo mặc định và cho phép điều chỉnh mức cân bằng qua --max-num-batched-tokens. Tài liệu tuning của vLLM nêu rõ tradeoff: các giá trị nhỏ hơn, khoảng 2048, cho inter token latency (ITL) tốt hơn vì ít prefill làm gián đoạn decode hơn; các giá trị lớn hơn cho TTFT tốt hơn vì có nhiều prefill token hơn trong một batch. Flag này biểu diễn trực tiếp lựa chọn giữa prefill và decode dưới dạng một con số có thể điều chỉnh. p99 không còn chấp nhận được từ mức nào là câu hỏi về capacity, và số user đồng thời mà một LLM tự host có thể phục vụ giải quyết vấn đề này bằng cùng các metric.

Vì sao GPU lớn hơn đôi khi không thay đổi gì?

Vì “lớn hơn” thường có nghĩa là nhiều năng lực tính toán hơn, trong khi decode không cần nhiều năng lực tính toán.

Hãy so sánh hai dòng trong biểu đồ ở trên. A100 80GB có băng thông được công bố là 2039 GB/s, còn L40S là 864 GB/s. Mức trần decode cũng khớp chính xác: 127 token mỗi giây so với 54. RTX 4090 là một card rất nhanh xét theo hầu hết tiêu chí, và băng thông 1008 GB/s đặt mức trần của nó ở 63. Dù hai card còn khác nhau ở điểm nào, decode một stream vẫn bám theo mức băng thông trên bảng thông số.

Vì vậy, có 2 cách để decode nhanh hơn: đọc ít byte hơn cho mỗi token (quantize weight hoặc chạy model nhỏ hơn), hoặc mua GPU có băng thông cao hơn. Prefill thì ngược lại. Prefill cần năng lực tính toán, nên card nhanh hơn thực sự rút ngắn TTFT với prompt dài. Nếu vấn đề là token đầu tiên mất 4 giây mới xuất hiện, phần cứng tốt hơn có thể giải quyết được. Nếu vấn đề là văn bản hiển thị chậm từng token, phần cứng tốt hơn có lẽ không giúp được.

Có nên chạy prefill và decode trên các worker riêng không?

Các stack serving lớn thực hiện đúng việc này. Kỹ thuật này gọi là tách prefill và decode. Một pool worker chỉ chạy prefill. Pool thứ hai chỉ chạy decode. KV cache do pool đầu tiên tạo ra được truyền sang pool thứ hai qua interconnect tốc độ cao. Cách này hiệu quả vì hai phase cần phần cứng và cơ chế scheduling khác nhau. Prefill cần năng lực tính toán và các batch token lớn. Decode cần băng thông và nhiều sequence chạy đồng thời. Tách hai phase cho phép mỗi pool scale độc lập. Nó cũng ngăn một prompt rất lớn làm stall mọi stream đang hoạt động.

Trên một VPS (virtual private server) chỉ có một GPU, cách này gần như không bao giờ đáng làm. Bạn sẽ chia một thiết bị thành hai phần để tự phục vụ lẫn nhau. Đồng thời, bạn sẽ biến thao tác truyền pointer thành việc truyền hàng gigabyte cache qua network. Kỹ thuật này chỉ đem lại hiệu quả khi bạn có đủ accelerator để dành hẳn từng máy cho mỗi phase, và có đủ traffic ổn định để giữ cả hai pool luôn bận. Nếu chưa đạt mức đó, chunked prefill mang lại phần lớn khả năng cô lập tương tự chỉ với một flag.

Cần thay đổi gì khi các chỉ số không đạt

Khi TTFT quá cao:

  • Rút ngắn prompt. Chi phí prefill tăng theo số token trong prompt, và system prompt được tính trong mọi request.
  • Bật prefix caching để phần prefix lặp lại chỉ được tính một lần thay vì mỗi lần đều tính lại.
  • Tăng --max-num-batched-tokens để mỗi step xử lý nhiều công việc prefill hơn.
  • Kiểm tra queue trước khi quy lỗi cho model. vllm:num_requests_waiting lớn hơn 0 nghĩa là request chưa bắt đầu chạy, đây là vấn đề về capacity.

Khi số token mỗi giây quá thấp:

  • Quantize các weight. Mỗi weight có ít byte hơn thì cần đọc ít byte hơn cho mỗi token.
  • Đối chiếu memory bandwidth được công bố của card với biểu đồ ở trên để xem mức sử dụng của bạn gần ceiling đến đâu.
  • Giảm --max-num-batched-tokens để prefill ít làm gián đoạn decode hơn.
  • Kiểm tra context length. Khi một conversation tăng lên hàng nghìn token, mỗi step phải đọc một KV cache lớn hơn nhiều.

Runtime cũng có ảnh hưởng ở đây, vì Ollama và vLLM lập lịch prefill và decode khác nhau, nên một setting có ích cho runtime này có thể không có tác dụng trên runtime kia. Trước tiên hãy đo ở cả hai phase, sau đó chỉ thay đổi một yếu tố.

FAQ

Vì sao token đầu tiên mất vài giây nhưng các token sau lại stream nhanh?

Thời gian chờ là giai đoạn prefill, còn streaming là giai đoạn decode. Prefill xử lý toàn bộ prompt trong một lượt tính toán bị giới hạn bởi năng lực tính toán trước khi có bất kỳ output nào, nên chi phí tăng theo độ dài prompt. Sau đó, decode phát ra mỗi bước một token với tốc độ do memory bandwidth quyết định, gần như không phụ thuộc vào độ dài prompt. Nguyên nhân thường gặp là system prompt dài được gửi trong mọi request. Prefix caching loại bỏ phần chi phí lặp lại này.

Prompt dài hơn có làm giảm số token mỗi giây không?

Có, nhưng chỉ một chút và vì lý do khác với TTFT. Mỗi bước decode đọc key và value của tất cả token trước đó, nên KV cache lớn hơn đồng nghĩa với việc phải đọc nhiều byte hơn cho mỗi token. Với một layout phổ biến có 8 billion parameter, cache chiếm khoảng 128 KiB cho mỗi token, nên context 8,000 token tương đương khoảng 1 GB được truy cập ở mỗi bước. Tác động lớn hơn của prompt dài vẫn là lên TTFT, không phải tốc độ streaming.

Thông số GPU nào dự đoán tốc độ decode?

Memory bandwidth. Lấy bandwidth được công bố chia cho kích thước của weights trong memory, bạn sẽ có giới hạn lý thuyết cho một stream. Một card có năng lực tính toán cao hơn nhưng cùng bandwidth sẽ không stream nhanh hơn. Đây cũng là lý do quantize xuống 8 bit giúp tăng gần gấp đôi tốc độ decode: số byte phải đọc cho mỗi token giảm một nửa mà không ảnh hưởng đến năng lực tính toán.

Vì sao throughput tăng khi tôi thêm user nhưng mỗi user lại thấy chậm hơn?

Một lần đọc weights có thể phục vụ một token cho mọi sequence trong batch, nên tổng số token mỗi giây tăng theo batch size. Tuy nhiên, mỗi token của từng user phải chờ một bước dùng chung, nên latency trên mỗi user cũng tăng. Hãy theo dõi p99 inter token latency thay vì chỉ nhìn số throughput tổng, và kiểm tra vllm:num_requests_waiting để xem request đang xếp hàng hay đang được xử lý.