vLLM Explained

Benchmarking vLLM: vllm bench serve, throughput, latency and startup

IntermediatevLLM conceptsabout 13 min read + 3 min deep dives

Code version: vLLM @ 3e44c0d · 2026-10-22

In one sentence

vllm bench serve is an HTTP client. It sends requests at a chosen rate, times the stream chunks, and reports latency percentiles and goodput.

The idea#

To tune a server, you need numbers that you can repeat. vllm bench has several subcommands. Only one of them talks to a running server:

  • vllm bench serve sends HTTP requests to a server that you started with vllm serve. It measures what a client sees. The metrics are time to first token (TTFT), time per output token (TPOT), inter-token latency (ITL), end-to-end latency (E2EL), throughput and goodput (Serving metrics: TTFT, TPOT, ITL, throughput, goodput).
  • vllm bench throughput, latency and startup start their own engine in the same command, with the offline LLM class. They measure the engine without HTTP.

The client controls the load: how many requests, how long each prompt is, how fast they arrive, and how many run at once. The server's own metrics measure the same requests from the inside (Metrics and observability: how to watch a vLLM server). The two views differ by design, and this page shows where.

An everyday comparison. Think of a test shopper. The shopper starts a stopwatch at the shop door and stops it when the bag is in the hand. The shop's own records start when the order reaches the counter. Both are correct; they time different things. The comparison stops at the crowd. The test shopper of vLLM can be a thousand shoppers at once, with a random rhythm of arrivals that you choose.

The picture#

Figure: the client clock starts before the server clocks and stops after them (1); the same metric names mean three different intervals (2).
Open full size ↗

Read panel 1 from left to right. The client clock starts when the POST leaves the client, before the server parses HTTP. The server clocks start only when rendering starts. Panel 2 is the summary to keep: compare a client number only with a client number.

In the code#

Step 1: the command and the request function#

vllm bench serve is a thin subcommand: it adds the arguments of vllm/benchmarks/serve.py and calls its main (vllm/entrypoints/cli/benchmark/serve.py:10-21). The default backend is openai and the default endpoint is /v1/completions (vllm/benchmarks/serve.py:1647-1668). The backend picks a request function; openai-chat sends chat requests, and there are functions for embeddings and rerank servers (vllm/benchmarks/lib/endpoint_request_func.py:1155-1172). Without --model, the client asks the server's /v1/models for the first model (vllm/benchmarks/serve.py:2126-2131, vllm/benchmarks/serve.py:170-176).

Each request is a stream with usage in the last chunk, so the client gets the server's token count (vllm/benchmarks/lib/endpoint_request_func.py:226-238). The client takes its time points here:

                            if choices := data.get("choices"):
                                # Note that text could be empty here
                                # e.g. for special tokens
                                text = choices[0].get("text")
                                timestamp = time.perf_counter()
                                # First token
                                if not first_chunk_received:
                                    first_chunk_received = True
                                    output.ttft = timestamp - st

                                # Decoding phase
                                else:
                                    output.itl.append(timestamp - most_recent_timestamp)

                                most_recent_timestamp = timestamp
                                generated_text += text or ""
                            elif usage := data.get("usage"):
                                output.output_tokens = usage.get("completion_tokens")
                                if (pt := usage.get("prompt_tokens")) is not None:
                                    output.prompt_len = pt
  1. The clock starts with time.perf_counter() just before the POST (vllm/benchmarks/lib/endpoint_request_func.py:248-252).
  2. Line 286: TTFT is the time of the first SSE chunk that has choices, even when its text is empty.
  3. Line 290: each later chunk with choices adds one ITL value. One chunk can carry several tokens (for example with --stream-interval), so the client ITL is per chunk, not per token.
  4. Line 295: the usage chunk gives completion_tokens; it does not move the end time.
  5. E2EL (latency) is the time of the last chunk with choices (vllm/benchmarks/lib/endpoint_request_func.py:307). A request with no such chunk is a failure (vllm/benchmarks/lib/endpoint_request_func.py:298-305).

Step 2: datasets#

--dataset-name picks where the prompts come from; the default is random (vllm/benchmarks/datasets/datasets.py:1593-1612):

