AI Engineering from Scratch · Manual© 2026 Rohit Ghumare · MIT license

vLLM 101

Serve, size, tune, and debug open models with vLLM, with measured answers for each decision

vLLM 0.31.0 · db9527a · 2026-10-05 · Edition 2026.10
  • Start
  • Size It
  • Tune for Your Traffic
  • Speed Features
  • Measure
  • Scale Out
  • Operate and Debug
  • How vLLM Works
Plate IOne block pool on one L4
One block pool on one L4 The vLLM 0.31.0 API server and EngineCore run on the host CPU and talk over ZMQ. The Scheduler inside EngineCore builds one step under a token budget of 2,048 tokens: requests A and B decode one token each, and request C adds a prefill chunk. The block table of the KVCacheManager maps each request to blocks that are not all next to each other. Below is one NVIDIA L4 with its SM array of 58 SMs and 24 GB of GDDR6, where the KV cache is a grid of 16-token blocks: 1,506 blocks hold 24,096 tokens, and the grid shows 40 of them: the blocks of A and B, some of the blocks of C, and free blocks. HOST CPU · VLLM 0.31.0 PROCESSES API server HTTP, tokenizer EngineCore Scheduler KVCacheManager block table A · 2, 13, 31 B · 7, 8, 26, 38 C · 17, 18, 27, 28, 35, more ZMQ allocate_slots x86_64 host ONE SCHEDULER STEP budget 2,048 tokens A decode, 1 token B decode, 1 token C prefill chunk, 2,046 tokens The decode slivers are drawn wider. Decodes go first, then a prompt chunk fills the rest. NVIDIA L4 · GPU MEMORY SM array 58 SMs, Ada KV cache · 16-token blocks 1,506 blocks, 24,096 tokens 24 GB GDDR6, six packages in use free · 40 of 1,506 shown With 8 users the log read: GPU KV cache usage 77.2%, prefix cache hit rate 87.2%. Block ids show the mapping only. Pool, block size, and budget are from our L4 runs.
The Scheduler fills one step under a 2,048-token budget. The block table maps each request to blocks that are not all next to each other. On our L4 the pool held 1,506 blocks of 16 tokens.

How to read this manual

This manual helps you serve, size, tune, and debug open models with vLLM, and it gives a measured answer for each decision.

The vLLM documentation tells you what each flag does. This manual tells you which flags matter for your GPU and your traffic, and what happened when we changed them.

Start here

These commands give you a server:

uv venv --python 3.12 --seed
source .venv/bin/activate
uv pip install vllm==0.31.0 --torch-backend=auto
vllm serve Qwen/Qwen3-0.6B --reasoning-parser qwen3 2>&1 | tee server.log
curl http://localhost:8000/v1/models

Run the curl command in a second terminal. Part 1 walks through each command and the output you should see.

What you can do after this manual

  • Install vLLM, serve a model, and read the startup lines that decide most answers.
  • Predict how many users fit on your GPU, and fix an out-of-memory failure at startup.
  • Pick the one or two flags that matter for your traffic, and leave the rest at the default.
  • Decide from a measurement when speculative decoding, FP8, or a compile level pays.
  • Debug a slow or broken server from the symptom you see.

Who this manual is for

You have called a model through an API, and now you want to serve an open model yourself. You know what a token and a GPU are. You have not read the code of a serving engine, and you do not need to.

How the parts are ordered

Parts 1 to 7 follow the order of your work. You start a server, size it, tune it for your traffic, add speed features, measure it, scale it, and run it. Each page answers one question and ends with a check that proves the change worked.

Part 8 explains how vLLM works inside. Read it when a default surprises you. The reference pages hold the flag table, the metrics, the incident index, and the lab notes.

PartWhat it covers
1 · StartInstall vLLM, serve a first model, check its health, name the numbers that judge a server, and set the common server options.
2 · Size ItMemory per token, out-of-memory fixes, unified memory on a DGX Spark, FP8 for the KV cache and the weights, and long contexts.
3 · Tune for Your TrafficRecipes for chat, RAG and agents, long documents, long sessions, JSON, batch jobs, multi-LoRA, reproducible outputs, RL rollouts, and reasoning models.
4 · Speed FeaturesSpeculative decoding, CUDA graphs and compile levels, async scheduling, and the attention backend.
5 · MeasureBenchmark your own server, watch it in production, and decide between vLLM and SGLang for one workload.
6 · Scale OutTensor, pipeline, and data parallelism, expert parallelism, prefill and decode on separate servers, Kubernetes, and other accelerators.
7 · Operate and DebugTroubleshooting by symptom, and running vLLM in production.
8 · How vLLM WorksProcesses, the scheduler, the KV block pool, attention backends, the model runner, compile and graphs, speculation, KV connectors, and history.
R · ReferenceLook up a flag, a metric, an incident, a version pin, or a figure here.

What we tested on

We tested on vLLM 0.31.0 with an NVIDIA L4 and a DGX Spark. The L4 served Qwen3-8B, and the DGX Spark served Qwen3-30B-A3B. Each measured number in this manual names its machine. The lab notes give the seeds, the clocks, and the full tables.

Four L4 sessions

The L4 did not run at one speed. Its SM clock changed with its temperature and with other work on the node. So the same Qwen3-8B load gave different times in different sessions. Each L4 table in this manual names its session. Compare two numbers only inside one session.

SessionDatesMedian SM clock1 user, median TPOT32 users, output tok/s
full clock, cooled before each run2026-10-17 to 2026-10-211,000 to 1,560 MHz60.4 ms273.9 to 275.1
throttled2026-10-22210 MHz at 1 user, 450 MHz at 32 users104.1 ms186.7
locked at 900 MHz2026-10-22881 to 900 MHz, 210 MHz for the 1-user baseline99.0 ms208.8
shared GPU, pool of 2,928 tokens2026-10-22368 to 450 MHz241.7 msnot run

A throttled GPU runs below its full clock because it is too hot or because it shares power with other work. A lock with nvidia-smi -lgc sets the highest clock. It cannot stop a hot GPU from slowing down below the lock.

Part 1

Start

You can serve a model with vLLM and prove it works before you change a single setting.

  1. 1.1Install vLLM and serve your first model
  2. 1.2Check that the server is healthy
  3. 1.3Understand what happens to your request
  4. 1.4Set the server options every deployment needs

Install vLLM and serve your first model

You can go from an empty machine to a streamed token from your own vLLM server with three commands and one request.

You have an NVIDIA GPU and a model name, and you want your own OpenAI-compatible endpoint. When you finish this section, you can install vLLM, start a server, send it a request, and read the startup log.

We use vLLM 0.31.0 in every command. vLLM publishes a new minor release about every two weeks, so pin the version in your install command.

Check the GPU first

First, check that the driver sees your GPU. Run this command:

nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv

You see one line for each GPU, with its name and its memory. If the command is not found, install the NVIDIA driver before you continue. vLLM does not install the driver for you.

Check the rest of your machine against this table. It comes from the vLLM 0.31.0 install guide. We did not test the lower limits.

You needValue at 0.31.0
Operating systemLinux, or WSL on Windows, for the uv and pip paths. Docker needs a host with the NVIDIA container toolkit
GPUNVIDIA, compute capability 7.5 or higher, such as a T4, an L4, an A100, or an H100
Drivernew enough for CUDA 12.9 for the default wheel, and R580 or newer for the CUDA 13 image
Python3.10 to 3.13
Hugging Face tokenonly for a model with restricted access, set as HF_TOKEN

Install vLLM

Pick one of three paths. Use the first path unless you have a reason to pick another.

Path 1, uv. uv creates a clean Python environment and picks the PyTorch build that matches your CUDA driver.

uv venv --python 3.12 --seed
source .venv/bin/activate
uv pip install vllm==0.31.0 --torch-backend=auto

Path 2, pip. Use a new virtual environment with Python 3.12, then install the same version.

python3.12 -m venv .venv && source .venv/bin/activate
pip install vllm==0.31.0

Path 3, Docker. The official image holds vLLM, PyTorch, and CUDA. We pulled vllm/vllm-openai:v0.31.0 on our arm64 DGX Spark, and it ran.

docker run --rm --gpus all --ipc=host -p 8000:8000 \
  -v ~/.cache/huggingface:/root/.cache/huggingface \
  vllm/vllm-openai:v0.31.0 Qwen/Qwen3-0.6B --reasoning-parser qwen3

The image starts the server for you, so with Docker you skip the next step. Mount your Hugging Face cache, or the container downloads the weights again on every start. Read the log with docker logs on the container.

Check the install with vllm --version. You see 0.31.0.

On the uv and pip paths, the first start compiles FlashInfer kernels, so it needs a CUDA toolkit with nvcc, and CUDA_HOME set to it. The Docker image has one. We installed vLLM 0.31.0 with uv on a host with only a driver, and every start stopped with Could not find nvcc. Install the toolkit, set CUDA_HOME, and start again. The troubleshooting page covers this and a toolkit that does not match.

Start the server

Now, start a small model. Qwen3-0.6B has 0.6 billion parameters, so its BF16 weights need about 1.2 GB.

vllm serve Qwen/Qwen3-0.6B --reasoning-parser qwen3 2>&1 | tee server.log

The server listens on port 8000. The first start downloads the weights, so it takes longer than the next start.

The tee command shows the log in the terminal and also writes it to server.log. Later pages search that file with grep, so keep this name for every start.

Qwen3 is a reasoning model. It writes its thinking first, between <think> tags, and then the answer. --reasoning-parser qwen3 tells vLLM to move the thinking out of the answer text. Without it, the thinking stays at the start of the answer. The reasoning models page explains the parser.

The startup log is long. Six lines tell you most of what you need. The listing shows them from our DGX Spark, where we served the larger Qwen3-30B-A3B. Your lines have the same shape with smaller numbers.

the startup lines to read, from a DGX SparktextFull log
(APIServer pid=1) INFO 10-21 10:53:02 [api_utils.py:347]  ▄▄ ▄█ █     █     █ ▀▄▀ █  version 0.31.0
(EngineCore pid=143) INFO 10-21 10:53:14 [gpu_worker.py:479] Using V2 Model Runner
(EngineCore pid=143) INFO 10-21 10:53:17 [cuda.py:526] Using FLASH_ATTN attention backend out of potential backends: ['FLASH_ATTN', 'FLASHINFER', 'TRITON_ATTN', 'FLEX_ATTENTION'].
(EngineCore pid=143) INFO 10-21 10:58:45 [model_runner.py:407] Model loading took 56.93 GiB memory and 330.542793 seconds
(EngineCore pid=143) INFO 10-21 10:59:46 [gpu_worker.py:692] Available KV cache memory: 31.32 GiB
(EngineCore pid=143) INFO 10-21 10:59:46 [kv_cache_utils.py:2464] GPU KV cache size: 234,960 tokens, Maximum concurrency for 8,192 tokens per request: 28.68x
Six lines cut from the start of the log. The banner, the full engine config, and the route list are cut.

Read the lines in this order:

  1. The version. It must match the version you pinned.
  2. The model runner. vLLM 0.31.0 uses the V2 model runner on CUDA by default. Some features switch it back to V1, and this line shows it.
  3. The attention backend. vLLM picks one for your GPU. A change to the KV cache data type can change this line.
  4. Model loading. This line gives the memory that the weights took and the time to load them.
  5. The available KV cache memory. This line gives the memory that is left for the KV cache after the weights and the startup profile.
  6. The KV cache size. This line gives the tokens that fit in the cache and the number of full-length requests that fit at the same time. Part 2 starts from this line.

The server is ready when the log shows Application startup complete.. On our DGX Spark, that took about eight minutes for 57 GiB of weights. The model loading line gives 330.5 seconds, and 325 of those seconds went to reading the weight files.

Fig. 1.1the six startup lines to readstructure
The six startup lines to read after every start Six lines from the startup log of vLLM 0.31.0 on a DGX Spark with Qwen3-30B-A3B: version 0.31.0, the V2 model runner, the FLASH_ATTN attention backend, 56.93 GiB of weights loaded in 330 seconds, 31.32 GiB left for the KV cache, and a pool of 234,960 tokens. LOG LINE OUR VALUE WHAT IT DECIDES version 0.31.0 the release that runs Using V2 Model Runner V2 which model runner runs attention backend FLASH_ATTN which kernels run attention Model loading took 56.93 GiB, 330 s memory the weights hold Available KV cache memory 31.32 GiB memory left for the KV cache GPU KV cache size 234,960 tokens how many users fit Find each line once after every start, before you send traffic.
Six startup lines show the version, the runner, the attention backend, the weight memory, the free KV memory, and the pool size. Read each row from left to right: the log line, our value on the DGX Spark, and what it decides.

Send the first request

Open a second terminal. Send one chat request with curl:

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "Qwen/Qwen3-0.6B", "messages": [{"role": "user", "content": "Name three prime numbers."}], "max_tokens": 64, "chat_template_kwargs": {"enable_thinking": false}}'

The chat_template_kwargs field turns the thinking off for this one request, so 64 tokens are enough for the answer. The reply is one JSON object. The answer text is in choices[0].message.content, and the token counts are in usage. In the server log, a new line shows the request path and 200 OK.

If you leave the thinking on, the model can spend all 64 tokens on it. The thinking then goes to the reasoning field, content is null, and finish_reason is length. We saw exactly this on an L4. Give a thinking request a much larger max_tokens.

The server speaks the OpenAI API, so the OpenAI Python client works without a change to your code:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
stream = client.chat.completions.create(
    model="Qwen/Qwen3-0.6B",
    messages=[{"role": "user", "content": "Name three prime numbers."}],
    stream=True,
    extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="", flush=True)

The tokens print one by one as the server streams them. That is your first streamed token.

When the output is different

  • The server stops with an out-of-memory error. Another program uses the GPU, or the model is too large. Read the out-of-memory page.
  • The answer starts with a long <think> block. The server runs without a reasoning parser. Start it with --reasoning-parser qwen3, or send "chat_template_kwargs": {"enable_thinking": false} in the request.
  • The answer is empty and finish_reason is length. The thinking used all of max_tokens. Raise max_tokens, or turn the thinking off for the request.
  • The request returns 404. The model name in the request must match the name in vllm serve. Run curl http://localhost:8000/v1/models to see it.
  • The answers use different sampling than you set. By default, vLLM applies the sampling settings from the model repository. Start the server with --generation-config vllm to use the vLLM defaults.
  • The start takes many minutes. The first start downloads the weights. On a DGX Spark, weight loading is slow in 0.31.0 for large models.

Check that the server is healthy

Three routes and one log line tell you in a minute that the server is up, that it loaded the right model, and how much it can hold.

Your server started and you want to know that it works before you send it real traffic. When you finish this section, you can check the health route, the model list, the metrics, and the KV cache size.

Ask the health route

Run this command:

curl -i http://localhost:8000/health

You see HTTP/1.1 200 OK and an empty body. The server log shows the same request.

the first health check and model list, from a DGX SparktextFull log
INFO:     Application startup complete.
INFO:     127.0.0.1:35152 - "GET /health HTTP/1.1" 200 OK
INFO:     127.0.0.1:34616 - "GET /v1/models HTTP/1.1" 200 OK
Three lines from the end of startup.

The health route answers 200 when the engine runs. It does not generate a token, so a 200 does not prove that a request completes. Send one short request as well, as in the first request.

If the route does not answer, the server is still loading or it stopped. Read the end of the server log. A load can take minutes for a large model.

Check the model name

Run this command:

curl http://localhost:8000/v1/models

The reply lists one model. Its id is the name your requests must use. Its max_model_len is the longest request in tokens that the server accepts.

Read the KV cache size

Go back to the startup log and find the line that starts with GPU KV cache size. On our DGX Spark it reads:

the KV cache line, from a DGX SparktextFull log
(EngineCore pid=143) INFO 10-21 10:59:46 [kv_cache_utils.py:2464] GPU KV cache size: 234,960 tokens, Maximum concurrency for 8,192 tokens per request: 28.68x
One line from the startup log.

The first number is the total tokens of context that the server can hold for all users together. The second number is how many requests of the full maximum length fit at the same time. Shorter requests fit in larger numbers.

If the first number is much smaller than you expected, another program holds GPU memory, or the memory fraction is low. The memory page shows how to predict this number before you start.

Read the metrics

Run this command:

curl -s http://localhost:8000/metrics | grep -E "^vllm:(num_requests_running|num_requests_waiting|kv_cache_usage_perc)\{"

You see three lines. With no traffic, the running and waiting counts are 0 and the KV cache usage is 0. Send a request in a second terminal and run the command again to see the counts change.

Three metrics give the first answer to most questions:

MetricWhat it tells you
vllm:num_requests_runningrequests that the engine works on now
vllm:num_requests_waitingrequests in the queue because the batch or the cache is full
vllm:kv_cache_usage_percthe part of the KV cache in use, from 0 to 1

A waiting count above 0 with the cache usage near 1 means the cache is full. The production page gives the full alert list.

Understand what happens to your request

A request spends one short phase reading the prompt and a longer phase writing the answer, and four numbers measure those two phases.

You sent a request and the answer came back, slowly at first and then token by token. When you finish this section, you can name the two phases of a request and the four numbers that every benchmark in this manual reports.

Our result: on our DGX Spark, 64 users got eight times the output of one user. Each user waited about 7.5 times longer for every token.

Two phases

Prefill: the step that reads the whole prompt at once and writes its keys and values into the KV cache. One prefill step handles many prompt tokens, so the GPU is busy with math.

Decode: the steps that write the answer, one token per request per step. Each step reads all the model weights to make one token for each running request. The GPU waits for memory more than for math.

vLLM does not run the two phases as separate stages. Each scheduler step has one token budget. Running requests get their next decode token first. Then the scheduler spends the rest of the budget on prompt tokens, in chunks. A long prompt can take several steps. Part 8 explains why vLLM works this way.

Fig. 1.2one request through three processesflow
One request through the three vLLM processes A client sends HTTP to the API server process, which tokenizes and sends the request over ZeroMQ to the engine core process. The engine core schedules it and sends the batch to a GPU worker, one for each GPU. Token ids come back the same way, and the API server streams text to the client. YOU PROCESS 1 PROCESS 2 ONE FOR EACH GPU client curl, OpenAI SDK, bench API server HTTP routes tokenizer engine core Scheduler KVCacheManager GPU worker GPUModelRunner forward pass 1 2 3 1 HTTP request, text in 2 ZeroMQ message, token ids 3 shared memory queue, one batch per step token ids back, one step at a time text streamed to the client A worker crash shows up in the engine core log. A slow tokenizer shows up as slow first tokens.
A request crosses three processes: the API server tokenizes it, the engine core schedules it, and a GPU worker runs the model. Solid arrows carry the request forward, dashed arrows carry token ids back, and the dotted arrow is the text stream.

Four numbers

NumberWhat it measuresWho feels it
TTFT, time to first tokenthe wait from the request to the first token, mostly queue time and prefilla user who waits for the answer to start
TPOT, time per output tokenthe mean time between tokens after the first onea user who reads a long answer
ITL, inter-token latencyeach gap between two tokens, so its 99th percentile shows the worst stallsa user who sees the stream stop and start
Throughputoutput tokens per second for all users togetherthe person who pays for the GPU

One server, two loads

The numbers trade against each other as users grow. Here is our DGX Spark serving Qwen3-30B-A3B with vLLM 0.31.0, from one user to 64 users at the same time.

Users at the same timeMedian TTFTMedian TPOTOutput tokens per second
1257 ms32.3 ms29.5
64965 ms243.8 ms239.3

With 64 users, the server wrote about eight times as many tokens per second. Each user waited almost four times as long for the first token, and each token took more than seven times as long. A busy server is cheaper per token and slower for each user.

Every tuning page in this manual moves one of these four numbers. Each page says which number it moves and which number it costs.

Fig. 1.3one server, two loadschart
More users, more tokens in total, a slower stream for each user vLLM 0.31.0 on a DGX Spark with Qwen3-30B-A3B, three seeds per point. Left: output tokens per second for all users rise from 29.5 at one user to 239.3 at 64 users. Right: the median time per output token for each user rises from 32.3 ms to 243.8 ms. 0 100 200 300 1 4 16 64 users at the same time output tokens per second, all users 0 100 200 300 1 4 16 64 users at the same time median ms per token, each user median of three runs, band from lowest to highest
On our DGX Spark, 64 users got eight times the total output of one user, and each user waited about 7.5 times longer for every token. Read the left chart for the server and the right chart for one user. Bands span the lowest and highest of three runs.

Set the server options every deployment needs

A handful of server options decide who can reach the server, what name clients send, and what the log keeps.

Your first server works, and now other programs must call it. When you finish this section, you can set the address, the model name, the API key, the chat template, and the logging.

We did not measure the speed of these options. We checked the address, the model name, the API key, the YAML file, and the removed log flag on an L4 with the small Qwen3-0.6B. The rest comes from the vLLM documentation and the source at 0.31.0.

Pick the address and the port

OptionDefault at 0.31.0Set it when
--hostnot set, so the server listens on every network interfacethe server must answer only one interface, such as 127.0.0.1 for a local test
--port8000another program holds port 8000, or you run two servers on one machine

The default listens on every interface, so other machines on the network can reach the server. For a test on your own machine, set --host 127.0.0.1.

Give the model a stable name

By default, the model name in the API is the name you passed to vllm serve, such as Qwen/Qwen3-8B. Clients must send that exact name. --served-model-name sets the name that clients use instead:

vllm serve Qwen/Qwen3-8B --served-model-name chat

Clients now send "model": "chat". You can then move to a new checkpoint without a change in the clients. The name also labels the metrics as model_name. If you give more than one name, the server answers to each of them.

Require an API key

--api-key makes the server require the key in the Authorization header. You can also set it with the VLLM_API_KEY environment variable, which keeps the key out of the process list.

Stop any server that already runs on port 8000. Then start the server with a key in the first terminal:

VLLM_API_KEY=change-me vllm serve Qwen/Qwen3-8B --host 127.0.0.1 2>&1 | tee server.log

When the server is ready, send a request with the key from a second terminal:

curl http://localhost:8000/v1/models -H "Authorization: Bearer change-me"

The key protects only the routes under /v1, /v2, /inference, and /cohere. Other routes on the same port, such as /invocations, /metrics, and /health, stay open. We checked this on an L4. Without the key, /v1/models returned 401. /health and /metrics returned 200, and so did a chat request to /invocations.

So the key does not make the server safe on a public network. Put a gateway in front of it, as the production page says.

Fix the chat template

vLLM formats chat messages with the chat template from the model repository. Some repositories have no template or a wrong one. Then the model sees a badly formatted prompt and answers poorly.

--chat-template takes a file path or the template text. --default-chat-template-kwargs sets template values for every request, such as '{"enable_thinking": false}' for Qwen3. A request can still set its own chat_template_kwargs. The reasoning models page uses this for Qwen3.

Control what the log keeps

OptionDefaultWhat it does
--enable-log-requestsoffwrites one line for each request, with its ID and parameters
--max-log-lenno limitcuts the prompt text in those lines to this many characters
VLLM_LOGGING_LEVELINFOsets the vLLM log level, such as DEBUG for a hard problem
--uvicorn-log-levelinfosets the log level of the HTTP server
--disable-uvicorn-access-logoffremoves the line for each HTTP request, such as each /health probe

With --enable-log-requests at the DEBUG level, the request lines include the prompt. Do not use that setting on a server that handles private data.

Older releases had a --disable-log-requests flag. At 0.31.0 that flag does not exist, and the request lines are off unless you turn them on.

Turn off usage statistics

vLLM sends anonymous usage data about the hardware and the model configuration by default. To turn it off, set VLLM_NO_USAGE_STATS=1 or DO_NOT_TRACK=1 in the environment of the server.

Keep the options in a file

vllm serve --config config.yaml reads the options from a YAML file. Each key is the long name of a flag without the two dashes, such as served-model-name: chat. An option on the command line wins over the same option in the file.

Check the options

Read the non-default args line near the top of the log. It lists every option that you changed from its default:

grep "non-default args" server.log

Then call /v1/models with and without the key. Without it, the server returns HTTP 401.

Part 2

Size It

The KV cache decides how many users fit, so count it before you pick a GPU or a flag.

  1. 2.1Count the memory before you pick a GPU
  2. 2.2Fix out of memory at startup
  3. 2.3Size memory on a DGX Spark
  4. 2.4Fit more users with an FP8 KV cache
  5. 2.5Fit a bigger model with FP8 weights
  6. 2.6Serve long contexts and hybrid models

Count the memory before you pick a GPU

The KV cache, not the weights, decides how many users fit, and you can count it from four numbers in the model config.

You chose a GPU and the server holds fewer users than you planned. When you finish this section, you can predict the KV pool and the number of users for a model and a context length.

Our result: on our L4 with Qwen3-8B, the 24,096-token pool fit 37 users with 640 tokens each, although the startup line printed 2.94.

Count the bytes for one token

Every token in a running request keeps its keys and values in GPU memory until the request ends. Four numbers in the model config give the size:

KV bytes per token = layers x KV heads x head size x 2 x bytes per value

The factor 2 counts the key and the value. Read the first three numbers from config.json as num_hidden_layers, num_key_value_heads, and head_dim. In BF16, a value takes 2 bytes. With an FP8 KV cache, it takes 1 byte.

ModelLayersKV headsHead sizeKV per token in BF16
Qwen/Qwen3-8B368128144 KiB
Qwen/Qwen3-30B-A3B-Instruct-250748412896 KiB

The mixture-of-experts model has almost four times the weights of the dense model, but it keeps fewer bytes for each token. The KV heads decide this number, not the parameter count.

Fig. 2.1the bytes for one tokenstructure
The bytes one token holds in the KV cache Bytes per token are layers times KV heads times head dimension times two, for keys and values, times two bytes for BF16. Qwen3-8B has 36 layers, 8 KV heads and dimension 128, so 144 KiB. Qwen3-30B-A3B has 48 layers and 4 KV heads, so 96 KiB. LAYERS KV HEADS HEAD DIM K AND V BF16 BYTES MODEL Qwen3-8B 36 x 8 x 128 x 2 x 2 144 KiB for every token of every user Qwen3-30B-A3B 48 x 4 x 128 x 2 x 2 96 KiB for every token of every user
Qwen3-8B holds 144 KiB of KV cache for every token, and Qwen3-30B-A3B holds 96 KiB, because it has fewer KV heads. Multiply the five numbers in each row. The last factor is two bytes for BF16. From the model configs on the Hugging Face Hub.

Turn bytes into users

vLLM gives the KV cache what is left after the weights and the startup profile. Divide that memory by the bytes per token to get the pool in tokens. Then divide the pool by the context that one user holds:

users that fit = KV pool in tokens / (prompt tokens + output tokens per user)

A request that needs more blocks than are free waits in the queue. If a running request needs a new block and none is free, vLLM stops it and computes it again later.

Check the count against the startup log

Read two lines after every start. On our DGX Spark, with Qwen/Qwen3-30B-A3B-Instruct-2507 and --gpu-memory-utilization 0.75, the log said:

the memory lines from a GB10 starttextFull log
(EngineCore pid=143) INFO 10-21 10:58:45 [model_runner.py:407] Model loading took 56.93 GiB memory and 330.542793 seconds
(EngineCore pid=143) INFO 10-21 10:59:46 [gpu_worker.py:692] Available KV cache memory: 31.32 GiB
The weights took 56.93 GiB, and 31.32 GiB was left for the KV cache.

At 96 KiB per token, 31.32 GiB holds about 342,000 tokens.

Fixed pool: in our sweeps, we set the pool size by hand with --num-gpu-blocks-override, which sets the number of 16-token blocks. We gave vLLM the same pool as the SGLang server on the same machine, so that the two engines had the same room. On the DGX Spark the pool was 234,960 tokens, and on the L4 it was 24,096 tokens. Your server sizes the pool from free memory, so it uses the full number.

The next line gives the pool in tokens and the users that fit at the full context length:

the pool line from the L4 comparison runtextFull log
INFO 10-17 20:13:17 [kv_cache_utils.py:2464] GPU KV cache size: 24,096 tokens, Maximum concurrency for 8,192 tokens per request: 2.94x
24,096 tokens at 144 KiB each is 3.31 GiB of KV cache.

The Maximum concurrency number assumes that every user fills the full --max-model-len. Real users hold less, so more of them fit. With 512 prompt tokens and 128 output tokens, the 24,096-token pool holds 37 users at their full length. During our load sweep, the log showed 40 running requests with the cache 99.5 percent full, because users early in their answer hold fewer tokens.

Fig. 2.2the pool decides the userscomparison
The pool in tokens decides how many users fit On the L4 the pool held 24,096 tokens. The startup line reports 2.94 users because it assumes every user fills the full 8,192-token context. With 512 prompt tokens and 128 output tokens, each user holds 640 tokens, so 37 users fit. KV POOL ON THE L4, QWEN3-8B 24,096 tokens, 3.31 GiB at 144 KiB each IF EVERY USER FILLS 8,192 TOKENS Maximum concurrency 2.94x, the number the startup line prints IF EACH USER HOLDS 640 TOKENS 37 users fit, each with 512 prompt and 128 output tokens
The same 24,096-token pool fits 2.94 users at the full context length, but 37 users who each hold 640 tokens. Each blue block is one user. The dashed part is pool that no full-length user fills.

Decide what to change

If the count is too small for your traffic, you have four choices. They are in order of cost:

  1. Lower --max-model-len to the longest context that your users send. The pool stays the same size, and one long request can no longer take all of it.
  2. Use an FP8 KV cache. It halves the bytes per token. The FP8 KV page shows the cost.
  3. Use FP8 weights. They free memory for the cache. The FP8 weights page shows when this pays.
  4. Use a GPU with more memory, or split the model across GPUs. The parallelism page covers this.

Fix out of memory at startup

One setting, the GPU memory fraction, decides most startup failures, and a shorter maximum length or a lower concurrency cap fixes most of the rest.

The server stopped before it printed the KV cache size line. When you finish this section, you can read the reason from the last lines of the log and pick the one flag that fixes it.

Know what vLLM claims at startup

