[Performance]: MAX_UTILIZATION causes a 40.6% output-throughput drop and 9.1x TPOT p99 under KV-cache oversubscription (Llama-3.3-70B-FP8, H200, v1.2.1)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 14.7k
- Forks
- 2.8k
- Avg merge
- 2d 23h
- Merged PRs (30d)
- 489
Description
Proposal to improve performance
N/A — this is a measured performance report and a request for guidance on the intended operating regime and observability.
Report of performance regression
Summary
While running a controlled three-engine benchmark study, we swept scheduler_config.capacity_scheduler_policy on TensorRT-LLM 1.2.1 (PyTorch backend, trtllm-serve) and found that switching from the default GUARANTEED_NO_EVICT to MAX_UTILIZATION causes a severe performance drop — but only when the workload's KV working set exceeds the KV-cache pool:
- Under KV pressure (long-context workload, details below): output-token throughput 161.0 → 95.7 tok/s (−40.6%), TPOT p99 122 → 1,111 ms (~9.1×), end-to-end p99 150 → 563 s.
- Off pressure (short-context workload, same server config, not pool-bound): the two policies are equivalent within noise — mean output-token throughput 574.7 vs 576.1 tok/s (+0.24%). Across the two repeats, per-run throughput was 574.4–575.1 tok/s (GUARANTEED_NO_EVICT) and 574.5–577.6 tok/s (MAX_UTILIZATION).
We understand MAX_UTILIZATION is documented as the more aggressive policy that may pause/evict requests, so this may well be a by-design trade-off rather than a bug. We are reporting it because (a) the magnitude is large: under KV pressure, the policy was worse on every listed metric except TTFT p50; off pressure, the two policies were effectively equivalent in the two repeats; and (b) the failure mode was not visible to us through any counter we could find: no warning, just a 9× p99 blowup. Questions for the team are at the end.
Environment
- TensorRT-LLM 1.2.1 (latest stable release at time of writing), NGC container
nvcr.io/nvidia/tensorrt-llm/release:1.2.1, run via Apptainer 1.4.3, PyTorch backend,trtllm-serve. - Container image digest:
sha256:b5f9b403c414d663fbdb7be754b90a063785d28827ca418184fa2497b644ec04(linux/amd64 manifest recorded in the local pull cache at build time); NGC build id 245654590. - Apptainer image (SIF) SHA256:
4a4c6fc242c818b20ea8346c0680440c64efaa6c7d2f7a34034a7464ccd455f0. tensorrt_llmPython package version inside the container: 1.2.1 (pip show tensorrt_llm).- Model:
nvidia/Llama-3.3-70B-Instruct-FP8, Hugging Face revisionfde04ee76a27704c88f569542ef023b57d4d0362(ModelOpt 0.27.1 unified checkpoint,quant_algo: FP8,kv_cache_quant_algo: FP8);model.safetensors.index.jsonSHA2566398071a55c63422e6607f3a2e5b9a28a90f51090aca7a2313386f42a3d957ec. - GPU: 1× H200 (141 GB), single node (HPC cluster, Slurm, cgroup-isolated one-job-per-GPU), TP=1.
- Driver: 580.65.06 (host
nvidia-smireports CUDA 13.0). - OS / Python inside container: Ubuntu 24.04.3 LTS, Python 3.12.3; in-container CUDA toolkit 13.1 (nvcc V13.1.80), cuDNN 9.17.0.29, NCCL 2.28.9, TensorRT 10.14.1.48, PyTorch 2.10.0a0+b4e4ee81d3.nv25.12.
Server launch
trtllm-serve serve $MODEL --backend pytorch --tp_size 1 \
--max_batch_size 64 --max_num_tokens 8192 --max_seq_len 20480 \
--kv_cache_free_gpu_memory_fraction 0.90 \
--config extra.yaml \
--host 127.0.0.1 --port $PORT
extra.yaml — baseline arm:
kv_cache_config:
enable_block_reuse: false # prefix caching off (cross-engine parity in our study)
dtype: fp8
max_tokens: 368640 # pool pinned to 368,640 KV token slots (see note below)
enable_chunked_prefill: true
extra.yaml — MAX_UTILIZATION arm (identical plus the policy):
kv_cache_config:
enable_block_reuse: false
dtype: fp8
max_tokens: 368640
enable_chunked_prefill: true
scheduler_config:
capacity_scheduler_policy: MAX_UTILIZATION
The server-side resolved config confirms the policy took effect: the launch log prints scheduler_config=SchedulerConfig(capacity_scheduler_policy=<CapacitySchedulerPolicy.MAX_UTILIZATION: 'MAX_UTILIZATION'>, ...) for the MAX_UTILIZATION arm and <CapacitySchedulerPolicy.GUARANTEED_NO_EVICT> for the baseline; both arms allocated the identical KV pool (Allocated 56.25 GiB for max tokens in paged KV cache (368640)). Excerpts are included in the repro bundle.
Note on the pinned pool: max_tokens: 368640 pins the KV pool to the same size across the three engines in our study (fraction-derived would have been ~403,680 tokens / 61.6 GiB on this GPU, so the cap binds). The pressure regime below is defined relative to this pool; with a larger pool the crossover point moves but the regime definition is the same.
Workload
- W-C (KV-pressure arm): 128 requests, each exactly 16,384 input tokens and up to 512 output tokens (
ignore_eoson), closed-loop concurrency 32, greedytemperature=0, streaming/v1/completions, two repeats with shuffled submission order (the request set is fixed; the repeat index seeds only the order shuffle). With ignore_eos, output lengths were verified two ways: by re-tokenizing the generated text and by the count of streamed content chunks (our client records both per request; it does not recordfinish_reason, andusageis not present in the streamed responses). GUARANTEED_NO_EVICT — every request in both repeats produced exactly 512 chunks re-tokenizing to exactly 512 tokens (totals 65,536/65,536). MAX_UTILIZATION — 3 of 256 request-runs (all in one repeat) received fewer than 512 content chunks from the server (509, 395, 329), i.e., those streams genuinely ended early server-side; becausefinish_reasonwas not recorded, we cannot determine the server-side stop cause post hoc. Two further request-runs (one per repeat) received the full 512 chunks but re-tokenize to 284 tokens — a decode→re-encode artifact of the re-tokenization, not early termination. Re-tokenized totals: 65,005/65,308 vs the nominal 65,536 (0.8% and 0.3% shortfall; see the counting-sensitivity note under the results table). The nominal full-residency demand of 32 concurrent sequences is 32 × 16,896 = 540,672 KV-token slots, 1.47× the configured pool. The pool therefore cannot keep all 32 sequences resident through their full requested lengths simultaneously. - W-B (control, not pool-bound): 512 requests × 2,048 in / 256 out, same client, concurrency 32; observed output lengths were exactly 256 for every request in all four runs.
- Warmup 32 requests @ concurrency 8 before measurement; fresh server per config; output tokens re-counted with the model tokenizer.
Metrics (definitions)
All metrics are client-side measurements taken by our benchmark client over streaming /v1/completions:
- Throughput = aggregate re-tokenized generated-text throughput: the sum of output tokens across all completed requests in a run divided by the wall-clock duration of the whole run, where each request's output tokens are counted by re-tokenizing its generated text with the model tokenizer. The client does not record server-side token counts (
usage) orfinish_reason; it does record the number of streamed content chunks per request, which we use as a server-side cross-check (see the counting-sensitivity note below). - TTFT (per request) = time from sending the request to client receipt of the first non-empty content chunk.
- TPOT (per request) =
(t_last − t_first) / (n_out − 1), wheret_first/t_lastare client receipt times of the request's first/last content chunks — i.e., the request's mean inter-token time, not a percentile over individual inter-token gaps. - End-to-end latency (per request) = time from send to the last received chunk.
- Reported p50/p99 are percentiles (linear interpolation) of these per-request values — percentiles over the 128 returned request records for W-C (512 for W-B).
Observed vs expected
Mean of the two repeats per cell. For aggregate output throughput, the largest difference between the two repeats of a cell was 0.7% (worst cell across our whole three-engine sweep); the observed 40.6% throughput delta is therefore much larger than the repeat-to-repeat variation seen in this experiment. Tail percentiles vary more between repeats; per-repeat TPOT p99 values are listed under the table.
W-C, concurrency 32 — KV-pressure regime
| policy | output tput (tok/s) | TTFT p50 (ms) | TPOT p50 (ms) | TPOT p99 (ms) | e2e p99 (s) |
|---|---|---|---|---|---|
| GUARANTEED_NO_EVICT (default) | 161.0 | 35,774 | 118.5 | 122.2 | 150.3 |
| MAX_UTILIZATION | 95.7 (−40.6%) | 27,145 | 122.6 | 1,110.7 (9.1×) | 563.1 (3.7×) |
Per-repeat TPOT p99 (W-C): GUARANTEED_NO_EVICT 122.0 / 122.5 ms; MAX_UTILIZATION 1,169.0 / 1,052.4 ms (the table shows the mean of the two repeats). Per-request raw values are included in the repro bundle for independent recomputation.
W-B, concurrency 32 — same server config, not pool-bound
| policy | output tput (tok/s) | TTFT p50 (ms) | TPOT p50 (ms) |
|---|---|---|---|
| GUARANTEED_NO_EVICT | 574.7 | 2,468 | 44.49 |
| MAX_UTILIZATION | 576.1 | 2,496 | 44.38 |
- TTFT p50 actually improves under MAX_UTILIZATION (27.1 s vs 35.8 s) on W-C — consistent with requests being admitted earlier; the aggregate token clock then slows ~40% and the tail explodes.
- Expected (naively, from the name and docs): equal-or-better utilization of the KV pool, at worst a modest tail cost. Observed: under KV pressure, output throughput, TPOT p50/p99, and end-to-end p99 were worse, while TTFT p50 improved. Off pressure, the two policies were effectively equivalent across the two repeats.
- Token-counting sensitivity: the −40.6% delta uses re-tokenized counts (see Metrics). Recomputing throughput from the same wall-clock durations with nominal counts (128 × 512 tokens per run) gives 161.0 → 96.2 tok/s (−40.2%); with server-emitted content-chunk counts, 161.0 → 96.0 tok/s (−40.4%). The conclusion is insensitive to the counting method.
- Our working hypothesis is that under oversubscription the policy admits more prefills than the pool sustains and then repeatedly pauses/evicts running requests, potentially including context recomputation for evicted requests, burning compute on repeated prefill. We have not confirmed the mechanism because we did not have a version-appropriate exposed counter for pause/evict/recompute activity in our setup —
trtllm-serve1.2.1 with the PyTorch backend exposed no/metricsendpoint we could poll, and we did not find an equivalent counter.
Reproducibility notes
- Both arms ran in the same Slurm job on the same GPU/node, fresh server per arm, identical warmup; the baseline arm reproduces the same config measured in a different job on a different node two days earlier within 0.6%.
- All request tasks reached a terminal stream state with no client-side or transport exceptions. However, 3 of 256 MAX_UTILIZATION W-C request-runs returned fewer than the requested 512 content chunks. Because
finish_reasonwas not recorded, we classify these as early-terminated outputs with unknown server-side cause, not as confirmed full-length completions. All 512 W-B requests returned the requested 256 chunks in every run. The server accepted the policy (verified in the server-side resolved-config dump, quoted above). - A self-contained repro bundle (launch script, both YAMLs, workload generator, benchmark client, per-repeat raw results with per-request rows, resolved-config excerpts): https://gist.github.com/Smallfu666/c896b3461e055b3bd59a986cb06d6815 (16 files: launch script, both YAMLs, deterministic workload generator, benchmark client, all 8 per-repeat raw result sets with per-request rows, and both server resolved-config excerpts)
Misc discussion on performance
Questions for the team:
- Is this magnitude of degradation under KV-cache oversubscription known/expected for MAX_UTILIZATION? The docs frame it as maximizing batch utilization with possible eviction; if a ~40% throughput and ~9× TPOT-p99 penalty in oversubscribed regimes is the intended trade-off, a warning in the
scheduler_configdocs would save users from discovering it in production. - Is there a regime where MAX_UTILIZATION is expected to win? In the two concurrency-32 regimes tested it was neutral off-pressure and worse under pressure on the metrics listed; we may simply not have hit the regime the policy is designed for.
- Observability: what is the supported way to observe pause/evict/recompute activity from
trtllm-servewith the PyTorch backend in 1.2.1? Specifically: doiter_stats_max_iterations/request_stats_max_iterationsorreturn_perf_metricsin the LLM-API config apply totrtllm-serveon the PyTorch backend in 1.2.1, and is the Prometheus/metricsendpoint documented for other frontends available in this configuration? In our runs the resolved config showedreturn_perf_metrics=Falseandrequest_stats_max_iterations=None(defaults), and the server exposed no/metricsendpoint.
Related prior report: the closest issue we found is #9931, where enabling MAX_UTILIZATION under KV-cache exhaustion produced a crash on 1.2.0rc builds. That issue is now closed. We did not identify a linked fix commit. Our v1.2.1 runs did not crash; all requests completed, but they showed severe performance degradation in the same broad policy-plus-KV-pressure regime. We do not know whether the mechanisms are related.
Happy to run additional configurations (different pool sizes, concurrencies, or a 1.3.0 RC) if that helps.
Your current environment
System Information:
- OS: Ubuntu 24.04.3 LTS (inside NGC container); host cluster runs Slurm with cgroup GPU isolation
- Python version: 3.12.3
- CUDA version: 13.1 (in-container nvcc V13.1.80; host driver reports CUDA 13.0)
- GPU model(s): 1× NVIDIA H200 (141 GB)
- Driver version: 580.65.06
- TensorRT version: 10.14.1.48
- PyTorch version: 2.10.0a0+b4e4ee81d3.nv25.12
- TensorRT-LLM version: 1.2.1
- Container:
nvcr.io/nvidia/tensorrt-llm/release:1.2.1, image manifest digestsha256:b5f9b403c414d663fbdb7be754b90a063785d28827ca418184fa2497b644ec04(linux/amd64), NGC build id 245654590; run via Apptainer 1.4.3; SIF SHA2564a4c6fc242c818b20ea8346c0680440c64efaa6c7d2f7a34034a7464ccd455f0 - Model checkpoint:
nvidia/Llama-3.3-70B-Instruct-FP8@ revisionfde04ee76a27704c88f569542ef023b57d4d0362;model.safetensors.index.jsonSHA2566398071a55c63422e6607f3a2e5b9a28a90f51090aca7a2313386f42a3d957ec
Detailed output:
$ nvidia-smi
NVIDIA-SMI 580.65.06 Driver Version: 580.65.06 CUDA Version: 13.0
GPU 0: NVIDIA H200 (143771 MiB), Persistence-M: On, ECC: 0 (enabled)
$ nvcc --version # inside container
Cuda compilation tools, release 13.1, V13.1.80
$ python3 --version # inside container
Python 3.12.3
$ pip show tensorrt torch tensorrt_llm # inside container
tensorrt 10.14.1.48
torch 2.10.0a0+b4e4ee81d3.nv25.12
tensorrt_llm 1.2.1
# selected container labels (apptainer inspect, NGC release:1.2.1, build id 245654590):
cuDNN 9.17.0.29, NCCL 2.28.9+cuda13.0, cuBLAS 13.2.0.9, flashinfer-python 0.6.4
base image: nvcr.io/nvidia/tensorrt-llm/release:1.2.1 (Ubuntu 24.04)
- Make sure you already searched for relevant issues, and checked the documentation and examples for answers to frequently asked questions.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked repro bundle's launch script, extra.yaml files, benchmark client, raw results, and resolved-config excerpts; verify the policy and pressure/control comparison first. Investigate the MAX_UTILIZATION behavior in the PyTorch backend and determine whether the eviction or recomputation effects are expected; done means an evidence-based explanation plus version-appropriate guidance or observability documentation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, pytorch
- Domain
- backend, observability, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100