NameWhat it gives
randomsynthetic prompts of --random-input-len tokens (default 1024) and --random-output-len output tokens (default 128); --random-range-ratio spreads the lengths; --random-prefix-len adds a shared prefix (vllm/benchmarks/datasets/datasets.py:1887-1921)
random-mm, random-rerankrandom multimodal prompts; random rerank pairs
sharegpt, burstgptreal conversations or a real request trace, from --dataset-path
hfHugging Face datasets with their own loader classes (for example MT-Bench, GSM8K, VisionArena, InstructCoder), all based on HuggingFaceDataset (vllm/benchmarks/datasets/datasets.py:3094-3128)
custom, custom_audio, custom_imageyour own JSONL file
prefix_repetitionprompts with repeated prefixes, to test prefix caching
spec_bench, speed_benchspeculative decoding workloads
timed_tracea trace with its own timestamps; --self-timed then defaults to on, and --ignore-eos is set (vllm/benchmarks/serve.py:2196-2201)

The random dataset makes token ids from a seeded generator, decodes them and encodes them again, so the prompt has the asked length (vllm/benchmarks/datasets/datasets.py:556-579). --seed (default 0) seeds it and the arrival times (vllm/benchmarks/datasets/datasets.py:1585, vllm/benchmarks/serve.py:2060-2061). --num-prompts defaults to 1,000 (vllm/benchmarks/datasets/datasets.py:70).

Step 3: request rate, burstiness and max concurrency#

--request-rate is in requests per second; the default inf sends all requests at time 0 (vllm/benchmarks/serve.py:1745-1753). For a finite rate, the client draws the gap before each request from a gamma distribution:

            if current_request_rate == float("inf"):
                delay_ts.append(0)
            elif burstiness == float("inf"):
                # when burstiness tends to infinity, the delay time becomes constant
                # and tends to the inverse of the request rate
                delay_ts.append(1.0 / current_request_rate)
            else:
                theta = 1.0 / (current_request_rate * burstiness)

                # Sample the request interval from the gamma distribution.
                # If burstiness is 1, it follows exponential distribution.
                delay_ts.append(np.random.gamma(shape=burstiness, scale=theta))

        # Calculate the cumulative delay time from the first sent out requests.
        for i in range(1, len(delay_ts)):
            delay_ts[i] += delay_ts[i - 1]
        if ramp_up_strategy is None and delay_ts[-1] != 0:
            # When ramp_up_strategy is not set, we assume the request rate is fixed
            # and all requests should be sent in target_total_delay_s, the following
            # logic would re-scale delay time to ensure the final delay_ts
            # align with target_total_delay_s.
            #
            # NOTE: If we simply accumulate the random delta values
            # from the gamma distribution, their sum would have 1-2% gap
            # from target_total_delay_s. The purpose of the following logic is to
            # close the gap for stabilizing the throughput data
            # from different random seeds.
            target_total_delay_s = total_requests / request_rate
            normalize_factor = target_total_delay_s / delay_ts[-1]
            delay_ts = [delay * normalize_factor for delay in delay_ts]
  • --burstiness is the shape of the gamma distribution, default 1.0 (vllm/benchmarks/serve.py:1754-1765). The scale is 1 / (rate × burstiness), so the mean gap is always 1 / rate. Burstiness 1 gives exponential gaps (a Poisson process). Below 1, gaps vary more (bursts); above 1, they are more even.
  • Lines 513-526: without a ramp-up, the client then scales all gaps so that the last request starts exactly at num_prompts / rate. The comment says that this keeps the throughput stable across seeds.
  • --max-concurrency N wraps each request in an asyncio.Semaphore(N) (vllm/benchmarks/serve.py:1001-1013). Requests that wait for the semaphore start their TTFT clock only when they get it; the wait is kept as client_queue_time (vllm/benchmarks/serve.py:1014-1016). With a rate and a concurrency limit, the real rate can be lower than the asked rate (vllm/benchmarks/serve.py:1679-1690).
  • --ramp-up-strategy linear|exponential with start and end rates changes the rate during the run (vllm/benchmarks/serve.py:1965-1987).

Step 4: warm-up and the ready check#

Two start steps are off by default. --ready-check-timeout-sec defaults to 0, so the client skips the initial test request and prints "Skipping endpoint ready check." (vllm/benchmarks/serve.py:1989-1994, vllm/benchmarks/serve.py:908-925). --num-warmups defaults to 0 (vllm/benchmarks/serve.py:1780-1785). A first request after a server start can pay one-time costs, so a run without warm-up includes them in its first results.

Step 5: how the metrics are computed#