vLLM does four things before it serves a request:

  1. It loads the weights.
  2. It runs one profile pass with a full batch to measure the activation memory.
  3. It captures CUDA graphs.
  4. It gives the KV cache the rest of its share of the GPU.

The share is --gpu-memory-utilization. The default is 0.92, so vLLM claims 92 percent of the total GPU memory for itself. The number is a limit for one vLLM instance. It is not a fraction of the memory that is free when the server starts. On unified memory, such as a DGX Spark, it is not always a hard limit, as the DGX Spark page explains.

Find your failure in the log

Read the last 30 lines of the server log. Most startup failures match one of three cases.

What the log saysCauseFix
The KV cache needed for one request at the maximum length is larger than the available KV cache memoryThe weights and the profile leave too little for one full-length requestLower --max-model-len. The error gives the largest length that fits.
The free memory on the device is less than the desired GPU memory utilizationAnother process holds GPU memoryLower --gpu-memory-utilization below the free share, or stop the other process
CUDA out of memory during the profile run or the graph captureThe batch in the profile pass or the graphs need more memory than is leftLower --max-num-seqs or --max-num-batched-tokens, or lower --max-cudagraph-capture-size
Fig. 2.3where a start failsdecision
Where the start fails tells you which setting to change A start can fail at three points. If loading the weights fails, the model is too big for the GPU, so use FP8 weights or more GPUs. If the memory check fails, other programs hold memory, so lower the memory fraction. If the KV check fails, the maximum length needs more cache than is free, so lower the maximum length. THE LOG STOPS AT loading weights before the load line memory check free memory is less KV check max seq len needs THE CAUSE weights too big for this GPU memory is shared other programs hold it cache too small for one full request CHANGE THIS FP8 weights or --tensor-parallel-size lower the fraction --gpu-memory-utilization shorter context --max-model-len
The last line before the start fails tells you which of three settings to change. Read down each column: where the log stops, the cause, and the flag to change.

Fix a shared GPU

On a shared GPU, set the fraction yourself. We ran vLLM 0.31.0 on an L4 that another team also used. Their processes held part of the memory, so we set --gpu-memory-utilization 0.83 and a pool of 2,928 tokens.

The first start still failed, because we also set the maximum length to 2,928 tokens. One request at that length needed slightly more KV cache than was free, and the error said that 2,912 tokens would fit:

the maximum length error on the shared L4 GPUtextFull log
(APIServer pid=3681321) INFO 10-22 00:35:04 [api_utils.py:286] non-default args: {'model_tag': 'Qwen/Qwen3-8B', 'host': '127.0.0.1', 'port': 30100, 'model': 'Qwen/Qwen3-8B', 'max_model_len': 2928, 'gpu_memory_utilization': 0.83, 'num_gpu_blocks_override': 183}
(EngineCore pid=3682071) ERROR 10-22 00:36:18 [core.py:1433] ValueError: To serve at least one request with the model's max seq len (2928), (0.4 GiB KV cache is needed, which is larger than the available KV cache memory (0.4 GiB). Based on the available memory, the estimated maximum model length is 2912. Try increasing `gpu_memory_utilization` (which also controls CPU memory on the CPU backend) or decreasing `max_mod
The first line gives the settings. The second line gives the error and the length that fits. Our capture cut the second line at 420 characters. In vLLM 0.31.0, the rest of the message tells you to raise the memory fraction or to lower the maximum length.

We set --max-model-len 2048, which is enough for our 640-token requests, and the server started. Later we raised it to 2,304 tokens, so that our tests with 2,048-token prefixes fit.

So on a shared GPU, the fraction and the maximum length work together. Lower the maximum length to the longest request you send before you lower the fraction.

Set the KV cache size directly

If you know the KV cache size you want, set it in bytes with --kv-cache-memory-bytes. vLLM then ignores --gpu-memory-utilization for the cache. Use this when two vLLM instances share one GPU and each one needs a known pool.

--num-gpu-blocks-override sets the number of 16-token blocks for a test, as in our fixed pool. Do not use it in production, because it does not check that the memory exists.

Check the fix

Start the server again with tee server.log, as in the first start, and wait for the pool line:

grep "GPU KV cache size" server.log

If the line is there, the server has a pool. Compare its size with your count.

Size memory on a DGX Spark

On unified memory the GPU and the operating system share one pool, so read free memory from the system, not only from the GPU.

You run vLLM on a DGX Spark or another machine where the CPU and the GPU share memory. When you finish this section, you can pick a memory fraction that leaves room for the operating system. You also know why the first start takes minutes.

Our result: with a fraction of 0.75, the weights took 56.93 GiB and the KV cache 31.32 GiB of the 121.69 GiB of memory.

Know what "GPU memory" means here

The GB10 in a DGX Spark has no separate GPU memory. The CPU and the GPU use the same LPDDR5x memory, which NVIDIA sells as 128 GB. On our machine, the startup log reported 121.69 GiB in total. The operating system, the page cache, and every other program use the same memory.

vLLM 0.31.0 knows this. On an integrated GPU it reads free memory from the operating system. The GPU driver reports too little free memory on these machines. It also releases its cached GPU memory when the whole system is more than 80 percent full. So --gpu-memory-utilization is a share of the whole machine, not of a separate card.

Do not trust the fraction as a hard limit

The fraction sets the share that vLLM plans for. On unified memory it does not always limit what vLLM takes. Reports from other users show two cases. We did not see them in our run:

  • Issue 46307 reports a GB10 at a fraction of 0.70. It used about 112 GiB during the startup profile, against a share of about 83 GiB. The machine stopped responding. The issue was closed as stale with no fix.
  • Issue 44740 reports a negative estimate of the CUDA graph memory with MTP speculation on the GB10. The KV cache grew too large, and the start ran out of memory. The issue is open.

In our run, the numbers matched the share of 0.75. That is one run, with no speculation.

To give the cache a fixed size, set it in bytes with --kv-cache-memory-bytes, as the out-of-memory page explains. Check that the weights, the cache, and a few GiB for the profile leave room for the operating system.

Pick the fraction

Add the weights, the KV cache you want, and a few GiB for the startup profile and the CUDA graphs. Divide the sum by the total memory. Leave room for the operating system and the page cache, which use the same memory.

Our run with Qwen/Qwen3-30B-A3B-Instruct-2507 in BF16 used a fraction of 0.75:

PartSize
The share for vLLM, 0.75 of 121.69 GiB91.3 GiB
The weights56.93 GiB
Free for the KV cache31.32 GiB
The rest, for the profile and the graphsabout 3 GiB

At 96 KiB per token, the 31.32 GiB holds about 342,000 tokens. That is enough for more than 500 users with 640 tokens each. Our sweeps used a smaller fixed pool. The fraction left about 30 GiB for the operating system and every other program.

We ran one fraction only. A sweep of the fraction against the KV pool and the swap activity is planned and not run yet.

Fig. 2.4one pool on a DGX Sparklayers
On a DGX Spark, vLLM takes a share of one memory pool The GB10 reports 121.7 GiB in total. With a fraction of 0.75, vLLM takes 91.3 GiB: 56.93 GiB of weights, 31.32 GiB of KV cache, and about 3 GiB for the startup profile and the CUDA graphs. The other 30.4 GiB stays with the operating system, the page cache, and other programs. ONE 121.7 GIB POOL, CPU AND GPU TOGETHER vLLM, --gpu-memory-utilization 0.75, 91.3 GiB 56.93 GiB weights 31.32 GiB KV cache about 3 GiB profile and graphs 30.4 GiB operating system, page cache, other programs
With a fraction of 0.75, vLLM takes 91.3 of the 121.7 GiB that the CPU and the GPU share on a DGX Spark. The bar is the whole memory, drawn to scale. The blue rule marks the share of vLLM.

Expect a slow first start

The first start took more than five minutes. The log shows where the time went:

the weight load on a GB10textFull log
(EngineCore pid=143) INFO 10-21 10:58:45 [model_runner.py:407] Model loading took 56.93 GiB memory and 330.542793 seconds
330 seconds to load 56.93 GiB of weights.

The load copies each tensor from the memory-mapped safetensors file into the GPU allocation, one tensor at a time. On the GB10, a copy from a file-backed page is slow. An open vLLM issue measured about 1.7 ms for each tensor copied from the file view. A copy in host memory first took about 0.2 ms.

Release 0.31.0 made a different part of the load faster. An indexed lookup of expert names saves about 25 s on a DGX Spark. The cost of each tensor copy stays.

Two things help:

  • Keep the weights on the local NVMe drive, not on a network mount.
  • Start the server once and keep it running. A restart pays the full load again.

Check the fraction

After the start, read the two memory lines and the system memory:

grep -E "Model loading took|Available KV cache memory|GPU KV cache size" server.log
free -g

If free -g shows almost no available memory while the server is idle, lower the fraction by 0.05 and start again. If the KV cache line is larger than your share allows, set the cache size in bytes instead.

Fit more users with an FP8 KV cache

An FP8 KV cache doubles the tokens that fit, and on an L4 it also changes the attention backend without a warning.

Your server queues requests because the KV cache is full. When you finish this section, you can double the KV pool and check the cost in speed and accuracy.

Our result: on our L4, the first FP8 start failed, and the Triton backend fixed it. With the pool held fixed, one user got 2.3 percent faster per token.

Turn it on

The KV cache stores two values for each layer, head, and token. By default, vLLM stores them in the model data type, BF16 for Qwen/Qwen3-8B, at 2 bytes per value. FP8 stores each value in 1 byte:

vllm serve Qwen/Qwen3-8B --kv-cache-dtype fp8

The weights stay in BF16. Only the KV cache changes.

Count what it gives you

Use the formula from the memory page with 1 byte per value. For Qwen/Qwen3-8B, a token needs 144 KiB in BF16 and 72 KiB in FP8.

Fig. 2.5the KV tokens in one GiBchart
An FP8 KV cache holds twice the tokens in each GiB Computed KV cache tokens that fit in one GiB for Qwen3-8B, which has 36 layers, 8 KV heads and a head size of 128. BF16 uses 2 bytes per value, 144 KiB per token, and holds 7,281 tokens per GiB. FP8 uses 1 byte per value, 72 KiB per token, and holds 14,563 tokens per GiB. These numbers come from the formula, not from a run. 0 6,000 12,000 18,000 KV tokens per GiB 7,281 BF16, 144 KiB per token 14,563 FP8, 72 KiB per token Computed from the bytes per token. Our runs held the block count fixed.
For Qwen3-8B, one GiB of KV cache holds about 7,300 tokens in BF16 and about 14,600 tokens in FP8. The numbers come from the bytes per token, not from a run.

The same memory holds twice the tokens. So twice the users fit at the same length, or each user can have a context twice as long.

Fix the start on an L4

On our L4, the first start with --kv-cache-dtype fp8 failed. The cause is the attention backend:

  1. vLLM tries FlashAttention first on the L4.
  2. FlashAttention in vLLM 0.31.0 reads an FP8 KV cache only on Hopper and Blackwell data center GPUs. The L4 is an Ada GPU, SM 8.9.
  3. vLLM moves to FlashInfer, the next backend in its list.
  4. FlashInfer builds its kernels on the first start. On our node, that build failed.
the FlashInfer build error on our L4textFull log
(EngineCore pid=814129) INFO 10-22 09:11:03 [cuda.py:526] Using FLASHINFER attention backend out of potential backends: ['FLASHINFER', 'TRITON_ATTN'].
(EngineCore pid=814129) ERROR 10-22 09:11:53 [core.py:1433] /data/vllm/.venv/lib/python3.12/site-packages/flashinfer/data/cccl/libcudacxx/include/cuda/std/__cccl/cuda_toolkit.h:41:8: error: #error "CUDA compiler and CUDA toolkit headers are incompatible, please check your include paths"
RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}
Our FP8 start on 2026-10-22. vLLM picked FlashInfer from two valid backends. The build of its FP8 prefill kernel failed, because the CUDA compiler and the CUDA headers came from different releases.

The FlashInfer sampler fails to build in the same way on this node. So our sweep tool starts vLLM with VLLM_USE_FLASHINFER_SAMPLER=0.

Pick the Triton backend. It reads an FP8 KV cache and does not use the FlashInfer build. Also turn off the FlashInfer sampler, as our sweep tool does:

VLLM_USE_FLASHINFER_SAMPLER=0 vllm serve Qwen/Qwen3-8B --kv-cache-dtype fp8 --attention-backend TRITON_ATTN

The attention backend page explains how vLLM picks a backend.

See what our run shows

We measured the FP8 KV cache with the Triton backend against the BF16 default on our L4 with Qwen/Qwen3-8B on 2026-10-22. Both ran in the throttled session, at 1 and 32 users. Raw runs

We held the pool at 24,096 tokens in both runs, as a fixed pool. So this run does not show the larger pool. It shows only the change in speed.

Users, L4 throttled sessionMedian TPOT, BF16 / FP8Output tok/s, BF16 / FP8Median SM clock, BF16 / FP8Seeds, BF16 / FP8
1104.1 / 101.8 ms10.59 / 10.75210 / 210 MHz3 / 2
32142.7 / 138.0 ms186.68 / 192.00450 / 480 MHz3 / 2

At one user, FP8 was 2.3 percent faster per token, at the same clock, and the seed ranges did not overlap. At 32 users, the seed ranges overlapped, and the FP8 run had a higher clock. So we do not claim a gain at 32 users.

Two things changed together: the KV data type and the backend. The table does not show which one gave the gain. The GPU was throttled for both runs, so read the ratio, not the times.

Check the accuracy

On 2026-10-23 we ran the first 500 GSM8K questions, 8-shot and greedy, on our L4. Qwen3-8B has no KV scales, so the FP8 KV runs used a scale of 1.0. The FP8 KV runs used the Triton backend, as above.

GSM8K accuracy, 500 questions, 8-shot, Qwen3-8B on vLLM 0.31.0textFull log
engine   config                     correct   accuracy   Wilson 95% interval
…
vLLM     BF16                       459/500   91.8%      89.1 to 93.9
vLLM     FP8 weights                453/500   90.6%      87.7 to 92.9
vLLM     FP8 KV cache               454/500   90.8%      87.9 to 93.0
vLLM     FP8 weights + FP8 KV       453/500   90.6%      87.7 to 92.9
The vLLM rows. The SGLang rows of the same file are cut.

Every interval overlaps the BF16 interval, so this run did not show a difference. With 500 questions, it cannot rule out a loss of a few points. That is one model and one benchmark, so run the same check on your model and your task. A model with a different attention layout can lose more.

The post "The State of FP8 KV-Cache and Attention Quantization in vLLM" on the vLLM blog, of April 2026, tested uncalibrated FP8 KV caches. Its authors are from AWS and Red Hat. In their tests, most models stayed close to BF16. Some did not. They name Kimi-K2.5, which uses the FlashMLA backend, as a model that did not work well with uncalibrated scales. We did not measure these results ourselves.

Check that your scales load

A checkpoint with calibrated scales does not always use them. At 0.31.0, issue 54623 reports that the Qwen3.5 model family never loads its KV scales. Issue 60333 reports the same for one MTP draft loader. Both issues are open.

First, look for the scales in the checkpoint. Search the weight index in the Hugging Face cache for tensor names that end in k_scale or v_scale. Change the model folder name to your model:

grep -oE '"[^"]*(k|v)_scale"' ~/.cache/huggingface/hub/models--Qwen--Qwen3-8B/snapshots/*/model.safetensors.index.json | head

No output means that the checkpoint has no scales.

A checkpoint with one weight file has no index. For it, list the tensor names with the safetensors Python package.

Then read the startup log. The warning Using KV cache scaling factor 1.0 means that vLLM uses a scale of 1.0. If the checkpoint has scales and the log still shows the warning, the loader dropped them.

Last, run your own prompts with and without FP8, and compare the answers before you go to production.

Know what changed after 0.31.0

Release 0.32.0 adds an NVFP4 KV cache on SM 8.x and SM 12.x GPUs, which include the L4 and the GB10. It stores fewer bytes per token than FP8. We did not test it.

Fit a bigger model with FP8 weights

FP8 weights free memory for the KV cache, and the speed change depends on what your GPU supports.

Your model fits, but only with a small KV pool. When you finish this section, you can decide between a BF16 and an FP8 checkpoint at the same memory budget.

Our result: on our clock-locked L4, the FP8 checkpoint gave 36 percent more output than BF16 at 32 users, at the same pool.

Pick a checkpoint

An FP8 checkpoint stores most weights in 1 byte instead of 2. Many model makers publish one next to the BF16 model. For Qwen/Qwen3-8B, the FP8 checkpoint is Qwen/Qwen3-8B-FP8, and you serve it like any other model:

vllm serve Qwen/Qwen3-8B-FP8

vLLM reads the quantization from the checkpoint config, so you set no extra flag. If no FP8 checkpoint exists, vLLM can quantize a BF16 model when it loads, with --quantization fp8_per_tensor. We did not test that path.

Know what your GPU runs

The L4 is an Ada GPU, SM 8.9, and it has FP8 math. So vLLM runs the matrix math of the model in FP8, and the speed can rise as well as the free memory.

GPUs before Ada, such as the A100, have no FP8 math. On those, vLLM keeps the weights in FP8 and runs a weight-only kernel. You keep the memory saving, but expect less speed gain. We did not test such a GPU.

Count the memory it frees

The weights take close to half the memory in FP8. vLLM gives the memory that it frees to the KV pool. For Qwen/Qwen3-8B, each GiB of KV pool holds about 7,300 tokens in BF16, as the FP8 KV cache page shows.

In our runs, we held the pool at 24,096 tokens as a fixed pool, so that both runs had the same room. So we did not measure the larger pool. The gain in room comes from the bytes, not from a run.

See what our runs show

We measured Qwen/Qwen3-8B-FP8 against Qwen/Qwen3-8B on our L4 on 2026-10-22, in the session locked at 900 MHz. We ran 1 and 32 users with two seeds each. Raw runs

Fig. 2.6FP8 weights against BF16 at 32 userschart
FP8 weights at 32 users with the clock locked at 900 MHz Qwen3-8B in BF16 against Qwen3-8B-FP8 on an NVIDIA L4 at 32 users, SM clock locked at 900 MHz, KV pool fixed at 24,096 tokens, two seeds. Output was 208.8 tokens per second for BF16 and 284.7 for FP8. The median time per token was 119.9 ms for BF16 and 83.8 ms for FP8. output tok/s, higher is better 0 200 400 208.8 BF16 284.7 FP8 median ms per token, lower is better 0 50 100 150 119.9 BF16 83.8 FP8 Both runs at 32 users with the same KV pool. Median of two seeds.
With the clock locked and the same KV pool, FP8 weights gave 284.7 against 208.8 output tokens per second at 32 users. Each bar is the median of two seeds. The BF16 run had a median clock of 881 MHz and the FP8 run 900 MHz.
Users, L4 locked sessionMedian TPOT, BF16 / FP8Output tok/s, BF16 / FP8Median SM clock, BF16 / FP8
32119.9 / 83.8 ms208.8 / 284.7881 / 900 MHz
1not compared / 35.3 msnot compared / 25.4210 / 900 MHz

At 32 users, FP8 gave 36 percent more output and cut the time per token by 30 percent. The seed ranges did not overlap for either number. The BF16 clock was about 2 percent lower, much less than the gap.

At one user, the BF16 run fell to 210 MHz, below the lock, because the GPU throttled. So we do not compare the two at one user.

Use a 4-bit or 8-bit integer checkpoint

FP8 halves the weights. A 4-bit checkpoint stores most weights in about half a byte, so a model with twice the parameters can fit. On a 24 GB GPU such as the L4, a 4-bit checkpoint is a common way to serve a 14B model.

vLLM 0.31.0 reads these formats from the checkpoint config, so you set no flag for a published checkpoint:

FormatWhat it storesNotes at 0.31.0
AWQ and GPTQ4-bit weights, 16-bit mathruns from the Turing GPUs on, with the fast Marlin kernels on Ampere, Ada, and Hopper
compressed-tensors W4A16 or W8A84-bit or 8-bit weights, made with the LLM Compressor librarythe vLLM project suggests this library to make your own checkpoint
bitsandbytes4-bit weights, made when the server loads the modelneeds the separate vllm-bnb-plugin package, then set --quantization bitsandbytes for a model that is not quantized
GGUFthe llama.cpp file formatneeds the separate vllm-gguf-plugin package, and the vLLM documentation calls it experimental

For example, Qwen publishes a 4-bit AWQ checkpoint of Qwen3-14B:

vllm serve Qwen/Qwen3-14B-AWQ --max-model-len 8192

We started this AWQ checkpoint once on an L4, with no --quantization flag. The log showed quantization=auto_awq and the Marlin kernel. The weights took 9.44 GiB, and the pool held 36,272 tokens. We did not measure its speed or its accuracy.

We also started two of the other formats on the shared second GPU of our L4 node on 2026-10-23. That GPU was throttled, so we give no speeds.

  • bitsandbytes. Without the plugin, --quantization bitsandbytes stops at start with Unknown quantization method: bitsandbytes. Install the plugin first, with uv pip install vllm-bnb-plugin, or pip install vllm-bnb-plugin on the pip path. With it, Qwen/Qwen3-1.7B loaded in 1.33 GiB against 3.22 GiB in BF16, which is 41 percent. The pool grew from 92,960 to 108,512 tokens.
  • GGUF. With the plugin installed, vllm serve unsloth/Qwen3-0.6B-GGUF:Q4_K_M --tokenizer Qwen/Qwen3-0.6B loaded 0.55 GiB of weights, and a chat request returned HTTP 200.

The 41 percent is for one small model. It includes the embeddings and the output layer, which stay in 16-bit, so the share can differ on a larger model. We did not measure the accuracy of either format.

A 4-bit model can lose more accuracy than an FP8 model, and the loss depends on the model and the method. Test both formats on your own prompts.

Check the accuracy

On 2026-10-23 we ran the first 500 GSM8K questions, 8-shot and greedy, on our L4, with Qwen/Qwen3-8B against Qwen/Qwen3-8B-FP8:

GSM8K accuracy, 500 questions, 8-shot, Qwen3-8B on vLLM 0.31.0textFull log
engine   config                     correct   accuracy   Wilson 95% interval
…
vLLM     BF16                       459/500   91.8%      89.1 to 93.9
vLLM     FP8 weights                453/500   90.6%      87.7 to 92.9
vLLM     FP8 KV cache               454/500   90.8%      87.9 to 93.0
vLLM     FP8 weights + FP8 KV       453/500   90.6%      87.7 to 92.9
The vLLM rows. The SGLang rows of the same file are cut.

FP8 weights scored 1.2 points lower, and the two intervals overlap. So this run did not show a difference, and it cannot rule out a loss of a few points. It is one model and one benchmark. Run your own prompts on both checkpoints, and compare the answers before you go to production. We did not run this check on the 4-bit formats.

Serve long contexts and hybrid models

A long context and a hybrid model each change how vLLM counts memory, and both can fail at startup for a reason the error hides.

You want a context longer than the default, or you serve a model that mixes attention layers with sliding-window or state-space layers. When you finish this section, you can set the maximum length on purpose and read what the cache does for each kind of layer.

We did not run a long-context or a hybrid model for this manual. This page comes from the vLLM documentation, the source at 0.31.0, and the release notes.

Set the maximum length on purpose

If you do not set --max-model-len, vLLM takes the length from the model config. For Qwen/Qwen3-8B that is 40,960 tokens. At 144 KiB per token, one request at that length needs 5.6 GiB of KV cache. On a 24 GB GPU, the 16.4 GB of weights can leave room for less than one such request, and then the start fails.

Set --max-model-len to the longest prompt plus answer that your users send. The pool size stays the same. A shorter limit only stops one request from claiming the whole pool, and it lets the start check pass.

Check the length after an upgrade

Some models extend their context with a scaling method such as YaRN. In release 0.30.0, vLLM changed its YaRN handling to match the Hugging Face Transformers library. For some models, the length that vLLM derives from the config became shorter. The release notes give one model that went from 131,072 to 32,768 tokens.

After an upgrade, read the derived length in the startup log. If it changed, set --max-model-len yourself, or check that the model still supports the longer length.

Know how a hybrid model uses the cache

A hybrid model has more than one kind of layer. vLLM puts each kind in its own KV cache group. The table gives the rules for each group.

Layer kindWhat it keepsWhen a cached prefix is a hit
Full attentionkeys and values for every tokenevery block of the prefix is still in the cache
Sliding windowkeys and values for the last window of tokensthe blocks inside the last window are still in the cache
State space, such as Mambaone state for each request, not one entry for each tokena saved state must exist for that point in the prompt, and the rules for saving it changed between releases

The group design lets one pool serve all the kinds. It also means that the prefix-cache hit rate of a hybrid model differs from the hit rate of a full-attention model with the same prompts.

Avoid the removed and renamed flags

Hybrid support changes fast. Three changes between 0.29 and 0.32 matter:

  • The all mode for the Mamba cache was deprecated in 0.30, and it made vLLM use the older model runner. Release 0.32 removed it.
  • Release 0.31 renamed --enable-mamba-fine-grained-prefix-cache to --enable-mamba-shared-prefix-checkpoint.
  • After the removal of the all mode, an open issue reports that --mamba-block-size has no effect.

If a hybrid model starts slower or uses more memory after an upgrade, check these flags first. --disable-hybrid-kv-cache-manager turns the groups off, as a test of whether the groups cause the problem.

Check the setup

Start the server and read the pool line. It gives the maximum concurrency at your maximum length:

grep "GPU KV cache size" server.log

The pool line prints only when the start succeeds. If the start stops with the error about the KV cache and the maximum length, lower --max-model-len until the pool line prints. Then read its concurrency, and compare it with the users you plan to serve at full length.

Part 3

Tune for Your Traffic

Each kind of traffic needs a different one or two flags, and most defaults are already right.

  1. 3.1Tune chat for many users
  2. 3.2Make repeated prompts hit the prefix cache
  3. 3.3Keep long prompts from stalling short ones
  4. 3.4Keep long sessions cheap
  5. 3.5Return valid JSON and tool calls
  6. 3.6Run batch and offline jobs
  7. 3.7Serve many fine-tunes with multi-LoRA
  8. 3.8Get reproducible outputs
  9. 3.9Use vLLM as an RL rollout engine
  10. 3.10Serve reasoning models

Tune chat for many users

For chat, the worst gap between tokens matters more than throughput, so watch P99 ITL as users grow.

Your chat service streams smoothly for a few users and has long gaps between tokens for many. When you finish this section, you can keep the stream steady as the number of users grows.

Our result: on our L4, about two in three requests met our latency target at 16 users, and none at 32. Capping the batch did not fix it.

Know what a chat user feels

A chat user feels two numbers. The first is the time to the first token (TTFT). The second is the gap between tokens while the answer streams (ITL). Throughput is what you pay for, but users do not see it.

Watch the P99 ITL, the worst gap in 100. One long pause in a stream feels worse than a slightly slower stream. Set a target for both numbers. Our target was a first token within 1 second and 100 ms or less per token.

See where the stream breaks

We measured users at once from 1 to 64 on our L4 with Qwen/Qwen3-8B on 2026-10-21, in the full-clock session. Each request sent 512 prompt tokens and asked for 128 tokens, with three seeds per point. Raw runs

Users, L4 full-clock sessionOutput tok/sMedian TTFTP99 ITLShare of requests that met the targetMedian SM clock
8112.80.25 s67 ms75%1,485 MHz
16201.30.29 s85 ms64%1,455 MHz
32275.11.80 s613 ms0%1,275 MHz
64242.311.18 s1,233 ms0%1,005 MHz

Up to 16 users, each new user added output, and the stream stayed steady. But at 16 users, one request in three already got its first token too late. At 32 users, the output still rose, but the P99 ITL grew seven times and no request met the target. The server was busy, and the users waited.

Treat results below 32 users as affected by prefix-cache reuse

This sweep kept one server for all the user counts, and it did not reset the prefix cache between them. Each seed sent the same first prompts at every count, so many requests found their prompt in the cache and skipped the prefill. On 2026-10-23 we ran the sweep again on the same L4, with a reset before each run and the clock locked at 900 MHz. We compared it with an unflushed run of that same session:

median TTFT with and without a cache flush, Qwen3-8B on our L4 at 900 MHztextFull log
median TTFT, flushed against unflushed
users   vLLM                                   SGLang
4       650 / 145 ms (4.5x)                    532 to 645 / 141 ms (3.8 to 4.6x)
8       951 to 978 / 264 to 274 ms (3.6x)      1,174 to 1,273 / 173 to 178 ms (6.6 to 7.3x)
16      1,550 to 1,587 / 294 to 295 ms (5.3x)  1,804 to 1,944 / 218 ms (8.3 to 8.9x)
32      1,982 to 2,084 / 2,012 to 3,091 ms     3,216 to 3,228 / 2,710 to 2,917 ms
…
The median TTFT rows. The share that met the target and the P99 ITL lines are cut.

With the reset, 50 percent of the requests met our target at 8 users, against 75 to 81 percent without it. At 16 users the share fell from about 65 percent to 25 percent. The P99 ITL at 16 users was 522 ms with the reset, against 82 to 84 ms in the unflushed run of the same session. From 32 users the queue dominated, and the reset changed little. So the stream breaks between 8 and 16 users on this card, not between 16 and 32. The table above stays as a record of the full-clock session.

Each step has a budget of 2,048 tokens on this GPU. When new prompts arrive, their chunks share the step with the running streams, so the running streams pause. The token budget page explains the order.

Know what a cap on sequences does

--max-num-seqs sets how many requests run at once. On the L4 the default is 256, and the token budget page gives the rule for other GPUs. We set it to 16 and ran the same sweep:

Fig. 3.1a cap of 16 sequences as users growchart
A cap of 16 sequences against the default as users grow Output tokens per second and median time to first token against users at the same time, on an NVIDIA L4 with Qwen3-8B, three seeds per point, 2026-10-21. The two configs match up to 16 users. At 32 users, the default gave 275.1 tokens per second with a first token in 1.8 s, and the cap of 16 gave 171.8 tokens per second with a first token in 12.3 s. default --max-num-seqs 16 0 100 200 300 1 4 16 64 users output tok/s 0.1 1 10 100 1 4 16 64 users median first token, s
A cap of 16 sequences matched the default up to 16 users, and at 32 users it cut output from 275 to 172 tokens per second. Each point is the median of three seeds, and the bands span them. The time to the first token uses a log scale.
Users, L4 full-clock sessionConfigOutput tok/sMedian TTFTMedian ITLP99 ITLMedian SM clock
32default275.11.8 s80.5 ms613 ms1,275 MHz
32--max-num-seqs 16171.812.3 s69.8 ms595 ms1,365 MHz

The cap made each stream a little smoother. But half the users waited in the queue for their first token, and output fell by 38 percent. The cap run had the higher clock, so the clock does not explain the loss. At 64 users, the cap run fell to 630 MHz, so we do not compare that row.

A cap does not make room for more users. It only chooses who waits.

Try a cap at 64 users

At 64 users, the P99 ITL of vLLM on our L4 was more than a second, against about 150 ms for SGLang. On 2026-10-23 we tried to close that gap with a cap. The clock was locked at 900 MHz, and each run had two seeds and a cache reset. --max-num-active-seqs is a second cap at 0.31.0, on the requests that run in one step.

caps at 64 users on our L4, vLLM 0.31.0 against SGLang 0.5.22textFull log
config                        median SM clock (s1/s2)   median TTFT       P99 ITL               output tok/s
SGLang default                900 / 900                 19.4 s            143 to 153 ms         225 to 231
…
vLLM default, earlier run     900                       11.8 s            1,359 ms              220
…
--max-num-seqs 32             900 / 795                 17.2 to 20.6 s    1,163 to 1,399 ms     196 to 230
…
--max-num-active-seqs 48      900 / 900                 11.8 s            1,329 to 1,373 ms     215 to 218
…
--max-num-active-seqs 16      795 / 683                 44.8 to 48.8 s    1,133 to 1,139 ms     129 to 136
Rows from the evidence file. The rows that ran far below the lock are cut.

No cap that we tried brought the P99 ITL of vLLM near the SGLang value. At 16 or 32 running requests, the P99 ITL stayed above 1.1 s, and the first token waited 17 to 65 s. Many runs in this block ran below the lock, and the file lists them all. So on this card, a cap does not fix the long gaps at 64 users. Fewer users per replica does.

See what a small pool does

The KV pool limits the users too. We measured a pool of 6,560 tokens, with --num-gpu-blocks-override 410 and --max-model-len 4096, against our pool of 24,096 tokens. Both ran on our L4 on 2026-10-22 in the session locked at 900 MHz, at 32 users and two seeds each.

Pool, L4 locked sessionMedian TTFTP99 TTFTMedian SM clock
24,096 tokens3.1 s7.1 s881 MHz
6,560 tokens31.6 s48.0 s248 MHz

A request of 640 tokens needs 640 tokens of pool by its end. So only about 10 requests fit in 6,560 tokens at once. The other 22 waited for a running request to finish.

vLLM starts a new request only when its blocks fit in the pool. A running request that needs a new block can push another one out, which vLLM counts as a preemption. So most of the 32 requests waited in the queue before they started.

The small-pool run fell to about 250 MHz, below the lock. A clock 3.6 times lower can make a step up to 3.6 times slower. The first token came 10 times later, so the queue caused most of the wait. Do not compare the throughput or the time per token of these two runs.

Tune it

  1. Set your targets for the first token and the time per token.
  2. Run your own load at more and more users, and find the last count that meets both.
  3. If the KV cache usage is near 100 percent and requests wait, the pool is too small. Add room with an FP8 KV cache, or FP8 weights. A shorter maximum length does not help here, because the pool stays the same size.
  4. If the pool has room but the P99 ITL is too high, the GPU is full. Add a GPU or a replica. A cap on sequences moves the wait to the first token.

On our DGX Spark, vLLM and SGLang gave the same output at 32 and 64 users. But the P99 ITL of vLLM was about twice as high, 478 against 220 ms at 32 users. So also compare the P99 ITL when you choose an engine for chat.

Make repeated prompts hit the prefix cache

Prefix caching is on by default, and it pays only when the shared part of the prompt comes first.

Every request repeats the same long system prompt. When you finish this section, you can order a prompt so that the cache hits, and prove the hit from a metric.

Our result: on our L4, the cache kept the first token near 400 ms at every prefix length. Without it, the first token took 1,213 ms at 1,024 shared tokens.

See what the cache saves

Prefix caching is on by default in vLLM 0.31.0. We ran the same workload with it on and off on the L4 with Qwen/Qwen3-8B. Each run sent 4 groups of 16 prompts at 8 users. The prompts in a group shared a system prompt of the length in the table. Each prompt then added a 128-token question and asked for 64 tokens back.

Shared prompt tokens, L4 full-clock sessionMedian TTFT, cache onMedian TTFT, cache offOutput tok/s, on / off
none408 ms392 ms113.8 / 113.6
256488 ms753 ms111.5 / 99.1
1,024396 ms1,213 ms106.0 / 70.9
2,048410 ms2,550 ms100.2 / 46.6
4,096375 ms13,353 ms86.8 / 19.6

With the cache on, the first token took about 400 ms at every prefix length. With the cache off, the first token grew with the shared part, because every request computed the whole prompt again.

Read the last two rows with care. Without the cache, the GPU throttled. The median clock fell to 1,035 MHz at 2,048 tokens and to 675 MHz at 4,096 tokens. The cleanest comparison is the 1,024-token row, where both runs held 1,410 MHz or more. There the cache cut the first token about 3 times and gave 50 percent more output. The runs with the cache on were on 2026-10-17 and 2026-10-18, and the runs with it off on 2026-10-21.

The server log reports the hit rate every few seconds:

the prefix cache in the middle of a shared-prefix runtextFull log
INFO 10-18 00:43:16 [loggers.py:320] Engine 000: Avg prompt throughput: 231.0 tokens/s, Avg generation throughput: 93.6 tokens/s, Running: 8 reqs, Waiting: 0 reqs, GPU KV cache usage: 77.2%, Prefix cache hit rate: 87.2%
87 percent of the prompt tokens came from the cache.
Fig. 3.2the cache on and offchart
With the cache on, the first token stays near 400 ms at every shared length Median time to first token against the shared system prompt length, on an NVIDIA L4 with Qwen3-8B, 8 users, three seeds, log scale. With the cache on, it stays between 375 and 488 ms. With it off, it grows to 1,213 ms at 1,024 tokens and 13,353 ms at 4,096 tokens, where the GPU clock also fell. 300 1k 3k 10k 0 1,024 2,048 3,072 4,096 shared system prompt, tokens median TTFT, ms (log scale) 13,353 1,213 prefix cache on, the default prefix cache off
With the prefix cache on, the first token stayed near 400 ms at every shared length on our L4. Read the log scale with care: each grid line is about three times the one below. Bands span three runs.

Put the shared part first

The cache matches a prompt from its first token, one 16-token block at a time. It stops at the first block that differs. So a cache hit needs the same tokens at the start of the prompt, in the same order.

  1. Put the parts that never change first: the system prompt, the tool definitions, and the fixed instructions.
  2. Put the parts that change rarely next, such as the documents of one session.
  3. Put the parts that change on every request last: the user question, the time, and the request ID.

A timestamp or an ID near the start of the prompt changes the first block, and every later block then misses. Check your chat template too. Some templates put the date into the system prompt.

vLLM caches only full blocks. A shared part of 20 tokens gives one cached block of 16, and the last 4 tokens are always computed.

Fig. 3.3a prefix hitstructure
A prefix hit reuses whole 16-token blocks up to the first difference Two prompts share a system prompt. Each full 16-token block gets a hash that includes the hash of the block before it. The second prompt finds the first three block hashes in the map and reuses those blocks. Its fourth block differs, so the chain stops and the rest is computed. REQUEST A, ALREADY SERVED REQUEST B, ARRIVES LATER block 1 h1 cache hit h1 block 2 h2 cache hit h2 block 3 h3 cache hit h3 block 4 h4a compute h4b block 5 h5a compute h5b differs here, chain stops Each hash covers its 16 tokens and the hash before it, so h3 names the first 48 tokens. A shared part of 20 tokens gives one cached block, and 4 tokens are always computed. Put the shared part first, in the same order, to keep the chain long.
A new request reuses cached blocks from the start of its prompt and stops at the first block that differs. Read the top row first, then the bottom row. Dashed blocks are computed again.

Keep tenants apart

Two users with the same prompt prefix share cached blocks. That saves compute, and it also lets one user's timing reveal what another user sent. To keep tenants apart, send a cache_salt field with each request. Requests with different salts never share blocks. The LoRA adapter is part of the block key too, so two adapters never share blocks.

Prove the hit

Read two counters from /metrics:

curl -s localhost:8000/metrics | grep -E "^vllm:prefix_cache_(queries|hits)"

The hits divided by the queries is your hit rate. For one prompt, send the same long prompt twice and compare the second time to first token with the first.

If the hit rate is low, the cause is almost always the prompt order. A full pool is the next cause, because the cache keeps old blocks only until a running request needs the memory.

Keep long prompts from stalling short ones

The batch token budget trades the first-token time against the gaps between tokens, so set it for the latency you promise.

Long prompts arrive and short requests stall behind them. When you finish this section, you can choose a batch token budget for your latency target.

Our result: on our DGX Spark at low load, a budget of 16,384 tokens raised the P99 ITL from 574 to 798 ms.

Know the budget

vLLM always splits long prompts into chunks. In each step, the running streams get their next token first, and a waiting prompt fills the rest of the budget. --max-num-batched-tokens sets the budget. The token budget page explains the order.

A larger budget lets a long prompt finish its prefill in fewer steps, so its first token comes sooner. But each of those steps takes longer, and every running stream waits for it. So at low load, a larger budget can give a faster first token and longer gaps between tokens.

Know your default

vLLM sets the budget and the cap on running requests from the GPU memory that it reads. The defaults also differ between vllm serve and the offline LLM class. Other manual pages link to this table.

GPU memory that vLLM readsBudget, vllm serveBudget, LLM class--max-num-seqs, both
160 GiB or more16,38416,3841,024
70 GiB or more, but not an A1008,19216,3841,024
less, or unknown2,0488,192256

Our DGX Spark has 121.69 GiB of memory that the CPU and the GPU share. Its startup log still shows a budget of 2,048, the value for a small or unknown GPU. Read the value that your server uses before you change it. The 0.31.0 log does not print the budget under its own name. Find compile_ranges_endpoints in the engine config line. The largest number in that list is the budget.

See what our runs show

We measured budgets of 2,048 and 16,384 tokens on our DGX Spark with Qwen3-30B-A3B on 2026-10-21. The prompts had 1,024 to 4,096 tokens, and each request asked for 64 to 256 tokens. We sent requests at a fixed rate, with three seeds per point. Raw runs

Fig. 3.4a larger token budget at low loadchart
A larger token budget at 0.5 requests per second on the DGX Spark vLLM with Qwen3-30B-A3B on an NVIDIA GB10, prompts of 1,024 to 4,096 tokens, 64 to 256 output tokens, 0.5 requests per second, three seeds. With a budget of 2,048 tokens, the median first token took 1,145 ms and the P99 ITL was 574 ms. With 16,384 tokens, the first token took 906 ms and the P99 ITL was 798 ms. median first token, ms 0 750 1,500 1,145 2,048 906 16,384 P99 ITL, ms 0 500 1,000 574 2,048 798 16,384 Lower is better on both. Bars are labelled by --max-num-batched-tokens.
At 0.5 requests per second, a budget of 16,384 tokens raised the P99 ITL from 574 to 798 ms. Each bar is the median of three seeds. The first-token times of the two budgets overlapped across seeds.
Requests per secondBudgetMedian TTFTMedian TPOTP99 ITL
0.52,0481,145 ms149.3 ms574 ms
0.516,384906 ms139.4 ms798 ms
12,048454 ms156.5 ms405 ms
116,384477 ms156.9 ms395 ms
22,048560 ms205.5 ms587 ms
216,384595 ms205.1 ms586 ms

At 0.5 requests per second, the larger budget raised the P99 ITL by 39 percent, and the seed ranges did not overlap. The first token came sooner, but the seed ranges of the first token overlapped. So the cost is clear, and the gain is not confirmed.

The median first token at 0.5 requests per second was slower than at 1 request per second, for both budgets. With the 2,048 budget, all three seeds at 0.5 were above 900 ms. All three at 1 were below 600 ms. So one slow seed does not explain it. We did not find the cause.

At 1 and 2 requests per second, the two budgets gave the same numbers within the seed ranges. At those rates, more requests share each step, so a single long prompt fills less of the budget.

Read our result as narrow: two budgets, three low rates, one model, and one machine. The 2026 paper "P-PAS: Prefill-Pressure Adaptive Scheduling for Long-Context LLM Serving" found that no single fixed budget in vLLM was best at every load. A large budget gave lower latency at low pressure. A small budget won when several prefills competed with running streams. We did not measure that crossover ourselves.

The run with no budget flag matched the run with 2,048 at every rate. That agrees with the 2,048 in the startup log. All runs held a median clock of 2,398 to 2,411 MHz.

Tune it

  1. Read the budget that your server uses.
  2. If the P99 ITL is too high and long prompts arrive, lower the budget. Try 2,048 if your default is larger.
  3. If the first token of long prompts is too slow and the P99 ITL has room, raise it.
  4. Measure at your real request rate, because the effect changed with the rate in our runs.
  5. Measure again when your load changes, because the best budget moves with the load.

vLLM also has --long-prefill-token-threshold. It caps the tokens that one long prompt gets in a step when other requests wait. It is off by default, and we did not test it.

Keep long sessions cheap

Old turns cost nothing while they stay in the cache, and offload keeps them when the GPU pool is full.

A long conversation slows down as it grows. When you finish this section, you can keep earlier turns cached without a full prefill.

Why a long session slows down

Each turn of a chat sends the whole conversation again. Without a cache, the server computes every earlier turn on every request, so the first token takes longer with each turn.

The prefix cache removes that cost while the earlier turns stay in the cache. Turn 10 starts with the same tokens as turn 9, so the blocks of turn 9 are a hit. Only the new user message and the answer need compute.

The cache keeps a block only until a running request needs the memory. On a busy server, the blocks of a user who paused for a minute can be gone when that user sends the next turn. The whole conversation is then computed again.

Keep the prompt stable across turns

The hit needs the same tokens in the same order:

  • Send earlier turns exactly as before. Do not trim, summarize, or reformat them between turns.
  • Keep the system prompt and the tool list the same for the whole session.
  • If you must shorten a conversation, cut whole turns from the middle, not from the start. A change at the start makes every later block miss.

Offload blocks to host memory

When the GPU pool is too small to keep idle sessions, vLLM can copy blocks to CPU memory before it drops them. A later request reloads them from CPU memory, which is faster than a full prefill of a long context.

The native offload needs one flag, the CPU memory to use in GiB:

vllm serve Qwen/Qwen3-8B --kv-offloading-size 32

--kv-offloading-backend picks the implementation. native is the default and uses the code in vLLM. lmcache uses the LMCache library, and it needs a separate LMCache server process. The offload trades CPU memory and PCIe transfer time for compute.

Not measured on our hardware. We have not timed a reload from CPU memory against a prefill on the L4 or the GB10.

Use a shared cache across servers

With several servers behind a router, a user's next turn can go to a server that never saw the earlier turns. Two fixes exist. A router that sends a session to the same server keeps the hit on that server. A shared KV store, through a KV connector such as LMCache or Mooncake, lets any server load the blocks. The KV connector page explains the second.

Check the session cache

Send turn 1 of a conversation, then turn 2 with turn 1 at its start. Read the prefix cache counters before and after turn 2:

curl -s localhost:8000/metrics | grep -E "^vllm:(external_)?prefix_cache_(queries|hits)_total"

During turn 2, vllm:prefix_cache_hits_total rises by about the length of turn 1, rounded down to full 16-token blocks. We checked this on an L4 with Qwen3-0.6B. Turn 1 had 2,182 prompt tokens. In turn 2 the counter rose by 2,176, which is 136 full blocks. With offload on, the startup log names the connector, OffloadingConnector for the native backend. A block that came back from CPU memory counts in the vllm:external_prefix_cache_hits_total counter.

Return valid JSON and tool calls

Structured output makes the model produce valid JSON, at a cost you can measure on your own schema.

Your application needs JSON that always parses. When you finish this section, you can request structured output and check its cost.

Ask for a schema

With structured output, vLLM limits each next token to the tokens that keep the output valid for your schema. The model cannot produce text that breaks the schema. You ask for it in the request, not with a server flag.

With the OpenAI client, use response_format:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
schema = {
    "type": "object",
    "properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
    "required": ["city", "days"],
}
reply = client.chat.completions.create(
    model="Qwen/Qwen3-8B",
    messages=[{"role": "user", "content": "Plan a trip to Lisbon. Answer in JSON."}],
    response_format={"type": "json_schema", "json_schema": {"name": "trip", "schema": schema}},
)
print(reply.choices[0].message.content)

For other limits, send structured_outputs in extra_body. It takes one of json, json_object, regex, choice, grammar, or structural_tag. Release 0.12.0 removed the older guided_json and guided_* fields. A recipe that uses them is out of date.

Know what runs on the server

The default backend setting is auto, which picks a backend for the request. You can also name one: xgrammar, guidance, outlines, or lm-format-enforcer. The server compiles your schema into a grammar the first time it sees the schema, and it caches the result. On each step it builds a mask of the allowed tokens and applies it before sampling.

Two costs follow:

  • The first request with a new schema waits for the compile.
  • Each step builds a mask, which adds CPU time for each token.

Not measured on our hardware. We have not timed the compile or the mask cost on the L4 or the GB10.

Return tool calls

For tool calls, start the server with a parser for your model's tool format:

vllm serve Qwen/Qwen3-8B --enable-auto-tool-choice --tool-call-parser hermes

The parser turns the model's text into the tool_calls field of the response. Pick the parser that matches your model family. The vLLM documentation lists them.

Avoid two known problems

  • Speculative decoding. A schema and a speculation method can disagree on the next tokens. An issue on 0.31 showed invalid JSON with MTP speculation for one model, and the issue is now closed. Test your schema with and without --speculative-config before you rely on both.
  • Reasoning models. A model that thinks before it answers needs the schema to apply to the answer only. Set the reasoning parser for your model, so the limit starts after the thinking ends. The reasoning models page shows the flag.

Check the result

Parse every response in your client, even with structured output on. Count the parse failures and the requests that hit max_tokens before the JSON closed. A response cut by the token limit is not valid JSON.

Run batch and offline jobs

When nobody waits for the first token, push concurrency up until throughput stops growing.

You must process a large set of prompts overnight. When you finish this section, you can get the most tokens per second when latency does not matter.

Our result: on our L4 with Qwen3-8B, output stopped growing at 32 users, near 274 tokens per second.

Pick the offline class or the server

For a fixed list of prompts, the offline LLM class runs the engine in your Python process. It has no HTTP layer and no client to tune:

from vllm import LLM, SamplingParams

prompts = ["Summarize the history of Lisbon in three sentences.", "Write a haiku about the sea."]
llm = LLM(model="Qwen/Qwen3-8B")
params = SamplingParams(temperature=0.7, max_tokens=256)
outputs = llm.generate(prompts, params)
for out in outputs:
    print(out.outputs[0].text)

Pass the whole list in one call. The engine schedules all the prompts itself and keeps the batch full.

For a file of OpenAI requests, use the batch command:

vllm run-batch -i requests.jsonl -o results.jsonl --model Qwen/Qwen3-8B

Use the server when other programs must send the work, or when the job shares the GPU with live traffic.

Find where throughput stops growing

When nobody waits for a first token, only the total output matters. Raise the concurrency until the output stops growing.

We ran Qwen/Qwen3-8B with 512-token prompts on our L4, in the full-clock session on 2026-10-17. The output grew from 13.5 tok/s with one user to 273.9 tok/s with 32 users. At 64 users it fell to 241.4 tok/s, because the pool was full and the GPU throttled. In a separate run on 2026-10-18, a fixed rate of 4 requests each second gave 286.6 tok/s, at 1,245 against 1,275 MHz. So on this card, the most output came at about 32 users at once. More users only made each request wait longer.

The ceiling on your setup depends on the model, the prompt and answer lengths, and the pool. Run the sweep with your own lengths to find it.

Raise the limits for a batch job

Two limits cap the batch. Their defaults depend on the GPU memory and on how you run vLLM:

  • --max-num-seqs caps the requests that run at once.
  • --max-num-batched-tokens caps the tokens in one step. The offline LLM class has a larger default than the server. The vLLM documentation suggests a value above 8,192 for throughput, most of all for small models on large GPUs.

The token budget page gives the default of each limit for your GPU.

A larger token budget lets long prompts finish their prefill in fewer steps. Running streams get less of each step, which a batch job does not care about.

We measured the budget on our DGX Spark, where 16,384 gave longer gaps between tokens at a low rate. The long prompts page has the numbers. We did not measure it for a batch job.

Check the run

Watch vllm:num_requests_running and vllm:kv_cache_usage_perc. If the cache usage stays near 1 and requests wait, the pool limits the batch. Add memory with an FP8 KV cache or a shorter maximum length. If the running count sits at the --max-num-seqs limit with free cache, raise the limit.

Serve many fine-tunes with multi-LoRA

One base model can serve many LoRA adapters, and the adapter count and rank set the cost.

You have many fine-tunes of one base model. When you finish this section, you can serve LoRA adapters per request from one base model.

Start the server with LoRA on

A LoRA adapter is a small set of extra weights on top of a base model. The server keeps one copy of the base model and applies the adapter that each request names. Requests for different adapters can run in the same batch.

vllm serve Qwen/Qwen3-8B \
  --enable-lora \
  --lora-modules support=/adapters/support legal=/adapters/legal \
  --max-loras 4 --max-lora-rank 64

Each path is a folder in the PEFT format that most training libraries write. It holds adapter_config.json and the weights in adapter_model.safetensors. vLLM also reads adapter_model.bin. A path can also be a Hugging Face repository name.

A request picks an adapter by its name in the model field:

curl http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
  -d '{"model": "support", "messages": [{"role": "user", "content": "Reset my password"}]}'

/v1/models lists the base model and each adapter.

We ran this setup on the shared second GPU of our L4 node on 2026-10-23. We used Qwen/Qwen3-0.6B and two public adapters of rank 8 and rank 64. /v1/models listed the base model, support, and legal, and a chat request to each adapter returned HTTP 200.

Size the three limits

FlagDefaultWhat it limits
--max-loras1adapters that can run in one batch on the GPU
--max-lora-rank16the largest adapter rank the server accepts
--max-cpu-lorasnot setadapters kept in CPU memory, ready to load

The default of one adapter for each batch is low for a server with many adapters. Requests for a second adapter wait until the first adapter's batch is done. Raise --max-loras to the number of adapters that are busy at the same time.

Set --max-lora-rank to the largest rank of your adapters, not higher. The vLLM documentation advises against a large default such as 256, because the server reserves memory for the largest rank.

Load adapters while the server runs

To add an adapter without a restart, start the server with VLLM_ALLOW_RUNTIME_LORA_UPDATING=True. Then post to /v1/load_lora_adapter with the name and the path, and to /v1/unload_lora_adapter to remove it. Turn this on only when you trust every caller of the server, because the route loads files from a path that the caller gives.

Know what LoRA changes elsewhere

  • Prefix cache. The adapter is part of each block key. Two adapters never share cached blocks, even for the same prompt.
  • CUDA graphs. vLLM captures separate graphs for batches with an adapter. More adapters and more graph sizes add startup time and memory.
  • Names. Since 0.31, an adapter cannot have the name of a served model.

Not measured on our hardware. We have not measured the cost of the adapter count or the rank on the L4 or the GB10.

Get reproducible outputs

A seed alone does not give the same output in a busy batch, and batch invariance costs speed.

You run evals and the scores change between runs. When you finish this section, you can get the same output for the same request.

Know why outputs change

The vLLM documentation says that vLLM does not promise the same output by default, for the sake of speed. Three things change the result of one request:

  • The batch. The kernels split their sums by the batch size. The same request adds its numbers in a different order in a batch of 3 than in a batch of 30. Floating-point sums depend on the order. A small difference in one logit can change a sampled token, and every later token then differs.
  • The schedule. In the default multiprocess server, the timing of requests decides which batch each one joins.
  • The prefix cache. A request that reuses cached blocks computes its prompt in a different way than a request with no cache hit. In our runs on 0.31.0, a warm cache changed the greedy output of 11 of 32 prompts. The 2026 paper "Same Request, Different Answer" found the same effect on vLLM 0.11.
  • The dtype. BF16 and FP16 round each number differently, so the same model in the two dtypes writes different text.

The seed defaults to 0 in the current engine, and you cannot unset it. Speculative decoding needs every worker to sample the same tokens. So a seed is always there, and it is not enough by itself.

Pick the fix for your case

CaseFixCost
Offline evals with the LLM classset VLLM_ENABLE_V1_MULTIPROCESSING=0, use the same seed, and send the same prompts in the same orderthe engine runs in your process, so the schedule repeats
A server with live trafficturn on the batch-invariant mode, as the next section showsslower kernels for every request
Greedy evals that must match exactlycombine greedy sampling with one of the two fixes aboveas above

Every fix also needs the same hardware, the same vLLM version, and the same settings. A new release or a new GPU changes the kernels, and the outputs change with them.

Turn on the batch-invariant mode

Set one environment variable when you start the server:

VLLM_BATCH_INVARIANT=1 vllm serve Qwen/Qwen3-8B 2>&1 | tee server.log

The mode needs an NVIDIA GPU with compute capability 8.0 or higher, which includes the L4 and the GB10. The vLLM documentation lists Qwen3-8B among the models it tested in this mode. The mode turns off some speed features that change the order of the sums, such as custom all-reduce with tensor parallelism.

We ran one probe with the small Qwen3-0.6B on an L4. With the mode on, a greedy prompt gave the same text alone and inside a batch of 32 requests. With the mode off, the two texts differed. One prompt on one small model proves little for yours, so run the test at the end of this page on your own model.

See what changed the output in our runs

We sent ShareGPT questions after one shared system prompt of about 170 tokens, greedy, with 128 output tokens and thinking off. We compared each output with the output of a second run, token by token. This was on the second GPU of our L4 node on 2026-10-22 and 2026-10-23, with Qwen/Qwen3-0.6B. That GPU is shared with its owner and had no clock lock.

A clock changes the speed of a run. It does not change the greedy tokens. Each count is still one session on one GPU.

outputs that differ at one user, out of 32textFull log
comparison                     SGLang 0.6B   vLLM 0.6B
flushed repeat against fresh   0 of 32       0 of 32
cache off against cache off    0 of 32       0 of 32
fresh against warm cache       21 of 32 (45) 11 of 32 (57)
cache on against cache off     25 of 32 (26) 21 of 32 (48)
The table for concurrency 1. The number in brackets is the median position of the first token that differs. The SGLang column comes from the same probe on SGLang 0.5.22.
  • One user, the same cache state. A repeat after a cache reset, or a repeat with the cache off, gave the same text for all 32 prompts.
  • One user, a warm cache. The same prompts against a warm cache changed 11 of 32 outputs. The cache on against the cache off changed 21 of 32.
  • Many users. At 64 users sent at once, two runs with the cache off differed in 50 of 64 outputs. The batch alone changed most outputs, so the cache effect could not be separated at that load.
  • The dtype. We sent 64 questions at one user with the cache off. BF16 against FP16 changed 48 of 64 outputs for Qwen3-0.6B and 56 of 64 for Qwen3-1.7B. The first different token came at a median position of 51 and 56. A repeat in the same dtype changed none.

So at one user, a cache reset is enough to repeat a greedy output exactly. Under load, a reset was not enough in our runs. For a live server, the batch-invariant mode is the fix this page covers, and we did not test it at that load. Our counts come from small models and one GPU, so test your own model.

Know what the batch-invariant mode does not cover

At 0.31.0 the mode is in beta. Work by other teams shows three limits. We did not measure them ourselves:

  • The speed cost. In the post "Defeating Nondeterminism in LLM Inference" of September 2025, Thinking Machines Lab ran 1,000 Qwen3-8B requests on vLLM. They took 26 s by default and 55 s with batch-invariant kernels, and 42 s with a better attention kernel. The 2026 CoRun paper reports that batch-invariant kernels more than double the latency and cut throughput by up to 74 percent.
  • Other changes in execution. The 2026 paper "Vosti: Specifying, Implementing, and Verifying Deterministic LLM Inference" tested changes such as prompt chunking and KV cache reuse. The batch-invariant mode of vLLM gave different logits under some of them.
  • Some model paths. At 0.31.0, a model that picks its own attention backend skips the check for the mode, so its output can still depend on the batch. Release 0.32.0 checks the backend of every attention layer. A fix for MoE models with data, tensor, and expert parallelism under torch.compile is not in 0.32.0.

So test the exact setup that you serve: the model, the parallel layout, the prefix cache setting, and the token budget.

Check the result

Run the same eval twice and compare the outputs token by token, not only the score. Between the two runs, reset the prefix cache or restart the server, as the benchmark page shows. If the outputs differ, send one failing prompt alone twice. If the two answers match when alone, the batch causes the difference.

Know the speed cost on our L4

We measured the cost on our L4 on 2026-10-23, with the clock locked at 900 MHz. The runs went in the order default, batch-invariant, default again, with two seeds each and a cache reset before each run.

the batch-invariant mode against the default, Qwen3-0.6B on vLLM 0.31.0textFull log
…
users   median TPOT default / batch-invariant   output tok/s default / batch-invariant
1       6.9 / 16.9 ms (2.4x)                    140.6 / 58.7 (58% lower)
8       9.9 / 18.2 ms (1.8x)                    755 / 420 (44% lower)
32      18.8 / 25.9 ms (1.4x)                   1,565 / 1,154 (26% lower)
…
The Qwen3-0.6B rows, where every run had a median clock of 900 MHz. The setup lines before them and the Qwen3-8B rows after them are cut.

On Qwen/Qwen3-8B at 8 users, the time per token was 1.4 times longer and the output tok/s 27 percent lower. At 32 users they were 1.3 times longer and 23 percent lower. The 1-user runs of the 8B model ran below the lock, so we leave them out. The relative cost fell as more users shared each step, and it was larger on the small model. Measure it on your model before you turn the mode on for a live server.

Use vLLM as an RL rollout engine

An RL trainer borrows the server between steps, and vLLM gives it routes to sleep, wake, and load new weights.

Your RL trainer must generate rollouts and then update the weights. When you finish this section, you can name the control routes an RL framework calls.

The loop

An RL step has two halves. The rollout half generates answers with the current weights. The training half computes a loss and updates the weights. Then the generator needs the new weights before the next rollout.

On one set of GPUs, the two halves take turns on the same memory. The generator must give its memory back during training and take it again after. The vLLM server provides the pieces for this, and many RL frameworks call them. The vLLM documentation lists verl, OpenRLHF, TRL, NeMo-RL, SkyRL, Prime-RL, and others among its users.

The pieces

The routes below exist only when you start the server with VLLM_SERVER_DEV_MODE=1. They free memory, replace weights, and run methods on every worker. Never expose a server in this mode outside the network of the trainer.

Sleep mode. Start the server with --enable-sleep-mode. A call to /sleep frees GPU memory, and /wake_up takes it back. At the deeper of its two levels, sleep drops the weights too, which suits a step where the trainer sends new weights anyway. --sleep-preserve-parameter-names keeps frozen weights on the GPU, so they do not move each step.

Pause and resume. /pause stops new generation, and /resume starts it again. A pause can abort the running requests, wait for them, or keep them.

Weight updates. The trainer starts a transfer with /init_weight_transfer_engine, then calls /start_weight_update, sends the weights, and calls /finish_weight_update. The transfer can run over NCCL between GPUs or through CUDA memory handles on one machine. /update_weight_version records which version the next rollouts use.

Collective calls. /collective_rpc runs a method on every worker. Frameworks use it for setup that vLLM does not cover.

External launch. With --distributed-executor-backend external_launcher, a launcher such as torchrun starts the vLLM workers inside the trainer's processes. Some colocated frameworks use this path.

Try sleep and wake by hand

Start a server in development mode with sleep mode on. --host 127.0.0.1 keeps the control routes on your own machine:

VLLM_SERVER_DEV_MODE=1 vllm serve Qwen/Qwen3-8B --enable-sleep-mode --host 127.0.0.1 2>&1 | tee server.log

In a second terminal, put the server to sleep, check it, and wake it:

curl -X POST 'http://localhost:8000/sleep?level=1'
curl http://localhost:8000/is_sleeping
curl -X POST 'http://localhost:8000/wake_up'
curl http://localhost:8000/is_sleeping

The two POST routes return HTTP 200 with an empty body. The first /is_sleeping call returns {"is_sleeping":true}, and the second returns {"is_sleeping":false}. Run nvidia-smi between the calls to see the GPU memory fall during the sleep and rise after the wake.

We ran these calls at level 1 with the small Qwen3-0.6B on an L4. Each POST returned 200 with an empty body, and the sleep took 1.9 s. The GPU memory fell from 14,861 MiB to 4,245 MiB and came back after the wake. The log line Sleep mode freed 10.37 GiB memory confirms the sleep.

Know the sleep mode problems at 0.31.0

Three problems with sleep mode are known at 0.31.0. We did not reproduce them ourselves:

  • A level 2 wake does not reload a speculative draft. Issue 52479 reports that the weight reload covers only the target model. The target model then rejects every draft token, so decode falls to about the speed with no speculation. The output stays correct, and no error appears. The issue is open.
  • A level 2 sleep clears calibrated KV scales. On compressed-tensors checkpoints with an FP8 KV cache, the first request after a reload of the weights returned NaN logprobs and a wrong first token. Release 0.32.0 fixes this.
  • A weight update during sleep stops the engine. Issue 57853 reports a segfault when the trainer sends weights after /sleep and before /wake_up. The API server stays up, and every later request fails. The issue is open.

So after each level 2 wake, reload the weights, reset the prefix cache, and read the speculative acceptance counters. Always wake the server before the trainer sends weights.

Check the rollout logprobs

The trainer often compares the logprobs that vLLM returned with the logprobs it computes itself. The two never match exactly, because the kernels differ. A small, steady gap is normal. A gap that grows means the weights did not reach the generator. Track the gap every step. The reproducibility page explains why the same weights still give different numbers.

Before you read the gap, check which logprob vLLM returns. With top-p or top-k, the two values differ for most tokens.

Logprobs mode: the server option --logprobs-mode, which picks the value that vLLM returns for each token. The default is raw_logprobs, the logprob over the full vocabulary. processed_logprobs gives the logprob after the sampling settings change the distribution. Two more modes, raw_logits and processed_logits, return logits.

We checked both logprob modes on the second GPU of our L4 node on 2026-10-23. We used Qwen/Qwen3-0.6B in BF16, 16 prompts of 48 tokens, temperature 1, and top-p 0.9. A float32 Transformers pass over the same tokens gave the reference. That GPU is shared and has no clock lock, but the clock does not change these values.

returned logprob minus the full-vocabulary referencetextFull log
engine and mode                                  returned minus reference, median   median absolute
vLLM --logprobs-mode raw_logprobs                0.0000                             0.005
vLLM --logprobs-mode processed_logprobs          +0.0527
The two vLLM rows. The SGLang rows are cut.
  • raw_logprobs matched the full-vocabulary logprob, with a median absolute gap of 0.005 from the kernels.
  • processed_logprobs was higher by minus the log of the probability mass that top-p kept. The residual against that formula had a median of 8e-9.
  • At top-p 0.9, the kept mass was below 0.99 for most sampled tokens, and the median shift was about 0.053.

So compare raw_logprobs with a trainer that scores over the full vocabulary. Compare processed_logprobs only with a trainer that scores over the same kept set.

Not measured on our hardware. We ran only that one level 1 check and the logprob check, both with small models. We have not timed a level 2 sleep or a weight update. Our Miles 101 manual measures the same loop with SGLang as the generator.

Serve reasoning models

A reasoning model writes its thinking before the answer, and the reasoning parser keeps the thinking out of the answer text.

Your Qwen3 server returns a long block of thinking where you expected a short answer. When you finish this section, you can separate the thinking from the answer, turn the thinking off, and limit its length.

We did not measure the speed of reasoning models for this manual. We checked the fields and switches on this page on an L4 with Qwen3-8B-FP8. That covers the reasoning field, enable_thinking per request and per server, the thinking budget, and the stream order. The rest comes from the vLLM documentation and the source at 0.31.0.

Know what the model writes

Reasoning model: a model that writes its reasoning steps first, between markers such as <think> and </think>, and then the final answer. Qwen3, DeepSeek-R1, and QwQ are examples. Qwen3 thinks by default.

Without a reasoning parser, vLLM returns the whole output in content. The answer then starts with the thinking. The thinking also counts against max_tokens, so a short limit can end the request before the answer starts.

Turn on the parser

Start the server with the parser for your model family. For Qwen3, the parser is qwen3:

vllm serve Qwen/Qwen3-8B --reasoning-parser qwen3 2>&1 | tee server.log

The default is no parser. vLLM does not pick one from the model name. The vLLM documentation lists the parser name for each supported family, such as deepseek_r1 for DeepSeek-R1 and QwQ.

With the parser, a chat response has two text fields:

FieldWhat it holds
choices[0].message.reasoningthe thinking, without the markers
choices[0].message.contentthe final answer

In a stream, each chunk carries the new text in delta.reasoning or in delta.content. Older releases called the field reasoning_content. At 0.31.0 the response field is reasoning, so update a client that reads the old name.

Turn the thinking off or limit it

You have three controls:

  • For one request. Send "chat_template_kwargs": {"enable_thinking": false} in the request body. With the OpenAI client, put it in extra_body.
  • For the whole server. Start the server with --default-chat-template-kwargs '{"enable_thinking": false}'. A request can still turn the thinking on again with its own chat_template_kwargs.
  • A budget for the thinking. Send thinking_token_budget in the request. When the thinking reaches that many tokens, vLLM makes the model close the thinking and start the answer. The parser must be on.

The name enable_thinking comes from the Qwen3 chat template. Other families use other names, so read the chat template of your model.

Know what changes for JSON and tools

With a reasoning parser on, structured output applies to the answer only, after the thinking ends. The thinking itself is free text. The Qwen3 parser also works with tool calls, so a request can return reasoning and tool_calls together. For tool calls, also start the server with --enable-auto-tool-choice and a tool call parser, such as --tool-call-parser hermes for Qwen3. The JSON page shows how to ask for a schema.

Give a thinking request a large max_tokens. If the limit ends the request inside the thinking, content is empty and finish_reason is length. A response that a limit cut is not valid JSON.

Check the setup

Send one request with the thinking on and one with it off:

curl -s http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" \
  -d '{"model": "Qwen/Qwen3-8B", "messages": [{"role": "user", "content": "Is 91 a prime number?"}], "max_tokens": 2048}'

The first reply has text in both reasoning and content. Add "chat_template_kwargs": {"enable_thinking": false} to the body and send it again. The second reply has an empty or missing reasoning field and a short content.

Part 4

Speed Features

A speed feature pays only for some traffic, so decide from a measurement on your own load.

  1. 4.1Speed up decode with EAGLE3
  2. 4.2Use n-gram speculation for copy-heavy work
  3. 4.3Keep CUDA graphs and compile on
  4. 4.4Leave async scheduling on
  5. 4.5Change the attention backend only for a reason

Speed up decode with EAGLE3

EAGLE3 pays at low concurrency, and the draft depth that pays falls as more users share each step.

Your users wait for long answers at low load. When you finish this section, you can choose the draft depth for your concurrency.

Our result: on our clock-locked L4, EAGLE3 at depth 3 gave 1.6 times the baseline output at 32 users, on random prompts with 128-token outputs.

Turn it on

EAGLE3 adds a small draft model that guesses the next tokens from the hidden states of the target model. The target model checks the guesses in one pass, as the speculative decoding page explains. The target model keeps only the guesses it would have written itself.

You need a draft that someone trained for your target model. For Qwen/Qwen3-8B, we used Tengyunw/qwen3_8b_eagle3:

vllm serve Qwen/Qwen3-8B \
  --speculative-config '{"method": "eagle3", "model": "Tengyunw/qwen3_8b_eagle3", "num_speculative_tokens": 3}'

num_speculative_tokens is the draft depth: the number of tokens the draft guesses in each step. If you leave it out, vLLM takes the value from the draft config, if the config has one. Set it yourself, so you know what runs.

EAGLE3 keeps async scheduling on. The async scheduling page lists the methods that turn it off.

Know why the depth matters

A deeper draft can give more tokens for each pass of the target model. But each guess also costs work in the check. With one user, the GPU has spare compute in each step, so the extra guesses are almost free. With many users, each step is already full, and wrong guesses take compute from real tokens.

See what our runs show

We measured EAGLE3 at depths 1, 3, and 5 against no speculation on our L4 with Qwen/Qwen3-8B on 2026-10-22. This was the session locked at 900 MHz, and we ran 1 and 32 users with two seeds each. The prompts came from the random dataset of the benchmark tool, with 512 tokens in and 128 tokens out. Raw runs

Fig. 4.1EAGLE3 output by draft depthchart
EAGLE3 output at 1 and 32 users with the clock locked at 900 MHz Median output tokens per second on an NVIDIA L4 with Qwen3-8B and the SM clock locked at 900 MHz, two seeds per bar, 2026-10-22. At one user, draft depth 1 gave 22.4, depth 3 gave 31.3, and depth 5 gave 31.7 tokens per second. The run with no speculation at one user is not shown, because its clock fell to 210 MHz. At 32 users, no speculation gave 208.8, depth 1 gave 271.1, depth 3 gave 333.6, and depth 5 gave 289.5 tokens per second. 1 user, output tok/s 0 20 40 22.4 k = 1 31.3 k = 3 31.7 k = 5 32 users, output tok/s 0 200 400 208.8 none 271.1 k = 1 333.6 k = 3 289.5 k = 5 Higher is better. k is the draft depth. The 1-user run with no speculation is left out: its clock fell to 210 MHz.
With the clock locked, a draft depth of 3 gave the highest output at 32 users, and depths 3 and 5 tied at one user. Each bar is the median of two seeds. The run with no speculation at one user is left out, because its clock fell to 210 MHz.
Users, L4 locked sessionConfigMedian TPOTOutput tok/sMedian SM clock
32no speculation119.9 ms208.8881 MHz
32depth 1100.4 ms271.1900 MHz
32depth 377.8 ms333.6900 MHz
32depth 583.7 ms289.5885 MHz
1depth 139.4 ms22.4900 MHz
1depth 327.8 ms31.3900 MHz
1depth 526.2 ms31.7900 MHz

At 32 users, depth 3 gave 1.6 times the output of no speculation. Its seed range did not overlap with any other config. Depth 5 gave less output than depth 3, so the extra guesses cost more than they gave.

At one user, depth 3 cut the time per token from 39.4 to 27.8 ms against depth 1. Depth 5 gave about the same as depth 3, and their seed ranges overlapped.

The run with no speculation at one user fell to 210 MHz, below the lock. A lock sets the highest clock, and it cannot stop the GPU from throttling. So we do not compare EAGLE3 against no speculation at one user.

We did not collect acceptance data from these runs, so we give no acceptance length here.

The median TPOT fell, but the gaps between the token groups grew. At 32 users, the median ITL rose from 84 ms with no speculation to 133 ms at depth 3. Each step now sends several tokens at once, so the stream arrives in larger and less frequent groups.

Know the limits of our result

Our gain holds for random prompts and outputs of 128 tokens. Other teams found that real traffic can give less. We did not measure these effects ourselves:

  • The 2026 paper "SPEED-Bench: A Unified and Diverse Benchmark for Speculative Decoding", by NVIDIA researchers, found that synthetic prompts overstate the speculative throughput of real prompts. It also found that the best draft depth changes with the batch size.
  • The 2026 paper "Test-Time Speculation" studied EAGLE-3 and other drafts on MATH answers that ran toward 20,000 tokens. The acceptance length fell from about 3.7 to about 1.5 over those answers.

So a gain on short random answers does not predict a gain on long chat answers or on reasoning traces.

Read the acceptance over a long answer

We measured the acceptance length against the position in the answer on 2026-10-23. We used Qwen/Qwen3-8B-FP8 with an EAGLE3 draft and 3 speculative tokens, on the second GPU of our L4 node. Each of 8 prompts ran with thinking on, greedy, at one user, up to 4,096 tokens.

mean acceptance length by position in the answertextFull log
segment of the output   SGLang                 vLLM
0 to 256                2.05 to 2.64 (8)       2.14 to 2.51 (8)
256 to 512              1.95 to 2.64 (8)       2.09 to 2.55 (8)
512 to 1,024            2.11 to 2.77 (8)       2.12 to 2.79 (8)
1,024 to 2,048          2.09 to 2.47 (5)       1.72 to 2.66 (5)
2,048 to 4,096          2.33 and 2.48 (2)      2.35 and 2.60 (2)
The range across prompts, with the number of prompts that reached each segment in brackets. The SGLang column comes from the same prompts on SGLang 0.5.22.

Up to 4,096 tokens, the acceptance length did not fall steadily. It stayed at about 2.1 to 2.8 tokens for each step on both engines. Only 2 of the 8 prompts ran past 2,048 tokens, so the last segment rests on two answers. The GPU was shared and had no clock lock, which changes the speed but not the acceptance. Our limit of 4,096 tokens came from the memory we could use on that GPU. So we did not reach the range of the paper.

A second test on 2026-10-23 used Qwen/Qwen3-8B in BF16 on the first GPU of the node. It ran 8 users, with answers of up to 2,048 tokens and no ignore-eos. The median answer was 1,800 to 2,000 tokens. vLLM gave the acceptance from its counters before and after each run:

EAGLE3 acceptance on answers of about 2,000 tokens, 8 userstextFull log
engine          dataset    mean acceptance length   accepted per draft position 1 / 2 / 3
SGLang EAGLE3   GSM8K      2.68
SGLang EAGLE3   ShareGPT   2.32
vLLM EAGLE3     GSM8K      2.69                     0.77 / 0.54 / 0.38
vLLM EAGLE3     ShareGPT   2.35                     0.67 / 0.42 / 0.26
The table of the file. The comparison with short outputs is cut.

The first draft token was accepted 77 percent of the time on GSM8K and 67 percent on ShareGPT, and each later position less. On short 128-token answers in the same session, SGLang gave 2.69 to 2.78 on GSM8K and 2.33 to 2.43 on ShareGPT. Its long answers stayed within 0.01 of those ranges. We have no short-answer acceptance for vLLM from this session, so the vLLM rows show only the level on long answers.

Read the acceptance on your own long answers before you trust a gain. The metrics at the end of this page give it.

Lock the clock before you compare

Our first EAGLE3 runs had no clock lock. At one user, the run with no speculation ran at 210 MHz, and the EAGLE3 run at about 1,400 MHz. A gap that large hides the effect of speculation, so we set those runs aside.

Before you compare speculative decoding with no speculation, lock the clock or record it for each run. Compare two runs only at the same clock. The lock needs root:

sudo nvidia-smi -lgc 900,900
sudo nvidia-smi -rgc

The first command locks the SM clock at 900 MHz, and the second releases the lock after the runs.

Choose the depth

  • For Qwen3-8B with this draft, start with a depth of 3. On our L4 it gave the best output at 32 users and tied at one user.
  • For a busy server, sweep the depth on your own model. On our L4, depth 5 lost output at 32 users.
  • If your load changes a lot through the day, vLLM can set the depth by batch size with num_speculative_tokens_per_batch_size. We did not test it.

Check the gain on your prompts

Run a sample of your own traffic with and without the config. Keep your real output lengths, and do not use --ignore-eos. Compare these numbers:

  1. The acceptance rate, from the two counters below.
  2. The median TPOT and the output throughput.
  3. The P99 ITL and the P99 TTFT, because a gain in the median can hide a loss in the tail.
  4. The acceptance rate of your longest answers alone, if your answers run to thousands of tokens.

Read the two counters from /metrics:

curl -s http://localhost:8000/metrics | grep -E "spec_decode_num_(draft|accepted)_tokens"

Divide the accepted tokens by the draft tokens. vLLM also writes a SpecDecoding metrics line to the log, with the mean acceptance length.

Use n-gram speculation for copy-heavy work

N-gram speculation needs no draft model and pays when the answer copies the prompt, and it turns off async scheduling.

Your users ask for edits of documents they send. When you finish this section, you can decide when n-gram speculation beats a draft model.

Our result: on our L4, n-gram cut the time per token from 242 to 77 ms for one user, on random prompts with 128-token outputs.

Turn it on

N-gram speculation guesses the next tokens from text that the request already holds. It takes the last few tokens of the output and finds the same tokens earlier in the request. Then it proposes the tokens that came after them. The target model then checks the guesses in one pass, as the speculative decoding page explains.

It needs no draft model, so it uses no extra GPU memory for weights. Start the server with a speculative config:

vllm serve Qwen/Qwen3-8B \
  --speculative-config '{"method": "ngram", "num_speculative_tokens": 3, "prompt_lookup_max": 4}'

num_speculative_tokens is the number of guesses in each step. prompt_lookup_max is the longest run of tokens that the proposer tries to match. Our runs used these two values.

Know when it pays

The proposer can only guess text that already appears in the request. Work that copies the prompt gives it many matches:

  • an edit of a document that the user sends
  • a summary that quotes the source
  • a code change that keeps most lines
  • extraction of fields from a long input

Open chat copies little. For that work, most guesses fail, and each step writes one token, as it would without speculation.

See what our run shows

We measured the time per token with and without n-gram speculation in the shared-GPU session of our L4 node, with Qwen/Qwen3-8B, on 2026-10-22. That GPU held a pool of 2,928 tokens, so we tested 1 and 4 users with two seeds each. The prompts came from the random dataset of the benchmark tool, with 512 tokens in and 128 tokens out. Raw runs

Fig. 4.2the time per token with n-gram speculationchart
N-gram speculation cut the time per token to about a third for one user Median time per output token on an NVIDIA L4 with Qwen3-8B, a small KV pool, two seeds, random prompts. At one user, 241.7 ms without speculation and 76.6 ms with n-gram. At four users, 272.6 ms and 101.3 ms. The GPU ran at 375 to 443 MHz in all four runs. 0 100 200 300 median ms per token 241.7 1 user, none 76.6 1 user, n-gram 272.6 4 users, none 101.3 4 users, n-gram Lower is faster. Random prompts, acceptance counters not recorded.
On our L4, n-gram speculation cut the median time per token to about a third for one user and to 37 percent for four users. Each bar is the median of two runs. Lower is faster.
Users, L4 shared-GPU sessionMedian TPOT, none / n-gramOutput tok/s, none / n-gramP99 ITL, none / n-gramMedian SM clock, none / n-gram
1241.7 / 76.6 ms3.98 / 7.39274 / 284 ms375 / 413 MHz
4272.6 / 101.3 ms13.02 / 19.22290 / 1,691 ms428 / 443 MHz

The GPU ran at 375 to 443 MHz, far below its full clock, because the GPU is shared and throttled. The times are slow for that reason. Read the ratio between the two columns, not the absolute times. The n-gram runs had a clock about 10 percent higher, which is small next to the threefold gain.

At one user, output rose by 86 percent. At four users it rose by 48 percent, and the worst token gap grew from 290 to 1,691 ms. We did not trace the cause of that gap.

We did not record the acceptance counters, so we cannot say how many guesses matched. Treat our gain as a result for random prompts and 128-token outputs only. On your own prompts, the gain depends on how much the output copies the input.

Another team reports the same limit, and we did not measure it ourselves. The paper "Speculative Decoding: Performance or Illusion?", posted at the end of 2025, studied speculation in vLLM. It found that the acceptance length changes a lot between requests and between datasets. Our own run also shows that a lower median can hide a worse tail. At four users the median time per token fell, and the P99 ITL rose.

Know what it costs

  • N-gram speculation turns off async scheduling, with only a warning in the log. The async scheduling page lists the methods that do this.
  • It runs on the older model runner, not on Model Runner V2.

Check the gain on your prompts

Run a sample of your own traffic with and without the config. Keep your real output lengths, and do not use --ignore-eos. Compare the median TPOT, the P99 ITL, and the P99 TTFT, not only the median. Then read two counters from /metrics:

curl -s http://localhost:8000/metrics | grep -E "spec_decode_num_(draft|accepted)_tokens"

Divide the accepted tokens by the draft tokens. A high share means that your output copies the prompt and the method pays. A low share means that a draft model such as EAGLE3 is the better test.

Keep CUDA graphs and compile on

The default compile level is the right one for most servers, and eager mode costs a few percent of decode speed.

Startup takes minutes and you want it faster. When you finish this section, you can decide what each compile level and eager mode cost.

Our result: on our L4, eager mode cost about 2 percent of the time per token in a cooled run. In a throttled run, -O0 cost about 2 percent for one user, and -O3 matched the default.

Know the switches

vLLM sets compile and CUDA graphs with one level, from -O0 to -O3. The default is -O2. The compile page explains what each part does.

SettingCompileCUDA graphsUse it for
-O2, the defaultonfull and piecewiseproduction
-O3the same as -O2 in 0.31.0nothing extra yet
-O1on, with fewer fusionspiecewise onlya faster start that keeps compile and graphs on, not measured by us
-O0offoffa fast start when you test a change
--enforce-eageroffoffdebugging a kernel, because it also skips the kernel warmup

Read what the start costs

The startup log gives the time of each part. On our DGX Spark, with Qwen3-30B-A3B, the two graph captures and the compile took about 96 seconds of a start of about eight minutes:

graph capture and compile time at startuptextFull log
(EngineCore pid=143) INFO 10-21 10:59:45 [model_runner.py:1087] Graph capturing finished in 39 secs, took 1.17 GiB
(EngineCore pid=143) INFO 10-21 11:00:51 [model_runner.py:1087] Graph capturing finished in 38 secs, took 0.31 GiB
(EngineCore pid=143) INFO 10-21 11:00:53 [core.py:373] init engine (profile, create kv cache, warmup model) took 127.54 s (compilation: 18.67 s)
The first two lines are the two capture passes. The last line gives the whole engine start and the compile share.

On that start, loading the weights took about 330 seconds, much more than the compile. A later start can read the compile cache, so the compile share gets smaller.

See what our runs show

We measured eager mode against the default on the L4 with Qwen/Qwen3-8B on 2026-10-21, in the full-clock session. Both runs used the same pool, seven concurrencies, and three seeds, with the GPU cooled before each run. Raw runs

Fig. 4.3eager mode against the defaultchart
Eager mode cost 1.2 to 2.3 percent of the time per token on the L4 Median time per output token against users at the same time on an NVIDIA L4 with Qwen3-8B, three seeds per point, both runs on 2026-10-21. Eager mode is 1.2 to 2.3 percent slower at every concurrency, for example 61.1 against 60.4 ms at one user and 131.4 against 129.2 ms at 64 users. 50 80 110 140 1 2 4 8 16 32 64 users at the same time median ms per token default, -O2 --enforce-eager
On our L4, eager mode was 1.2 to 2.3 percent slower per token at every concurrency. Bands span the lowest and highest of three runs. The two lines sit close because an 8B decode step waits on memory, not on kernel launches.
Users, L4 full-clock sessionMedian TPOT, default / eagerMedian SM clock, default / eager
160.36 / 61.07 ms1,275 / 1,290 MHz
865.75 / 66.98 ms1,485 / 1,515 MHz
32101.79 / 103.11 ms1,275 / 1,313 MHz
64129.15 / 131.42 ms1,005 / 1,020 MHz

The seed ranges did not overlap from 1 to 32 users, so the gap is real, and it is small. On our DGX Spark, eager mode cost 2 to 6 percent of the time per token.

We then ran the compile levels on 2026-10-22, in the throttled session, at 1 and 32 users. The default had three seeds and the other two levels had two. The GPU was throttled for all three: the median clock was 210 MHz at one user and 450 to 548 MHz at 32 users.

Users, L4 throttled sessionMedian TPOT, default / -O0 / -O3
1104.14 / 106.48 / 104.15 ms
32142.67 / 142.04 / 143.85 ms

At one user, -O0 was 2.2 percent slower, and the seed ranges did not overlap. At 32 users, the three levels overlapped. -O3 matched the default, as the code says it should at this version. Because of the low clock, read these as a comparison inside one session, not as speeds you will see.

Decide

Keep the default for production. The graphs and the compile cost about one and a half minutes on our DGX Spark start, and the cache makes later starts faster.

When you test a change and restart often, use -O0. It skips the compile and the graph capture, and it cost us about 2 percent of decode speed. Use --enforce-eager only to debug a kernel, because it also skips the warmup, so the first requests after a start are slow.

Leave async scheduling on

Async scheduling is on by default for most generate models, and a few settings turn it off without an error.

You read about async scheduling and you want to know whether to set it. When you finish this section, you can check whether your server runs with it, and name the settings that turn it off.

Our result: on a throttled L4, turning async scheduling off made one user about 3 percent slower per token. Treat it as an unconfirmed result, because the clock was low and we had two seeds.

Know what it does

Without async scheduling, each step runs in order. The scheduler picks a batch, the GPU runs it, and the scheduler reads the result before it picks the next batch. While the scheduler works, the GPU waits.

With async scheduling, the scheduler picks the next batch while the GPU still runs the current one. It counts the tokens that are not sampled yet as placeholders, so it does not schedule a request past its token limit. The GPU spends less time idle between steps. The Model Runner V2 page explains why the new model runner makes this possible.

Know when it is on

In vLLM 0.31.0, the setting has three values. --async-scheduling turns it on, --no-async-scheduling turns it off, and no flag lets vLLM decide. When you set no flag, vLLM turns it on unless, among other cases, one of these holds:

  • The model is a pooling model, such as an embedding model.
  • The speculation method is n-gram, suffix, Medusa, or MLP speculator. EAGLE, MTP, DFlash, and DSpark methods, the GPU n-gram method, and a draft model keep it on.
  • Pipeline parallelism is on and the old model runner runs.
  • The executor does not support it.

In those cases, vLLM turns it off. For most of them it writes a warning to the log, but for a pooling model only a debug line. The server runs, so the change is easy to miss. Read the startup log after you add a speculation method or change the parallel layout.

See how much two runs of one setting differ

We ran the L4 load sweep twice on 2026-10-21 with Qwen/Qwen3-8B, in the full-clock session. One run had no flag, and one had --async-scheduling. For this model with no speculation, the flag sets what the default already sets, so the two runs are the same setting.

The two runs differed by at most 1.8 percent of output and 0.8 percent of time per token. Their seed ranges overlapped at every concurrency. So on this card, a gap of a few percent between two configs is run-to-run variation unless the seed ranges separate. The lab notes have the full table under the configs vllm-b and vllm-async.

See what turning it off costs

We measured --no-async-scheduling against the default in the shared-GPU session of our L4 node on 2026-10-22, with Qwen/Qwen3-8B. That GPU held a pool of 2,928 tokens, so we tested 1 and 4 users. Raw runs

Users, L4 shared-GPU sessionMedian TPOT, on / offOutput tok/s, on / offMedian SM clock, on / offSeeds, on / off
1241.7 / 249.6 ms3.98 / 3.87375 / 368 MHz2 / 2
4272.6 / 277.2 ms13.02 / 12.85428 / 450 MHz2 / 1

At one user, the time per token rose by 3.3 percent with async scheduling off, and the seed ranges did not overlap. That gap is larger than the 0.8 percent that two runs of one setting differed by. But we measured that variation on the other GPU at full clocks. At four users, it rose by 1.7 percent, from one seed only.

The GPU ran at 368 to 450 MHz, far below its full clock. The runs with async scheduling off at one user had a clock 2 percent lower, close to the size of the effect. So read this as an unconfirmed result. The gain is small at these batch sizes, and we did not test it at a full clock or at high concurrency.

Decide

Set no flag, and let vLLM choose. If you add a speculation method or pipeline parallelism, read the log for the warning. If the warning appears, measure the time per token with and without the feature, because you lose async scheduling together with the feature.

Change the attention backend only for a reason

vLLM picks the attention backend for your GPU, and you have two reasons to override it.

A forum post says a different attention backend is faster. When you finish this section, you can read the backend vLLM picked and decide when to override it.

Our result: on our L4, a forced Triton backend fixed a start that failed in the backend vLLM picked.

Read the backend vLLM picked

vLLM writes one line at startup that names the backend and the other backends that could run. Search the log for it:

grep "attention backend" server.log

On our DGX Spark, with Qwen3-30B-A3B and the default settings, the line was:

the attention backend line on our DGX SparktextFull log
(EngineCore pid=143) INFO 10-21 10:53:17 [cuda.py:526] Using FLASH_ATTN attention backend out of potential backends: ['FLASH_ATTN', 'FLASHINFER', 'TRITON_ATTN', 'FLEX_ATTENTION'].
The list holds only the backends that can run with this model and these settings, in the order vLLM tries them.

Know the order vLLM tries

vLLM has one list of backends for each GPU class. It removes each backend that cannot run with your model and your settings, and it picks the first one that is left. For a standard model in vLLM 0.31.0, the order is:

GPUExamplesOrder
SM 10.xB200, GB200FlashInfer, FlashAttention, Triton, FlexAttention, TurboQuant
other NVIDIA GPUsL4 (SM 8.9), H100 (SM 9.0), DGX Spark (SM 12.1)FlashAttention, FlashInfer, Triton, FlexAttention, TurboQuant

The reasons for each removal go to the debug log, not to the info log. To see them, compare the list in the startup line with the table. A backend that is missing from the list was removed.

Know the switch that an FP8 KV cache makes

In vLLM 0.31.0, FlashAttention reads an FP8 KV cache only on SM 9.0 and on the SM 10.x family. So on an L4 or a DGX Spark, --kv-cache-dtype fp8 changes the backend with no warning. The FP8 KV cache page tells what happened on our L4.

Force a backend

Set the backend with --attention-backend:

vllm serve Qwen/Qwen3-8B --kv-cache-dtype fp8 --attention-backend TRITON_ATTN

If the backend you set cannot run with your model and settings, vLLM stops at startup with an error that gives the reason. It does not move to another backend. So a forced backend is safe to try: it runs, or it fails at once.

--block-size also removes backends. If it removes a backend that is higher in the list, vLLM writes a warning that names it.

See what our run shows

Our only forced-backend run changed the backend and the KV data type together, so its result does not show which backend is faster. We have not compared backends with one KV data type on any of our GPUs.

Decide

Leave the backend to vLLM. Override it for one of two reasons:

  • The start fails in the backend that vLLM picked, as FlashInfer did on our L4.
  • You measured a slowdown on your own load, and a different backend fixed it on the same load.

A forum post that says a backend is faster tested a different GPU, model, or load. Measure on yours before you change it.

Part 5

Measure

A benchmark tells you something only when it matches your traffic and records its conditions.

  1. 5.1Benchmark your server in 15 minutes
  2. 5.2Watch vLLM in production
  3. 5.3Decide between vLLM and SGLang

Benchmark your server in 15 minutes

One sweep over concurrency finds the point where your latency starts to grow, and four defaults change the result.

You changed a flag and you want to know if it helped. When you finish this section, you can run a concurrency sweep, find the latency jump, and avoid the four mistakes that make two runs disagree.

Our result: on our L4, output peaked near 275 tokens per second at 32 users and fell at 64 users.

Run one sweep

Our tables came from the benchmark client of SGLang 0.5.22, not from the vLLM client, at temperature 0. We used one client for both engines, so that the engine page compares the engines with one method. To compare your GPU with our table, use the same client. Install it in a separate virtual environment, so that it does not change your vLLM install:

uv venv bench --python 3.12 --seed
source bench/bin/activate
uv pip install sglang==0.5.22

Start the vLLM server in one terminal. In a second terminal, run the client once for each concurrency. This is the command that made our table:

for c in 1 2 4 8 16 32 64; do
  python -m sglang.benchmark.serving --backend vllm --port 8000 \
    --dataset-name random --random-input-len 512 --random-output-len 128 --random-range-ratio 1.0 \
    --max-concurrency $c --num-prompts $((c * 4 > 16 ? c * 4 : 16)) --seed 1 \
    --output-file sweep-c$c.jsonl --output-details
done

We ran the loop three times, with --seed 1, 2, and 3, and report the median. With --backend vllm, the client sends each request to /v1/completions. It sends one warmup request first, and it tells the server to ignore the end-of-sequence token, so every answer has the full 128 tokens.

Each run prints a summary with four numbers. They are the output throughput, the time to first token (TTFT), the time per output token (TPOT), and the inter-token latency (ITL). Keep the four numbers together. A throughput number alone hides the latency that your users feel.

Read the knee

Knee: the concurrency where the latency starts to rise fast while the output stops rising. Past the knee, more users get slower answers and the server writes almost no more tokens.

Here is the sweep from our L4 with Qwen/Qwen3-8B in BF16, run on 2026-10-21 in the full-clock session. Each number is the median of three seeds. The pool was 24,096 tokens.

Users, L4 full-clock sessionOutput tok/sMedian TTFTMedian TPOTP99 ITLMedian SM clock
113.5191 ms60.4 ms105 ms1,275 MHz
8112.8253 ms65.8 ms67 ms1,485 MHz
16201.3288 ms69.5 ms85 ms1,455 MHz
32275.11,800 ms101.8 ms613 ms1,275 MHz
64242.311,181 ms129.2 ms1,233 ms1,005 MHz

Up to 16 users, each new user adds output, and the time per token rises only from 60 to 70 ms. In this table, the median first token goes from 0.3 s to 1.8 s between 16 and 32 users. The P99 ITL goes from 85 to 613 ms.

Do not take that jump as the knee. This sweep did not reset the prefix cache between user counts, and each seed sent the same first prompts at every count. Below 32 users, many requests found their prompt in the cache. We ran the sweep again on 2026-10-23 with a reset before each run, with the clock locked at 900 MHz:

median TTFT with and without a cache flush, Qwen3-8B on our L4 at 900 MHztextFull log
median TTFT, flushed against unflushed
users   vLLM                                   SGLang
4       650 / 145 ms (4.5x)                    532 to 645 / 141 ms (3.8 to 4.6x)
8       951 to 978 / 264 to 274 ms (3.6x)      1,174 to 1,273 / 173 to 178 ms (6.6 to 7.3x)
16      1,550 to 1,587 / 294 to 295 ms (5.3x)  1,804 to 1,944 / 218 ms (8.3 to 8.9x)
32      1,982 to 2,084 / 2,012 to 3,091 ms     3,216 to 3,228 / 2,710 to 2,917 ms
…
The median TTFT rows. The share that met the target and the P99 ITL lines are cut.

With the reset, the median first token rose at every step. It was about 0.65 s at 4 users, 1.0 s at 8, and 1.6 s at 16. The P99 ITL jumped from about 70 ms at 8 users to 522 ms at 16. So on this card the knee sits between 8 and 16 users. The cache hid it. Pick your production concurrency below the knee that you measure with a reset.

At 64 users, the output fell. Two causes overlap here. At 640 tokens each, 64 users need more than the 24,096-token pool, so requests waited. The GPU also throttled, with a median clock of 1,005 MHz against 1,455 at 16 users. Our sweep cannot separate the two.

Fig. 5.1the throughput peak of the old sweepchart
The sweep finds the knee: output peaks at 32 users on the L4 Output tokens per second against users at the same time for vLLM 0.31.0 on an NVIDIA L4 with Qwen3-8B, three seeds per point. Output rises from 13.5 at one user to 275.1 at 32 users, then falls to 242.3 at 64 users, where requests queue for KV cache space. 0 100 200 300 1 2 4 8 16 32 64 users at the same time output tokens per second knee, 275 242 median of three runs, band from lowest to highest
Without a cache reset, output peaked at 275 tokens per second at 32 users, which is the throughput peak and not the latency knee. Users double at each step on the axis. The band spans the lowest and highest of three runs. The figure and the table show the same sweep of 2026-10-21, which had no cache reset. The latency knee with a reset is between 8 and 16 users, as the text says.

Avoid the four mistakes

  1. Reusing a warm cache. With the random dataset and a fixed seed, a second run sends the same prompts. The prefix cache then serves part of them, and the second run looks faster. The next section gives the protocol.
  2. Letting the model stop early. Without --ignore-eos, some answers end before the output length. The run then measures fewer tokens than you asked for.
  3. Skipping the warmup. The first requests after a start pay for compiled kernels and cold caches. Use --num-warmups or send a few requests before you measure.
  4. Ignoring the clock. A GPU that throttles or that other work shares runs slower. Record nvidia-smi --query-gpu=clocks.sm,temperature.gpu --format=csv -l 1 during the run. Compare two runs only at similar clocks.

Reset the prefix cache between runs

The prefix cache keeps the KV blocks of earlier prompts. It changes two things between runs: the speed, and sometimes the output of a greedy request. The reproducibility page shows the output effect.

To see the speed effect, we ran one load twice with the same seed and no reset. This was on the second GPU of our L4 node on 2026-10-22 and 2026-10-23. We used Qwen/Qwen3-0.6B, 64 prompts at 16 users, 512 tokens in and 128 out. That GPU is shared with its owner and had no clock lock, so trust the ratio and not the times.

median TTFT in ms, the same load four times in a rowtextFull log
engine        fresh   repeat, not flushed   repeat after a flush   repeat, not flushed
…
vLLM 0.6B     925     148                   1,163                  184
The vLLM row of the summary. The SGLang row is cut.

The repeat without a reset read a median TTFT about 6 times lower than the fresh run. After a reset, the TTFT came back to the level of the fresh run. The time per token changed by less than 15 percent, because a cache hit removes prefill work and leaves the decode work the same.

Use this protocol for every run that you compare:

  1. Start each run with an empty cache. Restart the server, or call POST /reset_prefix_cache. That route exists only with VLLM_SERVER_DEV_MODE=1, and a server in that mode must not be exposed.
  2. Use a new --seed for each run, so that the random dataset sends new prompts.
  3. Record the cached tokens. Read vllm:prefix_cache_hits_total and vllm:prefix_cache_queries_total before and after the run.
  4. For a count on each response, start the server with --enable-prompt-tokens-details. Then read usage.prompt_tokens_details.cached_tokens in each response.
  5. Write the cache setting and the cached tokens next to the result.

vllm bench sweep serve resets the server caches between its runs for you.

Choose a closed loop or a fixed rate

--max-concurrency keeps a fixed number of users busy. When the server slows down, the users send their next request later, so the load drops by itself. This closed loop never falls behind.

--request-rate sends requests at a fixed rate, whether the server keeps up or not. Past the rate the server can serve, the queue grows, and the first token takes longer with every request. Real traffic behaves more like a fixed rate. Run both before you set a capacity limit.

Use the vLLM client

vLLM has its own client, vllm bench serve. It reports the same four numbers, and it needs no second install. At 0.31.0, VLLM_USE_RUST_BENCH=1 runs a Rust version of the same client. This loop runs the same sweep with it:

for c in 1 2 4 8 16 32 64; do
  vllm bench serve --model Qwen/Qwen3-8B \
    --dataset-name random --random-input-len 512 --random-output-len 128 \
    --max-concurrency $c --num-prompts $((c * 4 > 16 ? c * 4 : 16)) \
    --seed $c --ignore-eos --num-warmups 2 \
    --save-result --result-filename sweep-c$c.json
done

We ran this loop as written on 2026-10-23, with Qwen/Qwen3-8B-FP8 on the shared second GPU of our L4 node. Each run finished and wrote its result file. The GPU was throttled, so we give no numbers from that run.

We ran three clients against one vLLM server with Qwen/Qwen3-0.6B on 2026-10-23: the SGLang client, vllm bench serve, and its Rust version. This was the shared second GPU of our L4 node. Its clock was throttled, but it stayed at 420 to 495 MHz for every run. We reset the cache before every run and alternated the clients over two rounds.

output tok/s from three clients, two rounds eachtextFull log
users   temperature   SGLang client    vllm bench       vllm bench, Rust
…
64      0             359, 348         379, 377         371, 365
64      0.7           331, 343         360, 363         356, 353
The rows at 64 users from the throughput table. The other rows and the TTFT table are cut.
  • The two vLLM clients agreed. The Python and the Rust versions of vllm bench serve gave the same numbers within the noise at 1, 16, and 64 users.
  • The SGLang client read a slower server. Its median TTFT was 3 to 10 percent higher than vllm bench serve. At 16 and 64 users, its output throughput was 2 to 8 percent lower. At one user, one of its rounds read a higher throughput than every other round, so we give no throughput gap there.
  • The prompts differ. The two clients send different prompt tokens from their random datasets, so part of a small gap can come from the prompts.
  • Temperature changes the result. Temperature 0.7 raised the median TPOT over temperature 0. It was 10 to 13 percent higher at one user, and 3 to 6 percent higher at 16 and 64 users.

So our tables, from the SGLang client at temperature 0, can read a few percent slower than a run with vllm bench serve. Pick one client and one temperature, and keep them for every run that you compare.

Watch vLLM in production

Five metrics show most problems before users report them.

Your server is in production and you must know when it is in trouble. When you finish this section, you can pick the metrics to alert on.

Scrape the endpoint

vLLM exports Prometheus metrics at /metrics on the same port as the API. No flag is needed. Add a job like this one to the scrape_configs list of your Prometheus config, and change the target to your server:

scrape_configs:
  - job_name: vllm
    scrape_interval: 15s
    metrics_path: /metrics
    static_configs:
      - targets: ["vllm-host:8000"]

We did not run Prometheus against our servers.

Alert on five metrics

MetricAlert whenWhat it usually means
vllm:num_requests_waitingabove 0 for more than a minutethe server cannot admit all the traffic
vllm:kv_cache_usage_percabove 0.95 for more than a minutethe pool is full, so new requests wait or preempt
vllm:num_preemptions_totalthe rate is above 0requests are stopped and computed again
vllm:time_to_first_token_secondsthe 99th percentile passes your targetusers wait for the first word
vllm:inter_token_latency_secondsthe 99th percentile passes your targetthe stream has long gaps between tokens

Set the latency targets from your product, not from the defaults of a dashboard. Our sweeps used 1,000 ms for the first token and 100 ms for each output token.

The two latency metrics are histograms, so the 99th percentile comes from a PromQL query. This expression is above 0 when the P99 of the first-token time over the last five minutes is more than 1 second:

histogram_quantile(0.99, sum by (le) (rate(vllm:time_to_first_token_seconds_bucket[5m]))) > 1

Use the same form with vllm:inter_token_latency_seconds_bucket and a target of 0.1 for the token gaps.

Read them together

One metric alone misleads. Read them in pairs:

  • Waiting above 0 with the cache usage below 0.9. The pool has room, but the batch is full. --max-num-seqs or --max-num-batched-tokens limits the admission.
  • Waiting above 0 with the cache usage near 1. The pool limits the admission. Add memory, or serve fewer users on this GPU.
  • A slow first token with no waiting requests. The prompts are long, or the tokenizer in the API server is slow. Compare the queue time, vllm:request_queue_time_seconds, with the first-token time.
  • Preemptions with long answers. Running requests grow until the pool runs out. Lower the concurrency, or use an FP8 KV cache.

Watch the cache and speculation

Two more pairs of counters tell you whether a feature pays:

  • The prefix cache hits divided by the queries give the hit rate. A rate that falls after a release of your application often means a change at the start of the prompt.
  • With speculative decoding, the accepted tokens divided by the draft tokens give the acceptance rate. A falling rate means the proposer guesses badly on the current traffic.

Watch the GPU too

The vLLM metrics do not show the GPU clock or temperature. A hot or shared GPU runs slower, and every latency rises with no change in the vLLM metrics. Export nvidia-smi data or use the NVIDIA DCGM exporter next to the vLLM metrics.

Not measured on our hardware. Our sweeps did not scrape /metrics. Our run with a small pool made requests wait in the queue, but we did not save its log or its metrics. The chat page shows that run.

Decide between vLLM and SGLang

With a fixed number of users, the engines tied on throughput on our machines, and the shape of the traffic decided latency.

You are choosing a serving engine and you want a measurement, not a reputation. When you finish this section, you can match your traffic to one of our side-by-side runs and say how far the result goes.

Our result: on our L4, throughput matched within 3 percent up to 32 users. On our DGX Spark, the worst token gap of vLLM was about twice that of SGLang from 32 users.

Know what we compared

We ran vLLM 0.31.0 and SGLang 0.5.22 with their defaults on two machines:

  • An NVIDIA L4 with Qwen/Qwen3-8B in BF16, in the full-clock session. Both pools held about 24,100 tokens. The load runs were on 2026-10-17, and the rate and waves runs on 2026-10-18.
  • A DGX Spark with Qwen/Qwen3-30B-A3B-Instruct-2507 in BF16. The pools held 234,960 tokens for vLLM and between 233,303 and 251,910 tokens for SGLang. Each SGLang run was on the same day as its vLLM run, 2026-10-21.

Each point is the median of three seeds, from one benchmark client for both engines. This is two models on two cards. Treat the results as a guide to what you should measure, not as a rule.

Read the result for your traffic

Your trafficWhat we measuredPick
Many users at once, near the pool limitThroughput tied within 3 percent up to 32 users on the L4. vLLM gave the first token sooner, SGLang gave a smoother stream.vLLM for the first token, SGLang for the stream
A fixed request rate past what the GPU can serveAt 4 requests per second, vLLM gave 11 percent more output on the L4vLLM
Prompts of mixed length, 16 users at oncevLLM had the lower P99 ITL on both machinesvLLM by default
Prompts of the same length, 16 users at onceSGLang had the lower P99 ITL on both machinesSGLang
Long prompts of mixed length at a fixed rate, DGX SparkvLLM had the lower P99 ITL at 0.5 requests per second, SGLang at 2measure at your rate
Repeated prefixes that fit the poolBoth caches gave the first token within about 100 ms of each othereither one
A mixture-of-experts model on a DGX Spark, many usersThroughput tied. The vLLM P99 ITL was about twice the SGLang value from 32 users.SGLang for the stream

See the numbers behind each row

Many users at once, L4. At 32 users, vLLM gave 273.9 tok/s and SGLang 271.3. The median first token took 1,813 ms on vLLM and 2,871 ms on SGLang. The P99 ITL was 619 ms on vLLM and 102 ms on SGLang. At 64 users the output differed by 6 percent. The vLLM run had a lower median clock, 1,013 against 1,215 MHz, so we do not count that gap.

These runs did not reset the prefix cache. We ran them again with a reset at 900 MHz on 2026-10-23, and the direction held. At 32 users the median first token was 2.0 to 2.1 s on vLLM and 3.2 s on SGLang. The P99 ITL was 668 to 964 ms on vLLM and 105 to 106 ms on SGLang. At 16 users it was 522 ms against 82 ms.

A fixed rate, L4. At 4 requests per second, vLLM gave 286.6 tok/s and SGLang 257.5. The seed ranges did not overlap. At 8 requests per second the ranges overlapped. The runs at 16 requests per second ran below our clock floor, the lowest clock we accept for a run, so we left them out.

Mixed and equal lengths, both machines. Our waves workload sent 64 prompts at 16 users, with a length ratio that sets how much the prompt and output lengths vary. The lab notes define it. With mixed lengths on the L4, the P99 ITL was 191 ms on vLLM and 363 ms on SGLang. With equal lengths it was 502 ms on vLLM and 81 ms on SGLang. The DGX Spark showed the same direction: 188 against 198 ms with mixed lengths, and 394 against 159 ms with equal lengths.

Mixture of experts, DGX Spark. At 64 users, vLLM gave 239.3 tok/s and SGLang 245.1, inside the seed spread. The P99 ITL was 528 ms on vLLM and 271 ms on SGLang, and 478 against 220 ms at 32 users. The seed ranges did not overlap. The median first token was also longer on vLLM at every load from 2 users.

Long prompts at a low rate, DGX Spark. With prompts of about 1,000 to 4,000 tokens at 0.5 requests per second, vLLM had the lower P99 ITL, 571 against 922 ms. At 1 and 2 requests per second, SGLang had the lower one. For this sweep the SGLang pool was 7 percent larger than the vLLM pool, so read this row with care.

Fig. 5.2throughput on the L4chart
On the L4 the two engines wrote the same tokens per second up to 32 users Output tokens per second against users at the same time on an NVIDIA L4 with Qwen3-8B, the same 24,096-token KV pool, three seeds per point. The two lines are within 3 percent up to 32 users. At 64 users SGLang wrote 256.6 and vLLM 241.4 tokens per second. 0 100 200 300 1 2 4 8 16 32 64 users at the same time output tokens per second vLLM 0.31.0 SGLang 0.5.22
On our L4 with Qwen3-8B, vLLM and SGLang wrote the same output within 3 percent up to 32 users. Both servers had the same KV pool. Bands span three runs.
Fig. 5.3the worst token gap on the GB10chart
On the GB10 the worst token gap of vLLM doubled from 32 users The 99th percentile inter-token latency against users at the same time on a DGX Spark with Qwen3-30B-A3B, three seeds per point. The engines match up to 8 users. At 32 users vLLM shows 478 ms and SGLang 220 ms, and at 64 users 528 ms and 271 ms. 0 200 400 600 1 2 4 8 16 32 64 users at the same time P99 inter-token latency, ms 478 220 vLLM 0.31.0 SGLang 0.5.22
On our DGX Spark, the 99th percentile token gap of vLLM was more than twice that of SGLang at 32 users. A higher line is a choppier stream. Bands span three runs.

Decide

Pick on the things that do not change with tuning first:

  1. Model support. Check that your model and its quantization run on the engine and the version you pin.
  2. Startup time. On our DGX Spark, vLLM took 330 seconds to load the 57 GiB of weights.
  3. Your hardware. A result on an L4 or a GB10 does not carry over to a datacenter GPU without a test.

Then run the sweep on your own traffic with both engines. Use one benchmark client and equal pools.

Part 6

Scale Out

Add GPUs only after one GPU is tuned, and pick the layout from the shape of the work.

  1. 6.1Choose tensor, pipeline, or data parallelism
  2. 6.2Serve MoE models with expert parallelism
  3. 6.3Split prefill and decode
  4. 6.4Run vLLM on Kubernetes
  5. 6.5Run vLLM on other accelerators

Choose tensor, pipeline, or data parallelism

Tensor parallelism splits a model that does not fit, and data parallelism copies a model that does.

One GPU is not enough. When you finish this section, you can pick the parallel layout from a short decision tree.

Answer three questions

QuestionIf yesIf no
Does the model fit on one GPU, with room for the KV cache you need?Use data parallelism: one full copy for each GPU, --data-parallel-size N. The output grows with the copy count.Split the model across GPUs. The next question decides where.
Does the split model fit inside one machine?Keep the split inside the machine. The next question picks the layout.Add pipeline parallelism across machines, as the list below the table says.
Do the GPUs have NVLink, and does the GPU count divide the model evenly?Use tensor parallelism, --tensor-parallel-size N.Use pipeline parallelism inside the machine, --pipeline-parallel-size N.

When the model needs more than one machine:

  • Set --pipeline-parallel-size to the number of machines, and --tensor-parallel-size to the GPUs in each machine.
  • The tensor-parallel size must divide the attention-head count of the model.
  • If the GPUs in each machine do not divide it, use --tensor-parallel-size 1 and set the pipeline size to the total GPU count.
Fig. 6.1three questionsdecision
Three questions pick the parallel layout First ask whether the model and the KV cache you need fit on one GPU. If yes, add copies with data parallelism. If no, ask whether the split model fits inside one machine. If no, add pipeline parallelism across machines with tensor parallelism inside each. If yes, ask whether the GPUs have NVLink and the GPU count divides the model evenly. If yes, use tensor parallelism. If no, use pipeline parallelism inside the machine. Fits one GPU with its cache? data parallelism --data-parallel-size N Split model fits one machine? NVLink and an even split? pipeline + tensor across machines tensor parallel --tensor-parallel-size pipeline parallel --pipeline-parallel-size yes no yes no yes no More output from a model that fits.
Three questions pick the layout, and inside one machine the link between GPUs and the split pick tensor or pipeline parallelism. Start at the top and follow the answer.

Know what each layout costs

LayoutWhat it splitsCommunicationBest for
Tensoreach layer across GPUsafter every layer, so it needs a fast link such as NVLinka model too big for one GPU, inside one machine
Pipelinethe layers into stages on different GPUsonce for each stage boundarya model too big for one machine, or GPUs in one machine without NVLink
Datanothing, each GPU holds a full copynone between copiesmore output from a model that fits
Expertthe experts of a mixture-of-experts modelbefore and after the expert layerslarge MoE models, covered on the MoE page

Tensor parallelism also splits the weights and the KV cache, so each GPU has more room for the cache. When the weights fill most of one GPU, two GPUs can hold several times the KV pool of one.

Tensor parallelism sends data after every layer. On GPUs joined only by PCIe, such as two L4s in one host, that traffic can cost more than the split saves. The vLLM documentation suggests pipeline parallelism on such a machine, because it sends data only at stage boundaries. Pipeline parallelism also accepts a GPU count that does not divide the model evenly. Measure the layouts against each other on such a machine before you pick.

Not measured on our hardware. We have not run vLLM on two L4s with tensor parallelism or data parallelism.

Start a second machine

vLLM runs one server across machines with Ray, the default for more than one machine, or with its own multiprocess launcher. We did not run either. The steps below come from the vLLM 0.31.0 documentation, for two machines with 8 GPUs each.

First, check that the GPUs can talk to each other. The vLLM troubleshooting guide has a short test script that runs NCCL and Gloo all-reduce calls. Save it as test.py on each machine. Run it on both machines at once, with NODE_RANK=0 on the first machine and NODE_RANK=1 on the second:

NCCL_DEBUG=TRACE torchrun --nnodes 2 --nproc-per-node 8 \
  --rdzv_backend=static --rdzv_endpoint=<head-ip>:29400 \
  --node-rank $NODE_RANK test.py

When the network and the drivers work, each process prints four success lines, the first of them PyTorch NCCL is successful!. The guide text names a different line, so trust the lines that the script prints.

With Ray. The vLLM repository has a run_cluster.sh script that starts the vLLM image and a Ray node on each machine. Give each machine its own VLLM_HOST_IP, on a private network. On the first machine, run:

bash run_cluster.sh vllm/vllm-openai:v0.31.0 <head-ip> --head ~/.cache/huggingface -e VLLM_HOST_IP=<head-ip>

On the second machine, run:

bash run_cluster.sh vllm/vllm-openai:v0.31.0 <head-ip> --worker ~/.cache/huggingface -e VLLM_HOST_IP=<worker-ip>

Keep both shells open, because closing one stops the cluster. Then, inside a container, check that Ray sees every node and GPU:

ray status

Start the server on one machine only. It uses the GPUs of every machine:

vllm serve Qwen/Qwen3-30B-A3B-Instruct-2507 \
  --tensor-parallel-size 8 --pipeline-parallel-size 2 \
  --distributed-executor-backend ray

Without Ray. Run vllm serve on both machines with the same model and sizes. Add --nnodes 2 --node-rank 0 --master-addr <head-ip> on the first machine. Add --nnodes 2 --node-rank 1 --master-addr <head-ip> --headless on the second machine, which then runs workers and no API server.

The traffic between the machines is not encrypted, and it can run code on the other side. Keep it on a private network that untrusted machines cannot reach.

Count the processes

With data parallelism, vLLM starts one engine core for each copy, and one coordinator that keeps them in step. With tensor parallelism, it starts one worker for each GPU under one engine core. The process page gives the count.

Use context parallelism for very long prompts

For prompts of hundreds of thousands of tokens, vLLM can split one sequence across GPUs with prefill and decode context parallelism. Use it only when one long request is the problem, not when many short ones are.

Serve MoE models with expert parallelism

Expert parallelism spreads the experts of a MoE model across GPUs, and one GPU can still serve a small MoE model.

You serve a mixture-of-experts model. When you finish this section, you can decide when expert parallelism helps.

Our result: one DGX Spark served Qwen3-30B-A3B without expert parallelism, at 239 tokens per second for 64 users.

Know what changes with a MoE model

A mixture-of-experts layer has many experts, and each token uses only a few of them. Qwen/Qwen3-30B-A3B-Instruct-2507 has 30 billion parameters, and about 3 billion are active for each token. So the model needs memory for all its weights, but each token costs compute for only a small part.

Start with one device if the model fits

On our DGX Spark, one GB10 held the whole model in BF16. The weights took 56.93 GiB, and the server gave 239.3 tok/s at 64 users. One device needs no communication between GPUs. If the model and its KV cache fit, start there.

Our reading: a batch of many tokens uses many different experts, so a decode step reads more of the weights as the batch grows. On a device with limited memory bandwidth, that makes the time per token grow with the batch. On our GB10, the median time per token went from 32 ms at one user to 244 ms at 64 users.

Add expert parallelism for large models

On one machine with eight GPUs, spread the experts across all of them:

vllm serve <model> --data-parallel-size 8 --enable-expert-parallel

For a model that does not fit one machine, such as the large DeepSeek models, run one command on each machine. This example uses two machines with eight GPUs each. First install DeepEP and DeepGEMM on both machines, as the expert parallel guide in the vLLM documentation describes. They are not part of the normal install, and a server with a DeepEP backend stops at startup without DeepEP. The first machine takes the requests:

vllm serve <model> --enable-expert-parallel \
  --all2all-backend deepep_low_latency \
  --data-parallel-size 16 --data-parallel-size-local 8 \
  --data-parallel-address 192.168.1.100 --data-parallel-rpc-port 13345

The second machine runs headless, with no API server. Its start rank is the count of ranks on the machines before it:

vllm serve <model> --enable-expert-parallel \
  --all2all-backend deepep_low_latency \
  --data-parallel-size 16 --data-parallel-size-local 8 \
  --data-parallel-start-rank 8 \
  --data-parallel-address 192.168.1.100 --data-parallel-rpc-port 13345 \
  --headless

Set the address to the IP of the first machine, and use the same RPC port on both. Across machines, the vLLM documentation uses the DeepEP kernels for the exchange of tokens.

With expert parallelism, each GPU holds some of the experts, and tokens travel to the GPU that holds their expert and back. The attention layers run in data parallel on each GPU. The all-to-all exchange of tokens needs fast links between the GPUs.

Two more features help at large scale:

  • Expert load balancing. --enable-eplb moves busy experts to spread the load, because some experts receive far more tokens than others.
  • Elastic expert parallelism. It adds or removes GPUs from a running deployment. Release 0.32 brought it to Model Runner V2.

Not measured on our hardware. We have one GPU on each machine, so we have not run expert parallelism.

Check the setup

On a multi-GPU deployment, watch the time per token as the load grows, and compare it with the same model on fewer GPUs. If the time per token rises faster than the output, the exchange between GPUs limits the step.

Split prefill and decode

Separate prefill and decode servers pay only when long prompts hurt the stream of other users.

Long prompts slow the stream for every user. When you finish this section, you can decide when disaggregated serving pays.

Know the problem it solves

On one server, a long prompt shares each step with the running streams. While it computes, every stream waits a little longer for its next token. With many long prompts, the worst token gaps grow for everyone.

Disaggregated serving runs two kinds of server. A prefill server computes the prompt and builds its KV cache. A decode server receives that cache and generates the answer. Long prompts then never share a step with a stream.

Know what it does not solve

The vLLM documentation says that disaggregated prefill does not raise throughput. It gives you control over the first token and the stream as two separate numbers, and it controls the tail of the inter-token latency. It costs a transfer of the KV cache for every request, and more servers to run.

Even for the tail, the gain is a condition, not a default. The paper "Revisiting Disaggregated Large Language Model Serving for Performance and Energy Implications" compared split servers with servers that run both stages. It found that the gain is not guaranteed and depends on the request load and on the path that moves the KV cache. We did not measure this ourselves.

Pick it when the 99th percentile of the inter-token latency matters and long prompts cause it. Do not pick it to serve more users on the same GPUs. Before you keep it, compare it with the same GPUs run as plain servers, at your real load.

Set it up

Each server gets a KV connector and a role in --kv-transfer-config. The NIXL connector is the common choice. It needs the NIXL library, which the vLLM wheel does not install. Install it in the same environment as vLLM first. The vLLM requirements at 0.31.0 pin version 1.4.1:

uv pip install nixl==1.4.1

The prefill server produces the cache, and the decode server consumes it. On one host with two GPUs, give each server its own GPU, its own port, and its own NIXL side channel port:

CUDA_VISIBLE_DEVICES=0 VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-8B --port 8100 \
  --kv-transfer-config '{"kv_connector": "NixlConnector", "kv_role": "kv_producer"}'
CUDA_VISIBLE_DEVICES=1 VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-8B --port 8200 \
  --kv-transfer-config '{"kv_connector": "NixlConnector", "kv_role": "kv_consumer"}'

Each server writes Creating v1 connector with name: NixlConnector and Initializing NIXL wrapper in its startup log. If the log shows NIXL is not available, the library is missing from the environment.

Then put a router in front. For each request, the router sends the request to the prefill server first and reads the KV transfer parameters from its response. It then sends the request to the decode server with those parameters, so that the decode server can load the cache. The vLLM repository has example scripts and a proxy for this setup. Its own CI tests it on pairs of L4 GPUs.

If a cache transfer fails, the default policy fails the request. The kv_load_failure_policy field set to recompute computes the prompt again on the decode server instead.

Not measured on our hardware. We have not run disaggregated serving with vLLM on two L4s.

Fig. 6.2prefill and decode on two serversflow
Split prefill and decode: the KV cache moves between two servers A router sends a new request to a prefill server, which computes the prompt and writes its KV cache. The NixlConnector copies the cache to a decode server, which then streams the tokens back. Both servers set a kv_connector in --kv-transfer-config. router picks a pair prefill server computes the prompt decode server writes the tokens NixlConnector copies KV blocks Both servers: --kv-transfer-config with "kv_connector": "NixlConnector".
A prefill server computes the prompt, and a KV connector copies its cache to a decode server that writes the tokens. Teal arrows move the KV cache. The dotted arrow is the token stream.

Size the two sides

The ratio of prefill to decode servers depends on your prompt and answer lengths. Long prompts with short answers need more prefill capacity. Short prompts with long answers need more decode capacity. Measure the first token and the stream on each side, and move one server at a time.

Run vLLM on Kubernetes

A layer above vLLM adds routing and scaling, and you pick it by the features you need.

You must run many vLLM servers on a cluster. When you finish this section, you can pick the layer that runs vLLM on Kubernetes.

Start with what one Deployment gives you

The vLLM documentation shows a plain Kubernetes setup. It has a volume for the weights, a secret for the Hugging Face token, a Deployment that runs vllm serve, and a Service. A small Helm chart in the vLLM repository packages the same pieces. It adds a job that downloads the model and an optional Kubernetes autoscaler.

Here is a minimal version in our own form, for one GPU and one model. We did not run it on a cluster. It needs a cluster where the NVIDIA device plugin offers nvidia.com/gpu. For a model with restricted access, add a Secret named hf-token with a token key. Create a Secret named vllm-api-key with a key key, so that the /v1 routes need the key.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-qwen3-8b
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vllm-qwen3-8b
  template:
    metadata:
      labels:
        app: vllm-qwen3-8b
    spec:
      terminationGracePeriodSeconds: 120
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.31.0
          args: ["Qwen/Qwen3-8B", "--max-model-len", "8192", "--reasoning-parser", "qwen3"]
          ports:
            - containerPort: 8000
          env:
            - name: HF_TOKEN
              valueFrom:
                secretKeyRef:
                  name: hf-token
                  key: token
                  optional: true
            - name: VLLM_API_KEY
              valueFrom:
                secretKeyRef:
                  name: vllm-api-key
                  key: key
          resources:
            limits:
              nvidia.com/gpu: 1
          startupProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 10
            failureThreshold: 90
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            periodSeconds: 30
            failureThreshold: 3
          volumeMounts:
            - name: hf-cache
              mountPath: /root/.cache/huggingface
            - name: shm
              mountPath: /dev/shm
      volumes:
        - name: hf-cache
          emptyDir: {}
        - name: shm
          emptyDir:
            medium: Memory
            sizeLimit: 2Gi
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-qwen3-8b
spec:
  selector:
    app: vllm-qwen3-8b
  ports:
    - port: 8000
      targetPort: 8000

The image starts vllm serve, so args holds only the model and the flags. The startup probe allows 90 checks 10 seconds apart, which is 15 minutes. Our DGX Spark took about 8 minutes for its first start, so set the limit from your own measured cold start. The probes stop Kubernetes from sending traffic or restarting the pod while the weights load.

Replace the emptyDir cache with a PersistentVolumeClaim, so a new pod does not download the weights again. The memory volume at /dev/shm gives the processes shared memory, as --ipc=host does with Docker. The API key does not protect /health or /metrics, so also limit which pods can reach the Service with a NetworkPolicy.

This is enough for a few replicas of one model. A plain Service spreads requests at random, so a user's next turn can go to a replica that never cached the earlier turns. An autoscaler on CPU use also scales on the wrong signal, because GPU load is not CPU load.

Pick a layer for the features you need

LayerWhat it addsPick it when
vLLM production stacka Helm chart, a router that keeps sessions on one replica, KV cache offloading through LMCache, and Grafana dashboardsyou want the reference setup from the vLLM project
llm-da router that reads vLLM metrics and KV cache events to pick a replica, prefill and decode split, wide expert parallelismyou run large models or split prefill and decode on a cluster
KServea model-serving platform with a vLLM runtime, and an LLM resource built on llm-dyour company already runs KServe for other models
AIBrixa control plane with routing, LoRA management, and autoscaling on engine metricsyou need many models and adapters with one control plane
Ray Serve LLMvLLM inside Ray Serve, with multi-model servingyour stack already runs on Ray

llm-d published its version 1.0 on 2026-10-20. It joined the CNCF as a sandbox project in March 2026. Its router follows the Gateway API Inference Extension, a Kubernetes project for routing to model servers.

Know which vLLM features the layers use

The layers read the same vLLM interfaces. Check that your vLLM version supports the ones your layer needs:

  • Metrics. The routers read vllm:num_requests_waiting, vllm:num_requests_running, and vllm:kv_cache_usage_perc to pick a replica.
  • KV cache events. With --kv-events-config, vLLM publishes each block it stores and removes. A router uses these events to send a request to the replica that holds its prefix.
  • KV connectors. The prefill and decode split and the shared cache use --kv-transfer-config.
  • Health. /health serves the startup, liveness, and readiness probes.

Not measured on our hardware. We have no cluster run, and we did not apply the manifest above.

Run vLLM on other accelerators

vLLM supports other accelerators through plugins, and each plugin follows its own release.

Your hardware is not an NVIDIA GPU. When you finish this section, you can find the plugin for your hardware and its version.

Know what is built in and what is a plugin

vLLM 0.31.0 has built-in support for NVIDIA GPUs, AMD GPUs through ROCm, Intel GPUs, and CPUs. Other hardware runs through a plugin. A plugin is a separate Python package that registers a platform with vLLM when it is installed. Install it next to vLLM, and vLLM finds it.

The plugin design came from an RFC in December 2024. Since then, several backends that lived inside vLLM moved out into plugins.

Find your plugin

HardwarePluginHow it follows vLLM
Google TPUtpu-inference, which is moving to the name vllm-jaxa release for each vLLM release, with its own image
Huawei Ascend NPUvllm-ascendrelease candidates for recent vLLM versions, and final releases about every three months
Intel Gaudivllm-gaudireleases based on a named vLLM version
AWS Trainium and Inferentiavllm-neuronreleases that name the vLLM version they build on
IBM Spyresendnn-inferenceits own version numbers
Apple silicon GPUvllm-metal, a community plugin on MLXits own releases

Check the version before you plan

A plugin often supports an older vLLM than the newest release. In October 2026, the newest final release of vllm-ascend targeted vLLM 0.23.0, while vLLM itself was at 0.32.0. The TPU plugin had a release for vLLM 0.31.0 within days of it.

Before you plan a feature on your hardware:

  1. Find the newest plugin release, and read the vLLM version it targets.
  2. Read that vLLM version's documentation, not the newest one.
  3. Check the feature table of the plugin. Some features of the CUDA path, such as some quantization formats and attention backends, have no version on other hardware.

Not measured on our hardware. We ran only NVIDIA GPUs.

Part 7

Operate and Debug

Start from the symptom you see, read one log line or metric, and change one setting.

  1. 7.1Troubleshoot by symptom
  2. 7.2Run vLLM in production

Troubleshoot by symptom

Most failures have a known cause that one log line or one metric shows.

Something is wrong and you know what you see, not why. When you finish this section, you can match the symptom to a cause, run one check, and apply one fix.

Find your symptom in the list. Each entry gives the likely cause, the check that confirms it, and the fix. Issue numbers refer to the vLLM repository, with their state at version 0.31.0. The entries marked "from the vLLM documentation" come from the vLLM troubleshooting guide at 0.31.0, and we did not reproduce them.

Fig. 7.1symptom to pagedecision
Each troubleshooting entry: the symptom, the first check, and where the fix is Twenty-four symptoms in five groups: the server does not start, requests fail, the server is slow, the output is wrong, and the benchmark is wrong. Each row gives the first log line or metric to check and the section or action that fixes it. WHAT YOU SEE CHECK FIRST THEN the server does not start download hangs cache folder size hf download model not supported architecture in error supported list driver or PTX error error text update driver KV cache and max length length in error 2.2 free memory below share nvidia-smi 2.2 crash with FP8 KV cache backend line 2.4 DGX Spark out of memory free -g 2.3 slow first start, GB10 Model loading took 2.3 compile step fails torch.compile error clear cache multi-GPU start hangs NCCL_DEBUG=TRACE 6.1 shorter max length derived length 2.6 requests fail HTTP 400, context length counts in error shorter request every request fails EngineCore error restart the server is slow first token takes seconds waiting, KV usage 3.1 long gaps, many users P99 against median 3.3 preemptions rise num_preemptions 3.1 n-gram gives less async warning 4.2 eager start slow first requests 4.3 NVFP4 slow on GB10 TPOT against 0.28 pin release the output is wrong answer starts with think content field 3.10 one token repeated cache hit, spec turn one off invalid JSON with spec run without spec 3.5 output changes per run same request alone 3.8 the benchmark is wrong second run faster prefix cache hits 5.1
Start from the symptom, check one number or log line, and then go to the page that fixes it. Each row is one entry below, in the same order. Read the symptom, the first check, and where the fix is.

The server does not start

The start hangs while it downloads the model

  • Cause: the download is slow, or the model needs a Hugging Face token. From the vLLM documentation.
  • Check: the log stops before the Model loading took line, and the Hugging Face cache folder grows slowly or not at all.
  • Fix: download the model first with the hf download command, and set HF_TOKEN for a model with restricted access. Then set HF_HUB_OFFLINE=1, so vLLM uses the local copy only.

The start stops with a message that the model is not supported

  • Cause: the model architecture is not in vLLM 0.31.0, or a missing package stops vLLM from importing the model code. From the vLLM documentation.
  • Check: the error names the architectures that failed, such as Model architectures [...] are not supported for now or failed to be inspected.
  • Fix: check the supported models list for your version. If the model is in the list, read the log above the error for an import failure.

The start stops with a message about the driver or the PTX toolchain

  • Cause: the CUDA build of vLLM is newer than your NVIDIA driver supports. From the vLLM documentation.
  • Check: the error says The NVIDIA driver on your system is too old, or that the PTX was compiled with an unsupported toolchain.
  • Fix: update the driver. In the official image, on some datacenter GPUs, -e VLLM_ENABLE_CUDA_COMPATIBILITY=1 turns on the CUDA compatibility libraries.

The start stops with a message about the KV cache and the maximum length

  • Cause: one request at --max-model-len needs more KV cache than is left after the weights.
  • Check: the error gives the largest length that fits.
  • Fix: set --max-model-len to your longest request. The startup page has the details.

The start stops with a message about free memory and the memory fraction

  • Cause: another process holds GPU memory, so the share you asked for is not free.
  • Check: run nvidia-smi and look at the processes on the GPU.
  • Fix: lower --gpu-memory-utilization below the free share, or stop the other process.

The server crashes at start after you set an FP8 KV cache

  • Cause: the FP8 cache moved vLLM to the FlashInfer backend, and FlashInfer could not compile its kernels. Issue 60262 tracks this, and it is open.
  • Check: find the line that names the attention backend, and the FlashInfer error after it.
  • Fix: add --attention-backend TRITON_ATTN. The FP8 KV cache page shows our case.

The first start stops with a message that nvcc is missing

  • Cause: FlashInfer compiles its kernels at the first start, and the host has a driver but no CUDA toolkit. We saw this on a new uv install of 0.31.0 on our L4 node.
  • Check: the error comes from flashinfer/jit/cpp_ext.py and says Could not find nvcc and default cuda_home='/usr/local/cuda' doesn't exist.
  • Fix: install a CUDA toolkit, set CUDA_HOME to it, and start again. Or use the official image, which has the toolkit.

The sampler fails to compile after you set CUDA_HOME

  • Cause: the toolkit on the host does not match the CUDA build of PyTorch, so the FlashInfer sampler cannot compile. With a host toolkit at nvcc 13.4, we saw this on our L4 node.
  • Check: the error says CUDA compiler and CUDA toolkit headers are incompatible.
  • Fix: install the toolkit version that matches your PyTorch build. Or start with VLLM_USE_FLASHINFER_SAMPLER=0, as the FP8 KV cache page does.

A DGX Spark runs out of memory although the fraction leaves room

  • Cause: on unified memory, --gpu-memory-utilization does not always limit what vLLM takes. Issue 46307 reports about 112 GiB in use at a fraction of 0.70, and it was closed as stale with no fix. Issue 44740 reports a KV cache that was too large with MTP speculation on the GB10, and it is open.
  • Check: compare the Available KV cache memory line with the share you expect, and watch free -g during the start.
  • Fix: set the cache size in bytes with --kv-cache-memory-bytes. The DGX Spark page has the details.

The first start on a DGX Spark takes many minutes

  • Cause: the weight load copies each tensor from the file map, and that copy is slow on the GB10. Issue 58726 tracks this, and it is open.
  • Check: read the Model loading took line. Our 57 GiB load took 330 seconds.
  • Fix: keep the weights on a local drive and keep the server running. The DGX Spark page has the details.

The start fails in the compile step

  • Cause: a broken compile cache, or a Triton library that does not match your PyTorch. From the vLLM documentation.
  • Check: the error comes from torch.compile or from the torch/_inductor folder.
  • Fix: start once with VLLM_DISABLE_COMPILE_CACHE=1. If that works, delete ~/.cache/vllm/torch_compile_cache. Use -O0 to start without the compile while you debug.

A server on several GPUs hangs at start

  • Cause: the GPUs cannot reach each other through NCCL, because of the network, the drivers, or a missing shared memory mount. From the vLLM documentation.
  • Check: start again with NCCL_DEBUG=TRACE and read where the log stops. Run the NCCL test that the parallelism page describes.
  • Fix: give a container --ipc=host or a large /dev/shm. On PCIe machines, the vLLM guide suggests NCCL_CUMEM_HOST_ENABLE=0 or a driver update for a peer access error.

The maximum length is shorter after an upgrade

  • Cause: release 0.30.0 changed how vLLM reads context scaling such as YaRN, and some models now get a shorter derived length.
  • Check: compare the derived length in the startup log with the old one.
  • Fix: set --max-model-len yourself, after you confirm that the model supports the length.

Requests fail while the server runs

A request returns HTTP 400 about the maximum context length

  • Cause: the prompt tokens plus max_tokens are more than --max-model-len.
  • Check: the error says This model's maximum context length is and gives the counts. When max_tokens alone is above the limit, the error says that max_tokens cannot be greater than max_model_len, as it did on our L4.
  • Fix: send a shorter prompt or a smaller max_tokens, or start the server with a larger maximum length if the pool allows it.

Every request fails after an error in the engine

  • Cause: the engine core process stopped, for example after a GPU error. The API server stays up, but it cannot serve. From the vLLM source.
  • Check: the log shows EngineCore encountered an issue, and the real error is in the lines above it.
  • Fix: fix the cause in the earlier lines, then restart the server. Let your orchestrator restart it, because the server does not recover alone.

The server is slow

The first token takes seconds, but the stream is fast after it

  • Cause: requests wait in the queue because the pool or the batch is full.
  • Check: vllm:num_requests_waiting is above 0 and vllm:kv_cache_usage_perc is near 1.
  • Fix: serve fewer users on the GPU, or make room in the pool with an FP8 KV cache or a shorter maximum length.

The stream has long gaps when many users are active

  • Cause: new prompts share decode steps with running requests, and long prompts take a large part of each step.
  • Check: compare the P99 ITL with the median ITL. A large gap means stalls, not a slow GPU.
  • Fix: lower the token budget, as the token budget page shows. On our machines the gaps also depended on the prompt lengths and on the engine, as the engine page shows.

The preemption counter rises

  • Cause: a running request needed a new block and none was free. vLLM stops the request and computes it again later.
  • Check: vllm:num_preemptions_total rises, and the periodic log line shows a preemption count.
  • Fix: raise --gpu-memory-utilization, or lower --max-num-seqs or --max-num-batched-tokens.

N-gram speculation gives less than you expected

  • Cause: at 0.31.0, the n-gram method makes vLLM use the older model runner, and it turns off async scheduling.
  • Check: the startup log has a warning that async scheduling is off.
  • Fix: compare it with no speculation on your traffic, with the same model runner on both sides.

The first requests after a start in eager mode are slow

  • Cause: since 0.31.0, --enforce-eager also turns off the warmup that compiles kernels at startup, so the first requests compile them.
  • Check: the first few requests are slower than the rest.
  • Fix: send a few warmup requests before you serve traffic, or use -O0 instead.

NVFP4 decode is slower on a DGX Spark than on older releases

  • Cause: issue 59770 reports a slower decode for one NVFP4 model on the GB10 since 0.29.0. It is open.
  • Check: compare the time per token on your version and on 0.28.
  • Fix: pin the faster release for that model until the issue closes.

The output is wrong

The answer starts with a long thinking block

  • Cause: a reasoning model, such as Qwen3, runs without a reasoning parser.
  • Check: the content field starts with <think>.
  • Fix: start the server with the parser for the model, such as --reasoning-parser qwen3. The reasoning models page has the details.

The model repeats one token after a prefix-cache hit

  • Cause: issue 60174 reports corrupt output on 0.30 and 0.31 when DFlash2 or DSpark speculation runs with the prefix cache. From the third request, each answer that starts from cached blocks is one token, over and over. It was closed on 2026-10-22, after 0.31.0, so check that your version has the fix.
  • Check: the problem starts only after a cache hit, and it goes away with --no-enable-prefix-caching.
  • Fix: turn off the prefix cache or the speculation method for that model.

JSON output is invalid with speculative decoding on

  • Cause: a JSON schema and a speculation method can disagree on the next tokens. Issue 60830 showed this for MTP on one model, and it is closed.
  • Check: run the same requests without --speculative-config.
  • Fix: upgrade, or turn off speculation for JSON traffic.

The output changes from run to run

  • Cause: vLLM does not promise the same output by default. The batch that a request joins changes the order of the sums. A prefix-cache hit can also change a greedy output. In our runs with Qwen3-0.6B at one user, a warm cache changed 11 of 32 greedy outputs. That GPU was a shared L4 with no clock lock. A change of dtype, such as BF16 to FP16, also changes most outputs.
  • Check: send the same request alone twice. If the answers match, the batch causes the change. If they differ, the second request can hit the prefix cache. Repeat the test with --no-enable-prefix-caching. If the answers now match, the cache causes the change.
  • Fix: offline, set VLLM_ENABLE_V1_MULTIPROCESSING=0. Online, the batch-invariant mode helps, but at 0.31.0 it does not cover every path. Reset the prefix cache between runs that you compare. The reproducibility page covers it.

The benchmark is wrong

The second benchmark run is faster than the first

  • Cause: with a fixed seed, the random dataset sends the same prompts again, and the prefix cache serves them. In our test with Qwen3-0.6B at 16 users, a repeat with no reset read a median TTFT about 6 times lower. That GPU was a shared L4 with no clock lock.
  • Check: vllm:prefix_cache_hits_total rises during the second run.
  • Fix: reset the prefix cache before each run, use a new seed, and record the cached tokens. The benchmark page gives the protocol. The POST /reset_prefix_cache route exists only with VLLM_SERVER_DEV_MODE=1, so use it only on a server that is not exposed.

Run vLLM in production

vLLM releases every two weeks, so pin the image and plan for cold starts and upgrades.

Your server must survive restarts and upgrades. When you finish this section, you can deploy a server that starts fast and upgrades safely.

Pin the image

Pin an exact version, such as vllm/vllm-openai:v0.31.0, never latest. A new minor release comes about every two weeks, and each one changes defaults or removes flags. Between 0.30 and 0.32, for example:

  • Release 0.31 made --enforce-eager also skip the kernel warmup.
  • Release 0.31 renamed a Mamba prefix-cache flag and removed the slow tokenizer mode.
  • Release 0.32 dropped Python 3.10 and changed the default CORS origins from every origin to none.

The last change breaks any browser client that calls the server directly. After an upgrade to 0.32, set --allowed-origins to the origins you trust.

Plan for the cold start

A start has three slow parts: the weight load, the compile, and the CUDA graph capture. On our DGX Spark, the model load alone took 330 seconds for 57 GiB.

  • Weights. Keep them on a local disk. A network mount or a fresh download makes every start slower.
  • Compile. vLLM caches compiled code under ~/.cache/vllm. Keep that folder on a persistent volume, so a restart reuses it.
  • Preload. Release 0.31 added vllm preload, which loads and warms a model before the server needs it.

vllm bench startup measures a cold and a warm start. Run it once for each new image.

Not measured on our hardware. We have not timed a warm start with a kept compile cache.

Check health before traffic

Use /health for the liveness probe. For readiness, send one short real request, which proves that the engine can generate. Give both probes a start delay longer than your measured cold start.

Shut down cleanly

Send SIGTERM to the server process, not a forced kill. Before the shutdown, remove the pod from the load balancer so that no new requests arrive. Give the pod a grace period at least as long as your longest request. A forced kill drops every running request.

Change settings in this order

Change one setting at a time, and measure after each change:

  1. The memory: the maximum length and the memory fraction.
  2. The concurrency: --max-num-seqs and the token budget.
  3. The data types: an FP8 KV cache, then FP8 weights.
  4. The speed features: speculative decoding and the compile level.

Each later step depends on the ones before it. A speed feature measured on a server that runs out of memory measures the memory, not the feature.

Read the security advisories

The vLLM project published dozens of security advisories in the weeks around 0.31 and 0.32. Several were rated high. Many came through multimodal inputs, and some through the KV transfer connectors. Do not expose the server to the internet without a gateway in front of it. Use --api-key, as the server options page shows, and read the advisories before you pick a release.

Part 8

How vLLM Works

The internals explain the defaults, and you can read this part when a default surprises you.

  1. 8.1Why vLLM runs three kinds of process
  2. 8.2Why one token budget replaces a prefill phase
  3. 8.3How the block pool manages KV memory
  4. 8.4How vLLM picks an attention backend
  5. 8.5What Model Runner V2 changed
  6. 8.6How compile and CUDA graphs are captured
  7. 8.7How speculative decoding checks drafts
  8. 8.8How KV connectors move the cache
  9. 8.9Where vLLM came from

Why vLLM runs three kinds of process

This page explains the API server, the engine core, and the workers, and why they are separate processes.

vllm serve with one GPU starts three processes, and the count grows with every GPU you add. When you finish this section, you can name each process, say what it owns, and predict the process count for a parallel setup.

Three jobs that compete for one CPU

A serving engine does three kinds of work. It speaks HTTP and turns text into tokens and back. It decides which requests run in the next step. It runs the model on the GPU.

In one Python process, these jobs take turns on one interpreter lock. While the server tokenizes a long prompt, the scheduler waits, and the GPU waits for the scheduler. On a fast GPU, a decode step takes only milliseconds. A few milliseconds of Python work between steps then leaves the GPU idle for a large share of the time.

vLLM gives each job its own process:

ProcessWhat it ownsHow many
API serverHTTP, the OpenAI routes, tokenization, multimodal input, and the stream back to the clientone by default, or one for each data-parallel rank
Engine corethe scheduler, the KV cache manager, and the loop that sends work to the workersone for each data-parallel rank
GPU workerthe weights and the forward pass on one GPUone for each GPU, so tensor size times pipeline size for each engine core

The API server and the engine core exchange messages over ZeroMQ sockets. The engine core and its workers share memory queues. A data-parallel setup adds one more process, a coordinator that keeps the ranks in step.

Count the processes

The vLLM design documents give a formula for the count. Add the API servers, the data-parallel engine cores, and the GPU workers, plus one coordinator for data parallelism. A server with -tp 4 runs one API server, one engine core, and four workers, so six processes.

This matters when something goes wrong. A crash in a worker shows up in the engine core log. A slow tokenizer shows up as a long time to first token while the GPU is idle. When you read a log, first find which process wrote the line.

Fig. 8.1six processes for four GPUstree
A server with -tp 4 runs six processes vllm serve starts one API server process. It starts one engine core process, which runs the scheduler and the KV cache manager. The engine core starts one GPU worker for each GPU, four workers for tensor parallel size 4. Six processes in total. API server HTTP, tokenizer engine core scheduler, KV manager ZeroMQ worker 0 GPU 0, rank 0 worker 1 GPU 1, rank 1 worker 2 GPU 2, rank 2 worker 3 GPU 3, rank 3 Count: 1 API server + 1 engine core + 4 workers = 6 processes.
A server with tensor parallel size 4 runs six processes: one API server, one engine core, and four GPU workers. Read from the top down. Each worker owns one GPU.

Why not threads

Threads in one Python process share the interpreter lock, so they would not remove the wait. Separate processes also let vLLM restart or replace one part. The repository also has an experimental front end written in Rust. It replaces the Python API server and talks to the same engine core over the same sockets.

Separate processes have a cost: each request and each output is copied across a process boundary. The copy is small next to a model step, so the design accepts it. An environment variable, VLLM_ENABLE_V1_MULTIPROCESSING=0, runs the engine core in the API server process instead. The reproducibility guide uses it for offline runs, because one process makes the order of scheduling repeatable.

Why one token budget replaces a prefill phase

This page explains the scheduler loop, decode first, and preemption by recompute.

Most explanations of LLM serving describe a prefill phase and a decode phase. The vLLM scheduler has neither. When you finish this section, you can explain what the scheduler does in one step, and why that design makes chunked prefill the default.

One question for every request

The scheduler asks the same question about every request: how many tokens does this request still need to compute? A new request needs all of its prompt tokens. A running request needs one token, or more with speculative decoding. A comment in the scheduler source says the same in its own words. No decode phase and no prefill phase exist, only a count of tokens still to compute.

In each step, the scheduler has a budget of tokens, set by --max-num-batched-tokens. For vllm serve on a GPU with less than 70 GiB, such as the L4, the default is 2,048. It spends the budget in this order:

  1. It gives each running request the tokens it needs, up to the budget.
  2. It admits waiting requests while budget and room for running requests remain. --max-num-seqs limits the running requests. For vllm serve, the default is 256 on a GPU with less than 70 GiB and 1,024 on a larger one other than an A100.

A long prompt that does not fit the rest of the budget gets a part of the budget now and the rest in later steps. That is chunked prefill. It is on by default, because the budget design needs nothing else to support it.

Fig. 8.2one token budgettimeline
One 2,048-token budget per step: decode first, prompt chunks fill the rest Three scheduler steps with the default budget of 2,048 tokens. Eight running requests take one decode token each, and a new 4,096-token prompt fills the rest in chunks of 2,040 tokens. In step 3 the last 16 prompt tokens run, and the new request decodes from step 4. BUDGET OF EACH STEP, --MAX-NUM-BATCHED-TOKENS 2048 step 1 2,040 prompt tokens of the new request step 2 2,040 prompt tokens of the new request step 3 the last 16 prompt tokens, decode starts in step 4 decode, one token for each running request a chunk of a new prompt
Each step spends 2,048 tokens: running requests get a decode token first, and a new prompt fills the rest in chunks. Each row is one scheduler step. Prompt chunks are drawn to scale, and decode blocks are widened so you can see them. Dashed space is unused budget.

Why decode goes first

Running requests come first, so a user who already sees a stream keeps seeing it. A new prompt waits for the budget that is left. The vLLM documentation states the trade. A smaller budget gives a smoother stream, and a larger one gives a faster first token for long prompts. The small default of 2,048 is a choice for the stream. GPUs with 70 GiB or more get 8,192, and the long prompts page lists the rule.

What happens when memory runs out

A running request needs a new KV block every 16 tokens. If no block is free, the scheduler preempts a request. Under the default first-come policy, it picks the newest running request. It frees all of its blocks and puts the request back at the head of the waiting queue. When the request runs again, vLLM computes its tokens again from the start.

Older vLLM versions could also copy the blocks to CPU memory and back. The current engine only recomputes, because the documentation judges the recompute cheaper in this architecture. The cost shows up as a longer time for the preempted request, and as the vllm:num_preemptions counter.

Why one budget

One budget for every kind of work keeps the scheduler simple. Prefix caching only lowers the count of tokens to compute. Speculative decoding only raises the count for a running request. The same loop serves all of them. The cost is that one number decides the balance between the first token and the stream. No single value of that number suits every workload.

How the block pool manages KV memory

This page explains hash-keyed blocks, eviction, and KV groups for hybrid models.

vLLM started as a paper about one idea: store the KV cache in fixed-size blocks, the way an operating system stores memory in pages. When you finish this section, you can explain how vLLM finds a block again for a repeated prefix. You can also say which block leaves first.

Blocks, not one buffer for each request

At startup, vLLM splits the KV cache into blocks of 16 tokens and keeps all of them in one pool. A request gets blocks one at a time as it grows. Its blocks do not need to be next to each other in memory. A small table maps the request's token positions to block numbers.

This removes the two wastes of a buffer for each request. No memory sits reserved for a length the request never reaches. No gaps form between requests of different lengths. The original paper called this PagedAttention.

Finding a prefix again

When a block fills, vLLM computes a hash of its tokens together with the hash of the block before it. The chain means that one hash names a whole prefix, not only 16 tokens. The hash also covers other inputs that change the keys and values. These are the LoRA adapter, the images in the prompt, and an optional salt for each request.

A new request computes the same chain of hashes over its prompt. The engine looks each hash up in a map and stops at the first miss. Every block before the miss is a hit, and its tokens need no compute. Only full blocks are cached, so the last part of a prompt below 16 tokens is always computed.

Since version 0.11, the hash is SHA-256 by default. A faster hash exists as an option, but the documentation warns that a collision could return the wrong cache entries, or leak data between tenants.

Fig. 8.3the block poolstructure
One pool of 16-token blocks serves every request The KV cache is a pool of 16-token blocks. Each request has a block table that maps its token positions to block numbers anywhere in the pool. Full blocks are listed in a hash map for prefix hits. Free blocks wait in a queue, and the least recently used cached block is evicted first. BLOCK POOL ON THE GPU #0 #1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #11 #12 #13 #14 #15 BLOCK TABLES request A #2, #3, #7 request B #5, #6, #10 cached prefix #11, #12 by hash Dashed blocks are free and wait in a queue. A request takes one block each time it fills 16 tokens. When the queue is empty, the least recently used cached block is evicted first.
The KV cache is one pool of 16-token blocks that requests take in any order, mapped by a block table for each request. Coloured blocks belong to the request of the same colour. Dashed blocks are free.

Eviction

A block that no request uses goes to a free list, and it keeps its hash. A later request with the same prefix can still use it. When vLLM needs a new block, it takes the block at the head of the free list, which is the one unused the longest. Only then does the old content go away.

So the cache is the pool. No separate cache memory exists, and a busy server keeps fewer old prefixes than an idle one.

Hybrid models get groups

A model with sliding-window layers or state-space layers does not keep one entry for each token in every layer. The engine puts each kind of layer in its own group. Each group has its own page size and its own rule for a cache hit. A sliding-window layer, for example, needs only the blocks inside the last window. The groups share one pool. The long-context page covers what this means for your settings.

How vLLM picks an attention backend

This page explains the selection order for each GPU class and what vLLM picks when the first choice cannot run.

The attention kernel is the one part of the forward pass that changes most between GPUs. When you finish this section, you can predict the backend vLLM picks on your GPU, and explain why one flag change can swap it.

A list for each GPU class

vLLM 0.31.0 has more than 30 attention backends. On a CUDA GPU, it walks an ordered list and takes the first backend that can run the model with your settings. For a model with standard attention, the list is:

GPUOrder
Blackwell datacenter GPUs, such as the B200FlashInfer, FlashAttention, Triton, FlexAttention, TurboQuant
Every other CUDA GPU, which includes the L4 and the GB10FlashAttention, FlashInfer, Triton, FlexAttention, TurboQuant

Models with multi-head latent attention, such as the DeepSeek family, have their own lists.

FlashAttention itself has versions. vLLM uses version 3 on Hopper GPUs and version 4 on the Blackwell datacenter GPUs where it is supported. Every other GPU gets version 2, so both our L4 and our GB10 ran FlashAttention 2. Our GB10 log names the choice and the list:

the backend line from a GB10 starttextFull log
(EngineCore pid=143) INFO 10-21 10:53:17 [cuda.py:526] Using FLASH_ATTN attention backend out of potential backends: ['FLASH_ATTN', 'FLASHINFER', 'TRITON_ATTN', 'FLEX_ATTENTION'].
vLLM took the first entry of the list.

Why a flag changes the backend

Each backend declares what it supports: the data type of the KV cache, the head size, the block size, and the CUDA graph modes. If your settings ask for something the first backend cannot do, vLLM moves to the next one.

The FP8 KV cache is the common case. On an L4, FlashAttention 2 cannot read an FP8 cache. With --kv-cache-dtype fp8, vLLM skips it and picks FlashInfer if it is installed, or Triton if it is not. Your FP8 result then measures a new data type and a new kernel at the same time. On our L4 the FlashInfer build failed, as the FP8 KV cache page shows.

Why the order differs

Our reading: the order reflects which kernels were fastest and best tested on each class when the list was written. FlashInfer has kernels tuned for the Blackwell datacenter GPUs, so it comes first there. On older classes, FlashAttention has the longer record. The list is a judgment that changes between releases, not a law.

That is why the manual tells you to read the backend line after every start, and after every flag change. If two runs used different backends, you compared kernels, not settings. --attention-backend pins one backend by its enum name, such as TRITON_ATTN.

What Model Runner V2 changed

This page explains the new model runner and why async scheduling needs it.

At 0.31.0, vLLM has two model runners, and the one you get depends on your flags. When you finish this section, you can say which runner your server uses, and why a speculation method can change it.

What a model runner does

The model runner lives in each GPU worker. It takes the batch that the scheduler chose, builds the input tensors, runs the forward pass, and samples the next tokens. It also loads the weights, runs the startup profile, and captures the CUDA graphs.

The first runner of the V1 engine grew to more than 7,500 lines. The design notes of the vLLM team say that the first version had design mistakes and technical debt. The team then built Model Runner V2 from first principles. The new runner is about a third of that size.

Two ideas

A fixed table of request rows. Model Runner V2 keeps a persistent batch with one row for each --max-num-seqs slot, 256 on our GPUs. Each request owns one row for its whole life. A preempted request leaves its row, the same as a finished one. The runner updates only what changed in each step, instead of building the batch again.

No waits for the CPU. The runner keeps its work on one CUDA stream and avoids points where the CPU must wait for the GPU. That lets the scheduler plan the next step while the GPU still runs the current one. The name for this is async scheduling, and it is on by default for most generate models.

When you get the old runner

Model Runner V2 is the default on CUDA when Triton is installed. vLLM 0.31.0 uses the old runner instead in these cases, among others:

  • The n-gram, suffix, Medusa, or MLP speculator methods.
  • The Mamba cache mode all, which release 0.32 removed.
  • The stock torch.compile mode, and some parallel features such as sequence parallelism with tensor parallelism.

VLLM_USE_V2_MODEL_RUNNER=0 or =1 pins the choice.

This matters most for speculation. EAGLE3 keeps the new runner and async scheduling. N-gram speculation moves you to the old runner and turns async scheduling off. A comparison of the two methods is then also a comparison of two runners.

Where it goes next

The vLLM roadmap for the fourth quarter of 2026 lists the full removal of the old runner. Each release since 0.30 moved more features to the new one. If a flag puts you on the old runner, check the next release before you build on that combination.

How compile and CUDA graphs are captured

This page explains torch.compile, piecewise and full graphs, and the capture sizes.

vLLM spends the last part of every start on compiling and capturing. When you finish this section, you can say what each optimization level turns on, and why a full CUDA graph cannot cover every batch.

Two separate tools

torch.compile traces the model's Python code into a graph and generates fused kernels. Fusion joins small operations, such as a norm and a quantize step, into one kernel. The compiled code goes to a cache under ~/.cache/vllm/torch_compile_cache. A second start with the same settings skips most of the work. The server finishes all compiles before it serves the first request.

A CUDA graph records a sequence of kernel launches with fixed shapes and replays it with one CPU call. A decode step for a small batch is hundreds of short kernels, and a graph removes the CPU cost of launching each one.

The graph modes

A graph needs fixed shapes, and attention does not always have them. A batch can mix prompts of different lengths. vLLM handles this with modes:

ModeWhat it records
NONEnothing
PIECEWISEeverything except attention, in pieces around each attention call
FULL_DECODE_ONLYthe whole step, only for batches of single-token decodes
FULL_AND_PIECEWISEthe whole step where the attention backend allows it, and pieces otherwise

The default is FULL_AND_PIECEWISE. At run time, a dispatcher picks a mode for each batch from its token count, its request count, and whether it is uniform. If the attention backend cannot replay a full graph for a batch, the dispatcher uses the piecewise one.

On the L4 and the GB10, FlashAttention 2 supports full graphs only for uniform batches, such as pure decode steps. A mixed batch with a new prompt runs piecewise.

The optimization levels

-O0 to -O3 set compile and graphs together:

LevelCompileGraphsFusions
-O0offoffoff
-O1onpiecewisenorm and activation quantize
-O2, the defaultonfull and piecewisemore, such as all-reduce with norm
-O3the same as -O2 at 0.31.0

A flag that you set yourself wins over the level. --enforce-eager turns off compile and graphs in one switch. Since 0.31.0 it also turns off the kernel warmup at startup, which -O0 keeps.

Capture sizes

vLLM captures graphs for a list of batch sizes. A batch larger than the largest size runs without a full graph. --max-cudagraph-capture-size sets the largest size, and --cudagraph-capture-sizes sets the list. More sizes cost startup time and GPU memory, which comes out of the KV pool.

vllm bench startup measures the start with a cold and a warm compile cache, so you can see what each choice costs.

How speculative decoding checks drafts

This page explains draft proposal, verification, and acceptance.

Speculative decoding promises more tokens for each step without a change to the output. When you finish this section, you can explain how vLLM keeps that promise, and why the gain shrinks as the batch grows.

Guess, then check

A decode step on a small batch reads all the weights to produce one token for each request. Most of the GPU sits idle while it waits for memory. Speculative decoding uses that idle time.

A cheap proposer guesses the next few tokens. The target model then runs once over the guesses and computes its own distribution at every position. A rejection sampler walks the guesses in order. It keeps each guess that the target model's distribution supports, and at the first rejection it samples a correct token from the target and stops. One step therefore gives at least one token and at most the number of guesses plus one.

The check is what keeps the output the same. With greedy sampling, a guess stays only if it equals the target's top token. With random sampling, the rejection rule keeps the output distribution equal to the target's distribution.

Fig. 8.4one verify passsequence
One verify pass keeps the drafts up to the first rejection, plus one token Top: a proposer drafts three tokens, the target model checks them in one pass, keeps the first two, rejects the third, and adds its own token. Bottom: one real request with Qwen3-8B on our L4 and EAGLE3 with three drafts per step wrote 12 tokens. One came from prefill and 11 from 6 verify passes, which kept 5 of 18 drafts. THE RULE, BY POSITION draft 1 draft 2 draft 3 kept kept rejected target token One target pass writes the kept drafts and one token of its own. ONE REAL REQUEST: QWEN3-8B ON OUR L4, EAGLE3, 3 DRAFTS Paris 12095 . 13 The 576 capital 6722 of 315 Italy 15344 is 374 Rome 21718 . 13 The 576 capital 6722 of 315 Token 1 came from prefill. The other 11 came from 6 verify passes. Drafts kept per pass: 0 in 3 passes, 1 in 2 passes, 3 in 1 pass. So 5 of 18 drafts were kept, about 1.8 tokens per pass.
One target pass keeps the drafts up to the first rejection and adds one token, so one real request got about 1.8 tokens per pass. The top half shows the rule by position. The bottom half is a real Qwen3-8B request on our L4, served by SGLang with EAGLE3, the same rule vLLM uses. Green tokens came from verify passes.

The proposers

vLLM 0.31.0 has several kinds of proposer, set with --speculative-config:

MethodWhere the guesses come from
ngramearlier text in the same prompt that matches the last few tokens
suffixa suffix tree over earlier prompts and answers
eagle, eagle3a small draft head that reads the target model's hidden states
*_mtpextra prediction heads that come with some models, such as DeepSeek
draft_modela separate small model

N-gram and suffix methods cost almost nothing, and they help when the answer copies the prompt, as in code edits. A draft head like EAGLE3 needs a trained head for your model, and it helps on open text. For Qwen/Qwen3-8B, a public EAGLE3 head exists with one layer and a draft vocabulary of 32,000 tokens.

Why the gain falls with load

The idle time that speculation uses exists only while the step reads more memory than it computes. As the batch grows, the step does more compute for each byte, and the idle time goes away. The checks of rejected guesses then cost real compute. The vLLM documentation rates the gain of EAGLE as high at a low request rate and lower at a high one.

The dynamic mode follows from this. It sets the number of guesses from the batch size and stops guessing for large batches. At 0.31.0, its online example runs on the older model runner.

What to watch

The engine exports the proposed and the accepted tokens. The acceptance rate and the mean number of tokens for each step tell you whether the proposer guesses well on your text. A high acceptance rate with no gain in the time per token means the batch is already compute-bound.

How KV connectors move the cache

This page explains the connector interface and the transfer layers.

Some setups need the KV cache of a request in a place other than the GPU that computed it. When you finish this section, you can name the jobs a KV connector does, and say what disaggregated prefill buys and what it does not.

Why move the cache

Three setups need it:

  • Disaggregated prefill. One server computes the prompt, and another server generates the answer. The KV cache must move from the first to the second.
  • Offloading. Blocks that do not fit on the GPU go to CPU memory or to storage. They come back when a later request needs the same prefix.
  • A shared cache. Several servers read prefixes from one store, so a prefix computed once serves all of them.

One interface, many transports

vLLM puts these jobs behind one interface, the KV connector. The scheduler asks the connector which blocks of a request it can load from outside. It then counts those tokens as computed, the same as a prefix-cache hit. The worker asks the connector to load or save the blocks around each forward pass.

At 0.31.0, the repository has connectors for several transports:

ConnectorWhat it moves the cache with
NIXLthe NVIDIA transfer library, over RDMA or the network
Mooncakethe Mooncake transfer engine
LMCachethe LMCache library, for CPU, disk, and shared stores
Offloadingthe native offload to CPU memory
MultiConnectortwo or more connectors together

Set the connector with --kv-transfer-config, which gives the connector name, the role of the server, and the options of the transport. The role says whether the server produces the cache, consumes it, or does both.

What disaggregation buys

The vLLM documentation says that disaggregated prefill does not raise throughput. It gives two other reasons to use it. You can tune the first token and the stream on separate servers. You can also keep long prompts from stalling the stream of other users, which controls the tail of the inter-token latency.

The price is a transfer for every request, and more machines to run. If a load fails, the default policy is to fail the request. A recompute policy exists as an option.

Where it goes

The connector interface is one of the most active parts of the code. In October 2026, the open work had three parts. One was KV offloading that does not depend on the tensor-parallel size. Another was a compression layer for the transferred blocks. The last was a fix for blocks released during a remote read. Pin the version when you build on a connector, and read its open issues first.

Where vLLM came from

This page explains the Berkeley project, the V0 to V1 change, and the governance of the project.

The vLLM you install today is the second engine under the same name. When you finish this section, you can say where the project started, why it rewrote its engine, and who decides its direction now.

A Berkeley project with one idea

The repository started on 2023-02-09 at the University of California, Berkeley, with commits by Woosuk Kwon. The first public release, v0.1.0, came on 2023-06-20, together with a blog post that introduced PagedAttention. The paper on the same idea appeared at SOSP 2023.

The idea was to store the KV cache in fixed blocks, the way an operating system stores memory in pages. A server could then fit more requests on one GPU, and requests could share blocks. Early users ran it behind the Chatbot Arena and the Vicuna demos of the LMSYS team.

Features in the first engine

Between 2023 and 2025, the first engine gained most of the features this manual covers:

ReleaseDateWhat it added
v0.3.02024-01-31experimental multi-LoRA, experimental prefix caching, and an FP8 KV cache
v0.4.02024-03-30automatic prefix caching, and early chunked prefill and speculative decoding
v0.6.02024-09-04about twice the throughput of v0.5.3 with the default settings, an async output processor, and an optional multi-step scheduler

Our reading: each feature went into a design that did not plan for it, and the code grew harder to change.

The rewrite

The team built a second engine, called V1. Its goals were a simple and modular code base, almost no CPU overhead, and the main optimizations on by default. V1 appeared for testing in v0.7.0 on 2025-01-27, with torch.compile on by default. It became the default engine in v0.8.0 on 2025-03-18. Release v0.11.0 on 2025-10-02 removed the old engine.

The ideas on the other pages of Part 8 come from this rewrite. One token budget replaced the prefill and decode paths. Chunked prefill and prefix caching became the default. The process split put the scheduler in its own process. Model Runner V2 later rebuilt the model runner, which the team said had design mistakes.

Releases now

Until v0.11, a new minor release came every one to five months. Since v0.12.0 in December 2025, a new minor release comes about every two weeks. By 2026-10-18 the project had published 108 releases on GitHub. Each release changes defaults and removes deprecated flags, so a recipe from a few months ago can be wrong today. This manual pins 0.31.0 for that reason.

Who runs it

The project started the incubation process at the LF AI and Data Foundation in July 2024. On 2025-05-07, it became a hosted project of the PyTorch Foundation. Five lead maintainers and 55 active committers review changes. Companies contribute engineers to the project. Red Hat brought in the Neural Magic team when it bought that company in January 2025.

The pace has a cost that the project discusses in public. In September and October 2026, two RFCs on the project said that review cannot keep up with the number of pull requests. One proposed a reviewer role. The other proposed evals and benchmarks that contributors run on their own GPUs, so that a pull request comes with its evidence.

Reference

Reference

Look up a flag, a metric, an incident, a version pin, or a figure here.

  1. R.1Flags and defaults
  2. R.2Metrics
  3. R.3Incident index
  4. R.4Version pins
  5. R.5Glossary
  6. R.6Sources
  7. R.7Lab notes
  8. R.8Index of figures

Flags and defaults

This page lists the flags the manual uses, grouped by user job, with the default at 0.31.0.

Use this page to look up a flag that a section names.

Defaults are for vllm serve at 0.31.0 on a CUDA GPU, read from the configuration classes in the source.

Server

FlagDefaultWhat it setsPage
--hostnot set, so every network interfacethe address the server listens onserver options
--port8000the HTTP portserver options
--served-model-namethe model namethe name that clients send in modelserver options
--api-keynot setthe key that the /v1 routes requireserver options
--chat-templatethe template of the modelthe chat template, as a file or as textserver options
--default-chat-template-kwargsnot settemplate values for every requestreasoning models
--reasoning-parsernot setthe parser that moves thinking out of the answerreasoning models
--enable-log-requestsoffone log line for each requestserver options

Memory and length

FlagDefaultWhat it setsPage
--gpu-memory-utilization0.92the share of total GPU memory for one instancestartup
--kv-cache-memory-bytesnot setthe KV cache size in bytes, which overrides the share for the cachestartup
--max-model-lenthe model configthe longest prompt plus answer for one requestlong contexts
--block-size16tokens in one KV block, or the attention backend's preferenceblock pool
--kv-cache-dtypeautothe data type of the KV cache, such as fp8attention backend
--num-gpu-blocks-overridenot seta fixed number of KV blocks, for testsstartup

Scheduling

FlagDefaultWhat it setsPage
--max-num-batched-tokens2048 for vllm serve on a GPU under 70 GiB or an A100, 8192 from 70 GiB, 16384 from 160 GiBthe token budget for one steptoken budget
--max-num-seqs256 on a GPU under 70 GiB or an A100, 1024 from 70 GiBthe most requests that run at oncetoken budget
--enable-chunked-prefillonsplitting a long prompt across stepstoken budget
--async-schedulingon for most generate modelsplanning the next step while the GPU runsModel Runner V2
--scheduling-policyfcfsfirst come first served, or prioritytoken budget
--long-prefill-token-thresholdnot seta cap on the tokens of one prefill in one steptoken budget

Cache

FlagDefaultWhat it setsPage
--enable-prefix-cachingonreuse of cached prefix blocksblock pool
--prefix-caching-hash-algosha256the hash of each blockblock pool
--disable-hybrid-kv-cache-manageroffone group for every layer kindlong contexts
--kv-transfer-confignot seta KV connector and its roleKV connectors

Execution

FlagDefaultWhat it setsPage
-O0 to -O3-O2the compile and CUDA graph levelcompile and graphs
--enforce-eageroffno compile, no CUDA graphs, and no kernel warmupcompile and graphs
--max-cudagraph-capture-sizeset by the levelthe largest batch with a captured graphcompile and graphs
--attention-backendchosen from a listthe attention kernel, by enum nameattention backend
--speculative-confignot setthe speculation method and its options, as JSONspeculative decoding

Parallelism

FlagDefaultWhat it sets
--tensor-parallel-size, -tp1GPUs that split each layer
--pipeline-parallel-size, -pp1GPUs that split the layers into stages
--data-parallel-size, -dp1full copies of the model
--enable-expert-paralleloffexperts spread across GPUs for mixture-of-experts models

Environment variables

VariableDefaultWhat it sets
VLLM_ENABLE_V1_MULTIPROCESSING1the engine core in its own process
VLLM_USE_V2_MODEL_RUNNERnot seta pinned choice of model runner
VLLM_BATCH_INVARIANT0the batch-invariant mode, when set to 1
VLLM_API_KEYnot setthe API key, as --api-key sets it
VLLM_LOGGING_LEVELINFOthe vLLM log level
VLLM_NO_USAGE_STATS0no usage statistics, when set to 1

Metrics

This page lists the vLLM metrics the manual uses and the page that uses each one.

Use this page to find a metric that a section names.

vLLM exports the metrics at /metrics in the Prometheus format. Every name starts with vllm:.

Every counter shows on /metrics with a _total suffix, as these tables list it.

Load and memory

MetricTypeWhat it countsPage
vllm:num_requests_runninggaugerequests that the engine works on nowhealth
vllm:num_requests_waitinggaugerequests in the queuehealth
vllm:kv_cache_usage_percgaugethe share of the KV cache in use, from 0 to 1health
vllm:num_preemptions_totalcounterrequests stopped to free blockstroubleshooting

Latency

MetricTypeWhat it counts
vllm:time_to_first_token_secondshistogramthe time from arrival to the first token
vllm:inter_token_latency_secondshistogramthe time between two tokens of one request
vllm:request_time_per_output_token_secondshistogramthe mean time for each output token of a request
vllm:request_queue_time_secondshistogramthe time a request waited before it ran

Cache and tokens

MetricTypeWhat it countsPage
vllm:prefix_cache_queries_totalcounterprompt tokens looked up in the prefix cachetroubleshooting
vllm:prefix_cache_hits_totalcounterprompt tokens found in the prefix cachetroubleshooting
vllm:prompt_tokens_totalcounterprompt tokens processed
vllm:generation_tokens_totalcounteroutput tokens generated

Speculative decoding

MetricTypeWhat it countsPage
vllm:spec_decode_num_draft_tokens_totalcountertokens the proposer guessedspeculative decoding
vllm:spec_decode_num_accepted_tokens_totalcounterguessed tokens the target model keptspeculative decoding

What our runs recorded

Our benchmark client recorded TTFT, TPOT, ITL, and throughput for each request. A sampler recorded the GPU clock and temperature every second. We did not scrape /metrics during the sweeps.

Incident index

This page lists every issue the manual cites, with its status at 0.31.0.

Use this page to check whether a problem you see is known.

States are as of 2026-10-22. Numbers refer to issues in the vLLM repository.

Issues

IssueWhat it reportsStatePage
44740a negative CUDA graph memory estimate with MTP speculation on the GB10, which makes the KV cache too largeopenDGX Spark
46307memory use above the --gpu-memory-utilization share during the startup profile on the GB10, which stopped the hostclosed as stale on 2026-10-22, with no fixDGX Spark
52479a level 2 wake from sleep that does not reload the speculative draft, so no draft token is acceptedopenRL rollouts
54623the Qwen3.5 family never loads checkpoint KV scales, so an FP8 KV cache runs at scale 1.0openFP8 KV cache
57853a weight update sent while the engine sleeps segfaults the engine coreopenRL rollouts
60333one MTP draft loader drops FP8 KV scales, and the draft layers use scale 1.0openFP8 KV cache
58726slow weight load on the GB10, from per-tensor copies out of the file mapopenDGX Spark
59770slower NVFP4 decode for one model on the GB10 since 0.29.0opentroubleshooting
60174one repeated token after a prefix-cache hit with DFlash2 or DSpark speculation on 0.30 and 0.31closed on 2026-10-22troubleshooting
60262a crash at start when an FP8 KV cache picks FlashInfer without a working kernel buildopentroubleshooting
60830invalid JSON with MTP speculation and a JSON schema on one modelclosedtroubleshooting
60838--mamba-block-size has no effect after the removal of the all Mamba cache modeopenlong contexts
61024the project roadmap for Q4 2026, which lists the removal of the old model runneropenModel Runner V2
61886KV offloading that does not depend on the tensor-parallel sizeopenKV connectors
59538a compression layer for KV transfer and storageopenKV connectors
61229KV blocks released during a remote read in the NIXL connectoropenKV connectors
58253a request for a reviewer role, to raise review capacityopenhistory
61698a request for reproducible evals and benchmarks that contributors run on their own GPUsopenhistory

Our own incidents

DateMachineWhat happenedPage
2026-10-22L4, shared GPUthe start failed, because one request at a maximum length of 2,928 tokens needed slightly more KV cache than was freestartup
2026-10-21GB10the first weight load of 56.93 GiB took 330 secondsDGX Spark

Version pins

This page lists the versions of vLLM, PyTorch, CUDA, and the images that our runs used.

Use this page to match your setup to ours before you compare a number.

vLLM

ItemValue
Release0.31.0
Tag commitdb9527a46873454610df6dbedf79a36d6bf1a7f6, dated 2026-10-02
GitHub releasepublished 2026-10-05
PyTorch that the CUDA wheel requires2.13.0
CUDA of the default image13.0
Python of the default image3.12

The machines

ItemNVIDIA L4DGX Spark
GPUNVIDIA L4, compute capability 8.9NVIDIA GB10, compute capability 12.1
Memory24 GB GDDR6, 22.1 GiB visibleunified LPDDR5x sold as 128 GB, 121.69 GiB visible to the GPU
CPU architecturex86_64aarch64
Driver615.71.09580.159.03
How vLLM ranthe pip wheel in a virtual environmentthe image vllm/vllm-openai:v0.31.0
Attention backendFlashAttention 2, by the selection rule for this GPUFlashAttention 2, named in the startup log as FLASH_ATTN

Models

ModelMachineUse
Qwen/Qwen3-8BL4every L4 run except the FP8 weight run
Qwen/Qwen3-8B-FP8L4the FP8 weight run
Tengyunw/qwen3_8b_eagle3L4the EAGLE3 draft
Qwen/Qwen3-30B-A3B-Instruct-2507DGX Sparkall GB10 runs

The comparison engine and the client

ItemValue
SGLang0.5.22, for the head-to-head runs
Benchmark clientsglang.benchmark.serving from SGLang 0.5.22, with --backend vllm for the vLLM runs
PyTorch of the client environment2.14.1 with CUDA 13.0

Glossary

This page defines each term the manual uses, with the matching SGLang term.

Use this page when a section uses a term you do not know.

TermMeaningSGLang term
API serverthe process that serves HTTP, tokenizes, and streams the answerthe tokenizer manager in the main process
async schedulingplanning the next step while the GPU runs the current onethe overlap scheduler
attention backendthe kernel family that computes attentionthe attention backend
blocka fixed group of 16 tokens in the KV cachea page, with a page size of 1 by default
block poolall KV blocks, allocated at startupthe KV pool
chunked prefillsplitting a long prompt across stepschunked prefill
clock floorthe lowest median SM clock that our sweep tool accepts for a run, below which it repeats the runnot an SGLang term
CUDA grapha recorded sequence of kernel launches that replays with one calla CUDA graph
engine corethe process that runs the scheduler and the KV cache managerthe scheduler process
fixed poola KV pool that we set by hand with --num-gpu-blocks-override, so that two engines get the same roomnot an SGLang term
ITLinter-token latency, the time between two tokens of one streamITL
kneethe concurrency where latency starts to rise fast while the output stops risingnot an SGLang term
KV cachethe keys and values of past tokens, kept for the next stepsthe KV cache
KV connectorthe interface that loads and saves KV blocks outside the GPUthe disaggregation transfer backend and HiCache storage
Model Runner V2the newer code that builds batches and runs the forward pass in a workerthe model runner
preemptionstopping a running request to free its blocks, then computing it againretraction
prefix cachingreusing the KV blocks of a matching prompt prefixthe radix cache
reasoning parserthe server option that moves the thinking of a reasoning model out of the answer textthe reasoning parser
throttledrunning below the full clock because the GPU is too hot or shares power with other worknot an SGLang term
token budgetthe most tokens the scheduler gives out in one stepthe chunked prefill size and the running request limit together
TPOTtime per output token, the mean time for each output token of a requestTPOT
TTFTtime to first tokenTTFT
wavesour workload of 64 prompts at 16 users, with a length ratio that sets how much the lengths varynot an SGLang term
workerthe process that holds the weights for one GPUthe TP worker

Sources

This page ranks the sources of the manual by authority.

Use this page to judge how much weight a statement in the manual carries.

When two sources disagree, the higher one wins, and the page says so.

RankSourceWhat the manual takes from it
1the vLLM source at release 0.31.0every default, every flag name, and every mechanism in Part 8
2our own runs on an NVIDIA L4 and a DGX Sparkevery number marked as measured
3vLLM issues and pull requestsknown failures and the history of each feature
4the vLLM release noteswhat each release added, changed, or removed
5the vLLM documentation at release 0.31.0documented behavior, checked against the source
6papersthe original design of PagedAttention and speculative decoding
7blog posts and talks by the vLLM team and otherscontext and history, never a default or a number

What we did not use

We did not use benchmark numbers from vendors or from other teams as evidence for a decision. Where the manual reports a result from another team, it names the team once and says that the number is theirs.

Lab notes

This page records the hardware, the versions, the seeds, the clocks, and the full tables behind each measurement.

Use this page to check a number from another section, or to repeat a run.

Method

  • Hardware and versions. The version pins page lists them.
  • Client. Every run used the SGLang 0.5.22 benchmark client. The vLLM runs used --backend vllm, which sends each request to /v1/completions. The client sends one warmup request first, and it asks the server to ignore the end-of-sequence token unless you pass --disable-ignore-eos. Its random dataset cuts or repeats real chat text to the asked length. The benchmark page prints the full command.
  • Waves. The waves workload sends 64 prompts at 16 users. The length ratio sets how far the prompt and output lengths can vary. At 1.0 every request has 512 prompt and 128 output tokens. At 0 each length is drawn at random from 1 token up to that size.
  • Seeds. The runs to 2026-10-21 used seeds 1, 2, and 3. Most runs on 2026-10-22 used seeds 1 and 2, and the tables give the count. Tables give the median, and some columns give the lowest and the highest.
  • Server settings. The sweep tool starts every vLLM server with VLLM_USE_FLASHINFER_SAMPLER=0, because the FlashInfer sampler fails to build on our L4. It also sets --seed 0, --max-model-len, --num-gpu-blocks-override, and --gpu-memory-utilization. The L4 used 8,192 tokens, 1,506 blocks, and 0.9. The GB10 used 8,192 tokens, 14,685 blocks, and 0.75. The shared L4 GPU used 2,048 or 2,304 tokens, 183 blocks, and 0.83. The small-pool run used 4,096 tokens and 410 blocks.
  • Rounding. The tables and the sections use one rule. They round the decimal value from the summary file, and a half goes up, so 1,012.5 shows as 1,013.
  • Pools. On the L4, vLLM ran with --num-gpu-blocks-override 1506, a pool of 24,096 tokens, against 24,094 for SGLang. On the GB10, vLLM ran with 14,685 blocks, a pool of 234,960 tokens. The SGLang pools on the GB10 were 233,303 tokens for load, 234,958 for waves, and 251,910 for mixed.
  • Clocks. A sampler recorded the SM clock and the temperature every second. The sweep tool can wait for the GPU to cool before a run. It can also repeat a run when the median clock falls below a clock floor, a lowest clock that we set for each sweep. The clock column shows the result for each point. On 2026-10-22, most runs had the SM clock locked at 900 MHz with nvidia-smi -lgc. A lock sets the highest clock, but a GPU at a high temperature still slows below it. The sections compare two points only at similar clocks, and they say so where a point ran below the lock or in a throttled session.
  • Dates. The L4 load runs of vllm and sglang were on 2026-10-17, and the vllm-b, vllm-async, vllm-eager, and vllm-seqs-16 load runs on 2026-10-21. The L4 rate and waves runs were on 2026-10-18. The L4 prefix runs with the cache on ran from 2026-10-17 into 2026-10-18, and the run without the prefix cache was on 2026-10-21. Every GB10 run in these tables was on 2026-10-21. Every run in the three L4 tables for 2026-10-22 was on that day. The runs on the shared GPU of the L4 node had a pool of 2,928 tokens, so they tested 1 and 4 users only.
  • The cache between user counts. The load tables before 2026-10-23 kept one server for each config and did not reset the prefix cache between user counts. Each seed sent the same first prompts at every count. Below 32 users these tables read the first token too fast, by 3.6 to 5.3 times for vLLM on our L4. The rerun of 2026-10-23 resets the cache before every run, and the benchmark page compares the two.
  • Runs of 2026-10-23. The L4 runs of that day used the first GPU with the clock locked at 900 MHz. Each run had two seeds and a cache reset first. The card ran hot, so many runs fell below the lock. Each evidence file gives the median clock of its rows, and the sections compare only rows at 900 MHz.

Not run yet

These runs are planned and are not in these tables:

  • --max-num-batched-tokens at 512, 2048, and 16384 on mixed prompts on the L4. The GB10 has 2048 and 16384.
  • -O1 on the L4.
  • a backend comparison with one KV data type.

Tables

The config vllm-async passes --async-scheduling. For these models with no speculation, that is the default, so vllm-async is a second run of the baseline setting on both machines.

L4, load: users at once, 512 prompt and 128 output tokens

UsersConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
1vllm13.5 (13.5 to 13.5)192 ms60.4 ms105 (104 to 105) ms1,290 MHz
1sglang13.8 (13.7 to 14.1)185 ms60.2 ms104 (104 to 104) ms1,290 MHz
1vllm-b13.5 (13.1 to 13.8)191 ms60.4 ms105 (104 to 105) ms1,275 MHz
1vllm-async13.3 (13.1 to 13.6)191 ms60.4 ms105 (104 to 105) ms1,268 MHz
1vllm-eager13.2 (13.1 to 13.4)190 ms61.1 ms107 (107 to 107) ms1,290 MHz
1vllm-seqs-1613.4 (13.1 to 13.6)190 ms60.4 ms105 (105 to 105) ms1,275 MHz
2vllm31.3 (31.3 to 31.3)132 ms63.4 ms64 (64 to 64) ms1,500 MHz
2sglang31.2 (31.2 to 31.2)116 ms63.7 ms65 (65 to 65) ms1,500 MHz
2vllm-b31.3 (31.3 to 31.3)132 ms63.4 ms64 (64 to 64) ms1,500 MHz
2vllm-async31.3 (31.3 to 31.3)132 ms63.4 ms64 (64 to 64) ms1,500 MHz
2vllm-eager30.8 (30.7 to 30.8)141 ms64.5 ms65 (65 to 65) ms1,515 MHz
2vllm-seqs-1631.3 (31.3 to 31.3)132 ms63.4 ms65 (64 to 65) ms1,500 MHz
4vllm61.8 (61.8 to 61.8)141 ms64.1 ms65 (65 to 65) ms1,515 MHz
4sglang61.6 (61.5 to 61.6)137 ms64.4 ms67 (65 to 67) ms1,530 MHz
4vllm-b61.8 (61.8 to 61.8)142 ms64.1 ms65 (65 to 66) ms1,560 MHz
4vllm-async61.8 (61.8 to 61.8)141 ms64.1 ms65 (65 to 65) ms1,515 MHz
4vllm-eager60.7 (60.7 to 60.7)143 ms65.3 ms66 (66 to 66) ms1,560 MHz
4vllm-seqs-1661.6 (61.3 to 61.6)142 ms64.3 ms65 (65 to 66) ms1,515 MHz
8vllm112.8 (112.8 to 112.9)253 ms65.8 ms68 (68 to 70) ms1,470 MHz
8sglang112.5 (112.4 to 112.6)170 ms66.1 ms71 (70 to 73) ms1,485 MHz
8vllm-b112.8 (112.7 to 112.8)253 ms65.8 ms67 (67 to 68) ms1,485 MHz
8vllm-async112.7 (112.6 to 112.8)261 ms65.8 ms67 (67 to 68) ms1,500 MHz
8vllm-eager111.2 (111.2 to 111.4)206 ms67.0 ms68 (68 to 68) ms1,515 MHz
8vllm-seqs-16112.5 (112.4 to 112.6)260 ms65.9 ms67 (67 to 67) ms1,485 MHz
16vllm200.9 (200.5 to 201.2)289 ms69.5 ms80 (80 to 105) ms1,425 MHz
16sglang201.1 (201.1 to 201.2)212 ms70.1 ms81 (81 to 82) ms1,470 MHz
16vllm-b201.3 (200.9 to 201.3)288 ms69.5 ms85 (81 to 89) ms1,455 MHz
16vllm-async201.4 (200.8 to 201.5)284 ms69.5 ms85 (79 to 87) ms1,425 MHz
16vllm-eager198.4 (198.2 to 198.4)278 ms71.1 ms80 (80 to 85) ms1,455 MHz
16vllm-seqs-16200.9 (200.6 to 201.1)288 ms69.7 ms80 (79 to 86) ms1,440 MHz
32vllm273.9 (273.8 to 274.4)1,813 ms102.2 ms619 (612 to 622) ms1,275 MHz
32sglang271.3 (270.3 to 272.2)2,871 ms96.8 ms102 (102 to 103) ms1,320 MHz
32vllm-b275.1 (274.8 to 275.2)1,800 ms101.8 ms613 (608 to 614) ms1,275 MHz
32vllm-async275.3 (273.8 to 275.3)1,807 ms102.1 ms613 (611 to 619) ms1,275 MHz
32vllm-eager273.2 (272.4 to 273.3)1,768 ms103.1 ms600 (595 to 601) ms1,313 MHz
32vllm-seqs-16171.8 (171.3 to 172.5)12,330 ms81.1 ms595 (587 to 603) ms1,365 MHz
64vllm241.4 (241.4 to 246.4)10,953 ms129.2 ms1,363 (1,204 to 1,436) ms1,013 MHz
64sglang256.6 (255.8 to 260.5)18,356 ms106.2 ms114 (114 to 118) ms1,215 MHz
64vllm-b242.3 (242.0 to 243.8)11,181 ms129.2 ms1,233 (1,187 to 1,437) ms1,005 MHz
64vllm-async242.0 (241.2 to 253.3)11,087 ms128.2 ms1,127 (1,116 to 1,423) ms998 MHz
64vllm-eager242.5 (241.3 to 245.6)10,941 ms131.4 ms1,326 (1,270 to 1,427) ms1,020 MHz
64vllm-seqs-16138.1 (136.9 to 139.4)39,890 ms103.7 ms1,143 (1,086 to 1,282) ms630 MHz

L4, rate: requests each second, 512 prompt and 128 output tokens

RateConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
0.5vllm70.6 (67.2 to 92.3)317 ms72.7 ms214 (208 to 215) ms1,485 MHz
0.5sglang70.6 (67.2 to 92.3)284 ms74.7 ms235 (234 to 235) ms1,485 MHz
1vllm123.6 (118.4 to 154.5)180 ms68.2 ms77 (77 to 79) ms1,485 MHz
1sglang123.3 (118.4 to 153.6)166 ms74.0 ms114 (113 to 166) ms1,500 MHz
2vllm227.8 (216.5 to 231.8)233 ms88.7 ms355 (351 to 358) ms1,418 MHz
2sglang226.3 (215.5 to 230.1)221 ms99.9 ms387 (384 to 429) ms1,433 MHz
4vllm286.6 (283.7 to 291.7)8,304 ms126.9 ms621 (620 to 627) ms1,245 MHz
4sglang257.5 (254.9 to 259.7)9,575 ms115.1 ms680 (383 to 811) ms1,350 MHz
8vllm261.8 (259.4 to 264.3)27,510 ms126.0 ms1,184 (1,089 to 1,361) ms1,035 MHz
8sglang253.9 (250.7 to 260.7)37,211 ms105.2 ms277 (259 to 317) ms1,215 MHz
16vllm205.8 (204.8 to 208.4)94,322 ms185.2 ms1,448 (1,448 to 1,483) ms450 MHz
16sglang218.2 (215.6 to 229.0)97,542 ms115.2 ms137 (120 to 163) ms600 MHz

L4, waves: 64 prompts at 16 users, by length ratio (1.0 means equal lengths)

RatioConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
0vllm174.4 (164.1 to 195.0)284 ms75.0 ms202 (180 to 215) ms1,493 MHz
0sglang158.2 (146.7 to 176.9)283 ms86.7 ms302 (287 to 305) ms1,485 MHz
0.5vllm169.6 (165.2 to 173.4)353 ms81.4 ms191 (189 to 219) ms1,455 MHz
0.5sglang159.6 (155.8 to 162.0)325 ms87.8 ms363 (360 to 370) ms1,470 MHz
1vllm181.7 (181.7 to 182.8)1,499 ms76.8 ms502 (500 to 503) ms1,410 MHz
1sglang182.3 (181.5 to 182.7)1,775 ms74.7 ms81 (79 to 81) ms1,440 MHz

L4, prefix: shared prefix tokens, 4 groups of 16 prompts, 8 users

PrefixConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
0vllm113.8 (113.7 to 114.3)408 ms65.2 ms134 (69 to 167) ms1,485 MHz
0vllm-no-prefix-cache113.6 (113.5 to 114.3)392 ms65.3 ms135 (129 to 173) ms1,485 MHz
0sglang113.5 (113.5 to 113.5)354 ms66.1 ms72 (72 to 75) ms1,485 MHz
256vllm111.5 (111.4 to 111.7)488 ms64.8 ms127 (66 to 129) ms1,485 MHz
256vllm-no-prefix-cache99.1 (99.0 to 99.6)753 ms70.2 ms323 (255 to 379) ms1,463 MHz
256sglang110.9 (110.8 to 111.2)470 ms67.2 ms72 (72 to 72) ms1,485 MHz
1,024vllm106.0 (105.7 to 106.8)396 ms68.9 ms189 (73 to 190) ms1,455 MHz
1,024vllm-no-prefix-cache70.9 (70.9 to 71.0)1,213 ms94.9 ms602 (598 to 604) ms1,410 MHz
1,024sglang105.7 (103.2 to 105.8)466 ms66.8 ms73 (73 to 73) ms1,485 MHz
2,048vllm100.2 (99.5 to 100.9)410 ms72.3 ms151 (128 to 165) ms1,455 MHz
2,048vllm-no-prefix-cache46.6 (46.2 to 46.7)2,550 ms119.8 ms1,026 (961 to 1,049) ms1,035 MHz
2,048sglang100.4 (99.6 to 102.5)441 ms70.5 ms151 (74 to 153) ms1,455 MHz
4,096vllm86.8 (85.6 to 89.4)375 ms80.8 ms230 (140 to 232) ms1,410 MHz
4,096vllm-no-prefix-cache19.6 (19.5 to 19.7)13,353 ms192.9 ms1,579 (1,531 to 1,589) ms675 MHz
4,096sglang88.4 (88.3 to 88.9)474 ms75.6 ms162 (82 to 214) ms1,440 MHz

L4, throttled session: 512 prompt and 128 output tokens, 2026-10-22

The GPU ran far below its full clock in this session. Compare these rows only with each other, at matching clocks.

UsersConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clockSeeds
1vllm-fast-b10.6 (10.4 to 10.7)705 ms104.1 ms105 (105 to 105) ms210 MHz3
1vllm-O010.5 (10.4 to 10.6)707 ms106.5 ms107 (107 to 107) ms210 MHz2
1vllm-O310.6 (10.5 to 10.7)705 ms104.2 ms105 (105 to 105) ms210 MHz2
1vllm-kv-fp8-triton10.8 (10.6 to 10.9)717 ms101.8 ms102 (102 to 102) ms210 MHz2
32vllm-fast-b186.7 (185.4 to 187.1)4,181 ms142.7 ms1,705 (1,424 to 1,789) ms450 MHz3
32vllm-O0191.5 (191.0 to 192.1)4,051 ms142.0 ms1,459 (1,354 to 1,565) ms548 MHz2
32vllm-O3189.9 (188.5 to 191.2)4,086 ms143.9 ms1,602 (1,417 to 1,787) ms536 MHz2
32vllm-kv-fp8-triton192.0 (183.3 to 200.7)4,267 ms138.0 ms1,672 (1,603 to 1,741) ms480 MHz2

L4, clock locked at 900 MHz: 512 prompt and 128 output tokens, 2026-10-22

The lock sets the highest clock. A GPU at a high temperature still slows below it, as the 1-user runs of vllm-lock-b and vllm-lock-small-pool show.

UsersConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clockSeeds
1vllm-lock-b11.2 (10.9 to 11.5)554 ms99.0 ms105 (105 to 105) ms210 MHz2
1vllm-lock-fp825.4 (24.5 to 26.3)239 ms35.3 ms48 (45 to 52) ms900 MHz2
1vllm-lock-eagle3-k122.4 (21.7 to 23.1)240 ms39.4 ms100 (84 to 115) ms900 MHz2
1vllm-lock-eagle3-k331.3 (31.2 to 31.4)255 ms27.8 ms78 (78 to 78) ms900 MHz2
1vllm-lock-eagle3-k531.7 (30.6 to 32.9)267 ms26.2 ms110 (83 to 136) ms900 MHz2
1vllm-lock-small-pool11.6 (10.9 to 12.3)449 ms82.6 ms105 (105 to 105) ms281 MHz2
32vllm-lock-b208.8 (206.8 to 210.8)3,090 ms119.9 ms1,406 (1,390 to 1,422) ms881 MHz2
32vllm-lock-fp8284.7 (272.3 to 297.1)2,862 ms83.8 ms1,143 (1,027 to 1,260) ms900 MHz2
32vllm-lock-eagle3-k1271.1 (261.2 to 281.0)1,004 ms100.4 ms818 (784 to 852) ms900 MHz2
32vllm-lock-eagle3-k3333.6 (327.8 to 339.5)890 ms77.8 ms771 (763 to 780) ms900 MHz2
32vllm-lock-eagle3-k5289.5 (280.4 to 298.5)1,071 ms83.7 ms915 (836 to 995) ms885 MHz2
32vllm-lock-small-pool80.4 (79.8 to 81.1)31,568 ms120.6 ms768 (765 to 771) ms248 MHz2

L4, shared GPU with a pool of 2,928 tokens: 512 prompt and 128 output tokens, 2026-10-22

UsersConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clockSeeds
1vllm-fast-b4.0 (3.9 to 4.0)1,437 ms241.7 ms274 (255 to 293) ms375 MHz2
1vllm-ngram7.4 (7.3 to 7.5)1,428 ms76.6 ms284 (277 to 291) ms413 MHz2
1vllm-no-async3.9 (3.8 to 3.9)1,467 ms249.6 ms283 (258 to 307) ms368 MHz2
4vllm-fast-b13.0 (13.0 to 13.1)4,945 ms272.6 ms290 (286 to 295) ms428 MHz2
4vllm-ngram19.2 (18.8 to 19.7)1,925 ms101.3 ms1,691 (1,669 to 1,713) ms443 MHz2
4vllm-no-async12.9 (12.9 to 12.9)4,690 ms277.2 ms309 (309 to 309) ms450 MHz1

GB10, load

UsersConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
1vllm29.5 (29.3 to 29.5)257 ms32.3 ms35 (34 to 42) ms2,411 MHz
1vllm-eager28.0 (28.0 to 28.2)249 ms33.9 ms37 (36 to 43) ms2,411 MHz
1vllm-async29.2 (29.2 to 29.4)255 ms32.5 ms35 (35 to 43) ms2,411 MHz
1sglang29.4 (29.1 to 29.8)270 ms32.0 ms35 (34 to 42) ms2,411 MHz
2vllm44.7 (44.5 to 45.0)151 ms44.0 ms49 (47 to 55) ms2,411 MHz
2vllm-eager42.3 (42.1 to 42.6)145 ms46.5 ms51 (50 to 53) ms2,411 MHz
2vllm-async44.2 (44.1 to 44.5)154 ms44.4 ms48 (48 to 56) ms2,411 MHz
2sglang44.6 (44.4 to 45.2)72 ms44.6 ms49 (48 to 56) ms2,411 MHz
4vllm57.1 (56.5 to 57.2)250 ms69.2 ms76 (76 to 77) ms2,411 MHz
4vllm-eager55.3 (54.9 to 55.4)245 ms71.4 ms78 (78 to 78) ms2,411 MHz
4vllm-async56.1 (55.6 to 56.1)256 ms70.6 ms77 (77 to 78) ms2,411 MHz
4sglang57.7 (57.6 to 58.1)115 ms69.5 ms79 (75 to 82) ms2,411 MHz
8vllm75.7 (71.8 to 78.7)378 ms99.9 ms115 (115 to 117) ms2,411 MHz
8vllm-eager72.8 (68.9 to 75.4)337 ms104.7 ms121 (121 to 123) ms2,411 MHz
8vllm-async74.9 (71.1 to 77.8)441 ms101.2 ms117 (117 to 118) ms2,411 MHz
8sglang75.5 (72.3 to 78.7)214 ms101.3 ms117 (117 to 121) ms2,411 MHz
16vllm101.1 (100.1 to 103.9)529 ms153.9 ms228 (179 to 235) ms2,411 MHz
16vllm-eager98.8 (97.7 to 101.1)541 ms158.2 ms216 (175 to 239) ms2,411 MHz
16vllm-async99.8 (99.0 to 102.7)555 ms156.3 ms226 (176 to 230) ms2,411 MHz
16sglang103.4 (102.4 to 106.7)258 ms152.2 ms164 (163 to 169) ms2,411 MHz
32vllm154.1 (152.1 to 154.8)644 ms198.2 ms478 (472 to 479) ms2,411 MHz
32vllm-eager149.1 (131.4 to 150.9)659 ms204.2 ms474 (465 to 474) ms2,411 MHz
32vllm-async150.2 (148.1 to 150.7)644 ms203.4 ms477 (473 to 479) ms2,411 MHz
32sglang154.6 (152.4 to 155.9)401 ms195.9 ms220 (216 to 220) ms2,411 MHz
64vllm239.3 (236.3 to 244.7)965 ms243.8 ms528 (510 to 529) ms2,398 MHz
64vllm-eager237.5 (232.9 to 240.0)774 ms248.0 ms509 (501 to 511) ms2,398 MHz
64vllm-async235.9 (233.2 to 240.2)961 ms249.1 ms527 (524 to 529) ms2,398 MHz
64sglang245.1 (239.4 to 247.7)573 ms246.0 ms271 (268 to 273) ms2,411 MHz

GB10, mixed: prompts of 1,024 to 4,096 tokens, 64 to 256 output tokens, by rate

RateConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
0.5vllm72.3 (59.0 to 72.7)1,135 ms148.5 ms571 (486 to 588) ms2,398 MHz
0.5vllm-mnbt-204872.0 (58.8 to 72.6)1,145 ms149.3 ms574 (483 to 594) ms2,398 MHz
0.5vllm-mnbt-1638472.5 (59.3 to 73.1)906 ms139.4 ms798 (685 to 799) ms2,398 MHz
0.5sglang70.9 (58.1 to 71.1)841 ms170.1 ms922 (811 to 925) ms2,411 MHz
1vllm107.8 (96.9 to 112.8)462 ms155.1 ms417 (181 to 432) ms2,411 MHz
1vllm-mnbt-2048107.8 (97.4 to 113.0)454 ms156.5 ms405 (187 to 427) ms2,411 MHz
1vllm-mnbt-16384107.2 (96.6 to 113.2)477 ms156.9 ms395 (201 to 427) ms2,411 MHz
1sglang108.8 (95.6 to 113.7)244 ms161.9 ms219 (179 to 236) ms2,411 MHz
2vllm128.8 (123.2 to 135.7)584 ms204.7 ms590 (474 to 615) ms2,411 MHz
2vllm-mnbt-2048128.1 (124.0 to 135.7)560 ms205.5 ms587 (477 to 609) ms2,411 MHz
2vllm-mnbt-16384127.6 (122.6 to 135.6)595 ms205.1 ms586 (479 to 609) ms2,411 MHz
2sglang133.2 (124.8 to 139.8)238 ms189.8 ms261 (234 to 286) ms2,411 MHz

GB10, waves

RatioConfigOutput tok/s (min to max)Median TTFTMedian TPOTP99 ITL (min to max)Median SM clock
0vllm97.0 (93.3 to 97.8)476 ms154.6 ms183 (181 to 184) ms2,411 MHz
0vllm-async96.7 (92.9 to 97.5)472 ms155.5 ms191 (182 to 195) ms2,411 MHz
0sglang96.6 (93.4 to 97.0)324 ms155.6 ms214 (191 to 225) ms2,411 MHz
0.5vllm99.0 (98.9 to 99.7)454 ms152.7 ms188 (182 to 191) ms2,411 MHz
0.5vllm-async98.9 (98.8 to 100.3)459 ms152.5 ms183 (182 to 193) ms2,411 MHz
0.5sglang99.5 (98.5 to 100.1)315 ms153.4 ms198 (193 to 201) ms2,411 MHz
1vllm97.7 (96.3 to 99.6)1,185 ms157.6 ms394 (389 to 402) ms2,411 MHz
1vllm-async97.7 (96.9 to 99.7)1,212 ms157.9 ms405 (360 to 411) ms2,411 MHz
1sglang101.4 (100.3 to 104.2)1,275 ms149.7 ms159 (159 to 190) ms2,411 MHz

Index of figures

Every figure of this manual, in order, by the one claim it makes.

Each row links to its figure. Measured numbers in a figure come from our runs on the GPUs, and each section links the files it uses. The colours follow the palette of the front matter:

  • blue is the vLLM server: the API server process and the engine core
  • violet is the model and its forward pass: kernels, attention backends, CUDA graphs
  • green is the KV cache: blocks, the block pool, cached prefixes
  • plum is model compute: prefill and decode steps
  • olive is the client and the network: HTTP, the OpenAI API, the benchmark client
  • indigo is streams and messages between processes, and streamed tokens
  • amber is lifecycle: request states and scheduler steps
  • teal is durable state: weights on disk, host memory, offloaded KV
  • rose is failure: out of memory, preemption, aborts, crashes
  • grey is outside vLLM: the operating system, other programs, the GPU driver
Fig.The claim it makes
1.1Six startup lines show the version, the runner, the attention backend, the weight memory, the free KV memory, and the pool size.
1.2A request crosses three processes: the API server tokenizes it, the engine core schedules it, and a GPU worker runs the model.
1.3On our DGX Spark, 64 users got eight times the total output of one user, and each user waited about 7.5 times longer for every token.
2.1Qwen3-8B holds 144 KiB of KV cache for every token, and Qwen3-30B-A3B holds 96 KiB, because it has fewer KV heads.
2.2The same 24,096-token pool fits 2.94 users at the full context length, but 37 users who each hold 640 tokens.
2.3The last line before the start fails tells you which of three settings to change.
2.4With a fraction of 0.75, vLLM takes 91.3 of the 121.7 GiB that the CPU and the GPU share on a DGX Spark.
2.5For Qwen3-8B, one GiB of KV cache holds about 7,300 tokens in BF16 and about 14,600 tokens in FP8.
2.6With the clock locked and the same KV pool, FP8 weights gave 284.7 against 208.8 output tokens per second at 32 users.
3.1A cap of 16 sequences matched the default up to 16 users, and at 32 users it cut output from 275 to 172 tokens per second.
3.2With the prefix cache on, the first token stayed near 400 ms at every shared length on our L4.
3.3A new request reuses cached blocks from the start of its prompt and stops at the first block that differs.
3.4At 0.5 requests per second, a budget of 16,384 tokens raised the P99 ITL from 574 to 798 ms.
4.1With the clock locked, a draft depth of 3 gave the highest output at 32 users, and depths 3 and 5 tied at one user.
4.2On our L4, n-gram speculation cut the median time per token to about a third for one user and to 37 percent for four users.
4.3On our L4, eager mode was 1.2 to 2.3 percent slower per token at every concurrency.
5.1Without a cache reset, output peaked at 275 tokens per second at 32 users, which is the throughput peak and not the latency knee.
5.2On our L4 with Qwen3-8B, vLLM and SGLang wrote the same output within 3 percent up to 32 users.
5.3On our DGX Spark, the 99th percentile token gap of vLLM was more than twice that of SGLang at 32 users.
6.1Three questions pick the layout, and inside one machine the link between GPUs and the split pick tensor or pipeline parallelism.
6.2A prefill server computes the prompt, and a KV connector copies its cache to a decode server that writes the tokens.
7.1Start from the symptom, check one number or log line, and then go to the page that fixes it.
8.1A server with tensor parallel size 4 runs six processes: one API server, one engine core, and four GPU workers.
8.2Each step spends 2,048 tokens: running requests get a decode token first, and a new prompt fills the rest in chunks.
8.3The KV cache is one pool of 16-token blocks that requests take in any order, mapped by a block table for each request.
8.4One target pass keeps the drafts up to the first rejection and adds one token, so one real request got about 1.8 tokens per pass.
About this edition

vLLM 101, edition 2026.10, by Rohit Ghumare. It is part of AI Engineering from Scratch, an open source course at aiengineeringfromscratch.com. The newest edition is always at aiengineeringfromscratch.com/manual-vllm-101.html.

vLLM 0.31.0 is the subject, at commit db9527a46873454610df6dbedf79a36d6bf1a7f6 of 2026-10-05, checked on 2026-10-22. Every listing comes from a recorded run of the capture kit in the manual's source directory, and every rule is written in our own words from a declared source.

© 2026 Rohit Ghumare · MIT license. You can copy and share this manual. Keep this page and the copyright line with it. Report an error at github.com/rohitg00/ai-engineering-from-scratch/issues.