profile

Get started

Diagnostic loop for vLLM on one GPU. Deterministic engineering: same server, same traffic, same verdict. Close the gap in a few measured iterations instead of guessing for days.

In one sitting

  • Install the binary
  • Run profile diagnose under live traffic
  • Apply the Fix, press Enter, read the delta
  • Repeat until the loop names a wall or goes quiet

Profile is not a dashboard and not an autotuner. It computes the hardware ceiling for your model and GPU, measures live throughput against it, names the one cause holding the server back, and gives you the vLLM flags to change. You apply the fix; Profile never restarts your server. No calibration run.

Start here, then Rules for thresholds. The rest is reference.

Prerequisites

  • One GPU: NVIDIA (NVML) or AMD (amdgpu). Profile probes NVIDIA first and falls back to AMD. --tensor-parallel-size greater than 1 is refused today.
  • vLLM with /metrics reachable (default http://localhost:8000/metrics).
  • Live traffic during the window. An idle server has no waste to find; Profile says so. Drive load with vllm bench serve if you need traffic.

Install and diagnose

# Install
curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/jungledesh/profile/releases/latest/download/profile-installer.sh | sh

# Diagnose (default window is 30s; raise when traffic cycles inside the window)
profile diagnose --url http://localhost:8000/metrics --duration 30s

Or build from source: cargo install --git https://github.com/jungledesh/profile. No curl-pipe: download the binary from the releases page.

Duration and traffic shape

Match --duration to the cycle. Default for steady load. Raise it when traffic repeats inside the window. Do not raise it when load changes between iterations.

ShapeWhat it looks like--duration
SteadyLoad holds stillDefault 30s is enough
Fast bounceWobble shorter than a slice (2s at 30s, 10s above)Already averaged. Leave it
Cycle near the windowAgents start, time out, and restart togetherRaise so several cycles fit, up to 30m. A ~10 min cycle needs about 30m
Step between iterationsFlat during a run, different on the nextNot a duration problem

Optimization Workflow

When sampling ends, Profile prints a summary block. Look at ISSUES. The Fix: tells you what to change in your vLLM startup command.

Read

Profile prints a performance snapshot followed by an ISSUES block. Here is an example. Do exactly what the Fix: section recommends.

+----------------------------------------------------------------------------------------------------------------------+
|PROFILE v2.2.1 [muse-glimmer-30b] [NVIDIA GeForce RTX 5090] (5m from 2026-08-14 10:30:26 UTC)                         |
|                                                                                                                      |
|GPU =>               decode_eff ~3.5% | power 531W | 4.05 J/tok | $2.10/1M output tok (est) | vRAM 29/32GB            |
|                     mem_util 37%                                                                                     |
|                                                                                                                      |
|vLLM =>                                                                                                               |
|REQUESTS             run 9 (27.4%) | wait 15 | max 32                                                                 |
|LATENCY              ttft 32.8s (p95 66.9s) | tpot 58ms (p95 89ms)                                                    |
|CACHE                kv_cache 88.6% avg (99.9% peak) | pfix_cache 22.7%                                               |
|THROUGHPUT           131 tok/s                                                                                        |
|TRAFFIC              qps 0.5 | req_total 642 | gen_total 175857 | preempt/s 0.16 | preempt_total 59                   |
|                                                                                                                      |
|ISSUES:                                                                                                               |
|                                                                                                                      |
|[!] KV Cache Pressure                                                                                                 |
|    Seen in 100% of windows                                                                                           |
|    Cause:                                                                                                            |
|      KV cache 89% avg in fired windows, 100% peak (threshold: 88%).                                                  |
|      Scheduler evicting; 15 requests queued on KV admission.                                                        |
|                                                                                                                      |
|    Fix:                                                                                                              |
|      • Raise --gpu-memory-utilization (check vRAM header for avail mem) to expand KV pool.                           |
|      • Switch --kv-cache-dtype fp8 to halve KV memory footprint (affects output quality).                            |
|      • Lower --max-num-seqs to reduce KV demand.                                                                     |
|        Cuts throughput. Revert after pressure clears.                                                                |
|      • Reduce client concurrency toward sustained running (9 in-flight).                                            |
|        Cuts queue wait, not throughput. Demand exceeds admitted capacity.                                            |
|      • Lower --max-model-len 32768 → 21933. Observed p99 21.9k tokens per request.                                   |
|        ~1% of observed requests ran longer; those are rejected with a 400, not truncated.                            |
|                                                                                                                      |
|    Expected: TTFT and TPOT recover once evictions stop.                                                              |
|    Confidence: High                                                                                                  |
+----------------------------------------------------------------------------------------------------------------------+

Every value is measured or marked. A dash (-) means the metric was not readable. (est) means the number came from the physics model. A tilde (~) marks a value derived from an estimated ceiling. Gaps are never filled with guesses.

Note: [!] KV Cache Pressure corresponds directly to rule R2 in the Flag Recommendations Map below.

Restart & Measure

Apply primary and secondary fixes together in one restart. One flag per restart wastes time. Restart vLLM. Profile resumes on vLLM re-start and measures the new baseline. Repeat this process until the bottleneck clears or you reach hardware saturation.

Apply the fix above. Profile re-measures after your change.

Press Enter when done.

Connection restored. Resuming in 5s...

New --max-num-seqs [current: 32]: 12

Measuring delta...

  Config changed. Baseline reset.

  Throughput          131 → 421 tok/s
  TTFT                32857 → 224ms (p95 66946 → 500ms)
  TPOT                58.0ms → 23.0ms (p95 58.0 → 40.0ms)

ECONOMICS:
  J/tok               4.05 → 1.08
  Cost/1M output tok  $2.10 → $0.65 (est)

Regressions are labelled, not buried:

  Throughput          183 → 131 tok/s  worse
  TTFT                430 → 32797ms (p95 2108 → 66870ms)  worse
  TPOT                28.1 → 58.5ms (p95 49.5 → 89.4ms)  worse
  Decode eff.         -1.4pp

ECONOMICS:
  J/tok               2.35 → 4.05  worse
  Cost/1M output tok  $1.50 → $2.10 (est)  worse

Iterate

Notice the throughput dropped? Profile reports regressions honestly. Fixing one bottleneck often exposes the next. Keep iterating; results improve over multiple restarts.

Loop behavior

  • Same-primary reveal. When the same primary fires again, Profile lists the rules it suppressed under that block. No flat-delta gate: re-fire alone is enough, whether or not you applied a fix in between.
  • Prefill unread re-fire exit. When Prefill is primary twice in a row and both Fix blocks include the unread --max-num-batched-tokens guide (common when vLLM never emits the gauge), the second table still prints, then the loop exits: no new server lever to apply. First unread show stays open (Confirm + guide). Bound Severe/compute-wall terminals are unchanged.
  • Empty-fix reveal. When the primary has no lever left (fix list empty or exhausted), Profile shows the suppressed alternatives under that same block immediately, without waiting for a second fire.
  • Dead-end. When no config lever remains, Profile names the wall (replica, or the physics boundary), points at scale-out, and surfaces what it was holding.
  • Oscillation escape. When R2 (KV pressure) and R5 (concurrency saturation) alternate on --max-num-seqs, Profile names the bracket tried and suggests the midpoint. It offers this at most three times, then names the wall.
  • Config-skip. Where Profile can read your running config, it skips levers you already set: prefix caching on, fp8 KV already active, chunked prefill on, --max-num-batched-tokens already at or above the suggestion, seats already at the target. Exception: --kv-offloading-size is re-derived on each R2 fire; if the new size differs from what is set, the flag is offered again with the new number (host-RAM subline included). If derived equals set, it stays quiet.

Quiet end

When no rule fires, Profile names the cap and stops inventing flags. RTX 5090 Muse run, after the KV fix:

+-----------------------------------------------------------------------------------------------------------+
|PROFILE v2.2.1 [muse-glimmer-30b] [NVIDIA GeForce RTX 5090] (5m from 2026-08-14 10:45:47 UTC)              |
|                                                                                                           |
|GPU =>               decode_eff ~11.2% | power 455W | 1.08 J/tok | $0.65/1M output tok (est) | vRAM 30/32GB|
|                     mem_util 69%                                                                          |
|                                                                                                           |
|vLLM =>                                                                                                    |
|REQUESTS             run 10 (84.1%) | wait 0 | max 12                                                      |
|LATENCY              ttft 224ms (p95 500ms) | tpot 23ms (p95 40ms)                                         |
|CACHE                kv_cache 22.5% avg | pfix_cache 95.0%                                                 |
|THROUGHPUT           421 tok/s                                                                             |
|TRAFFIC              qps 1.3 | req_total 660 | gen_total 199567 | preempt/s 0.00 | preempt_total 0         |
|                                                                                                           |
|ISSUES:                                                                                                    |
|                                                                                                           |
|No issues detected.                                                                                        |
|Capped by vLLM overhead: batch healthy, memory free. GPU waits on CPU work between steps.                  |
+-----------------------------------------------------------------------------------------------------------+

vLLM Flag Recommendations Map

Profile detects these bottlenecks and recommends the following vLLM flag changes:

DiagnosisWhen it firesvLLM flags to change
R1 Under-batchingKnown GPU: config-relative efficiency <60% with occupancy <75%, no backlog. Occupancy denom is min(max_num_seqs, ridge, observed kv_cache_max_concurrency). Unknown GPU: occupancy <25%. Soft field: ranks above Prefill/Prefix on first fire.Batch more requests or raise client concurrency
R2 KV cache pressureKV ≥88% (avg or peak) AND (eviction: preempt/s >0.02 or swapped ≥2, OR waiting >2)Split Cuts/Safe. Cuts: lower --max-model-len or --max-num-seqs. Safe: --enable-prefix-caching, raise --gpu-memory-utilization, fp8 KV, --kv-offloading-size
R2b KV admission backlogKV near full (≥88% avg or peak), queue ratio ≥30%, scheduler not at seat cap, free KV below demand. May fire on the same windows as R2; when both are significant, R2 wins at report construction.Expand KV pool: raise --gpu-memory-utilization, fp8 KV, or reduce --max-model-len
R3 Low prefix reuseActive traffic (running >0.75), mean prompt ≥20 tok, QPS × mean prompt ≥1000. Caching off: fires on volume alone. Caching on: hit rate <35%.--enable-prefix-caching; restructure prompts if already on
R4 OOM riskWeights overflow VRAM, or free VRAM cannot hold one worst-case request. Not fired when dtype is fallback (bf16 assumed).--tensor-parallel-size at minimum needed, raise --gpu-memory-utilization, or use a smaller model
R5 Concurrency saturationCollector seat_wall_cooccurred (a scrape with running in the churn band at --max-num-seqs and waiting ≥2; slack = clamp(completions/s × 250ms, 1, max(0.01 × max, 1))), plus window waiting ≥2 and queue ratio ≥30%. No peak/3% fallback: flag unread or false → silent.Below 80% KV: raise --max-num-seqs to bounded target. At/above 80% KV: name the wall or add a replica.
R6 Prefill-boundEffective prompt/gen ratio ≥5 (prefix hits removed from prompt tok/s), decode efficiency <40%. Muted when TPOT is measured and under 4× its floor. Bound path silences R1; soft field does not.Prefix caching first when confirmed off. Then chunked prefill; --max-num-batched-tokens (no blind Set when unread); at compute wall, disaggregate or add a replica
R7 Config headroom--max-num-seqs below 90% of recommended target, occupancy ≥50%, ≤1 waitingRaise --max-num-seqs; output names the binding wall
Note: Profile may list several fixes in one Fix: block. Apply them together when relevant. See Rules for thresholds and edge cases.

Profile CLI Configuration

These are flags for the profile CLI itself, not vLLM. Each also has an env var (PROFILE_URL, PROFILE_DURATION, PROFILE_MAX_NUM_SEQS, PROFILE_TENSOR_PARALLEL_SIZE, PROFILE_COST_PER_HOUR, PROFILE_VERBOSE).

FlagDefaultDescription
-u, --urlhttp://localhost:8000/metricsvLLM metrics endpoint
--duration30sCollection window per iteration. Minimum 30s, maximum 30m. Units s or m only, not ms/mins. Match to traffic shape (above).
-m, --max-num-seqsPrompted if unreadSkip the prompt. Preflight reads /metrics when the gauge is present; otherwise you are asked.
--tensor-parallel-sizeUnsetMust be 1. Values above 1 are refused. Pass 1 to skip the GPU-assignment prompt. Profile still reports when a model needs TP to fit at all.
--cost-per-hourCatalog estimateGPU cost in USD/hr. Must be a positive number.
-v, --verboseOffRules that did not fire, physics limits, and extra GPU, latency, cache, and config detail

profile help, profile completions <SHELL>, and profile man exist. They are not diagnose flags.

Proof

RTX 5090 · Muse Glimmer 30B (NVFP4): 81 → 421 tok/s (5.2x). Cost $3.41 → $0.65 / 1M tok (81% lower). SWE-Bench agents. Flags: --max-model-len 25000, --max-num-seqs 12, --gpu-memory-utilization 0.98, --kv-cache-dtype fp8. Ended quiet, capped by vLLM overhead. Every iteration · Video

H100 80GB HBM3 · Qwen3.8-27B: 257 → 490 tok/s (1.9x). Cost $3.23 → $1.69 / 1M tok (48% lower). TTFT 1.9s → 539ms. Path 257, 278, 490. Raising agents without a KV fix sent TTFT to 172s; Profile labelled it worse, then prefix + fp8 KV + seats 22 recovered it. Ended capped by traffic. Every iteration · Video

The flood is in the record on purpose. A server already near its ceiling has nothing to recover, and Profile says so instead of inventing a flag.

Why Profile?

For everyone running vLLM on one GPU. The value is time: a few measured iterations instead of days of guessing. Profile plugs into the serving stack; it replaces nothing. Not yet: multi-GPU sharding, and engines beyond vLLM.

ProfileDashboardsKernel profilersAutotunersSimulators
Hardware ceiling from physicsyesnononopredicted
Live server, production trafficyesyesyesnono
Names one root causeyesnononono
Prescribes the changeyesnonoconfig onlyconfig only
Measures the delta after the fixyesnonopartialno
Cost per million tokensyesnononono
No auto-restart, no synthetic loadyesyesyesnon/a

Rules

Eight rules (R1–R7, R2b sibling). Each fires when a specific bottleneck is confirmed under load.

New here? Start at the Get started page.
Rules evaluate on structurally valid windows (window_is_evaluable). A recommendation surfaces only when the signal is persistent: at least 3 windows and at least 25% of all evaluable windows in the run.
Display metrics (like requests_running and requests_waiting) are synced to the final session snapshot rather than locally aggregated window approximations, to prevent visual contradictions between the summary header and the recommendation blocks.

R1: Under-batching

ConditionThreshold
Known GPU: config-relative efficiency<60% of config capacity (decode ceiling × min(max_num_seqs, ridge))
Known GPU: occupancy<75% of effective max
No backlogwaiting < 2
Unknown GPU fallback: occupancy<25% of effective max (same binder as known-GPU: min of config, ridge, observed KV)

R1 still evaluates when R6 (Prefill-bound) fires on the same window. On a pressed box (bound path), R6 suppresses R1 via the mutual exclusivity table so under-batching is held for reveal, not silently deferred. Under soft field (waiting < 2, KV mean < 80%, running well below the decode ridge Traffic floor), that ME row is skipped: R1 owns first fire; Prefill/Prefix land in suppressed_recs for same-primary remeasure reveal only. Fix is client-side (batch more requests / raise concurrency) and names the binding wall: config cap, compute ridge, or memory limit.

Fix: Batch more requests or increase client concurrency.

R2: KV cache pressure

R2 and R2b are sibling rules in the same DAG layer. Both surface KV-related pressure. Both detectors may count the same window; when both are significant at report time, construction keeps R2 and skips R2b (else if), not the mutual exclusivity table. The ME table silences R2/R2b under R4 weights-alone overflow, and R1 under R6 on the bound path only (soft field skips that row; see Ranking).

Main path (R2)

ConditionThreshold
KV near full (avg or peak)≥88%
Harm signal (at least one)preempt/s > 0.02, or swapped ≥ 2, or waiting > 2

Both conditions must hold. KV at 88% with no evictions and no queue is healthy and hot; R2 stays quiet.

The fix block is split into Cuts throughput and Safe to apply. The seat direction line (--max-num-seqs) gives direction only, not a specific ceiling number. --max-model-len shrink prefers observed p99 prompt + p99 gen; falls back to averages when p99s are missing.

--kv-offloading-size: on every R2 fire, Profile re-derives the size (supply-capped by host / container RAM). If the derived value differs from what is already set, the Safe block offers the new number (e.g. set 4, derived 12 → Set --kv-offloading-size 12 (est)) with the Host RAM subline. If derived equals set, the lever stays quiet.

Confidence: windows_fired / total_evaluable. Ranges from Low to High as more windows confirm the signal.

Admission backlog path (R2b)

ConditionThreshold
KV near full (avg or peak)≥88% (same bar as R2)
Queue ratiowaiting / (running + waiting) ≥ 30%
Free KV tokens< demand from queued requests (waiting × prompt_tokens_mean)
Concurrency caprunning < max_num_seqs (scheduler not at cap)

Fires when KV is near full and the scheduler is holding requests in queue to protect KV memory. Both R2 and R2b may count the same window; when both are significant, report construction keeps R2 and skips R2b (else if). The fix is to expand the KV pool, not reduce concurrency.

Fix: Raise --gpu-memory-utilization if VRAM headroom exists; switch to fp8 KV cache; or reduce --max-model-len.

KV near-full uses avg or peak ≥88%. Peak matters because a window can average 70% and still spike to 92%: the spike is what causes preemptions; the average alone can hide it. Peak is also what the header shows when it exceeds avg by ≥10pp or reaches 95%.

R3: Low prefix reuse

ConditionThreshold
Active traffic: running>0.75
Mean prompt tokens≥20
Prompt token throughput (QPS × mean prompt)≥1000 tok/s
Prefix caching offFires on volume alone (hit rate not required)
Prefix caching on: hit rate<35%

Confidence: 0.95 when prefix caching is off (direct fix available); 0.90 when caching is on and hit rate is low.

Fix: If prefix caching is disabled: enable --enable-prefix-caching. If already enabled: move shared instructions to the start, standardize templates, avoid unique tokens at the beginning.

R4: OOM risk

Fires regardless of traffic. Configuration fact, not runtime observation. Does not fire when dtype is bf16 fallback (insufficient information to prescribe confidently).

FormulaWhat it computes
kv_headroom_gb = (vram_gb × gpu_mem_util) − 3.0 − weight_gb / tp VRAM remaining after weights and activation buffer. Negative means weights alone exceed the VRAM budget.
min_tp = ceil(weight_gb / ((vram_gb × gpu_mem_util) − 3.0)) Minimum tensor parallel degree to fit the model

Two fire paths: (1) kv_headroom_gb < 0, weights overflow. (2) Weights fit but free VRAM cannot hold one worst-case request's KV + state.

Fix: --tensor-parallel-size at the computed minimum; raise --gpu-memory-utilization; use a smaller model; or the model does not fit on this hardware.

The 3.0 GB buffer reserves activation memory. kv_headroom_gb negative means the model cannot run at all on this TP configuration, not just that KV space is tight.

R5: Concurrency saturation

ConditionThreshold
Seat wall co-occurrenceCollector seat_wall_cooccurred: any 250ms scrape with running in the churn band at max_num_seqs and waiting ≥ 2. Slack = clamp(completions/s × 250ms, 1, max(0.01 × max, 1)). Samples above max + 0.5 are evaluated-false (chunked cross-step batching), not a hit. Zero evaluable samples or flag unread/false → R5 silent. No peak/3% fallback.
Queue ratio (window)waiting / (running + waiting) ≥ 30%
Waiting floor (window)≥ 2

R5 fires without a KV <80% gate. The 80% check is in the fix branch only: below it, Profile raises --max-num-seqs to a bounded target; at or above it, Profile names the wall and recommends a replica instead.

Fix: Below 80% KV: raise --max-num-seqs to the bounded target (80% margin on the binding wall, then capped by a live-traffic floor when one exists). Above 80% KV: name the wall (replica; or lower --max-model-len to shrink KV demand).

vLLM ≤0.18.0 doesn't expose vllm_max_num_seqs. Pass -m <value> or R5 won't fire.

R6: Prefill-bound

ConditionThreshold
Effective prompt/gen ratio≥5 (prefix-cache hits removed from prompt tok/s)
Decode efficiency<40%
TPOT muteMuted when TPOT is measured and under 4× its floor. Still fires when TPOT or floor is missing (confidence capped at 0.5).

R6 fires at DAG layer 5. Severity from effective prompt/gen ratio: Mild ≥5, Moderate ≥10, Severe ≥20 (confidence tiers 0.65 / 0.75 / 0.85; capped at 0.5 when TPOT unverified). On the bound path, the suppression table silences R1 (Under-batching) so a pressed, prefill-bound server does not get “send more traffic” as primary; R1 still evaluates into suppressed_recs for reveal. Under soft field, that ME row is skipped so R1 owns first fire and Prefill is held for remeasure reveal (light-load Prefill is not the setup wall).

Fix: Prefix caching first when confirmed off (Some(false)). Enable chunked prefill only when confirmed off; Confirm when unread (never Enable on unknown). When configured --max-num-batched-tokens is readable and below the launch default (2048), Set to 2048 plus optional page floor; never Set down when already above default; when unread, guide with floor/directions (no blind Set). At the compute wall (configured within 20% of a derived ridge/workload recommendation) or Severe FLOPs wall (ratio ≥ 20): disaggregate or add a replica (terminal when no local knob remains), and show suppressed alternatives under the same block when applicable.

R7: Config headroom

ConditionThreshold
--max-num-seqs vs recommended target<90% of the target
Occupancy≥50% of configured max_num_seqs (running / max_num_seqs)
Waiting≤1

Optimistic rule (DAG layer 6). Only fires when no active bottleneck is present. The recommended target is shared with R5: 80% margin on the binding wall (ridge or observed KV capacity). Output names what binds the target.

Fix: Raise --max-num-seqs to the computed target.

Confidence

Per-rule, not a unified formula.

RuleConfidenceCondition
R10.80 (known GPU) / 0.50 (unknown)Fixed per GPU-knowledge path
R2 / R2bwindows_fired / total_evaluableDensity: rises as more windows confirm the signal
R30.95 (caching off) / 0.90 (caching on, low hit rate)Fixed per caching path
R40.95dtype or quantization from env var (DTYPE/VLLM_DTYPE, QUANTIZATION/VLLM_QUANTIZATION)
R40.90vLLM-reported dtype or quantization, or catalog default
R4Not firedweight dtype is bf16 fallback
R50.50Empirical KV bound used
R50.90TTFT and KV cache both present
R50.60One or both absent
R60.85 / 0.75 / 0.65 by severity; capped at 0.5 when TPOT unverifiedSevere / Moderate / Mild from prompt/gen ratio
R70.80 (Observed/ridge) / 0.60 (derived) / 0.50 (empirical)By binding-wall source

User-facing labels: High (≥0.8), Medium (≥0.6), Low (<0.6).

Rule significance gate

rule_is_significant = fired >= 3  AND  fired / total_evaluable_windows >= 25%

Shared by windowed rules (R1–R3, R5–R7, R2b). A rule that fired once is noise. R4 is exempt: it is a config-fact path from baseline headroom, not a per-window fire count, so it skips this gate.

Ranking

score = impact × confidence  // impact: 1-5, confidence: 0.0-1.0

Rules are ranked by score within the winning DAG layer. The highest-scoring rule in the winning layer is the primary recommendation. All others are held. They surface when the same primary re-fires, or immediately when the primary has no fix left to offer.

DAG layers: R4, R2, R2b at layer 2 (highest priority). R5 at 3. R1 at 4. R3 and R6 at 5. R7 at 6 (lowest). The winning layer is the lowest-numbered layer that has a significant rule. Rules in higher-numbered layers are suppressed when a lower layer wins.

Mutual exclusivity: R4 (weights overflow alone) silences R2 and R2b. R6 (Prefill-bound) silences R1 (Under-batching) on the bound path. Soft field (waiting < 2, KV mean < 80%, running < max(0.25 × ridge, 8), same floor as the Traffic limiter): skip that R6→R1 row so Under-batching owns first fire; Prefill and Prefix fall to suppressed_recs for same-primary remeasure reveal. Missing soft-field inputs keep bind-path ME. These suppressions run before the layer filter. R2 vs R2b is handled separately in eval (else if), not this table.

When no rule fires

If efficiency is still low, a diagnose-only fallback names the shape of the underuse (soft under-fed inject can also reuse R1's fix when R1 gates miss but the field is soft and Prefill would own the page). If nothing fires at all, Profile names the boundary capping a healthy server (PrimaryLimiter). Verdicts use run-level aggregates, never one snapshot.

  • Capacity (memory): Capped by memory: KV cache at N% avg (R2 fires at 88%). Concurrency cannot grow further on this pool.
  • Traffic: Capped by traffic: N requests running, no queue. Raise client concurrency in steps; compute ridge ~R, but KV or admission can bind first. Ridge is the decode compute knee, not a seat target.
  • Physics (hardware): Capped by hardware: TPOT within 1.2× its floor, or efficiency headroom below 10%. Scale out.
  • Prefill interference: Capped by prefill: prompt work at Nx of decode (effective). Requires measured effective prompt/decode ratio ≥ 0.5; chunked prefill is a precondition, not evidence.
  • Framework overhead: Capped by vLLM overhead: TPOT above floor with no other healthier name. Requires measured TPOT.
  • Waiting unread: Waiting unread; cannot name a healthy cap. Not a Capped by line.
  • Mean waiting ≥ 2: no healthy Capped by over a flood; rules own the story, or silence.
  • Speculation suspected: decline a hardware Physics cap. Scoreboard: Note: Throughput above the decode ceiling (speculative decoding likely). Efficiency % does not apply. Limiter / healthy exit: same body without Note:.
  • Unknown ceiling: Hardware ceiling unknown (...) instead of naming a boundary it cannot prove.

Data

A 2 minute run is 12 windows: 8 active, 3 idle, 1 dropped. Throughput is computed from the 8 active windows only.
Idle and dropped windows are excluded from active-window averages (throughput, latency, running). KV average uses all evaluable windows.

What, when, and how we collect.

← Back to Get started

Cadence

GPU and vLLM are scraped in parallel at ~250ms intervals inside each collection window. Window size: 2s when --duration is 30s or less; 10s otherwise. Each window produces one snapshot from ~9 or ~40 polls respectively.

The 1-second observation skew gate (|gpu_observed_at − vllm_observed_at| > 1s) applies only to energy and cost metrics. No rule skips based on skew.

Within each window, different metric types are treated differently:

TypeTreatmentEdge cases
Gauges Last scrape in the window kv_cache_peak_perc is max across all scrapes in the window, not last. KV cache can spike and recover within a window; last scrape alone misses it. Shown in output when peak > avg + 10pp or peak ≥ 95%. Same pattern for VRAM peak (≥90% of total) and GPU temp peak (≥80°C).
Histograms Δsum/Δcount, first to last scrape Falls back to last-scrape cumulative mean when Δcount ≤ 0 (no new completions in window). p99 has no fallback; stale p99 is worse than none.
Counters Rates = Δ / window duration. Raw totals from last scrape. None on counter reset (negative delta) or zero-duration window. Zero delta is valid: no activity, not missing data.

Evaluable window gate

Two-tier gate. Structural validity and active traffic are separate checks.

Tier 1: structural. Did the endpoint respond and does the window have a valid duration? All windowed rules (R1–R3, R5–R7, R2b) use this check. R4 is a static config-fact path and does not require an evaluable window.

window_duration_secs  is present and positive
num_requests_running  is Some (even if zero)

running = 0 is valid data: the server responded and reported no active requests. None means the endpoint did not respond.

Tier 2: active. Was the server doing real work? Aggregated means use this check. Throughput, efficiency %, GPU utilization, and latency averages are computed over active windows only.

window_is_evaluable(s) && !window_is_idle(s)

// idle: running < 1 AND generation_tokens_per_sec < 1 AND waiting < 1
// (None for any field counts as < 1)

Active is the inverse of idle for evaluable windows. Low numbers are included; blank numbers are not. Idle windows pass tier 1 but not tier 2 and are excluded from the averages.

Multi-window aggregation, which window set applies to which field, is in Math.

Source 1: GPU telemetry

NVIDIA via NVML, or AMD via libamdgpu_top when NVML is unavailable. Polled at 250ms intervals. Same gauge fields either way; FLOPs and bandwidth still come from the GPU catalog.

FieldTypeWhat it isHow collected
gpu_util_pctGaugeFraction of time any kernel was executing. Not SM occupancy.Mean across window polls
mem_util_pctGaugeFraction of time the memory controller was busyMean across window polls
power_wattsGaugePower draw (W)Mean across window polls
power_limit_wattsGaugeDriver-set TDP limit (W)Read when assembling each window's GPU row
vram_used_mbGaugeVRAM in use (MiB)Last poll
vram_total_mbGaugeTotal device VRAM (MiB)Last poll
temperature_cGaugeGPU core temp (°C)Last poll
sm_clock_mhzGaugeSM clock frequency (MHz)Last poll
Two values are computed from the polls, not read directly: vram_peak_mb (max of vram_used_mb across all polls) and temperature_peak_c (max of temperature_c across all polls). Derived within the window, not collected.
Neither NVML nor the AMD path exposes theoretical peak FLOPs or memory bandwidth. Those come from the GPU catalog, looked up once at startup from gpu_name, labeled (est) in output. Host RAM (for --kv-offloading-size sizing) is read separately from the host / container memory path.

Source 2: vLLM Prometheus

Scraped from /metrics at ~250ms intervals. See vLLM docs for the full metric reference.

FieldTypeWhat it is
vllm:num_requests_runningGaugeActive requests in flight
vllm:num_requests_waitingGaugeRequests queued, not yet scheduled
vllm:num_requests_swappedGaugeRequests with KV blocks evicted to CPU
vllm:kv_cache_usage_perc / vllm:gpu_cache_usage_percGaugeKV cache fill ratio, 0-1. Primary name is kv_cache_usage_perc; falls back to gpu_cache_usage_perc on older vLLM.
vllm:cpu_cache_usage_percGaugeCPU KV cache fill ratio
vllm:time_to_first_token_secondsHistogramTime from request arrival to first output token. TTFT mean + p95 + p99.
vllm:request_time_per_output_token_secondsHistogramPer-token decode latency. TPOT mean + p95 + p99.
vllm:time_per_output_token_secondsHistogramTPOT, older metric name. Fallback when primary is absent.
vllm:request_prefill_time_secondsHistogramPrefill latency per request
vllm:request_queue_time_secondsHistogramTime spent waiting before scheduling
vllm:request_prompt_tokensHistogramPrompt token count per request
vllm:generation_tokens_total / vllm:iteration_tokens_total_sumCounterCumulative tokens generated. Primary is generation_tokens_total; falls back to iteration_tokens_total_sum on older vLLM.
vllm:request_success_totalCounterCumulative completed requests. Legacy name: vllm:request_success.
vllm:num_preemptions_totalCounterCumulative KV cache preemptions. Legacy name: vllm:num_preemptions.
vllm:cache_config_infoGauge (labels)Static cache config: block_size, cache_dtype, prefix_caching, and sometimes chunked_prefill. Modern vLLM often omits chunked prefill and max-num-batched-tokens here; Profile then probes /info and /server_info best-effort. Missing fields stay unread, never guessed.
vllm:max_num_seqsGaugeConcurrency cap. Absent in vLLM ≤0.18.0; pass -m.
vllm:prefix_cache_hits_total, vllm:prefix_cache_queries_total, vllm:external_prefix_cache_hits_total, vllm:external_prefix_cache_queries_totalCounterInternal and external prefix cache hits and queries. Legacy names without _total also supported.

Catalog-derived fields

Looked up once at startup. See Catalog for supported GPUs and models.

FieldSourceIf absent
peak_flops_tc_tflops, peak_bw_gbpsGPU catalog. BF16 Tensor Core dense throughput, physics roofline input.Ceilings not computed
param_count, active_param_countModel catalog. MoE: total for weight/OOM; active for roofline.Roofline skipped
bytes_per_param (weights)vLLM-reported quantization → QUANTIZATION/VLLM_QUANTIZATION env → vLLM-reported dtype → DTYPE/VLLM_DTYPE env → catalog default → bf16 (2 bytes). kv_cache_dtype is not in this chain; it sets KV element bytes only.bf16 fallback, labeled in output
All catalog-derived numbers are labeled (est) in output. Upper-bound approximations, not measured values.

Fallbacks

FieldPrimaryFallbackIf absent
max_num_seqsvllm:max_num_seqs gauge-m CLI flagR1 and R5 silent
tensor_parallel_size--tensor-parallel-size CLI flagTreated as 1. Values above 1 are refused at launch; multi-GPU is not supported.Ceilings and kv_headroom unscaled
TPOT histogramrequest_time_per_output_tokentime_per_output_tokenNone
bytes_per_param (weights)vLLM-reported quantizationQUANTIZATION/VLLM_QUANTIZATION env → vLLM-reported dtype → DTYPE/VLLM_DTYPE env → catalog → bf16. KV width uses kv_cache_dtype only.bf16, labeled in output
All windows non-evaluable-Chronologically last raw windowLabeled as non-evaluable

Catalog

Missing your GPU or model? Request it here.

GPU specs, model parameters, and cloud prices resolved once at startup.

← Back to Get started

GPUs

BF16 Tensor Core dense TFLOPs and memory bandwidth feed the roofline math. On-demand prices from src/context/gpu_prices.json (updated 2026-08-13). Use --cost-per-hour for exact rates. A dash means no catalog price.

GPUArchBF16 TC Dense TFLOPsMem BW GB/sOn-demand $/hr
H100 PCIeHopper756.02,000$2.89
H100 NVLHopper835.53,900$3.19
H100 SXMHopper989.03,350$2.99
H200Hopper989.04,800$4.39
A100 80GBAmpere312.02,039$1.49
A100 40GBAmpere312.01,555$1.50
A10GAmpere126.0600$1.00
L40SAda181.03864$0.99
RTX 5090Blackwell209.51,792$0.99
RTX 4090Ada165.01,008$0.69
RTX 3090 TiAmpere80.01,008$0.22
RTX 3090Ampere71.16936$0.46
RTX A6000Ampere77.4768$0.49
RTX PRO 6000 BlackwellBlackwell250.01,792$1.99
B200Blackwell2,250.07,700$5.89
B300Blackwell2,500.08,000$7.39
GB200Blackwell2,250.07,700$8.50
GB10 (DGX Spark)Blackwell212.9273-
MI355XCDNA42,500.08,000-
MI350XCDNA42,300.08,000-
MI325XCDNA31,307.46,000$3.50
MI300ACDNA3980.65,300$2.00
MI300XCDNA31,307.45,300$3.49
MI250X (per GCD)CDNA2191.51,638.4$1.50
MI250 (per GCD)CDNA2181.01,638.4$1.30
MI210CDNA2181.01,638.4$1.00
RX 9070 XTRDNA4194.6644-
RX 9070RDNA4144.5644-
Radeon PRO W7900RDNA3123.0864-
Radeon PRO W7800RDNA390.5576-
RX 7900 XTXRDNA3123.0960-
RX 7900 GRERDNA392.0576-
RX 7900 XTRDNA3103.0800-
RX 7800 XTRDNA374.6624-
RX 7700 XTRDNA370.3432-
RX 7600 XTRDNA345.1288-
BF16 TC Dense TFLOPs are tensor-core throughput with FP32 accumulate where the catalog distinguishes it (L40S uses 181.03; datasheet 362.05 is FP16-accumulate). GB200 matches B200 physics (superchip name). B300 is Blackwell Ultra at 8 TB/s. H100 NVL is the 94 GB HBM3 SKU (3.9 TB/s). GB10 BW is LPDDR5X system bandwidth, not HBM. MI250 / MI250X values are per GCD (ROCm sees each GCD as a device). A plain "A100" without a size token (80GB or 40GB) will not match. All catalog ceilings labeled (est) in output.

Models

Matched by token against the model name vLLM reports at startup.

ModelTotal paramsActive paramsMoE
Llama 4 Maverick400B17BYes
Llama 4 Scout109B17BYes
Llama 3 405B405B-No
Llama 3 70B70B-No
Llama 3 8B8B-No
Llama 3.2 3B3B-No
Llama 3.2 1B1B-No
Nemotron 70B70B-No
Nemotron 8B8B-No
Muse Glimmer 30B29.6B27.8B (text)No
Qwen3.8 27B27.78B27B (text)No
Qwen3.6 27B27B-No
Qwen3 235B235B22BYes
Qwen3 32B32B-No
Qwen3 30B30B3BYes
Qwen3 14B14B-No
Qwen3 8B8B-No
Qwen3 4B4B-No
Qwen3 1.7B1.7B-No
Qwen3 0.6B0.6B-No
Qwen2.5 72B72B-No
Qwen2.5 32B32B-No
Qwen2.5 14B14B-No
Qwen2.5 7B7B-No
Qwen2.5 3B3B-No
Qwen2.5 1.5B1.5B-No
Qwen2.5 0.5B0.5B-No
DeepSeek 70B70B-No
DeepSeek 7B7B-No
DeepSeek R1 Distill Llama 70B70B-No
DeepSeek R1 Distill Qwen 32B32B-No
DeepSeek R1 Distill Qwen 14B14B-No
DeepSeek R1 Distill Qwen 7B7B-No
DeepSeek R1 Distill Llama 8B8B-No
DeepSeek R1 Distill Qwen 1.5B1.5B-No
Mistral Large 123B123B-No
Mixtral 8x22B141B39BYes
Mixtral 8x7B47B13BYes
Mistral 7B7B-No
Gemma 4 31B/27B31B-No
Gemma 4 26B-A4B25.2B3.8BYes
Gemma 3 27B27B-No
Gemma 3 12B12B-No
Gemma 3 4B4B-No
Gemma 3 1B1B-No
Gemma 2 2B2B-No
Gemma 27B27B-No
Gemma 9B9B-No
GLM 32B32B-No
Phi-4 mini3.8B-No
Phi 4 14B14B-No
MoE models: active_param_count drives roofline ceilings; param_count (total) drives weight_gb and OOM headroom. Catalog includes models whose 4-bit weights fit the largest listed GPU (~288 GB). DeepSeek 671B / R1 / V3, Mistral Large 3 675B, Kimi K2, and GLM 744B are omitted. Qwen3 has no official 7B or 72B dense SKU. Muse Glimmer 30B is dense text plus a vision encoder: 29.6B total, 27.8B text stack on the decode roof. Qwen3.8 27B is hybrid DeltaNet + attention: 27.78B loaded (safetensors), 27B text stack on the decode roof. Qwen3.6 27B is the same hybrid class. The 2.4T Qwen3.8 Max is omitted. Gemma 4 27B is catalogued as 31B (Google's HF name vs param count). Unrecognized models skip roofline entirely. To add a model or GPU, open an issue or submit a PR to the catalog files in src/context/.

Math

The formulas Profile runs on collected data.

← Back to Get started

Profile measures behavior under load. Idle time has no waste to find. Ceilings are catalog-derived upper bounds, labeled (est). Missing data stays absent (None), never guessed.

Roofline: hardware ceilings

Baseline inputs

InputResolution
roofline_paramsactive_param_count if present, else param_count. Decode and prefill ceilings, efficiency %. Not ridge: ridge_batch_size is GPU peak FLOPS, peak BW, and weight dtype bits only (param count cancels in the weight-decode intensity model).
weight_paramsparam_count if present, else active_param_count. weight_gb and kv_headroom_gb (OOM check).
bytes_per_param (weights)vLLM-reported quantization → QUANTIZATION/VLLM_QUANTIZATION env → vLLM-reported dtype → DTYPE/VLLM_DTYPE env → catalog default → bf16 (2 bytes). fp8 = 1, fp16/bf16 = 2, fp32 = 4. kv_cache_dtype sets KV element bytes only, never weight width. Source labeled in output when fallback is used.
tensor_parallel_size--tensor-parallel-size CLI flag only in practice (TP >1 is refused at launch). Defaults to 1. Scales both ceilings and per-GPU KV headroom.
seq_len (prefill only)prompt_tokens_mean rounded → max_model_len → absent: prefill ceiling skipped.

Two ceilings, two binding constraints. LLM serving is memory-bandwidth-bound at decode and compute-bound at prefill.

FormulaResultBinding constraint
(peak_bw_gbps × tp) × 1e9 / (params × bytes_per_param) Decode ceiling (tok/s) Memory bandwidth, binding in steady-state serving. tp GPUs contribute tp× aggregate bandwidth.
(peak_flops × tp) × 1e12 / (2 × params × seq_len + attn_coeff × layers × seq_len²) Prefill ceiling (prompts/s) Compute, binding during prompt processing. Attention term is quadratic in seq_len; linear-only rooflines overstate long-context capability.
expected × 0.85 / expected × 1.05 Ceiling range (lower / upper) −15% / +5% band around expected. Reflects catalog approximation, not measured hardware.
(peak_flops × 1e12 × bits_per_param) / (peak_bw × 1e9 × 16) Ridge batch size Concurrent batch at which decode crosses from BW-bound to compute-bound. Below: BW limits throughput. At or above: compute limits throughput.

peak_bw_gbps, peak_flops, and params come from catalogs, not live measurement. All ceiling outputs labeled (est).

Efficiency % (decode_eff)

absolute_ceiling = decode_ceiling_tps × ridge_batch_size
efficiency_pct   = actual_tps / absolute_ceiling × 100

decode_ceiling_tps is TP-scaled (peak_bw × tp from the roofline above). The denominator is the absolute hardware ceiling, independent of current traffic. An idle server reads low, correctly. Requires generation_tokens_per_sec > 0. When measured decode beats the one-token-per-read roof (speculation suspected), efficiency, config-relative efficiency, and headroom are cleared: the scoreboard shows -, not a false %. Inside the estimate band without a speculation flag, values may still clamp; values derived from the estimated ceiling carry a tilde (~) in output.

Calibrated for decode-bound workloads. Prefill-heavy workloads (long prompts, short outputs) show artificially low efficiency %. The prefill ceiling is the relevant constraint there, not decode.

Weight footprint and headroom

weight_gb        = weight_params × bytes_per_param / 1e9
kv_headroom_gb   = (vram_total_gb × gpu_memory_utilization) − 3.0 − (weight_gb / tp)
tpot_floor_ms    = 1000 / decode_ceiling_tps
prefill_floor_ms = 1000 / prefill_ceiling_tps

kv_headroom_gb: per-GPU after the 3.0 GB activation buffer; negative means current TP is insufficient. tpot_floor_ms and prefill_floor_ms: theoretical minimum latencies at ceiling; actual values above these indicate overhead.

Economics

MetricFormula
tok / Wgeneration_tokens_per_sec / power_watts
J / tokenpower_watts / generation_tokens_per_sec
$ / 1M output tokcost_per_hr × 1e6 / (generation_tokens_per_sec × 3600). Omitted when the turnover gate fails (generation_tokens_completed < mean running): not enough completions to trust the rate.

Cost priority: --cost-per-hour flag, then GPU catalog on-demand price, then absent. Catalog-derived $/1M output tok is labeled (est) in output; user-provided rates are not. Energy metrics (J/tok, tok/W) use energy-pair windows only (aligned GPU power and vLLM tok/s in the same window).

Closed-loop delta

Computed after every re-measure cycle. Each metric line shows before → after. A worse suffix marks material regressions only (not every negative delta).

  Config changed. Baseline reset.

  Throughput          131 → 421 tok/s
  TTFT                32857 → 224ms (p95 66946 → 500ms)
  TPOT                58.0ms → 23.0ms (p95 58.0 → 40.0ms)

ECONOMICS:
  Cost/1M output tok  $2.10 → $0.65 (est)

When config drifted between windows (vLLM restart detected), the line reads Config changed. Baseline reset. and the efficiency arm of the delta is discounted; throughput alone decides improvement direction after a reset.

On regression: the degraded state becomes the new baseline and is pushed to the history stack. The loop continues through the normal apply/remeasure wait; there is no extra confirmation step beyond that.

Fieldworse threshold
ThroughputDrop ≥5%
Efficiency (pp)Drop ≥1.0pp
Cost/1M output tokRise >$0.01
J/tokRise >0.02
TTFT avgRise >5ms
TPOT avgRise >0.5ms

Per-window collection

Window size: 2s when --duration is 30s or less; 10s otherwise. Each window polls GPU (NVML, or AMD via libamdgpu_top) and vLLM in parallel at ~250ms intervals. These formulas produce one snapshot before multi-window aggregation.

Metric typeFormulaOn failure
Histogram mean (TTFT, TPOT, prefill, queue, prompt tokens)Δsum / Δcount first→last scrape; ×1000 for seconds-based latenciesLast-scrape cumulative mean when Δcount ≤ 0
Histogram p95 / p99Delta buckets, then linear interpolation at q=0.95 or q=0.99. If target falls in +Inf bucket, returns last finite bucket's upper bound (clamped).None. No cumulative fallback (stale percentile is worse than none)
Counter rate (tok/s, req/s, preemptions/s)(last − first) / window_duration_secsNone on negative delta or zero duration. Zero delta is valid idle, not missing
Prefix cache hit rateΔhits / ΔqueriesNone when Δqueries ≤ 0 (never divide by zero)
GPU util, powerMean across window pollsPoll skipped if GPU field absent
VRAM, temp (current)Last pollPeaks (vram_peak_mb, temperature_peak_c, kv_cache_peak_perc): max across polls/scrapes

Collection cadence and edge cases: Data.

Aggregation: combining windows

Each window produces one snapshot. Fields aggregate by type across windows. Active: server under real load. Evaluable: endpoint responded with valid data.

Field typeWindow setMethod
GPU util, power, running/waiting means, rates (tok/s, req/s)ActiveTime-weighted mean: Σ(value × duration) / Σ(duration)
Histogram means (TTFT, TPOT, prefill, queue latency)ActiveΣΔsum / ΣΔcount across active windows. Weighted by observation count, not time.
p95 / p99 (TTFT, TPOT; p99 also Prompt / Generation tokens)Active (Tokens p99: Evaluable)Merge per-window delta bucket vectors (sum counts at matching boundaries), recompute q=0.95 and q=0.99 via linear interpolation. Length or boundary mismatches (e.g. version skew) skip the window gracefully instead of aborting. Never average scalar percentile values. Scoreboard latency shows p95.
KV cache avg, prefix cache hit rate, prompt_tokens_meanEvaluableKV avg: time-weighted mean. Prefix hit rate: Σ Δhits / Σ Δqueries across all evaluable windows.
KV / VRAM / temp peaksEvaluablemax(per-window peak, last evaluable landing value) so aggregate peak ≥ displayed current.
State gauges (VRAM used, temp, sm_clock)Evaluable (last)Last evaluable window's landing value.
Cumulative counters (total tokens, total reqs)All (chronological last)Chronologically last collected window. Idle tail included. Preserves true Prometheus server totals.
All windows non-evaluable-Chronologically last raw window returned in full.

Limitations

Every boundary, and where the math is approximate.

Product boundaries

  • One GPU. --tensor-parallel-size greater than 1 is refused. KV and weight sharding math is single-GPU only. Profile still tells you when a model needs tensor parallelism to fit at all.
  • vLLM only. The engine boundary is clean, but SGLang is not built yet.
  • Ceilings are uncalibrated. Published specifications overestimate. Every ceiling-derived number is marked (est) or with a tilde.
  • Overhead-bound is named, not measured. Profile can say the GPU is idling on CPU work but cannot quantify it.
  • Unknown GPU or model gets no ceiling. Profile reports Hardware ceiling unknown with the reason, rather than guessing.
  • No load, no answer. Idle windows are skipped. Drive load with vllm bench serve when you need traffic.
  • The mean is one collection. A diagnose run aggregates many 2s or 10s slices into one summary. Cyclic load averaged over that duration can look like a state it is not. Raise --duration so several cycles fit, up to 30m. Load that changes between iterations is not a duration problem. See Duration and traffic shape.
  • You apply the fix. Profile never changes your server.

Measurement gaps

GapAffectsIn practice
MoE active parameters assume uniform routingR4, baselineIf traffic heavily skews to specific experts, actual VRAM usage may differ from the baseline active_param_count approximation.
Sampling cliff / Temperature absentR5Sampling temperature is not exposed in vLLM metrics, so R5 cannot factor token diversity into concurrency saturation logic.
MoE weight uses catalog total param_countR4, baselineRuntime VRAM may be lower if not all experts are loaded
TP >1 not supported. Multi-GPU deployments are not supported today.Baseline, R4, all rules--tensor-parallel-size values above 1 are refused at launch. Profile still reports when a model needs TP to fit at all.
Efficiency % uses theoretical BW ceiling, not measured HBMAll rulesCan't distinguish BW bottleneck from scheduler under-feeding
FLOPs/s not measurable via NVMLR1Prefill-heavy workloads show artificially low efficiency %
vllm_max_num_seqs absent in vLLM ≤0.18.0R5Requires -m flag
Chunked prefill: run can exceed max_num_seqsR5Samples above max + 0.5 are evaluated-false for the seat wall; R5 stays silent when the flag cannot be computed
GPU and model specs from catalog, not measuredBaselineOutput labels ceilings (est)
p99 absent on counter reset or zero-traffic windowDisplayBy design. Stale p99 is worse than none.

Design

The non-obvious choices and the reasoning behind them.

Option<T> over sentinels

Some(0.0) ≠ None. Every metric is optional. Missing data and zero are different. Treating them the same produces confident wrong answers.

Physics ceiling as a range

CeilingEstimate { lower, expected, upper }. Catalog-derived specs carry uncertainty. Output labels ceilings (est).

One recommendation per iteration

Rules ranked by impact × confidence within the winning DAG layer. One primary signal per iteration. Five simultaneous recommendations produce paralysis, not action.

Delta, not snapshot

Each iteration reports what changed since the last: before → after values with worse labels on material regressions. A snapshot without temporal comparison is a status report, not a diagnosis.

Shared traffic gate

window_is_evaluable is one shared helper, used by all rules. Idle state has no waste to measure.

Agnostic Telemetry Interfaces

The src/collectors module abstracts raw Prometheus and GPU metrics into a standard RawSnapshot structure. The engine reasons on those snapshots. The collector boundary is the I/O seam; rules and the limiter still encode vLLM-shaped signals (max_num_seqs, KV gauges, preemption counters). A new inference framework needs a collector and rule/limiter work for its signals.

Deterministic Reasoning

The diagnostic engine is decoupled from CLI execution and network I/O. run_diagnose collects windows and builds the run-level aggregate; engine::build_report_for_diagnose takes that aggregate and produces the Report; output::stdout formats it. The loop runner (profiler::loop_runner) runs conditionally only when there are sufficient evaluable, non-idle windows. This separation means the reasoning layer is deterministic and historical telemetry can be replayed for exact regression testing.

250ms collection interval

Fast enough to catch GPU utilization transients that a 1s or 5s poll would average away. Slow enough that scrapes per window add no meaningful load to the vLLM endpoint or GPU telemetry path.

Peak over average for KV cache

KV cache usage is tracked as the maximum across all scrapes in a window, not only the last value. Near-full for rules is avg or peak at the pressure bar. A spike that causes preemptions can recover before the window ends; last scrape alone can bury it.

Borrowing in the engine hot path

The diagnostic pipeline uses AnalysisInput<'a> to borrow contexts across the engine without copying static context every window. Collectors still allocate per-window snapshots and histogram vectors. A profiler that eats its target's memory bandwidth is worse than no profiler.

Graceful degradation over Panics

When integrating parallel telemetry sources (vLLM and GPU), mismatches occur. Version skew can change bucket boundaries in histograms; instead of panicking, the engine skips the corrupted merge and degrades gracefully. One bad scrape window should never destroy the entire session's dataset.

Code Hygiene & Security

rustls only, no OpenSSL. Read-only GPU telemetry. No third-party telemetry leaving the machine; Profile only talks to the configured vLLM endpoint (and best-effort /v1/models, /info, and /server_info on that host). Missing endpoints or fields stay unknown; Profile does not invent config.

Every pull request and merge to main runs the same gate (see .github/workflows/build.yml and CONTRIBUTING.md). Socket reports supply-chain risk on the same events. Never mask a scanner with || true; allowlist real false positives in deny.toml.

  • cargo audit: RustSec Advisory Database against the dependency tree.
  • cargo deny: license, advisory, and ban policy via cargo deny check --all-features (deny.toml).
  • OSV-Scanner: fails on HIGH or CRITICAL vulnerabilities in Cargo.lock (CVSS v3 ≥ 7.0).
  • Semgrep SAST: static application security testing on the source.
  • Socket: supply-chain scanning on pull requests and merges to main (malware, typo-squatting, risky dependency behavior). GitHub App check: Socket Sec: Project Report.
  • cargo fmt -- --check: standard Rust formatting.
  • cargo clippy --locked --all-targets --all-features -- -D warnings: zero-tolerance lint hygiene.
  • cargo test --locked: unit tests for rules, physics math, and mock payloads.

The Inference Problem

TL;DR

  • Inference is 80-90% of your AI system's lifetime cost, not training.
  • Production servers bleed compute across batching gaps, KV cache pressure, prefill overhead, low throughput, and high latency.
  • The problems are fixable. Most teams can't see them.

You send a prompt. A GPU somewhere processes it and sends back a response, one token at a time. That loop is inference. It is also where most of the money goes.

The AI inference cycle
The AI inference cycle. Full post.
80-90% of a production AI system's lifetime cost is inference, not training [1]
15× GPT-4 inference cost vs training cost. $2.3B in inference by end of 2024 vs ~$150M to train

While training is highly visible, inference constitutes the majority of expenditure.

An H100 rents for roughly $3/hr. When your GPU is underutilized, you get fewer tokens than the hardware can deliver. Same bill, lower output. That is over $1,000/month in cloud compute per GPU, wasted.

Common areas of compute waste include:

Throughput. A misconfigured vLLM server runs well below its hardware ceiling. When batching, memory, and scheduling are off, GPU utilization collapses. This represents a configuration bottleneck rather than a hardware limitation.

Latency. Real-time applications need time-to-first-token under 100ms. Most production setups don't get there without deliberate tuning.

KV cache pressure. Every token generated needs to remember everything before it. When that memory fills up, the system evicts. Throughput drops. Latency spikes. At 90% usage, you're already in trouble.

Batching. When batch size is 2 out of a possible 16, you're using 12% of the machine. The rest idles. You pay full price.

Prefill overhead. Before the first output token, the model processes your entire prompt. In workloads with 500+ token prompts, prefill alone dominates response time.

Observability. Most teams look at raw Prometheus metrics and guess. There is no standard path from "throughput is low" to "here is exactly why, here is the fix."

These are not edge cases. This is the normal state of a production inference server that has not been tuned.

Why This Matters Now

The standard story about early tech: Amazon lost money for years. Uber still does. Growth first, economics later. AI inference does not fit that story.

Amazon chose to lose money, a deliberate bet on demand. Companies running AI inference are burning money on hardware they already paid for, because it is sitting misconfigured. That is not a growth strategy. It is waste that shows up on your cloud compute bill this month.

Enterprises running production AI spend $50,000 to $500,000 per month on inference infrastructure. [2] Inference costs have dropped 280× since 2022, from $20 to $0.07 per million tokens. That gap between what hardware can do and what most teams extract from it is your cost problem today.

"The AI inferencing market will be much, much larger than the AI training market. People are running out of usable inference computing capacity." - Larry Ellison, Oracle earnings call [3]

The shortage is not GPUs. It is efficient use of the GPUs already running in production, including yours.