After all requests finish, calculate_metrics builds the result. TPOT is computed per request:

            actual_output_lens.append(output_len)
            total_input += outputs[i].prompt_len
            tpot = 0.0
            if output_len > 1:
                latency_minus_ttft = outputs[i].latency - outputs[i].ttft
                tpot = latency_minus_ttft / (output_len - 1)
                tpots.append(tpot)
            # Note: if output_len <= 1, we regard tpot as 0 for goodput
            all_tpots.append(tpot)
            itls += outputs[i].itl
            ttfts.append(outputs[i].ttft)

Step 6: client numbers vs server numbers#

Run vllm bench serve against a server and watch its /metrics at the same time: the numbers differ. These are the reasons, each from the code:

Metricvllm bench serve (client)Prometheus (server)
TTFTPOST sent → first chunk received; includes the network, HTTP parsing and SSE outputarrival_time (render start) → first output handled in the API server (vllm/renderers/base.py:1380, vllm/v1/metrics/stats.py:525)
ITLgap between SSE chunksgap between engine-core output timestamps (vllm/v1/metrics/stats.py:555)
TPOT(E2EL − TTFT) / (n − 1), only n > 1decode time / (n − 1), and 0 for n = 1, which enters the histogram (vllm/v1/metrics/stats.py:612-616)
Aborted requestscounted as failedno finished-request stats after a client disconnect (vllm/v1/engine/async_llm.py:807-809)
Clocktime.perf_counter()time.time() (front end) and time.monotonic() (engine core)

With --enable-per-request-metrics on the server, the client also stores the server's queue_time_ms and time_to_first_token_ms per request in its result data (vllm/benchmarks/lib/endpoint_request_func.py:110-125, vllm/benchmarks/serve.py:391-403). That server TTFT is the prefill interval only (Metrics and observability: how to watch a vLLM server). With speculative decoding, the client reads the server's vllm:spec_decode_* counters before and after the run and reports the acceptance (vllm/benchmarks/serve.py:996, vllm/benchmarks/serve.py:210-260).

Step 7: vllm bench throughput#

vllm bench throughput measures offline throughput: it builds an LLM from the engine flags and sends all prompts in one generate() call (vllm/benchmarks/throughput.py:37-48, vllm/benchmarks/throughput.py:129-151). It prints requests/s, total tokens/s and output tokens/s (vllm/benchmarks/throughput.py:1108-1114). Without --dataset-path, it uses the random dataset and needs --input-len or --random-input-len (vllm/benchmarks/throughput.py:642-650). --async-engine uses an in-process AsyncLLM instead (vllm/benchmarks/throughput.py:863-867, vllm/benchmarks/throughput.py:308-314). There is no HTTP, no SLO and no arrival process: all requests wait at time 0.

Step 8: vllm bench latency#

vllm bench latency measures the time of one batch. The batch has --batch-size (default 8) random prompts of --input-len (32) tokens. Each prompt gets exactly --output-len (128) tokens, because the command sets ignore_eos (vllm/benchmarks/latency.py:35-37, vllm/benchmarks/latency.py:96-103). It runs 10 warm-up iterations and 30 measured ones, and prints the mean and the 10th to 99th percentiles of the batch time (vllm/benchmarks/latency.py:45-53, vllm/benchmarks/latency.py:158-168).

Step 9: vllm bench startup#

vllm bench startup measures how long LLM(...) takes to start, cold and warm (vllm/benchmarks/startup.py:3-9). A cold run uses a new temporary VLLM_CACHE_ROOT and a fresh Inductor cache; a warm run reuses the caches (vllm/benchmarks/startup.py:109-128). Each run is a new subprocess (vllm/benchmarks/startup.py:133-147). The defaults are 3 cold runs, 1 warm-up and 3 warm runs (vllm/benchmarks/startup.py:193-210). It reports the start-up time and the compilation time of each run.

Deep dive#

The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.

Deep dive

Worked example: reading one result block (illustrative numbers)

Command (an example, not from the repository):

vllm bench serve --model meta-llama/Llama-3.1-8B-Instruct --dataset-name random \
  --random-input-len 1024 --random-output-len 128 --ignore-eos --num-prompts 1000 \
  --request-rate 20 --num-warmups 20 --metric-percentiles 50,99 --goodput ttft:500 tpot:50

Output (the format is from vllm/benchmarks/serve.py:1220-1280 and vllm/benchmarks/serve.py:1423-1440; all numbers are made up to show how to read them):

