<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Reproducibilidad on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/reproducibilidad/</link><description>Recent content in Reproducibilidad on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Tue, 16 Jun 2026 12:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/reproducibilidad/index.xml" rel="self" type="application/rss+xml"/><item><title>The reproducible harness: measuring cost, performance and energy in a single auditable experiment</title><link>https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/</link><pubDate>Tue, 16 Jun 2026 12:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point, thousands with a thin space. The dollar sign is not used (on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &amp;ldquo;data&amp;rdquo; series has produced three independent measurement axes: cost per million tokens (OpenCost + LiteLLM), performance under an SLO (GuideLLM + AIPerf) and energy per token (DCGM + Kepler). The problem is that the three have been measured in different articles, with different loads and at different times: they are not comparable with each other. This closing article describes the &lt;strong>integrated harness&lt;/strong> that runs the three axes &lt;strong>in the same experiment&lt;/strong>, on the same node (4×H100 SXM, a generic reference), with all the metadata fixed, the output in versioned JSON/CSV and an idempotent Kubernetes Job. The result is the &lt;strong>3-axis scorecard&lt;/strong> (€/1M tok, Wh/token, TTFT/ITL P99) that allows configurations to be compared on a &lt;strong>multi-objective Pareto frontier&lt;/strong> and any figure to be audited with the bench to reproduce it.&lt;/p>
&lt;hr>
&lt;h2 id="why-the-three-axes-must-be-measured-together">Why the three axes must be measured together&lt;/h2>
&lt;p>The series&amp;rsquo; opening post (&lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">The three axes&lt;/a>) established the identity:&lt;/p>
$$\text{CPM} = \frac{\text{cost/h}}{\text{throughput (tok/s)} \times 3.6 \times 10^{-3}}$$
$$\text{energy/token (Wh)} = \frac{\text{mean power (W)}}{\text{throughput (tok/s)} \times 3\,600}$$
&lt;p>Throughput is the common denominator. If it is measured in different experiments, different time, different load, different GPU temperature, then CPM and energy/token &lt;strong>do not share a denominator&lt;/strong>: they are three anecdotes, not a scorecard. The harness captures them in the same time window, over the same load, with Prometheus windows aligned to the second. Only then is the scorecard row coherent by construction.&lt;/p>
&lt;p>The second reason is reproducibility. The post &lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Bias and reproducibility in benchmarking&lt;/a> listed twelve biases that invalidate comparisons: an unpinned engine, an undeclared tokeniser, unfixed input/output lengths, missing warmup, a client outside the cluster. The harness removes all of them because the metadata are part of the Job, not of the documentation.&lt;/p>
&lt;hr>
&lt;h2 id="architecture-of-the-integrated-bench">Architecture of the integrated bench&lt;/h2>
&lt;p>The harness has four layers. Each one is OSS, exports to Prometheus and lives in the same Kubernetes namespace:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Layer&lt;/th>
&lt;th>Tool(s)&lt;/th>
&lt;th>Primary metric&lt;/th>
&lt;th>Export protocol&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Performance&lt;/strong>&lt;/td>
&lt;td>GuideLLM (SLO sweep) + AIPerf&lt;/td>
&lt;td>TTFT P99, ITL P99, goodput (tok/s)&lt;/td>
&lt;td>native JSON/CSV + &lt;code>/metrics&lt;/code> OpenMetrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Cost&lt;/strong>&lt;/td>
&lt;td>OpenCost + LiteLLM proxy&lt;/td>
&lt;td>CPM (€/1M tok), cost/request&lt;/td>
&lt;td>REST API + Prometheus scrape&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Energy (GPU)&lt;/strong>&lt;/td>
&lt;td>DCGM Exporter&lt;/td>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code> (W), &lt;code>DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION&lt;/code> (mJ)&lt;/td>
&lt;td>Prometheus DaemonSet&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Energy (pod)&lt;/strong>&lt;/td>
&lt;td>Kepler&lt;/td>
&lt;td>&lt;code>kepler_container_joules_total&lt;/code>, energy per pod&lt;/td>
&lt;td>Prometheus DaemonSet&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>MLPerf Power is used as an &lt;strong>external comparability reference&lt;/strong>: its published results, with hardware documented in detail, allow the harness figures to be calibrated for plausibility. The in-house bench does not aim to be an MLPerf submission, but to be &lt;strong>reproducible on your own cluster&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 320" role="img" aria-label="Harness architecture: a Kubernetes Job orchestrates GuideLLM and AIPerf as load generators, OpenCost and LiteLLM measure cost, DCGM and Kepler measure energy, everything converges on Prometheus and the JSON scorecard" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.2;marker-end:url(#ah)}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3;marker-end:url(#ah)}&lt;/style>
&lt;defs>&lt;marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="10" y="10" width="800" height="300" rx="10"/>
&lt;text x="20" y="28" class="tl">Namespace: benchmark&lt;/text>
&lt;rect class="bx" x="30" y="40" width="180" height="70" rx="6"/>
&lt;text x="40" y="60" class="tl">Job benchmark-run&lt;/text>
&lt;text x="40" y="76" class="ts">GuideLLM sweep (SLO)&lt;/text>
&lt;text x="40" y="92" class="ts">AIPerf fixed concurrency&lt;/text>
&lt;rect class="bx" x="30" y="130" width="180" height="60" rx="6"/>
&lt;text x="40" y="150" class="tl">LiteLLM proxy&lt;/text>
&lt;text x="40" y="168" class="ts">token counting + CPM&lt;/text>
&lt;rect class="bx" x="30" y="210" width="180" height="60" rx="6"/>
&lt;text x="40" y="230" class="tl">DCGM DaemonSet&lt;/text>
&lt;text x="40" y="248" class="ts">POWER_USAGE, ENERGY&lt;/text>
&lt;rect class="bx" x="30" y="280" width="180" height="28" rx="6"/>
&lt;text x="40" y="298" class="tl">Kepler DaemonSet&lt;/text>
&lt;rect class="bx" x="280" y="40" width="180" height="70" rx="6"/>
&lt;text x="290" y="60" class="tl">Inference endpoint&lt;/text>
&lt;text x="290" y="78" class="ts">vLLM / SGLang&lt;/text>
&lt;text x="290" y="96" class="ts">pinned model, FP8/FP16&lt;/text>
&lt;rect class="bx" x="280" y="130" width="180" height="60" rx="6"/>
&lt;text x="290" y="150" class="tl">OpenCost&lt;/text>
&lt;text x="290" y="168" class="ts">€/GPU-h per pod/ns&lt;/text>
&lt;rect class="bx" x="540" y="60" width="160" height="80" rx="6"/>
&lt;text x="550" y="82" class="tl">Prometheus&lt;/text>
&lt;text x="550" y="100" class="ts">15 s scrape&lt;/text>
&lt;text x="550" y="116" class="ts">30-day retention&lt;/text>
&lt;rect class="bx" x="540" y="170" width="160" height="80" rx="6"/>
&lt;text x="550" y="192" class="tl">Scorecard exporter&lt;/text>
&lt;text x="550" y="210" class="ts">PromQL → JSON/CSV&lt;/text>
&lt;text x="550" y="228" class="ts">versioned in git&lt;/text>
&lt;path class="ar" d="M210,75 L280,75"/>
&lt;path class="ar" d="M460,155 L540,155"/>
&lt;path class="ar" d="M210,155 L280,155"/>
&lt;path class="dsh" d="M210,240 L540,200"/>
&lt;path class="dsh" d="M210,294 L540,220"/>
&lt;path class="ar" d="M700,100 L700,170"/>
&lt;text x="550" y="278" class="ts">one row per (model, config, hardware)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="the-kubernetes-job-the-full-yaml">The Kubernetes Job: the full YAML&lt;/h2>
&lt;p>The experiment runs as a versioned &lt;strong>Kubernetes Job&lt;/strong>. All the relevant metadata are environment variables declared in the manifest: not in ad-hoc scripts, not in external documentation. The Job is idempotent (same name = same run) and leaves traces in the pod log and in the output volume.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">batch/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bench-llama3-70b-fp8-h100x4-20260616&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">benchmark&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/precision&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/engine&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-0.9.1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/hardware&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">h100x4-sxm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/isl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1024&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/osl&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;256&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/concurrency&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;32&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/tokenizer&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama-3-tokenizer-v3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bench/run-id&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;20260616T1200&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">backoffLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Never&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serviceAccountName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bench-runner&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bench-results-pvc&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Warmup: 60 s of traffic before the experiment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warmup&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/vllm-project/guidellm:0.4.2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">guidellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">benchmark&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">target&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">http://vllm-svc.inference.svc.cluster.local:8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">rate-type&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">concurrent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-seconds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;60&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">data&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">prompt_tokens=1024,output_tokens=256&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">GUIDELLM_ENV&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">production&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">guidellm-sweep&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/vllm-project/guidellm:0.4.2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">guidellm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">benchmark&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">target&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">http://vllm-svc.inference.svc.cluster.local:8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">rate-type&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">sweep&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-seconds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;120&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">data&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">prompt_tokens=1024,output_tokens=256&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">output-path&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">/results/guidellm-$(BENCH_RUN_ID).json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BENCH_RUN_ID&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fieldRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fieldPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metadata.labels[&amp;#39;bench/run-id&amp;#39;]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">aiperf-concurrent&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvcr.io/nvidia/aiperf:0.2.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">aiperf&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">profile&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">url&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">http://vllm-svc.inference.svc.cluster.local:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">meta-llama/Meta-Llama-3-70B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">concurrency&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;4,8,16,32&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">input-tokens&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;1024&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">output-tokens&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;256&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">num-requests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;200&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">output&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">/results/aiperf-$(BENCH_RUN_ID).json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">scorecard-exporter&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">python:3.12-slim&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">/scripts/export_scorecard.py&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">run-id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="s2">&amp;#34;$(BENCH_RUN_ID)&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">http://prometheus.monitoring.svc.cluster.local:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">output&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">/results/scorecard-$(BENCH_RUN_ID).json&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">BENCH_RUN_ID&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fieldRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">fieldPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metadata.labels[&amp;#39;bench/run-id&amp;#39;]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/results&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Job name (&lt;code>bench-llama3-70b-fp8-h100x4-20260616&lt;/code>) is the unique identifier of the run. Changing it is enough to register a variant. The labels are the metadata that the &lt;code>scorecard-exporter&lt;/code> reads to enrich the output JSON.&lt;/p>
&lt;hr>
&lt;h2 id="performance-layer-guidellm-and-aiperf">Performance layer: GuideLLM and AIPerf&lt;/h2>
&lt;h3 id="guidellm--the-slo-driven-sweep">GuideLLM — the SLO-driven sweep&lt;/h3>
&lt;p>GuideLLM (a vLLM project) generates realistic traffic patterns, synchronous, concurrent, poisson, throughput, &lt;strong>sweep&lt;/strong>, and captures full TTFT and ITL distributions (&lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">Red Hat Developer&lt;/a>). The &lt;code>sweep&lt;/code> mode ramps from idle to saturation over 10 rounds and identifies the &lt;strong>knee&lt;/strong>: the maximum load where goodput ≈ throughput under the declared SLO. The output is JSON/CSV with all the percentiles per round, ready to be versioned.&lt;/p>
&lt;p>For the harness, the bench&amp;rsquo;s reference SLO is:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Threshold&lt;/th>
&lt;th>Percentile&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT&lt;/td>
&lt;td>500 ms&lt;/td>
&lt;td>P99&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ITL (TPOT)&lt;/td>
&lt;td>50 ms/tok&lt;/td>
&lt;td>P95&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Error rate&lt;/td>
&lt;td>0.5 %&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Job command already appears in the YAML above. The JSON output includes, per round: rate (req/s), TTFT (P50/P95/P99), ITL (P50/P95/P99), throughput (tok/s) and goodput (tok/s). The goodput under the SLO at the knee is the value that enters the scorecard.&lt;/p>
&lt;p>For the cross-verification layer of the data and comparability with published results, see the post &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM in depth&lt;/a>.&lt;/p>
&lt;h3 id="aiperf--nvidias-concurrency-profiler">AIPerf — NVIDIA&amp;rsquo;s concurrency profiler&lt;/h3>
&lt;p>AIPerf (the successor to GenAI-Perf, repository &lt;code>ai-dynamo/aiperf&lt;/code>) measures TTFT, ITL, throughput and latency distributions at fixed concurrencies (&lt;a href="https://github.com/ai-dynamo/aiperf">GitHub ai-dynamo/aiperf&lt;/a>). Where GuideLLM gives the automatic sweep up to the knee, AIPerf gives the detailed profile at specific concurrencies (4, 8, 16, 32 in the example): it allows the throughput-latency curve to be characterised point by point.&lt;/p>
&lt;p>The two are complementary: GuideLLM finds the knee automatically; AIPerf confirms it and characterises the behaviour in the neighbourhood of that knee. Both outputs go to the results volume with the same &lt;code>BENCH_RUN_ID&lt;/code>. The deep analysis of AIPerf/GenAI-Perf is in &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf in depth&lt;/a>.&lt;/p>
&lt;p>Metrics the harness extracts from AIPerf for the scorecard:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># TTFT P99 at concurrency 16 (the closest to the sweep&amp;#39;s knee):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aiperf profile ... --concurrency &lt;span class="m">16&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.results[] | select(.concurrency==16) | .ttft_ms.p99&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Goodput (tok/s) at concurrency 16:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">aiperf profile ... --concurrency &lt;span class="m">16&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.results[] | select(.concurrency==16) | .output_token_throughput&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="cost-layer-opencost-and-litellm">Cost layer: OpenCost and LiteLLM&lt;/h2>
&lt;h3 id="opencost--the-denominator-in-euros-per-gpu-hour">OpenCost — the denominator in euros per GPU-hour&lt;/h3>
&lt;p>OpenCost (CNCF incubating, &lt;a href="https://opencost.io/">opencost.io&lt;/a>) allocates Kubernetes cost to namespace, label, pod and container in real time (&lt;a href="https://github.com/opencost/opencost">GitHub opencost/opencost&lt;/a>). For the harness, OpenCost answers the question: &lt;strong>how much does the &lt;code>vllm-svc&lt;/code> pod cost in euros per hour during the experiment&amp;rsquo;s window?&lt;/strong>&lt;/p>
&lt;p>The harness calls the OpenCost REST API when the experiment finishes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Cost of the inference namespace over the experiment window (1 hour)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s &lt;span class="s2">&amp;#34;http://opencost.monitoring.svc.cluster.local:9003/allocation&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --data-urlencode &lt;span class="s1">&amp;#39;window=2026-06-16T12:00:00Z,2026-06-16T13:00:00Z&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --data-urlencode &lt;span class="s1">&amp;#39;aggregate=namespace&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --data-urlencode &lt;span class="s1">&amp;#39;namespace=inference&amp;#39;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.data[0].inference.totalCost&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The returned cost (in EUR, configured with the node&amp;rsquo;s real prices) is divided by the throughput measured in that same window to obtain the CPM:&lt;/p>
$$\text{CPM} = \frac{\text{cost}_\text{namespace/h} \times 10^6}{\text{goodput (tok/s)} \times 3\,600}$$
&lt;p>The post &lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">OpenCost: cost allocation in Kubernetes&lt;/a> details the price configuration and the allocation by GPU label.&lt;/p>
&lt;h3 id="litellm--the-per-request-token-counter">LiteLLM — the per-request token counter&lt;/h3>
&lt;p>LiteLLM (&lt;a href="https://www.litellm.ai/">litellm.ai&lt;/a>, &lt;a href="https://github.com/BerriAI/litellm">GitHub BerriAI/litellm&lt;/a>) acts as an OpenAI-compatible proxy with token accounting per request, model and team. In the harness, GuideLLM and AIPerf point at the LiteLLM endpoint (which in turn forwards to vLLM): every request is recorded with &lt;code>prompt_tokens&lt;/code>, &lt;code>completion_tokens&lt;/code> and &lt;code>cost&lt;/code> (using the custom pricing configured for the on-prem node).&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># litellm-config.yaml (fragment of custom on-prem pricing)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">model_list&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">model_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-70b-fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">litellm_params&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">openai/meta-llama/Meta-Llama-3-70B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_base&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://vllm-svc.inference.svc.cluster.local:8000/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">api_key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sk-dummy&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">input_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000109&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1.09 EUR/1M tok on-prem&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">output_cost_per_token&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.00000109&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The LiteLLM records are exported to Prometheus as &lt;code>litellm_request_total_tokens&lt;/code> and &lt;code>litellm_spend_metric_total&lt;/code>, which the scorecard exporter consumes to compute the real CPM per token type (input vs output).&lt;/p>
&lt;hr>
&lt;h2 id="energy-layer-dcgm-and-kepler">Energy layer: DCGM and Kepler&lt;/h2>
&lt;h3 id="dcgm-exporter--gpu-power-and-energy">DCGM Exporter — GPU power and energy&lt;/h3>
&lt;p>DCGM Exporter (&lt;a href="https://github.com/NVIDIA/dcgm-exporter">GitHub NVIDIA/dcgm-exporter&lt;/a>, &lt;a href="https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html">docs&lt;/a>) exposes GPU metrics on &lt;code>/metrics&lt;/code> for Prometheus as a DaemonSet on the GPU nodes. The two energy metrics the harness uses:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>DCGM metric&lt;/th>
&lt;th>Type&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Use in the harness&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>DCGM_FI_DEV_POWER_USAGE&lt;/code>&lt;/td>
&lt;td>gauge&lt;/td>
&lt;td>W&lt;/td>
&lt;td>instantaneous power per GPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION&lt;/code>&lt;/td>
&lt;td>counter&lt;/td>
&lt;td>mJ&lt;/td>
&lt;td>energy accumulated since boot&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>To compute the energy consumed during the experiment&amp;rsquo;s window (without the idle baseline), the harness takes the difference of the counter before and after the sweep:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Deployment as a DaemonSet (fragment of the official chart)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm repo add gpu-helm-charts &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> https://nvidia.github.io/dcgm-exporter/helm-charts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">helm install dcgm-exporter gpu-helm-charts/dcgm-exporter &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --namespace monitoring &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set serviceMonitor.enabled&lt;span class="o">=&lt;/span>&lt;span class="nb">true&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --set serviceMonitor.interval&lt;span class="o">=&lt;/span>15s
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION&lt;/code> field (in mJ) allows the experiment&amp;rsquo;s energy to be computed with hardware-counter accuracy, not with an estimate:&lt;/p>
$$\text{experiment energy (J)} = \left(\text{counter}_\text{end} - \text{counter}_\text{start}\right) \times 10^{-3}$$
$$\text{Wh/token} = \frac{\text{experiment energy (J)}}{3\,600 \times \text{generated tokens}}$$
&lt;h3 id="kepler--pod-level-energy">Kepler — pod-level energy&lt;/h3>
&lt;p>Kepler (CNCF sandbox, &lt;a href="https://github.com/sustainable-computing-io/kepler">GitHub sustainable-computing-io/kepler&lt;/a>) uses eBPF to estimate energy consumption at container and pod level, exporting &lt;code>kepler_container_joules_total&lt;/code> to Prometheus (&lt;a href="https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/">Red Hat Emerging Technologies&lt;/a>). It combines RAPL (CPU/DRAM), NVML (GPU) and regression models when no sensors are available.&lt;/p>
&lt;p>In the harness, Kepler complements DCGM: DCGM gives the hardware measurement of the GPU (more precise for GPU-intensive loads such as LLM inference), while Kepler attributes the energy to the specific vLLM pod (including the node&amp;rsquo;s CPU contribution). The main metric the harness consumes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Energy of the vllm-svc pod during the experiment (J)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">increase&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">kepler_container_joules_total&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nl">container_namespace&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">inference&lt;/span>&lt;span class="p">&amp;#34;,&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nl">container_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">vllm&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="p">}[&lt;/span>&lt;span class="err">${BENCH_DURATION}&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The comparative analysis of DCGM vs Kepler vs Zeus is in &lt;a href="https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/">Energy tools: deployment, precision and overhead&lt;/a>.&lt;/p>
&lt;h3 id="the-mlperf-power-reference">The MLPerf Power reference&lt;/h3>
&lt;p>MLPerf Power (&lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">MLCommons&lt;/a>, IEEE HPCA 2025 paper) establishes the protocol for measuring the energy efficiency of ML systems with high-precision external measurement. The in-house bench &lt;strong>is not an MLPerf submission&lt;/strong> (that requires external power meters and committee review), but its published results are the calibration reference: if the harness gives a result of the same order as the MLPerf submission for the same hardware with the same model, the measurement is plausible. If it differs by more than a factor of 2, there is a methodological problem. See the post &lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">MLPerf Power: energy efficiency&lt;/a> for the current reference data.&lt;/p>
&lt;hr>
&lt;h2 id="unifying-the-metrics-the-promql-queries">Unifying the metrics: the PromQL queries&lt;/h2>
&lt;p>The &lt;code>scorecard-exporter&lt;/code> is the Job container that, once GuideLLM and AIPerf have finished, collects all the metrics from Prometheus and builds the JSON of the scorecard row. The key queries:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Mean GPU power during the sweep (W) — 4×H100 node&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">avg_over_time&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_POWER_USAGE&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">Hostname&lt;/span>&lt;span class="o">=~&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">gpu-node-.*&lt;/span>&lt;span class="p">&amp;#34;}&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="err">${BENCH_DURATION}:&lt;/span>&lt;span class="s">15s&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Total GPU energy during the sweep (mJ → convert to J)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">Hostname&lt;/span>&lt;span class="o">=~&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">gpu-node-.*&lt;/span>&lt;span class="p">&amp;#34;}&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">offset&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="k">sum&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">Hostname&lt;/span>&lt;span class="o">=~&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">gpu-node-.*&lt;/span>&lt;span class="p">&amp;#34;}&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">offset&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="err">BENCH_DURATION&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.001&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Tokens generated during the sweep (from LiteLLM)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">increase&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">litellm_request_total_tokens&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nl">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">llama3-70b-fp8&lt;/span>&lt;span class="p">&amp;#34;,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">token_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">completion&lt;/span>&lt;span class="p">&amp;#34;}[&lt;/span>&lt;span class="err">${BENCH_DURATION}&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Cost of the inference namespace over the window (from the OpenCost API)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># → REST call at the start and at the end of the Job, difference of accrual&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>${BENCH_DURATION}&lt;/code> variable is the real duration of the sweep (in Prometheus format, e.g. &lt;code>22m&lt;/code>), which the exporter computes as &lt;code>end_ts - start_ts&lt;/code> and substitutes into every query.&lt;/p>
&lt;p>The output JSON of each run has this structure:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;run_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;20260616T1200&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;metadata&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;model&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;meta-llama/Meta-Llama-3-70B-Instruct&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;engine&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm-0.9.1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;precision&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;fp8&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;hardware&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;4xH100-SXM-80GB&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;isl_tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;osl_tokens&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">256&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tokenizer&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;meta-llama-3-tokenizer-v3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slo_ttft_p99_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">500&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;slo_itl_p95_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">50&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;bench_tool_guidellm&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.4.2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;bench_tool_aiperf&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.2.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;dcgm_exporter&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;3.3.9&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;kepler&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.10.2&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;performance&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;goodput_tok_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3120&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ttft_p99_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">487&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;itl_p95_ms&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">42&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;throughput_peak_tok_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3890&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;elbow_concurrency&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">28&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;cost&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;cpm_eur_1m&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.97&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;gpu_cost_eur_h&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">10.8&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;cost_window_eur&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">3.24&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;energy&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;wh_per_token&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.00044&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;j_per_token&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.58&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;power_mean_w&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">4924&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;energy_total_kwh&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.287&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;pue&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">1.4&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;wh_per_token_pue_adjusted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">0.000616&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;carbon&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;grid_intensity_gco2_kwh&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">40&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;co2_per_1m_tokens_g&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mf">24.6&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>All the fields are computed from the same time windows. The JSON is versioned in git alongside the harness code. Reproducing any scorecard row is: &lt;code>git checkout &amp;lt;run-id&amp;gt; &amp;amp;&amp;amp; kubectl apply -f job.yaml&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="the-3-axis-scorecard-table-and-interpretation">The 3-axis scorecard: table and interpretation&lt;/h2>
&lt;p>The following table illustrates how five configurations of the same model (Llama 3 70B) on the same reference node (4×H100 SXM) compare with the harness. The figures are &lt;strong>illustrative of the order of magnitude&lt;/strong> (the bench fills them in with real measurements):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Config&lt;/th>
&lt;th>Engine&lt;/th>
&lt;th>Precision&lt;/th>
&lt;th>CPM (€/1M)&lt;/th>
&lt;th>Goodput (tok/s)&lt;/th>
&lt;th>Wh/tok&lt;/th>
&lt;th>TTFT P99 (ms)&lt;/th>
&lt;th>ITL P95 (ms)&lt;/th>
&lt;th>CO2 /1M (g, FR)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>A&lt;/strong>&lt;/td>
&lt;td>vLLM 0.9&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.64&lt;/td>
&lt;td>1,890&lt;/td>
&lt;td>0.00074&lt;/td>
&lt;td>498&lt;/td>
&lt;td>49&lt;/td>
&lt;td>29.6&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>B&lt;/strong>&lt;/td>
&lt;td>vLLM 0.9&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>0.97&lt;/td>
&lt;td>3,120&lt;/td>
&lt;td>0.00044&lt;/td>
&lt;td>487&lt;/td>
&lt;td>42&lt;/td>
&lt;td>17.6&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>C&lt;/strong>&lt;/td>
&lt;td>SGLang 0.4&lt;/td>
&lt;td>FP8&lt;/td>
&lt;td>0.88&lt;/td>
&lt;td>3,410&lt;/td>
&lt;td>0.00041&lt;/td>
&lt;td>421&lt;/td>
&lt;td>38&lt;/td>
&lt;td>16.4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>D&lt;/strong>&lt;/td>
&lt;td>vLLM 0.9&lt;/td>
&lt;td>FP8, ISL 512&lt;/td>
&lt;td>1.31&lt;/td>
&lt;td>2,240&lt;/td>
&lt;td>0.00056&lt;/td>
&lt;td>294&lt;/td>
&lt;td>31&lt;/td>
&lt;td>22.4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>E&lt;/strong>&lt;/td>
&lt;td>vLLM 0.9&lt;/td>
&lt;td>FP8, max-batch 128&lt;/td>
&lt;td>0.84&lt;/td>
&lt;td>3,590&lt;/td>
&lt;td>0.00040&lt;/td>
&lt;td>721&lt;/td>
&lt;td>55&lt;/td>
&lt;td>16.0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>How to read the table:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>B vs A&lt;/strong>: FP8 over FP16 cuts CPM by 41 % and energy per token by 41 % thanks to the same higher throughput. Both meet the SLO (TTFT P99 &amp;lt; 500 ms, ITL P95 &amp;lt; 50 ms). B dominates A on all three axes: it is Pareto-superior.&lt;/li>
&lt;li>&lt;strong>C vs B&lt;/strong>: SGLang gives 9 % more goodput and 9 % lower CPM with FP8. Both meet the SLO. C is Pareto-superior to B (if the engine is indifferent for the stack).&lt;/li>
&lt;li>&lt;strong>D vs B&lt;/strong>: a shorter ISL (512 tok) lowers latency (TTFT P99 294 ms vs 487 ms) but lowers goodput and raises CPM. If the use case demands TTFT &amp;lt; 300 ms, D is the candidate; if TTFT &amp;lt; 500 ms is enough, B is better on cost and energy.&lt;/li>
&lt;li>&lt;strong>E vs C&lt;/strong>: &lt;code>max-batch 128&lt;/code> raises goodput (+5 %) and lowers CPM, but the TTFT P99 breaks the SLO (721 ms &amp;gt; 500 ms) and so does the ITL P95 (55 ms &amp;gt; 50 ms). E has the best raw throughput &lt;strong>but is outside the SLO&lt;/strong>: it is not a valid candidate for the interactive chat use case.&lt;/li>
&lt;/ul>
&lt;p>The scorecard&amp;rsquo;s &lt;strong>Pareto frontier&lt;/strong>, under the declared SLO, includes only A, B, C and D (E falls out for breaking the SLO). Of those four, C dominates B which dominates A. D only enters the frontier if the use case demands TTFT P99 &amp;lt; 300 ms. The decision is not a number; it is the whole row plus the SLO.&lt;/p>
&lt;hr>
&lt;h2 id="the-multi-objective-pareto-frontier">The multi-objective Pareto frontier&lt;/h2>
&lt;p>With three minimisation metrics, CPM (€/1M tok), Wh/tok and TTFT P99 (ms), the Pareto frontier is defined as the set of configurations where none dominates another on all three axes simultaneously. Formally, config \( i \) &lt;strong>dominates&lt;/strong> config \( j \) if:&lt;/p>
$$\text{CPM}_i \leq \text{CPM}_j \;\land\; \text{Wh/tok}_i \leq \text{Wh/tok}_j \;\land\; \text{TTFT}_{i} \leq \text{TTFT}_{j}$$
&lt;p>with at least one strict inequality. The scorecard allows it to be computed over the set of runs with a simple vector operation. In the table&amp;rsquo;s example, the frontier under the SLO (with E excluded) is \(\{C, D\}\): C dominates on cost/energy/goodput; D dominates on latency. Between C and D the choice depends on the TTFT SLO of the specific use case.&lt;/p>
&lt;div class="diagram" style="max-width:700px;margin:1rem auto;">
&lt;svg viewBox="0 0 700 320" role="img" aria-label="Pareto frontier on two axes: CPM EUR per million tokens on the Y axis and goodput tok per s on the X axis; points A B C D E marked; E excluded for breaking the SLO; the Pareto curve passes through C and D" xmlns="http://www.w3.org/2000/svg">
&lt;style>.ax{fill:none;stroke:currentColor;stroke-width:1}.ts{font:11px sans-serif;fill:currentColor}.tl{font:600 12px sans-serif;fill:currentColor}.pt{fill:currentColor}.dsh{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3}&lt;/style>
&lt;line class="ax" x1="70" y1="30" x2="70" y2="270"/>
&lt;line class="ax" x1="70" y1="270" x2="660" y2="270"/>
&lt;text x="10" y="155" class="ts" transform="rotate(-90 10 155)">CPM (EUR/1M tok) ↑ worse&lt;/text>
&lt;text x="320" y="300" class="ts">goodput (tok/s) → better&lt;/text>
&lt;text x="70" y="26" class="ts">1.70&lt;/text>
&lt;text x="70" y="80" class="ts">1.40&lt;/text>
&lt;text x="70" y="134" class="ts">1.10&lt;/text>
&lt;text x="70" y="188" class="ts">0.90&lt;/text>
&lt;text x="70" y="242" class="ts">0.84&lt;/text>
&lt;text x="115" y="284" class="ts">1,800&lt;/text>
&lt;text x="263" y="284" class="ts">2,200&lt;/text>
&lt;text x="410" y="284" class="ts">3,100&lt;/text>
&lt;text x="520" y="284" class="ts">3,400&lt;/text>
&lt;text x="600" y="284" class="ts">3,600&lt;/text>
&lt;circle class="pt" cx="130" cy="55" r="5"/>
&lt;text x="140" y="52" class="tl">A (FP16)&lt;/text>
&lt;circle class="pt" cx="380" cy="170" r="5"/>
&lt;text x="390" y="167" class="tl">B (FP8)&lt;/text>
&lt;circle class="pt" cx="500" cy="152" r="5"/>
&lt;text x="510" y="148" class="tl">C (SGLang FP8) ★&lt;/text>
&lt;circle class="pt" cx="270" cy="195" r="5"/>
&lt;text x="280" y="192" class="tl">D (ISL 512)&lt;/text>
&lt;circle class="pt" cx="570" cy="138" r="4" opacity="0.4"/>
&lt;text x="580" y="135" class="ts" opacity="0.4">E (SLO broken)&lt;/text>
&lt;path class="dsh" d="M130,55 L270,195 L500,152"/>
&lt;text x="190" y="148" class="ts">Pareto frontier&lt;/text>
&lt;text x="72" y="286" class="ts">(A dominates only on latency; C+D on the frontier under the SLO)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="reproducible-design-the-twelve-mandatory-metadata">Reproducible design: the twelve mandatory metadata&lt;/h2>
&lt;p>The checklist that closes the bias dossier (&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Bias and reproducibility&lt;/a>). For the harness JSON to be auditable, the twelve fields must be present in every run:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Field&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Why it is mandatory&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Serving engine version&lt;/td>
&lt;td>&lt;code>vllm-0.9.1&lt;/code>&lt;/td>
&lt;td>the same model can perform differently across versions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>Model version (commit/tag)&lt;/td>
&lt;td>&lt;code>meta-llama/Meta-Llama-3-70B-Instruct@sha256:abc&lt;/code>&lt;/td>
&lt;td>avoids ambiguity between variants of the same name&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Precision&lt;/td>
&lt;td>&lt;code>fp8&lt;/code>&lt;/td>
&lt;td>FP16 vs FP8 changes throughput and latency&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Tokeniser and version&lt;/td>
&lt;td>&lt;code>meta-llama-3-tokenizer-v3&lt;/code>&lt;/td>
&lt;td>the ISL/OSL in tokens depends on the tokeniser&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>ISL (input sequence length)&lt;/td>
&lt;td>&lt;code>1024&lt;/code> tok&lt;/td>
&lt;td>changes the prefill time and the knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>OSL (output sequence length)&lt;/td>
&lt;td>&lt;code>256&lt;/code> tok&lt;/td>
&lt;td>changes the decode time&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>Hardware (model and count)&lt;/td>
&lt;td>&lt;code>4×H100-SXM-80GB&lt;/code>&lt;/td>
&lt;td>the basis of any comparison&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>Maximum concurrency tested&lt;/td>
&lt;td>&lt;code>32&lt;/code>&lt;/td>
&lt;td>defines the sweep&amp;rsquo;s range&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>Bench tool version&lt;/td>
&lt;td>&lt;code>guidellm-0.4.2&lt;/code>, &lt;code>aiperf-0.2.0&lt;/code>&lt;/td>
&lt;td>different versions can give different metrics&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>Declared SLO&lt;/td>
&lt;td>&lt;code>TTFT P99 &amp;lt; 500 ms, ITL P95 &amp;lt; 50 ms&lt;/code>&lt;/td>
&lt;td>the knee depends on the SLO; without it there is no goodput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>11&lt;/td>
&lt;td>Warmup duration&lt;/td>
&lt;td>&lt;code>60 s&lt;/code>&lt;/td>
&lt;td>without warmup the KV cache is cold and the first rounds are biased&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>12&lt;/td>
&lt;td>Harness version / run-id&lt;/td>
&lt;td>&lt;code>20260616T1200&lt;/code>&lt;/td>
&lt;td>identifies the run so it can be reproduced with &lt;code>git checkout&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A scorecard missing any of these twelve fields is an anecdote. With all twelve, it is an auditable datum. The run-id in the Job name (&lt;code>bench-llama3-70b-fp8-h100x4-20260616&lt;/code>) implicitly incorporates fields 1-3 and 7, forcing the Job name to change with every variant, which makes it impossible to overlap runs in the same namespace.&lt;/p>
&lt;hr>
&lt;h2 id="idempotence-and-versioning-of-results">Idempotence and versioning of results&lt;/h2>
&lt;p>The Kubernetes Job is idempotent: if the same manifest is applied again (same name), the Job already exists and Kubernetes does not relaunch it (&lt;code>backoffLimit: 0&lt;/code> policy). This avoids accidental duplicate runs. To relaunch, the Job has to be deleted (&lt;code>kubectl delete job &amp;lt;name&amp;gt;&lt;/code>) or the &lt;code>run-id&lt;/code> changed.&lt;/p>
&lt;p>Results are versioned with the following directory convention in the harness&amp;rsquo;s git repository:&lt;/p>
&lt;pre tabindex="0">&lt;code>results/
20260616T1200/
guidellm-20260616T1200.json
aiperf-20260616T1200.json
scorecard-20260616T1200.json
20260617T0900/
guidellm-20260617T0900.json
aiperf-20260617T0900.json
scorecard-20260617T0900.json
scorecard-aggregate.csv # all rows, for analysis and charts
&lt;/code>&lt;/pre>&lt;p>The &lt;code>scorecard-aggregate.csv&lt;/code> is the scorecard table in flat format: each row is a run, each column a field of the JSON. It is generated with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Generates the aggregate CSV from all the JSON files in results/&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python scripts/aggregate_scorecards.py results/ &amp;gt; scorecard-aggregate.csv
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The CSV is what feeds the Pareto charts, the reports and the architecture decisions. It is versioned in git; its diff is the configuration change.&lt;/p>
&lt;hr>
&lt;h2 id="how-it-connects-with-the-other-posts-in-the-series">How it connects with the other posts in the series&lt;/h2>
&lt;p>The harness is not a standalone article: it is the synthesis of the twenty-eight articles. Each tool has its deep dive:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Performance&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM in depth&lt;/a> — sweep, SLO, goodput, knee; &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf / AIPerf in depth&lt;/a> — concurrency, rate sweep, distribution metrics.&lt;/li>
&lt;li>&lt;strong>Cost&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">OpenCost: cost allocation in Kubernetes&lt;/a> — allocation by namespace/label, custom on-prem prices.&lt;/li>
&lt;li>&lt;strong>Energy&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/">Energy tools: deployment, precision and overhead&lt;/a> — DCGM vs Kepler vs Zeus, instrumentation overhead, relative precision; &lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">MLPerf Power: energy efficiency&lt;/a> — the external calibration reference.&lt;/li>
&lt;li>&lt;strong>Theoretical framework&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">The three axes: cost, performance and energy&lt;/a> — the CPM/energy/throughput identity that justifies measuring them together.&lt;/li>
&lt;li>&lt;strong>Bias&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Bias and reproducibility in benchmarking&lt;/a> — the twelve biases the harness removes.&lt;/li>
&lt;/ul>
&lt;p>The harness closes the series&amp;rsquo; dossier because it makes the opening article&amp;rsquo;s promise possible: &amp;ldquo;when someone disputes a number in the proposal, the answer is not &amp;lsquo;a blog says so&amp;rsquo;, but &amp;rsquo;this is the bench, this is the methodology, reproduce it&amp;rsquo;&amp;rdquo;. With the run-id and the git repo, reproducing is one command.&lt;/p>
&lt;hr>
&lt;h2 id="full-flow-of-a-benchmarking-session">Full flow of a benchmarking session&lt;/h2>
&lt;p>The harness&amp;rsquo;s standard operating procedure, from start to finish:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Make sure the inference endpoint is up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get svc vllm-svc -n inference
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Verify that DCGM and Kepler are scraping&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -s http://prometheus.monitoring.svc.cluster.local:9090/api/v1/query &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --data-urlencode &lt;span class="s1">&amp;#39;query=DCGM_FI_DEV_POWER_USAGE&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> jq &lt;span class="s1">&amp;#39;.data.result | length&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 3. Record the energy counter BEFORE the experiment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">PRE_ENERGY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>kubectl &lt;span class="nb">exec&lt;/span> -n monitoring dcgm-exporter-xxx -- &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> curl -s localhost:9400/metrics &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> grep DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{sum+=$2} END{print sum}&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 4. Launch the Job (unique name per run)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl apply -f jobs/bench-llama3-70b-fp8-h100x4-20260616.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 5. Wait for it to finish&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl &lt;span class="nb">wait&lt;/span> --for&lt;span class="o">=&lt;/span>&lt;span class="nv">condition&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">complete&lt;/span> job/bench-llama3-70b-fp8-h100x4-20260616 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -n benchmark --timeout&lt;span class="o">=&lt;/span>3600s
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 6. Record the energy counter AFTER&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">POST_ENERGY&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>kubectl &lt;span class="nb">exec&lt;/span> -n monitoring dcgm-exporter-xxx -- &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> curl -s localhost:9400/metrics &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> grep DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION &lt;span class="p">|&lt;/span> awk &lt;span class="s1">&amp;#39;{sum+=$2} END{print sum}&amp;#39;&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 7. Collect the results from the volume&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl cp benchmark/bench-run-pod:/results/ ./results/20260616T1200/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 8. Compute the experiment&amp;#39;s energy (mJ → kWh)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python scripts/calc_energy.py &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --pre &lt;span class="nv">$PRE_ENERGY&lt;/span> --post &lt;span class="nv">$POST_ENERGY&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --run-id 20260616T1200
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 9. Add a row to the aggregate CSV&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python scripts/aggregate_scorecards.py results/ &amp;gt; scorecard-aggregate.csv
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 10. Version it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git add results/20260616T1200/ scorecard-aggregate.csv
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git commit -m &lt;span class="s2">&amp;#34;bench: llama3-70b fp8 4xH100 ISL1024 OSL256 (20260616T1200)&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Steps 6-9 are candidates for automation as a second post-processing Job launched when the main one completes (with &lt;code>ownerReferences&lt;/code> or a clean-up CronJob). In the harness&amp;rsquo;s basic state, the manual steps are precisely the ones that force a review of the result before versioning it.&lt;/p>
&lt;hr>
&lt;h2 id="cost-of-the-experiment">Cost of the experiment&lt;/h2>
&lt;p>A GuideLLM sweep of 10 rounds at 120 seconds per round occupies the node for ~22-26 minutes. The AIPerf profile at 4 concurrencies (200 requests each) adds ~8-12 minutes. Total per run: ~35-40 minutes of a 4×H100 node.&lt;/p>
&lt;p>At the reference amortised cost (~10.8 €/h for 4×H100 on-prem):&lt;/p>
$$\text{cost per run} \approx 10.8 \times \frac{38}{60} \approx 6.84 \text{ EUR}$$
&lt;p>At the reference European cloud price (4 × 2.73 = 10.92 €/h):&lt;/p>
$$\text{cost per run (cloud)} \approx 10.92 \times \frac{38}{60} \approx 6.92 \text{ EUR}$$
&lt;p>A continuous benchmarking programme (10 configurations × 3 models × 2 sweeps/week) adds up to ~415 EUR/week. That is why the harness includes short sweeps (5 rounds, ~15 minutes) for CI and full sweeps for releases.&lt;/p>
&lt;hr>
&lt;h2 id="what-the-harness-does-not-measure-and-why">What the harness does not measure (and why)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Missing dimension&lt;/th>
&lt;th>Reason&lt;/th>
&lt;th>Where it is covered&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Output quality (MMLU, HumanEval)&lt;/td>
&lt;td>requires a quality evaluation bench, not a serving one&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">LLM quality benchmarks&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Training throughput&lt;/td>
&lt;td>the harness is exclusively for inference&lt;/td>
&lt;td>outside the scope of the &amp;ldquo;data&amp;rdquo; series&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Network and storage cost&lt;/td>
&lt;td>OpenCost can attribute it, but it requires additional configuration&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">OpenCost: cost allocation&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hourly carbon intensity&lt;/td>
&lt;td>use the ElectricityMaps API + the measured energy&lt;/td>
&lt;td>&lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">From the watt to carbon: PUE and grid mix&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Client-server network latency&lt;/td>
&lt;td>the Job runs inside the cluster; WAN latency requires an external client&lt;/td>
&lt;td>document as additional metadata&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>GuideLLM · GitHub (proyecto vLLM) — &lt;a href="https://github.com/vllm-project/guidellm">https://github.com/vllm-project/guidellm&lt;/a>&lt;/li>
&lt;li>GuideLLM · PyPI — &lt;a href="https://pypi.org/project/guidellm/">https://pypi.org/project/guidellm/&lt;/a>&lt;/li>
&lt;li>Red Hat Developer · desplegar y benchmarkear vLLM con GuideLLM en Kubernetes — &lt;a href="https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes">https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes&lt;/a>&lt;/li>
&lt;li>Red Hat Developer · GuideLLM: evaluar despliegues LLM para inferencia real — &lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference&lt;/a>&lt;/li>
&lt;li>AIPerf · GitHub (ai-dynamo/aiperf) — &lt;a href="https://github.com/ai-dynamo/aiperf">https://github.com/ai-dynamo/aiperf&lt;/a>&lt;/li>
&lt;li>GenAI-Perf · documentación NVIDIA Triton — &lt;a href="https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html">https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html&lt;/a>&lt;/li>
&lt;li>NVIDIA · LLM performance benchmarking con GenAI-Perf — &lt;a href="https://developer.nvidia.com/blog/llm-performance-benchmarking-measuring-nvidia-nim-performance-with-genai-perf">https://developer.nvidia.com/blog/llm-performance-benchmarking-measuring-nvidia-nim-performance-with-genai-perf&lt;/a>&lt;/li>
&lt;li>NVIDIA NIM Benchmarking · métricas (TTFT, ITL, throughput) — &lt;a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html">https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html&lt;/a>&lt;/li>
&lt;li>OpenCost · web oficial — &lt;a href="https://opencost.io/">https://opencost.io/&lt;/a>&lt;/li>
&lt;li>OpenCost · GitHub (opencost/opencost) — &lt;a href="https://github.com/opencost/opencost">https://github.com/opencost/opencost&lt;/a>&lt;/li>
&lt;li>OpenCost · CNCF blog (sandbox → incubating) — &lt;a href="https://www.cncf.io/blog/2022/12/06/opencost-a-new-cncf-sandbox-project-for-real-time-kubernetes-cost-monitoring/">https://www.cncf.io/blog/2022/12/06/opencost-a-new-cncf-sandbox-project-for-real-time-kubernetes-cost-monitoring/&lt;/a>&lt;/li>
&lt;li>LiteLLM · web oficial — &lt;a href="https://www.litellm.ai/">https://www.litellm.ai/&lt;/a>&lt;/li>
&lt;li>LiteLLM · GitHub (BerriAI/litellm) — &lt;a href="https://github.com/BerriAI/litellm">https://github.com/BerriAI/litellm&lt;/a>&lt;/li>
&lt;li>LiteLLM · Spend Tracking docs — &lt;a href="https://docs.litellm.ai/docs/proxy/cost_tracking">https://docs.litellm.ai/docs/proxy/cost_tracking&lt;/a>&lt;/li>
&lt;li>NVIDIA DCGM Exporter · documentación oficial — &lt;a href="https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html">https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html&lt;/a>&lt;/li>
&lt;li>NVIDIA DCGM Exporter · GitHub — &lt;a href="https://github.com/NVIDIA/dcgm-exporter">https://github.com/NVIDIA/dcgm-exporter&lt;/a>&lt;/li>
&lt;li>NVIDIA DCGM Exporter · métricas CSV — &lt;a href="https://github.com/NVIDIA/dcgm-exporter/blob/main/etc/dcp-metrics-included.csv">https://github.com/NVIDIA/dcgm-exporter/blob/main/etc/dcp-metrics-included.csv&lt;/a>&lt;/li>
&lt;li>Kepler (Kubernetes-based Efficient Power Level Exporter) · GitHub — &lt;a href="https://github.com/sustainable-computing-io/kepler">https://github.com/sustainable-computing-io/kepler&lt;/a>&lt;/li>
&lt;li>Red Hat Emerging Technologies · Introducing Kepler — &lt;a href="https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/">https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference Datacenter benchmark — &lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">https://mlcommons.org/benchmarks/inference-datacenter/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Power presentado en IEEE HPCA 2025 — &lt;a href="https://mlcommons.org/2025/03/ml-commons-power-hpca/">https://mlcommons.org/2025/03/ml-commons-power-hpca/&lt;/a>&lt;/li>
&lt;li>IEEE Xplore · MLPerf Power paper (μWatts to MWatts) — &lt;a href="https://ieeexplore.ieee.org/document/10946778/">https://ieeexplore.ieee.org/document/10946778/&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference v5.1 results (septiembre 2025) — &lt;a href="https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/">https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/&lt;/a>&lt;/li>
&lt;li>Red Hat · Efficient and reproducible LLM inference: MLPerf Inference v5.1 — &lt;a href="https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results">https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results&lt;/a>&lt;/li>
&lt;li>BentoML · Beyond tokens-per-second: cost, speed and quality in LLM inference — &lt;a href="https://www.bentoml.com/blog/beyond-tokens-per-second-how-to-balance-speed-cost-and-quality-in-llm-inference">https://www.bentoml.com/blog/beyond-tokens-per-second-how-to-balance-speed-cost-and-quality-in-llm-inference&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Measurement bias and reproducibility: why two benchmarks of the same model give figures that differ by up to 7×</title><link>https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/</link><pubDate>Tue, 16 Jun 2026 07:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The mathematical symbol is
reserved for formulas: amounts are expressed in € or USD. Thousands with a thin space
(\(1\,000\)).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Running the same Llama-3-70B-Instruct model on the same 4×H100 SXM 80 GB node with two
different tools can produce throughput results that differ by &lt;strong>7.2×&lt;/strong> without the engine
changing a single line of code. The cause is not the engine: it is the &lt;strong>measurement method&lt;/strong>. At
1,000 QPS, a single-process asyncio client (vLLM bench, SGLang bench, genai-perf
pre-AIPerf) processed &lt;strong>75,574 tokens&lt;/strong> against the &lt;strong>545,733 tokens&lt;/strong> of a multi-process
client, both measuring the same endpoint
(&lt;a href="https://arxiv.org/abs/2605.24217">arXiv 2605.24217&lt;/a>). The bias is not random: it is
systematic, directional and reproducible. This article catalogues the sources of bias, quantifies
their magnitude and describes the minimum harness that turns a number into an auditable datum.&lt;/p>
&lt;hr>
&lt;h2 id="the-sources-of-bias-a-catalogue-with-magnitudes">The sources of bias: a catalogue with magnitudes&lt;/h2>
&lt;p>Each source of bias moves the published number in a given direction and with a
characteristic magnitude. The table below orders the sources from largest to smallest impact
observed in the literature:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Source of bias&lt;/th>
&lt;th>Direction of the bias&lt;/th>
&lt;th>Documented magnitude&lt;/th>
&lt;th>Reference&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Single-process client saturation&lt;/strong> (Python GIL, asyncio)&lt;/td>
&lt;td>understates throughput; overstates latency&lt;/td>
&lt;td>up to &lt;strong>7.2×&lt;/strong> fewer tokens processed at 1,000 QPS&lt;/td>
&lt;td>arXiv 2605.24217&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tokenizer different from the model&amp;rsquo;s&lt;/strong> (LLMPerf uses a universal LlamaTokenizer)&lt;/td>
&lt;td>tok/s not comparable across different vocabularies&lt;/td>
&lt;td>variable; up to &lt;strong>33 %&lt;/strong> of client overhead in the TPS denominator&lt;/td>
&lt;td>NVIDIA blog (TPS = client overhead ÷ total duration)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ignore_eos missing or inconsistent OSL&lt;/strong>&lt;/td>
&lt;td>underestimates throughput; artificially shortens the real OSL&lt;/td>
&lt;td>up to premature termination; an unrealistic fixed-length benchmark&lt;/td>
&lt;td>vLLM bench docs; NVIDIA fundamental concepts&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Unrealistic ISL/OSL distribution&lt;/strong> (fixed length instead of a realistic distribution)&lt;/td>
&lt;td>an optimistic knee that does not resemble production&lt;/td>
&lt;td>the knee shifts; prefill understated with short prompts&lt;/td>
&lt;td>AIPerf sequence-length-distributions docs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Missing warmup / warm prefix cache&lt;/strong>&lt;/td>
&lt;td>overstates TTFT (artificially low on the first requests)&lt;/td>
&lt;td>TTFT underestimated by an already populated KV cache&lt;/td>
&lt;td>vLLM prefix caching docs; arXiv 2605.24217&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>&lt;code>concurrency&lt;/code> vs &lt;code>request-rate&lt;/code>&lt;/strong> as the load mode&lt;/td>
&lt;td>concurrency saturates symmetrically; request-rate can build a queue&lt;/td>
&lt;td>ITL overestimated if the queue grows without a ceiling with request-rate&lt;/td>
&lt;td>NVIDIA fundamentals; AIPerf docs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Measurement window too short&lt;/strong>&lt;/td>
&lt;td>does not capture the steady state; includes ramp-up&lt;/td>
&lt;td>throughput overestimated; latency underestimated&lt;/td>
&lt;td>meta-metrics arXiv 2508.10251&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Thermal state and GPU clocks&lt;/strong>&lt;/td>
&lt;td>thermal throttling reduces throughput without prior stabilisation&lt;/td>
&lt;td>temperature &amp;gt; 75 °C triggers throttling; a difference of ~3 W of stable power&lt;/td>
&lt;td>arXiv 2604.09048&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Warm prefix cache between runs&lt;/strong>&lt;/td>
&lt;td>overstates TTFT; unrealistic TTFT if the cache is pre-populated from the previous run&lt;/td>
&lt;td>a severe effect in multi-prompt evaluations with shared prefixes&lt;/td>
&lt;td>arXiv 2605.24217&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Mean instead of percentiles&lt;/strong>&lt;/td>
&lt;td>hides the latency tail&lt;/td>
&lt;td>P99 can be 4–6× the median at high concurrency&lt;/td>
&lt;td>NVIDIA fundamentals; Anyscale docs&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="the-dominant-bias-single-process-client-saturation">The dominant bias: single-process client saturation&lt;/h2>
&lt;p>The largest bias is not in the engine but in the &lt;strong>client that generates the load&lt;/strong>. The
single-process tools (vLLM bench, SGLang bench, genai-perf before its replacement by
AIPerf on 15 April 2026) use a single Python process with asyncio to manage the
concurrent requests. The Python Global Interpreter Lock (GIL) prevents a single process from
using more than one CPU core at a time, which introduces a bottleneck on the
client side that becomes critical at high concurrency.&lt;/p>
&lt;p>The mathematical effect: as the request rate rises, the client fails to dispatch
requests at the configured pace, accumulates queueing time on the client side, and that
time gets recorded as engine latency. The result is that TTFT and TPOT
&lt;strong>appear inflated&lt;/strong> and throughput &lt;strong>appears depressed&lt;/strong>, without the engine having changed
anything (&lt;a href="https://arxiv.org/abs/2605.24217">arXiv 2605.24217&lt;/a>).&lt;/p>
&lt;p>The documented discrepancy at 1,000 QPS:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Client architecture&lt;/th>
&lt;th>Tokens processed at 1,000 QPS&lt;/th>
&lt;th>Ratio&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Single-process (asyncio, Python GIL)&lt;/td>
&lt;td>75,574&lt;/td>
&lt;td>1× (baseline)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-process (distributed load)&lt;/td>
&lt;td>545,733&lt;/td>
&lt;td>&lt;strong>7.2×&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Note: both clients point at the &lt;strong>same endpoint&lt;/strong>; the difference is entirely attributable
to the benchmark client, not to the engine.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 220" role="img" aria-label="Client saturation diagram: a single-process client builds a queue on the client side and reports inflated latency; a multi-process client removes the bottleneck and measures the real engine" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#bm2)}&lt;/style>
&lt;defs>&lt;marker id="bm2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" class="tl">SINGLE-PROCESS (vLLM bench, SGLang bench, genai-perf pre-AIPerf)&lt;/text>
&lt;rect class="bx" x="20" y="32" width="140" height="44" rx="5"/>
&lt;text x="30" y="52" class="ts">1 Python proc.&lt;/text>
&lt;text x="30" y="67" class="ts">asyncio + GIL&lt;/text>
&lt;rect class="dsh" x="200" y="32" width="130" height="44" rx="5"/>
&lt;text x="210" y="52" class="ts">client queue&lt;/text>
&lt;text x="210" y="67" class="ts">(≠ engine queue)&lt;/text>
&lt;path class="ar" d="M160,54 L198,54"/>
&lt;path class="ar" d="M330,54 L375,54"/>
&lt;rect class="bx" x="375" y="32" width="110" height="44" rx="5"/>
&lt;text x="385" y="58" class="ts">Engine (vLLM…)&lt;/text>
&lt;text x="498" y="48" class="ts">TTFT/TPOT&lt;/text>
&lt;text x="498" y="63" class="ts">overestimated&lt;/text>
&lt;text x="498" y="78" class="ts">TPS underestimated&lt;/text>
&lt;text x="20" y="112" class="tl">MULTI-PROCESS (AIPerf, GuideLLM)&lt;/text>
&lt;rect class="bx" x="20" y="122" width="140" height="44" rx="5"/>
&lt;text x="30" y="142" class="ts">N processes&lt;/text>
&lt;text x="30" y="157" class="ts">no shared GIL&lt;/text>
&lt;path class="ar" d="M160,144 L375,144"/>
&lt;rect class="bx" x="375" y="122" width="110" height="44" rx="5"/>
&lt;text x="385" y="148" class="ts">Engine (vLLM…)&lt;/text>
&lt;text x="498" y="138" class="ts">measures the engine;&lt;/text>
&lt;text x="498" y="153" class="ts">7.2× more throughput&lt;/text>
&lt;text x="498" y="168" class="ts">captured&lt;/text>
&lt;text x="20" y="210" class="ts">The client architecture determines what gets measured: the client bottleneck or the engine's.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="tokenizer-bias-toks-are-not-comparable-across-vocabularies">Tokenizer bias: tok/s are not comparable across vocabularies&lt;/h2>
&lt;p>Tokens are not universal. Each model has its own tokenizer with its own
vocabulary. Llama-3 uses a vocabulary of 128,256 tokens; Gemma uses 256,128; earlier
models used 32,000–50,000. The same English text produces a different number of tokens
depending on the model&amp;rsquo;s tokenizer.&lt;/p>
&lt;p>The direct consequence for benchmarking: if the tool measures tok/s with a
tokenizer different from that of the model being served, the token count, and therefore the throughput in
tok/s and the cost per token, are biased. LLMPerf (archived in December 2025) used
&lt;strong>LlamaTokenizer&lt;/strong> universally for all models, which guaranteed internal consistency
in the leaderboard but made the tok/s &lt;strong>not comparable&lt;/strong> with measurements of other
models with different vocabularies.&lt;/p>
&lt;p>The TPS formula in LLMPerf additionally included the benchmark&amp;rsquo;s full denominator, the time
spent generating prompts, preparing requests and storing responses, which
NVIDIA estimated at up to &lt;strong>33 % of the total duration&lt;/strong> at concurrency 1. This makes
LLMPerf&amp;rsquo;s TPS systematically lower than that of GenAI-Perf/AIPerf for the same system,
without the engine being worse:&lt;/p>
$$\text{TPS}_{\text{LLMPerf}} = \frac{\text{output tokens}}{T_{\text{end}} - T_{\text{start}}}$$
$$\text{TPS}_{\text{GenAI-Perf}} = \frac{\text{output tokens}}{T_y - T_x}$$
&lt;p>Where \(T_{\text{start}}\) and \(T_{\text{end}}\) include the client overheads, while
\(T_x\) and \(T_y\) are the instant of the first request and that of the last token received,
respectively (&lt;a href="https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/">NVIDIA · Fundamental Concepts&lt;/a>).&lt;/p>
&lt;p>The operational rule: &lt;strong>always count tokens with the tokenizer of the model being served&lt;/strong>, not with a
proxy tokenizer.&lt;/p>
&lt;hr>
&lt;h2 id="the-bias-of-ignore_eos-and-an-inconsistent-osl">The bias of ignore_eos and an inconsistent OSL&lt;/h2>
&lt;p>Most LLM models generate a special end-of-sequence token (EOS) when
they consider the response complete. If the benchmark does not set &lt;code>ignore_eos=True&lt;/code>, the real
output length (OSL) varies from request to request according to the model&amp;rsquo;s natural length
distribution, which produces:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Inconsistent OSL&lt;/strong>: two runs with the same seed can produce different OSL
distributions if the model varies its natural output.&lt;/li>
&lt;li>&lt;strong>Spurious comparison&lt;/strong>: a &amp;ldquo;faster&amp;rdquo; model may be so simply because it generates
shorter responses (it hits EOS sooner), not because it has more real throughput.&lt;/li>
&lt;li>&lt;strong>Underestimated throughput&lt;/strong>: if the benchmark expects OSL=256 but the model stops at
OSL=80 on average, the throughput in tok/s appears higher but measures less work.&lt;/li>
&lt;/ol>
&lt;p>The &lt;code>ignore_eos&lt;/code> parameter (or &lt;code>--ignore-eos&lt;/code> in vLLM bench) instructs the engine to ignore the
EOS token and continue until reaching &lt;code>max_tokens&lt;/code>. It is &lt;strong>mandatory&lt;/strong> for the OSL to be the
configured one rather than the model&amp;rsquo;s natural one, and for two runs to be comparable
(&lt;a href="https://docs.vllm.ai/en/latest/benchmarking/cli/">vLLM benchmark CLI docs&lt;/a>).&lt;/p>
&lt;hr>
&lt;h2 id="the-bias-of-the-islosl-distribution">The bias of the ISL/OSL distribution&lt;/h2>
&lt;p>The distribution of input (ISL) and output (OSL) lengths determines what proportion of the
compute time goes to prefill (costly in TTFT) and how much to decode (costly in
ITL). A benchmark with a fixed length, say ISL=128, OSL=128, produces results that
do not resemble any real traffic.&lt;/p>
&lt;p>Real use cases have very different distributions:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Typical ISL (tokens)&lt;/th>
&lt;th>Typical OSL (tokens)&lt;/th>
&lt;th>Dominated by&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Translation&lt;/td>
&lt;td>500–2,000&lt;/td>
&lt;td>500–2,000&lt;/td>
&lt;td>balanced&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Generation (code, email)&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>~1,000&lt;/td>
&lt;td>decode (long OSL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Summarisation / RAG&lt;/td>
&lt;td>~1,000&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>prefill (long ISL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasoning (CoT)&lt;/td>
&lt;td>~100&lt;/td>
&lt;td>1,000–10,000&lt;/td>
&lt;td>very long decode&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A short-ISL benchmark with a model optimised for prefill will give an artificially
low TTFT and an artificially high throughput. The knee of the sweep shifts: with ISL=64 the
system admits more concurrency without breaking the TTFT SLO; with ISL=1,024 prefill saturates
earlier and the knee appears earlier. Using the wrong distribution means sizing for traffic
that does not exist.&lt;/p>
&lt;p>AIPerf introduces &lt;strong>sequence distributions&lt;/strong> with configurable per-component variance
to reproduce realistic traffic mixes
(&lt;a href="https://docs.nvidia.com/aiperf/tutorials/datasets-inputs/sequence-length-distributions-for-advanced-benchmarking">AIPerf · Sequence Length Distributions&lt;/a>):&lt;/p>
&lt;pre tabindex="0">&lt;code>--sequence-distribution &amp;#34;64|10,32|8:70;256|40,128|20:20;1024|100,512|50:10&amp;#34;
&lt;/code>&lt;/pre>&lt;p>This creates 70 % of requests with \(\text{ISL} \sim \mathcal{N}(64, 10)\) and
\(\text{OSL} \sim \mathcal{N}(32, 8)\), 20 % with medium ISL/OSL, and 10 % with
long ISL/OSL, far more faithful to real chatbot traffic than a fixed length.&lt;/p>
&lt;hr>
&lt;h2 id="the-bias-of-warmup-and-the-prefix-cache">The bias of warmup and the prefix cache&lt;/h2>
&lt;p>Two related but distinct sources of bias:&lt;/p>
&lt;p>&lt;strong>Missing warmup.&lt;/strong> The first requests of a benchmark hit a &amp;ldquo;cold&amp;rdquo; engine: the
GPU is in a low-clock state, the KV cache is empty, and the operating system may
be paging memory. The TTFT of the first requests is structurally higher than
that of the steady state. If the benchmark does not discard a warmup period, the mean
TTFT includes these outliers and overestimates the real production latency. Some frameworks
(GenAI-Perf/AIPerf) use a &lt;strong>sliding window&lt;/strong> that excludes the ramp-up and
ramp-down requests; others do not.&lt;/p>
&lt;p>&lt;strong>Warm prefix cache between runs.&lt;/strong> The prefix KV cache (prefix cache or prompt
cache) stores the computed results of repeated prompt prefixes. If the
benchmark runs multiple consecutive runs with the same prompts, the second and
subsequent runs find the KV cache already populated and report an artificially
low TTFT, that of the decode, not of the prefill. For a baseline benchmark, the prefix cache must
be cold; for one that simulates production with repeated prompts, warm. The distinction
must be made explicit
(&lt;a href="https://arxiv.org/abs/2605.24217">arXiv 2605.24217&lt;/a>; &lt;a href="https://docs.vllm.ai/en/stable/design/prefix_caching/">vLLM · Automatic Prefix Caching&lt;/a>).&lt;/p>
&lt;hr>
&lt;h2 id="the-bias-of-concurrency-vs-request-rate">The bias of &lt;code>concurrency&lt;/code> vs &lt;code>request-rate&lt;/code>&lt;/h2>
&lt;p>The two load control modes produce different latency distributions for the
same system:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Mode&lt;/th>
&lt;th>Semantics&lt;/th>
&lt;th>When to use&lt;/th>
&lt;th>Risk&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>concurrency N&lt;/strong>&lt;/td>
&lt;td>keeps exactly N requests in flight; as soon as one finishes, it launches another&lt;/td>
&lt;td>measuring the system under a fixed concurrency load&lt;/td>
&lt;td>over-represents sustained load; does not simulate real arrivals&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>request-rate r&lt;/strong> (constant or Poisson)&lt;/td>
&lt;td>launches one request every \(1/r\) seconds (constant) or with interarrival \(\sim \text{Exp}(1/r)\) (Poisson)&lt;/td>
&lt;td>simulating real traffic (random arrivals)&lt;/td>
&lt;td>if the engine cannot absorb r req/s, the queue grows without a ceiling&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>NVIDIA recommends the &lt;strong>concurrency&lt;/strong> mode for most capacity benchmarks
(&lt;a href="https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/">NVIDIA · LLM Benchmarking Fundamental Concepts&lt;/a>). The request-rate mode is more faithful for online traffic
(a Poisson distribution of arrivals), but if the rate exceeds the engine&amp;rsquo;s capacity the queue
grows indefinitely and the latency metrics include queueing time that can
dominate the TTFT, mixing queue behaviour with engine behaviour.&lt;/p>
&lt;p>A system measured at concurrency=16 and another at request-rate=16 req/s &lt;strong>are not under the
same load&lt;/strong>: fixed concurrency guarantees 16 simultaneous requests; the rate sets
the arrival pace but not the instantaneous concurrency. Comparing their results without adjustment
is incorrect.&lt;/p>
&lt;hr>
&lt;h2 id="the-bias-of-the-measurement-window-and-the-thermal-state">The bias of the measurement window and the thermal state&lt;/h2>
&lt;p>&lt;strong>Window too short.&lt;/strong> A 30-second benchmark may measure the system&amp;rsquo;s
ramp-up, not the steady state. The recommendation of arXiv 2508.10251 is that the measurement
window cover at least 3-5× the system&amp;rsquo;s ramp time under load, and that the
metrics be computed only over the steady window, excluding warmup and cooldown.&lt;/p>
&lt;p>&lt;strong>Thermal state of the GPU.&lt;/strong> Datacenter GPUs (H100 SXM, A100) operate with thermal
throttling above ~75 °C. If the GPU has not reached its steady-state temperature before the
benchmark, the first measurements correspond to a higher-clock state than the
sustainable one. Controlled experiments document that power must stabilise within a
range of &lt;strong>3 W&lt;/strong> for at least 30 seconds before the measurements are representative
(&lt;a href="https://arxiv.org/html/2604.09048v1">arXiv 2604.09048 · Watt Counts&lt;/a>). The effect is
especially severe in prefill benchmarks (TTFT), where the high clocks of the cold
state produce an artificially low TTFT.&lt;/p>
&lt;p>For a reproducible benchmark on 4×H100 SXM 80 GB: before measuring, run sustained load
for at least 2–3 minutes until &lt;code>nvidia-smi&lt;/code> reports a stable temperature and stable
power (variation &amp;lt; 3 W over 30 s). Only then start the measurement window.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-tools-compute-itl-differently">How the tools compute ITL differently&lt;/h2>
&lt;p>ITL (Inter-Token Latency) appears in every tool but its formula varies, and the
differences are not small:&lt;/p>
&lt;p>&lt;strong>GenAI-Perf / AIPerf:&lt;/strong>&lt;/p>
$$\text{ITL} = \frac{\text{e2e latency} - \text{TTFT}}{\text{output tokens} - 1}$$
&lt;p>TTFT is excluded from the numerator and the denominator discounts the first token. ITL is a
pure &lt;strong>decode&lt;/strong> metric, with no prefill contamination.&lt;/p>
&lt;p>&lt;strong>LLMPerf&lt;/strong> (archived Dec. 2025):&lt;/p>
$$\text{ITL}_{\text{LLMPerf}} = \frac{\text{e2e latency}}{\text{output tokens}}$$
&lt;p>TTFT &lt;strong>is included&lt;/strong> in the numerator. For short sequences (OSL &amp;lt; 50 tokens), TTFT
can represent 50–80 % of the e2e_latency, so that LLMPerf&amp;rsquo;s ITL measures
mainly prefill, not decode. Two systems with the same decode but different
prefill will show different ITLs in LLMPerf even if they are identical in decode speed.&lt;/p>
&lt;p>&lt;strong>vLLM bench (benchmark_serving.py):&lt;/strong>&lt;/p>
&lt;p>It computes ITL as the mean of the intervals between consecutive tokens of the output stream,
including intra-request variance. It can reveal decode jitter that the others average away.&lt;/p>
&lt;p>The practical consequence: &lt;strong>never cross a GenAI-Perf ITL with an LLMPerf one&lt;/strong> as if
they were the same metric. They are different formulas over the same signal.&lt;/p>
&lt;hr>
&lt;h2 id="comparability-across-tools-why-their-numbers-do-not-cross">Comparability across tools: why their numbers do not cross&lt;/h2>
&lt;p>The following table summarises the methodological differences that make one tool&amp;rsquo;s numbers
&lt;strong>structurally incompatible&lt;/strong> with another&amp;rsquo;s without an explicit adjustment:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>vLLM bench&lt;/th>
&lt;th>LLMPerf (archived)&lt;/th>
&lt;th>GenAI-Perf / AIPerf&lt;/th>
&lt;th>GuideLLM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Client architecture&lt;/strong>&lt;/td>
&lt;td>single-process asyncio&lt;/td>
&lt;td>single-process&lt;/td>
&lt;td>&lt;strong>multi-process&lt;/strong>&lt;/td>
&lt;td>&lt;strong>multi-process&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ITL includes TTFT&lt;/strong>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>&lt;strong>yes&lt;/strong>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>no&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TPS denominator&lt;/strong>&lt;/td>
&lt;td>\(T_y - T_x\)&lt;/td>
&lt;td>\(T_{\text{end}} - T_{\text{start}}\) (overhead included)&lt;/td>
&lt;td>\(T_y - T_x\)&lt;/td>
&lt;td>per round&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tokenizer&lt;/strong>&lt;/td>
&lt;td>that of the model served&lt;/td>
&lt;td>universal LlamaTokenizer&lt;/td>
&lt;td>that of the model served&lt;/td>
&lt;td>that of the model served&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Warmup / sliding window&lt;/strong>&lt;/td>
&lt;td>not automatic&lt;/td>
&lt;td>no&lt;/td>
&lt;td>&lt;strong>yes (sliding window)&lt;/strong>&lt;/td>
&lt;td>per round&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ignore_eos by default&lt;/strong>&lt;/td>
&lt;td>no&lt;/td>
&lt;td>no&lt;/td>
&lt;td>explicitly recommended&lt;/td>
&lt;td>configurable&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ISL/OSL distribution&lt;/strong>&lt;/td>
&lt;td>manual parameter&lt;/td>
&lt;td>manual parameter&lt;/td>
&lt;td>distributions with variance&lt;/td>
&lt;td>configurable &lt;code>--data&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Primary load mode&lt;/strong>&lt;/td>
&lt;td>concurrency&lt;/td>
&lt;td>concurrency (drained batches)&lt;/td>
&lt;td>concurrency (recommended)&lt;/td>
&lt;td>sweep + poisson&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structured output&lt;/strong>&lt;/td>
&lt;td>basic JSON&lt;/td>
&lt;td>JSON&lt;/td>
&lt;td>JSON + CSV&lt;/td>
&lt;td>JSON + HTML + CSV&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>An LLMPerf result and a GenAI-Perf result for the same endpoint can differ in TTFT,
ITL and TPS &lt;strong>simultaneously&lt;/strong> and in all of them for methodological reasons, not because of engine
differences. The only way to cross them is to run both tools on the same system
under the same conditions and compute the empirical conversion factor, which in practice
amounts to re-measuring with a single tool.&lt;/p>
&lt;hr>
&lt;h2 id="the-honest-harness-a-reproducibility-checklist">The honest harness: a reproducibility checklist&lt;/h2>
&lt;p>A benchmark whose number can be defended in an audit has to come accompanied by
all the metadata that allow it to be reproduced. The minimum to demand, organised by category:&lt;/p>
&lt;h3 id="hardware">Hardware&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metadata&lt;/th>
&lt;th>Example 4×H100&lt;/th>
&lt;th>Effect if not fixed&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU (model, variant, count)&lt;/td>
&lt;td>4× NVIDIA H100 SXM 80 GB&lt;/td>
&lt;td>H100 PCIe vs SXM differ in memory BW and NVLink&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU-GPU interconnect&lt;/td>
&lt;td>NVLink 4.0&lt;/td>
&lt;td>tensor parallelism depends on the interconnect BW&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CPU, RAM, CPU-GPU bandwidth&lt;/td>
&lt;td>2× Intel Xeon 8480+, 512 GB DDR5, PCIe 5.0&lt;/td>
&lt;td>the tokenisation bottleneck can be on the CPU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Thermal state at the start&lt;/td>
&lt;td>GPU temperature &amp;lt; 65 °C, stable power ± 3 W&lt;/td>
&lt;td>throttling alters TPS and TTFT by up to ~15 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU clocks&lt;/td>
&lt;td>without &lt;code>nvidia-smi -pm 1&lt;/code> they can vary&lt;/td>
&lt;td>a difference of up to ~10 % in throughput&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="software-and-model">Software and model&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metadata&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Effect if not fixed&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Engine + version + flags&lt;/td>
&lt;td>vLLM 0.8.4, &lt;code>--tensor-parallel-size 4 --gpu-memory-utilization 0.90&lt;/code>&lt;/td>
&lt;td>each version changes the scheduler and the KV cache management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Model + precision&lt;/td>
&lt;td>Llama-3-70B-Instruct, FP8&lt;/td>
&lt;td>FP16 vs FP8 differ by ~1.4–1.8× in throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tokenizer used for counting&lt;/td>
&lt;td>tokenizer of the model served (HF tokenizer)&lt;/td>
&lt;td>a universal LlamaTokenizer biases tok/s across vocabularies&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Generation seed&lt;/td>
&lt;td>&lt;code>--seed 42&lt;/code>&lt;/td>
&lt;td>without a fixed seed, the OSL varies run-to-run&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ignore_eos&lt;/code>&lt;/td>
&lt;td>&lt;code>True&lt;/code>&lt;/td>
&lt;td>without it, the OSL varies with the prompt content&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sampling parameters&lt;/td>
&lt;td>&lt;code>temperature=1.0, top_p=0.95&lt;/code>&lt;/td>
&lt;td>greedy vs sampling affect logit speed&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="load">Load&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metadata&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Effect if not fixed&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Bench tool + version&lt;/td>
&lt;td>AIPerf 0.2.1&lt;/td>
&lt;td>each version changes formulas and warmup&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ISL/OSL distribution&lt;/td>
&lt;td>\(\mathcal{N}(512, 64)\) ISL, \(\mathcal{N}(128, 20)\) OSL&lt;/td>
&lt;td>changing the distribution moves the knee&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Load mode&lt;/td>
&lt;td>concurrency, sweep 1–64&lt;/td>
&lt;td>concurrency vs request-rate: different distributions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Concurrency levels&lt;/td>
&lt;td>1, 2, 4, 8, 16, 32, 64 (full sweep, beyond the knee)&lt;/td>
&lt;td>without passing the knee, the safe capacity is unknown&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Duration per point&lt;/td>
&lt;td>120 s minimum per level&lt;/td>
&lt;td>short windows capture ramp-up, not the steady state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Warmup&lt;/td>
&lt;td>30 s excluded from the metric computation&lt;/td>
&lt;td>without warmup, the metrics include the cold state&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Prefix cache treatment&lt;/td>
&lt;td>cold (flush between runs) or warm (declared explicitly)&lt;/td>
&lt;td>warm cache: unrealistic TTFT for a baseline&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="slo-and-reported-metrics">SLO and reported metrics&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metadata&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Effect if not declared&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Declared SLO&lt;/td>
&lt;td>TTFT P99 &amp;lt; 500 ms, ITL P95 &amp;lt; 50 ms&lt;/td>
&lt;td>goodput depends on the SLO; without an SLO there is no goodput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reported percentiles&lt;/td>
&lt;td>P50, P95, P99 (not just the mean)&lt;/td>
&lt;td>the mean hides the tail; irrelevant for an SLO&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput vs goodput&lt;/td>
&lt;td>goodput under the SLO, not raw throughput&lt;/td>
&lt;td>catalogue throughput can be 5–10× the goodput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Total number of requests&lt;/td>
&lt;td>≥ 1,000 per concurrency level&lt;/td>
&lt;td>small samples: high variance in percentiles&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="methodological-defence-of-the-datum-before-an-audit">Methodological defence of the datum before an audit&lt;/h2>
&lt;p>A throughput number presented without the data sheet above is not a datum: it is an anecdote.
The validation pattern for a technical audit:&lt;/p>
&lt;p>&lt;strong>1. Traceability of the tool.&lt;/strong> The tool and its version must be pinnable to a
Git commit or a container image hash. AIPerf and GuideLLM export JSON with
version metadata; vLLM bench omits them by default and they have to be captured manually.&lt;/p>
&lt;p>&lt;strong>2. Reproducibility of the environment.&lt;/strong> The engine deployment script (or the Kubernetes
manifest) and the exact benchmark command must be enough for a third party to
reproduce the number on the same hardware. GuideLLM exports the benchmark file as an
authoritative record of the session: configuration, metadata, per-benchmark statistics and
request entries with individual timings
(&lt;a href="https://github.com/vllm-project/guidellm">GitHub vllm-project/guidellm&lt;/a>).&lt;/p>
&lt;p>&lt;strong>3. Verification of the steady state.&lt;/strong> The metrics must come from the benchmark&amp;rsquo;s
steady window (warmup excluded). The throughput vs concurrency curve must
show the knee, the point where throughput saturates and latency blows up, and extend
beyond it. Without a visible knee, the reported capacity may be the client&amp;rsquo;s limit, not
the engine&amp;rsquo;s.&lt;/p>
&lt;p>&lt;strong>4. Goodput, not raw throughput.&lt;/strong> The auditable throughput is the goodput under the declared
SLO, not the peak throughput. A system that reports 4,000 tok/s but whose goodput
under TTFT P99 &amp;lt; 500 ms is 1,800 tok/s has a real capacity of 1,800 tok/s for
interactive use cases.&lt;/p>
&lt;p>&lt;strong>5. Internal comparability.&lt;/strong> If two engines or two configurations are compared, the
tool, the load distribution, the SLO, the hardware and the warmup treatment
must be &lt;strong>identical&lt;/strong>. Any difference in these dimensions contaminates the comparison.&lt;/p>
&lt;p>The goodput formula as an auditable metric:&lt;/p>
$$\text{goodput} = \text{throughput} \times \Pr[\text{TTFT} \leq \text{SLO}_{\text{TTFT}}] \times \Pr[\text{ITL} \leq \text{SLO}_{\text{ITL}}]$$
&lt;p>Where the probabilities are estimated over the empirical distribution of the run. An engine
with a throughput of 4,000 tok/s and \(\Pr[\text{TTFT} \leq 500\,\text{ms}] = 0.45\) has
a goodput of \(4{,}000 \times 0.45 = 1{,}800\) tok/s.&lt;/p>
&lt;hr>
&lt;h2 id="a-numerical-example-the-same-node-four-different-measurements">A numerical example: the same node, four different measurements&lt;/h2>
&lt;p>Reference hardware: 4×H100 SXM 80 GB, model Llama-3-70B-Instruct FP8, tensor
parallel 4, SLO TTFT P99 &amp;lt; 500 ms.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Benchmark configuration&lt;/th>
&lt;th>Reported throughput&lt;/th>
&lt;th>Real goodput&lt;/th>
&lt;th>Factor vs goodput&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>vLLM bench, concurrency=32, no ignore_eos, no warmup, fixed OSL=64&lt;/td>
&lt;td>5,800 tok/s&lt;/td>
&lt;td>~1,200 tok/s (short OSL inflates TPS)&lt;/td>
&lt;td>&lt;strong>4.8×&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLMPerf, concurrency=32, LlamaTokenizer, overhead included in the denominator&lt;/td>
&lt;td>3,100 tok/s&lt;/td>
&lt;td>~1,600 tok/s (ITL includes TTFT)&lt;/td>
&lt;td>&lt;strong>1.9×&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>AIPerf, concurrency=32, ignore_eos, sliding window, model&amp;rsquo;s tokenizer&lt;/td>
&lt;td>3,400 tok/s&lt;/td>
&lt;td>3,350 tok/s&lt;/td>
&lt;td>&lt;strong>1.01×&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GuideLLM, sweep 1–64, poisson, realistic ISL/OSL distribution, declared SLO&lt;/td>
&lt;td>3,330 tok/s goodput (knee at round 6)&lt;/td>
&lt;td>3,330 tok/s&lt;/td>
&lt;td>&lt;strong>1.0× (honest baseline)&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The range: from 1,200 tok/s to 5,800 tok/s for the same engine, the same hardware and the same
model. The maximum factor is &lt;strong>4.8×&lt;/strong>. The cause is not the engine; it is the
instrumentation decisions.&lt;/p>
&lt;hr>
&lt;h2 id="decomposing-the-total-bias">Decomposing the total bias&lt;/h2>
&lt;p>The total observable bias between two tools is the multiplicative composition of the
individual biases. For a comparison of single-process vLLM bench vs multi-process GuideLLM
on the same system:&lt;/p>
$$\text{total factor} \approx \underbrace{f_{\text{client}}}_{\leq 7.2\times} \times \underbrace{f_{\text{tokenizer}}}_{1.0{-}1.3\times} \times \underbrace{f_{\text{ignore-eos}}}_{1.0{-}2.0\times} \times \underbrace{f_{\text{ISL/OSL}}}_{1.0{-}1.5\times} \times \underbrace{f_{\text{warmup}}}_{1.0{-}1.2\times}$$
&lt;p>The client saturation factor (\(\leq 7.2\times\)) dominates, but the other factors
multiply. Under adverse conditions (single-process client + different tokenizer + no
ignore_eos + unrealistic OSL + no warmup), the compound bias can exceed &lt;strong>15–20×&lt;/strong> for
the same system.&lt;/p>
&lt;hr>
&lt;h2 id="state-of-the-art-2026-what-has-changed">State of the art 2026: what has changed&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>genai-perf → AIPerf migration&lt;/strong> (15 Apr 2026): NVIDIA retired genai-perf and replaced it
with AIPerf, which is multi-process with automatic saturation detection, configurable
ISL/OSL distributions and a built-in sliding window. The migration removes the single-process
client bias from NVIDIA&amp;rsquo;s official tooling.&lt;/li>
&lt;li>&lt;strong>LLMPerf archived&lt;/strong> (Dec. 2025): the &lt;code>ray-project/llmperf&lt;/code> repository is archived and
in read-only mode. The historical results of the LLMPerf leaderboard are comparable
only among themselves; they must not be crossed with modern measurements without adjustment.&lt;/li>
&lt;li>&lt;strong>GuideLLM 0.5.x&lt;/strong> (2025–2026): a complete architectural refactor, multimodal support, and
authoritative JSON export with all the session metadata. It is the OSS standard for
SLO-driven evaluation.&lt;/li>
&lt;li>&lt;strong>arXiv 2605.24217&lt;/strong> (May 2026): the first formal characterisation of systematic measurement
bias in production LLM benchmarks, with a mathematical demonstration of the GIL effect and
a proposal for a multi-process harness.&lt;/li>
&lt;li>&lt;strong>arXiv 2508.10251&lt;/strong>: meta-metrics and good practice for system-level performance
benchmarking; it establishes the minimum dimensions of the parameter space that must be
declared for a result to be reproducible.&lt;/li>
&lt;li>&lt;strong>MLPerf Inference v5.1&lt;/strong> (Sep. 2025): 27 participants, a participation record. The
MLPerf rules require declaring the exact code, the dataset and the hardware, and admit
public review; it is the only industry benchmark with a formal review process for
reproducibility.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="cross-links-from-the-benchmarking-track">Cross-links from the benchmarking track&lt;/h2>
&lt;p>The biases described here apply to every tool in the catalogue. For the complete
data sheet of each tool:&lt;/p>
&lt;ul>
&lt;li>Full catalogue: &lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">LLM benchmarking tools, data sheet by data sheet&lt;/a>&lt;/li>
&lt;li>GuideLLM and SLO validation: &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM in depth: validating the SLO under load&lt;/a>&lt;/li>
&lt;li>AIPerf (ex genai-perf) and saturation detection: &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">NVIDIA GenAI-Perf in depth&lt;/a>&lt;/li>
&lt;li>State of the art of frameworks: &lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">LLM benchmarking: frameworks and state of the art&lt;/a>&lt;/li>
&lt;li>Engine decision on the Pareto axis: &lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Serving engines compared: the Pareto frontier&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>arXiv 2605.24217 · Identifying and Mitigating Systemic Measurement Bias in Production LLM Inference Benchmarks — &lt;a href="https://arxiv.org/abs/2605.24217">https://arxiv.org/abs/2605.24217&lt;/a>&lt;/li>
&lt;li>arXiv 2508.10251 · Meta-Metrics and Best Practices for System-Level Inference Performance Benchmarking — &lt;a href="https://arxiv.org/pdf/2508.10251">https://arxiv.org/pdf/2508.10251&lt;/a>&lt;/li>
&lt;li>arXiv 2604.09048 · Watt Counts: Energy-Aware Benchmark for Sustainable LLM Inference on Heterogeneous GPU Architectures — &lt;a href="https://arxiv.org/html/2604.09048v1">https://arxiv.org/html/2604.09048v1&lt;/a>&lt;/li>
&lt;li>NVIDIA Technical Blog · LLM Inference Benchmarking: Fundamental Concepts (ITL, TPS, ISL/OSL, ignore_eos, concurrency vs request-rate) — &lt;a href="https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/">https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/&lt;/a>&lt;/li>
&lt;li>NVIDIA AIPerf Docs · Sequence Length Distributions for Advanced Benchmarking — &lt;a href="https://docs.nvidia.com/aiperf/tutorials/datasets-inputs/sequence-length-distributions-for-advanced-benchmarking">https://docs.nvidia.com/aiperf/tutorials/datasets-inputs/sequence-length-distributions-for-advanced-benchmarking&lt;/a>&lt;/li>
&lt;li>NVIDIA AIPerf · Request Rate with Max Concurrency — &lt;a href="https://docs.nvidia.com/aiperf/tutorials/load-patterns-scheduling/request-rate-with-max-concurrency">https://docs.nvidia.com/aiperf/tutorials/load-patterns-scheduling/request-rate-with-max-concurrency&lt;/a>&lt;/li>
&lt;li>NVIDIA AIPerf · Comprehensive LLM Benchmarking Guide — &lt;a href="https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/">https://lucaberton.com/blog/nvidia-aiperf-llm-inference-benchmarking-guide/&lt;/a>&lt;/li>
&lt;li>vLLM Documentation · Benchmark CLI (ignore_eos, métricas disponibles) — &lt;a href="https://docs.vllm.ai/en/latest/benchmarking/cli/">https://docs.vllm.ai/en/latest/benchmarking/cli/&lt;/a>&lt;/li>
&lt;li>vLLM Documentation · Automatic Prefix Caching — &lt;a href="https://docs.vllm.ai/en/stable/design/prefix_caching/">https://docs.vllm.ai/en/stable/design/prefix_caching/&lt;/a>&lt;/li>
&lt;li>GitHub · ray-project/llmperf (archivado dic. 2025) — &lt;a href="https://github.com/ray-project/llmperf">https://github.com/ray-project/llmperf&lt;/a>&lt;/li>
&lt;li>GitHub · vllm-project/guidellm (exportación JSON autoritativa, metadatos de sesión) — &lt;a href="https://github.com/vllm-project/guidellm">https://github.com/vllm-project/guidellm&lt;/a>&lt;/li>
&lt;li>Red Hat Developer · GuideLLM: evaluar despliegues LLM para inferencia real — &lt;a href="https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference">https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference&lt;/a>&lt;/li>
&lt;li>MLCommons · MLPerf Inference v5.1 results (récord 27 participantes, reproducibilidad formal) — &lt;a href="https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/">https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/&lt;/a>&lt;/li>
&lt;li>arXiv 2502.16721 · Speed and Conversational LLMs: Not All Is About Tokens per Second (tokenizer incompatibilidad y tok/s entre vocabularios) — &lt;a href="https://arxiv.org/pdf/2502.16721">https://arxiv.org/pdf/2502.16721&lt;/a>&lt;/li>
&lt;li>Medium · LLM Inference Benchmarking (genAI-perf y vLLM, discrepancia 7,2×) — &lt;a href="https://kchandan.medium.com/llm-inference-benchmarking-genai-perf-and-vllm-5dd06b57428e">https://kchandan.medium.com/llm-inference-benchmarking-genai-perf-and-vllm-5dd06b57428e&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>