============ Serving Benchmark Result ============
Successful requests:                     1000
Failed requests:                         0
Request rate configured (RPS):           20.00
Benchmark duration (s):                  52.10
Total input tokens:                      1024000
Total generated tokens:                  128000
Request throughput (req/s):              19.19
Request goodput (req/s):                 17.60
Output token throughput (tok/s):         2456.81
Peak output token throughput (tok/s):    3120.00
Peak concurrent requests:                61.00
Total token throughput (tok/s):          22111.32
---------------Time to First Token----------------
Mean TTFT (ms):                          182.40
Median TTFT (ms):                        140.10
P50 TTFT (ms):                           140.10
P99 TTFT (ms):                           690.50
-----Time per Output Token (excl. 1st token)------
Mean TPOT (ms):                          24.30
Median TPOT (ms):                        23.90
P50 TPOT (ms):                           23.90
P99 TPOT (ms):                           38.20
---------------Inter-token Latency----------------
Mean ITL (ms):                           24.20
Median ITL (ms):                         19.80
P50 ITL (ms):                            19.80
P99 ITL (ms):                            95.70
==================================================
  1. Duration and throughput. 1,000 requests at 20 RPS start over 50 s (Step 3); the last ones finish at 52.1 s. Request throughput = 1000 / 52.10 = 19.19. It is below 20 because the duration includes the tail after the last send.
  2. Total token throughput = (1,024,000 + 128,000) / 52.10 = 22,111 (vllm/benchmarks/serve.py:781). Prompt tokens dominate it, so it says little about decode speed.
  3. Goodput 17.60 req/s = 917 good requests / 52.10 s. So 8.3 % of the requests missed at least one SLO. The mean TTFT (182 ms) and the mean TPOT (24 ms) are still far below the SLOs. The P99 TTFT (690 ms) shows the misses.
  4. ITL vs TPOT. The ITL median (19.8 ms) is below the TPOT median (23.9 ms), and the ITL P99 is high (95.7 ms). Some gaps contain a step with a large prefill chunk; TPOT spreads such spikes over the whole request.
  5. Peak values come from one-second buckets of the client's token times (vllm/benchmarks/serve.py:715-760). "Peak concurrent requests: 61" is the largest number of requests that were active in one second, as the client saw them. It is an upper bound for the requests in flight at one moment.
Deep dive

A benchmarking method (advice)

This method is advice; each step names the code that makes it work.

  1. Fix the inputs. Use one --seed, one dataset and fixed lengths, with --ignore-eos for synthetic data (Step 2).
  2. Warm up. Set --num-warmups (default 0) and --ready-check-timeout-sec (default 0) (Step 4). Better: run one short benchmark after each server start and throw it away.
  3. Sweep the request rate, not only inf. With inf, all requests arrive at once, and TTFT mostly measures the queue. Sweep for example 2, 4, 8, 16 RPS until goodput stops rising. vllm bench sweep automates grids.
  4. Compare at equal goodput. For two configurations, compare the highest request rate at which goodput stays at, say, 95 % of throughput, with the same SLOs.
  5. Watch the server at the same time. vllm:num_requests_waiting, vllm:kv_cache_usage_perc and preemptions explain why a rate fails (Metrics and observability: how to watch a vLLM server).
  6. Run the client on another machine or on free CPU cores. The client is one Python process; a busy client delays its own timestamps.
Deep dive

Burstiness, in numbers

For --request-rate 10, the mean gap is 0.1 s at every burstiness b. The gamma mean is shape × scale = b × 1/(10 b) = 0.1 s (vllm/benchmarks/serve.py:504-508). The spread changes: the coefficient of variation of a gamma distribution is 1/√b (general statistics). So b = 1 gives a standard deviation of 0.1 s, and b = 4 gives 0.05 s. b = 0.25 gives 0.2 s: arrivals come in clusters with long pauses. --burstiness inf gives a constant gap of 0.1 s (vllm/benchmarks/serve.py:499-502). A lower burstiness at the same rate gives higher TTFT percentiles, because requests come in groups.

Deep dive

Flags of vllm bench serve

FlagDefaultWhere
--backend, --endpointopenai, /v1/completionsvllm/benchmarks/serve.py:1647-1668
--dataset-name, --num-prompts, --seedrandom, 1000, 0vllm/benchmarks/datasets/datasets.py:1585-1612
--random-input-len, --random-output-len1024, 128vllm/benchmarks/datasets/datasets.py:1887-1898
--request-rate, --burstinessinf, 1.0vllm/benchmarks/serve.py:1745-1765
--max-concurrencynonevllm/benchmarks/serve.py:1678-1690
--num-warmups, --ready-check-timeout-sec0, 0vllm/benchmarks/serve.py:1780-1785, vllm/benchmarks/serve.py:1988-1994
--percentile-metrics, --metric-percentilesttft,tpot,itl, 99vllm/benchmarks/serve.py:1847-1866
--goodputnonevllm/benchmarks/serve.py:1867-1878
--ignore-eosoffvllm/benchmarks/serve.py:1831-1836
--profileoff; the server needs --profiler-configvllm/benchmarks/serve.py:1786-1790
--save-result, --save-detailedoffvllm/benchmarks/serve.py:1791-1801

The HTTP session has a total time limit of 6 hours per request (vllm/benchmarks/serve.py:867-871).

What can go wrong#

  • Huge TTFT at --request-rate inf. All requests arrive at time 0, so most of them wait in the queue (Step 3). Use a finite rate or --max-concurrency.
  • Output tokens below the asked length. The model stopped at an end token; use --ignore-eos (Step 2).
  • The first run is slower than the next ones. No warm-up by default (Step 4).
  • Client ITL is much larger than Prometheus ITL. The server sends several tokens per chunk (--stream-interval), or the client process is busy (Step 1, Step 6).
  • All requests fail with "Never received a valid chunk". The endpoint does not stream choices, for example the wrong --backend for --endpoint (vllm/benchmarks/lib/endpoint_request_func.py:298-305).
  • vllm bench throughput is faster than the server. It has no HTTP, no arrival process, and a larger default token budget (note in Step 9).

Why it matters. A benchmark number is only useful when you know its start point, its clock and its load. With that, you can compare two configurations fairly and explain the gap between the client view and the server view.

Check yourself#

  1. 1
    Which vllm bench subcommand needs a running vllm serve?
    easy
    Show answer

    Only serve: it sends HTTP requests to --host/--port or --base-url (vllm/benchmarks/serve.py:1654-1662). throughput, latency and startup build an LLM themselves (vllm/benchmarks/throughput.py:48, vllm/benchmarks/latency.py:88).

  2. 2
    A request produced exactly 1 output token. How does it count in the TPOT statistics of vllm bench serve, and in the goodput check?
    easy
    Show answer

    It is not in the TPOT statistics, because TPOT needs more than one token (vllm/benchmarks/serve.py:655-658). In the goodput check, its TPOT is 0 (vllm/benchmarks/serve.py:660), so it can only miss the TTFT or E2EL SLO.

  3. 3
    With --request-rate 10 --burstiness 0.5, what is the mean gap between requests, and how does the arrival pattern differ from the default?
    medium
    Show answer

    The mean gap is still 0.1 s: shape 0.5 × scale 1/(10 × 0.5) (vllm/benchmarks/serve.py:504-508). The gaps vary more than with the default 1.0, so requests come in bursts. The client also scales the gaps so that the last request starts at 100 s for 1,000 prompts (vllm/benchmarks/serve.py:513-526).

  4. 4
    With --max-concurrency 8, a request waits 2 s for the semaphore. Is the wait in its TTFT?
    medium
    Show answer

    No. The request function starts the clock only when it runs, after the semaphore (vllm/benchmarks/serve.py:1010-1013, vllm/benchmarks/lib/endpoint_request_func.py:248). The wait is kept as client_queue_time (vllm/benchmarks/serve.py:1016), which is reported only if you select it in --percentile-metrics.

  5. 5
    The client reports a P50 TTFT of 140 ms, the server's Prometheus P50 TTFT is 110 ms, and the per-request time_to_first_token_ms median is 60 ms. Explain all three.
    hard
    Show answer

    The client clock runs from the POST to the first chunk, with the network, HTTP parsing and SSE output (vllm/benchmarks/lib/endpoint_request_func.py:248, vllm/benchmarks/lib/endpoint_request_func.py:286). Prometheus runs from arrival_time at render start to the handled first output (vllm/renderers/base.py:1380, vllm/v1/metrics/stats.py:525): rendering, tokenization, the queue and prefill. The per-request value is only SCHEDULED to first token, the prefill on the engine clock (vllm/entrypoints/generate/base/serving.py:73-74). So 140 − 110 ms is outside the server, and 110 − 60 ms is front-end work plus the queue.