---
title: "El harness reproducible: medir coste, rendimiento y energía en un solo experimento auditable"
url: https://blog.lo0.es/posts/harness-reproducible-medicion-coste-rendimiento-energia/
markdown: https://blog.lo0.es/posts/harness-reproducible-medicion-coste-rendimiento-energia/index.md
lang: es
date: 2026-06-16
lastmod: 2026-06-17
author: "José Manuel Román"
description: "Cierre de la serie de datos: diseño completo del harness reproducible que captura a la vez €/1M tok (OpenCost/LiteLLM), Wh/token (DCGM/Kepler) y TTFT/ITL P99 (GuideLLM/AIPerf) en un Job de Kubernetes versionado. Incluye YAML, PromQL, tabla de scorecard de 3 ejes y checklist de reproducibilidad de 12 puntos."
tags: ["benchmarking","harness","reproducibilidad","cuadro-de-mando","guidellm","opencost","dcgm","sintesis","serie-datos"]
translations:
  en: https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/index.md
---

# El harness reproducible: medir coste, rendimiento y energía en un solo experimento auditable


> Notación: importes en **euros (N €)**, decimales con coma, millares con espacio fino. No se usa el símbolo de dólar (en este sitio es delimitador de fórmula).

## TL;DR

La serie "datos" ha producido tres ejes de medición independientes: coste por millón de tokens (OpenCost + LiteLLM), rendimiento bajo SLO (GuideLLM + AIPerf) y energía por token (DCGM + Kepler). El problema es que los tres se han medido en artículos distintos, con cargas distintas y en momentos distintos: no son comparables entre sí. Este artículo de cierre describe el **harness integrado** que ejecuta los tres ejes **en el mismo experimento**, sobre el mismo nodo (4×H100 SXM, referencia genérica), con todos los metadatos fijados, la salida en JSON/CSV versionados y un Job de Kubernetes idempotente. El resultado es el **scorecard de 3 ejes** (€/1M tok, Wh/token, TTFT/ITL P99) que permite comparar configuraciones sobre una **frontera de Pareto multi-objetivo** y auditar cualquier cifra con el banco para reproducirla.

---

## Por qué los tres ejes deben medirse juntos

El post de apertura de la serie ([Los tres ejes](https://blog.lo0.es/posts/tres-ejes-coste-rendimiento-energia-inferencia-llm/index.md)) estableció la identidad:

$$\text{CPM} = \frac{\text{coste/h}}{\text{throughput (tok/s)} \times 3{,}6 \times 10^{-3}}$$

$$\text{energía/token (Wh)} = \frac{\text{potencia media (W)}}{\text{throughput (tok/s)} \times 3\,600}$$

El throughput es el denominador común. Si se mide en experimentos distintos —diferente hora, diferente carga, diferente temperatura de GPU— el CPM y la energía/token **no comparten denominador**: son tres anécdotas, no un scorecard. El harness los captura en la misma ventana temporal, sobre la misma carga, con ventanas de Prometheus alineadas al segundo. Solo así la fila del scorecard es coherente por construcción.

La segunda razón es la reproducibilidad. El post [Sesgo y reproducibilidad en el benchmarking](https://blog.lo0.es/posts/sesgo-medicion-reproducibilidad-bench/index.md) listó doce sesgos que invalidan comparaciones: motor no pinneado, tokenizador no declarado, longitud de entrada/salida no fijada, warmup ausente, cliente fuera del cluster. El harness elimina todos ellos porque los metadatos son parte del Job, no de la documentación.

---

## Arquitectura del banco integrado

El harness tiene cuatro capas. Cada una es OSS, exporta a Prometheus y convive en el mismo namespace de Kubernetes:

| Capa | Herramienta(s) | Métrica primaria | Protocolo de export |
|---|---|---|---|
| **Rendimiento** | GuideLLM (sweep SLO) + AIPerf | TTFT P99, ITL P99, goodput (tok/s) | JSON/CSV nativo + `/metrics` OpenMetrics |
| **Coste** | OpenCost + LiteLLM proxy | CPM (€/1M tok), coste/petición | API REST + Prometheus scrape |
| **Energía (GPU)** | DCGM Exporter | `DCGM_FI_DEV_POWER_USAGE` (W), `DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION` (mJ) | DaemonSet Prometheus |
| **Energía (pod)** | Kepler | `kepler_container_joules_total`, energía por pod | DaemonSet Prometheus |

MLPerf Power se usa como **referencia de comparabilidad externa**: sus resultados publicados, con hardware documentado al detalle, permiten calibrar si las cifras del harness son plausibles. El banco propio no pretende ser un submission de MLPerf, sino ser **reproducible en el propio cluster**.

> **Diagrama:** Arquitectura del harness: Job Kubernetes orquesta GuideLLM y AIPerf como generadores de carga, OpenCost y LiteLLM miden coste, DCGM y Kepler miden energia, todo confluye en Prometheus y el scorecard JSON — [verlo en la versión HTML](https://blog.lo0.es/posts/harness-reproducible-medicion-coste-rendimiento-energia/)

---

## El Job de Kubernetes: YAML completo

El experimento se ejecuta como un **Kubernetes Job** versionado. Todos los metadatos relevantes son variables de entorno declaradas en el manifiesto: ni en scripts ad-hoc, ni en documentación externa. El Job es idempotente (mismo nombre = misma corrida) y deja trazas en el log del pod y en el volumen de salida.

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: bench-llama3-70b-fp8-h100x4-20260616
  namespace: benchmark
  labels:
    bench/model: llama3-70b
    bench/precision: fp8
    bench/engine: vllm-0.9.1
    bench/hardware: h100x4-sxm
    bench/isl: "1024"
    bench/osl: "256"
    bench/concurrency: "32"
    bench/tokenizer: meta-llama-3-tokenizer-v3
    bench/run-id: "20260616T1200"
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      serviceAccountName: bench-runner
      volumes:
        - name: results
          persistentVolumeClaim:
            claimName: bench-results-pvc
      initContainers:
        # Warmup: 60 s de tráfico previo al experimento
        - name: warmup
          image: ghcr.io/vllm-project/guidellm:0.4.2
          command:
            - guidellm
            - benchmark
            - --target
            - http://vllm-svc.inference.svc.cluster.local:8000
            - --rate-type
            - concurrent
            - --rate
            - "4"
            - --max-seconds
            - "60"
            - --data
            - prompt_tokens=1024,output_tokens=256
          env:
            - name: GUIDELLM_ENV
              value: production
      containers:
        - name: guidellm-sweep
          image: ghcr.io/vllm-project/guidellm:0.4.2
          command:
            - guidellm
            - benchmark
            - --target
            - http://vllm-svc.inference.svc.cluster.local:8000
            - --rate-type
            - sweep
            - --max-seconds
            - "120"
            - --data
            - prompt_tokens=1024,output_tokens=256
            - --output-path
            - /results/guidellm-$(BENCH_RUN_ID).json
          env:
            - name: BENCH_RUN_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['bench/run-id']
          volumeMounts:
            - name: results
              mountPath: /results

        - name: aiperf-concurrent
          image: nvcr.io/nvidia/aiperf:0.2.0
          command:
            - aiperf
            - profile
            - --url
            - http://vllm-svc.inference.svc.cluster.local:8000/v1
            - --model
            - meta-llama/Meta-Llama-3-70B-Instruct
            - --concurrency
            - "4,8,16,32"
            - --input-tokens
            - "1024"
            - --output-tokens
            - "256"
            - --num-requests
            - "200"
            - --output
            - /results/aiperf-$(BENCH_RUN_ID).json
          volumeMounts:
            - name: results
              mountPath: /results

        - name: scorecard-exporter
          image: python:3.12-slim
          command:
            - python
            - /scripts/export_scorecard.py
            - --run-id
            - "$(BENCH_RUN_ID)"
            - --prometheus
            - http://prometheus.monitoring.svc.cluster.local:9090
            - --output
            - /results/scorecard-$(BENCH_RUN_ID).json
          env:
            - name: BENCH_RUN_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.labels['bench/run-id']
          volumeMounts:
            - name: results
              mountPath: /results
```

El nombre del Job (`bench-llama3-70b-fp8-h100x4-20260616`) es el identificador único de la corrida. Cambiarlo es suficiente para registrar una variante. Los labels son los metadatos que el `scorecard-exporter` lee para enriquecer el JSON de salida.

---

## Capa de rendimiento: GuideLLM y AIPerf

### GuideLLM — el sweep dirigido por SLO

GuideLLM (proyecto vLLM) genera patrones de tráfico realistas —synchronous, concurrent, poisson, throughput, **sweep**— y captura distribuciones completas de TTFT e ITL ([Red Hat Developer](https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference)). El modo `sweep` barre de idle a saturación en 10 rondas e identifica el **codo**: la carga máxima donde el goodput ≈ throughput bajo el SLO declarado. La salida es JSON/CSV con todos los percentiles por ronda, lista para versionarse.

Para el harness, el SLO de referencia del banco es:

| Métrica | Umbral | Percentil |
|---|---|---|
| TTFT | 500 ms | P99 |
| ITL (TPOT) | 50 ms/tok | P95 |
| Tasa de error | 0,5 % | — |

El comando del Job ya aparece en el YAML anterior. La salida JSON incluye por ronda: tasa (req/s), TTFT (P50/P95/P99), ITL (P50/P95/P99), throughput (tok/s) y goodput (tok/s). El goodput bajo el SLO del codo es el valor que entra en el scorecard.

Para la capa de verificación cruzada de datos y comparabilidad con resultados publicados, véase el post [GuideLLM a fondo](https://blog.lo0.es/posts/guidellm-validacion-slo-bajo-carga/index.md).

### AIPerf — el perfilador de concurrencia de NVIDIA

AIPerf (sucesor de GenAI-Perf, repositorio `ai-dynamo/aiperf`) mide TTFT, ITL, throughput y latencias en distribución a concurrencias fijas ([GitHub ai-dynamo/aiperf](https://github.com/ai-dynamo/aiperf)). Donde GuideLLM da el sweep automático hasta el codo, AIPerf da el perfil detallado a concurrencias concretas (4, 8, 16, 32 en el ejemplo): permite caracterizar la curva throughput-latencia punto a punto.

Los dos son complementarios: GuideLLM halla el codo de forma automática; AIPerf lo confirma y caracteriza el comportamiento en el entorno de ese codo. Ambas salidas van al volumen de resultados con el mismo `BENCH_RUN_ID`. El análisis profundo de AIPerf/GenAI-Perf está en [GenAI-Perf a fondo](https://blog.lo0.es/posts/nvidia-genai-perf-a-fondo/index.md).

Métricas que el harness extrae de AIPerf para el scorecard:

```bash
# TTFT P99 a concurrencia 16 (la más cercana al codo del sweep):
aiperf profile ... --concurrency 16 \
  | jq '.results[] | select(.concurrency==16) | .ttft_ms.p99'

# Goodput (tok/s) a concurrencia 16:
aiperf profile ... --concurrency 16 \
  | jq '.results[] | select(.concurrency==16) | .output_token_throughput'
```

---

## Capa de coste: OpenCost y LiteLLM

### OpenCost — el denominador en euros por GPU-hora

OpenCost (CNCF incubating, [opencost.io](https://opencost.io/)) asigna coste de Kubernetes a namespace, label, pod y contenedor en tiempo real ([GitHub opencost/opencost](https://github.com/opencost/opencost)). Para el harness, OpenCost resuelve la pregunta: **¿cuánto cuesta en euros por hora el pod `vllm-svc` durante la ventana del experimento?**

El harness llama a la API REST de OpenCost al terminar el experimento:

```bash
# Coste del namespace inference en la ventana del experimento (1 hora)
curl -s "http://opencost.monitoring.svc.cluster.local:9003/allocation" \
  --data-urlencode 'window=2026-06-16T12:00:00Z,2026-06-16T13:00:00Z' \
  --data-urlencode 'aggregate=namespace' \
  --data-urlencode 'namespace=inference' \
  | jq '.data[0].inference.totalCost'
```

El coste devuelto (en EUR, configurado con precios reales del nodo) se divide por el throughput medido en esa misma ventana para obtener el CPM:

$$\text{CPM} = \frac{\text{coste}_\text{namespace/h} \times 10^6}{\text{goodput (tok/s)} \times 3\,600}$$

El post [OpenCost: cost allocation en Kubernetes](https://blog.lo0.es/posts/opencost-cost-allocation-kubernetes/index.md) detalla la configuración de precios y la asignación por label de GPU.

### LiteLLM — el token counter por petición

LiteLLM ([litellm.ai](https://www.litellm.ai/), [GitHub BerriAI/litellm](https://github.com/BerriAI/litellm)) actúa como proxy OpenAI-compatible con contabilidad de tokens por petición, modelo y equipo. En el harness, GuideLLM y AIPerf apuntan al endpoint de LiteLLM (que a su vez reenvía a vLLM): cada petición queda registrada con `prompt_tokens`, `completion_tokens` y `cost` (usando el pricing custom configurado para el nodo on-prem).

```yaml
# litellm-config.yaml (fragmento de pricing custom on-prem)
model_list:
  - model_name: llama3-70b-fp8
    litellm_params:
      model: openai/meta-llama/Meta-Llama-3-70B-Instruct
      api_base: http://vllm-svc.inference.svc.cluster.local:8000/v1
      api_key: sk-dummy
      input_cost_per_token: 0.00000109   # 1,09 EUR/1M tok on-prem
      output_cost_per_token: 0.00000109
```

Los registros de LiteLLM se exportan a Prometheus como `litellm_request_total_tokens` y `litellm_spend_metric_total`, que el scorecard exporter consume para calcular el CPM real por tipo de token (input vs output).

---

## Capa de energía: DCGM y Kepler

### DCGM Exporter — potencia y energía GPU

DCGM Exporter ([GitHub NVIDIA/dcgm-exporter](https://github.com/NVIDIA/dcgm-exporter), [docs](https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html)) expone métricas de GPU en `/metrics` para Prometheus como DaemonSet en los nodos GPU. Las dos métricas de energía que usa el harness:

| Métrica DCGM | Tipo | Unidad | Uso en el harness |
|---|---|---|---|
| `DCGM_FI_DEV_POWER_USAGE` | gauge | W | potencia instantánea por GPU |
| `DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION` | counter | mJ | energía acumulada desde boot |

Para calcular la energía consumida durante la ventana del experimento (sin la línea de base idle), el harness hace la diferencia del counter antes y después del sweep:

```bash
# Despliegue como DaemonSet (fragmento del chart oficial)
helm repo add gpu-helm-charts \
  https://nvidia.github.io/dcgm-exporter/helm-charts
helm install dcgm-exporter gpu-helm-charts/dcgm-exporter \
  --namespace monitoring \
  --set serviceMonitor.enabled=true \
  --set serviceMonitor.interval=15s
```

El campo `DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION` (en mJ) permite calcular la energía del experimento con exactitud de contador de hardware, no de estimación:

$$\text{energía experimento (J)} = \left(\text{counter}_\text{fin} - \text{counter}_\text{inicio}\right) \times 10^{-3}$$

$$\text{Wh/token} = \frac{\text{energía experimento (J)}}{3\,600 \times \text{tokens generados}}$$

### Kepler — energía a nivel de pod

Kepler (CNCF sandbox, [GitHub sustainable-computing-io/kepler](https://github.com/sustainable-computing-io/kepler)) usa eBPF para estimar el consumo energético a nivel de contenedor y pod, exportando `kepler_container_joules_total` a Prometheus ([Red Hat Emerging Technologies](https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/)). Combina RAPL (CPU/DRAM), NVML (GPU) y modelos de regresión cuando no hay sensores disponibles.

En el harness, Kepler complementa a DCGM: DCGM da la medición de hardware de la GPU (más precisa para cargas GPU-intensivas como la inferencia LLM), mientras Kepler atribuye la energía al pod de vLLM específico (incluida la contribución de CPU del nodo). La métrica principal que consume el harness:

```promql
# Energía del pod vllm-svc durante el experimento (J)
increase(
  kepler_container_joules_total{
    container_namespace="inference",
    container_name="vllm"
  }[${BENCH_DURATION}]
)
```

El análisis comparativo de DCGM vs Kepler vs Zeus está en [Herramientas de energía: deploy, precisión y overhead](https://blog.lo0.es/posts/herramientas-energia-deploy-precision-overhead/index.md).

### Referencia MLPerf Power

MLPerf Power ([MLCommons](https://mlcommons.org/benchmarks/inference-datacenter/), paper IEEE HPCA 2025) establece el protocolo de medición de eficiencia energética de sistemas ML con medición externa de alta precisión. El banco propio **no es un submission MLPerf** (requiere vatímetros externos y revisión por comité), pero sus resultados publicados son la referencia de calibración: si el harness da un resultado del mismo orden que el submission MLPerf del mismo hardware con el mismo modelo, la medición es plausible. Si difiere más de un factor 2, hay un problema metodológico. Véase el post [MLPerf Power: eficiencia energética](https://blog.lo0.es/posts/mlperf-power-eficiencia-energetica/index.md) para los datos de referencia actuales.

---

## Unificación de métricas: las queries PromQL

El `scorecard-exporter` es el contenedor del Job que, al terminar GuideLLM y AIPerf, recoge todas las métricas de Prometheus y construye el JSON de la fila del scorecard. Las queries clave:

```promql
# Potencia media GPU durante el sweep (W) — nodo 4×H100
avg_over_time(
  sum(DCGM_FI_DEV_POWER_USAGE{Hostname=~"gpu-node-.*"})[${BENCH_DURATION}:15s]
)

# Energía total GPU durante el sweep (mJ → convertir a J)
(
  sum(DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION{Hostname=~"gpu-node-.*"}) offset 0
  -
  sum(DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION{Hostname=~"gpu-node-.*"}) offset ${BENCH_DURATION}
) * 0.001

# Tokens generados durante el sweep (desde LiteLLM)
increase(
  litellm_request_total_tokens{model="llama3-70b-fp8", token_type="completion"}[${BENCH_DURATION}]
)

# Coste del namespace inference en la ventana (desde OpenCost API)
# → llamada REST al iniciar y al finalizar el Job, diferencia de accrual
```

La variable `${BENCH_DURATION}` es la duración real del sweep (en formato Prometheus, ej. `22m`), que el exporter calcula como `end_ts - start_ts` y sustituye en todas las queries.

El JSON de salida de cada corrida tiene esta estructura:

```json
{
  "run_id": "20260616T1200",
  "metadata": {
    "model": "meta-llama/Meta-Llama-3-70B-Instruct",
    "engine": "vllm-0.9.1",
    "precision": "fp8",
    "hardware": "4xH100-SXM-80GB",
    "isl_tokens": 1024,
    "osl_tokens": 256,
    "tokenizer": "meta-llama-3-tokenizer-v3",
    "slo_ttft_p99_ms": 500,
    "slo_itl_p95_ms": 50,
    "bench_tool_guidellm": "0.4.2",
    "bench_tool_aiperf": "0.2.0",
    "dcgm_exporter": "3.3.9",
    "kepler": "0.10.2"
  },
  "performance": {
    "goodput_tok_s": 3120,
    "ttft_p99_ms": 487,
    "itl_p95_ms": 42,
    "throughput_peak_tok_s": 3890,
    "elbow_concurrency": 28
  },
  "cost": {
    "cpm_eur_1m": 0.97,
    "gpu_cost_eur_h": 10.8,
    "cost_window_eur": 3.24
  },
  "energy": {
    "wh_per_token": 0.00044,
    "j_per_token": 1.58,
    "power_mean_w": 4924,
    "energy_total_kwh": 0.287,
    "pue": 1.4,
    "wh_per_token_pue_adjusted": 0.000616
  },
  "carbon": {
    "grid_intensity_gco2_kwh": 40,
    "co2_per_1m_tokens_g": 24.6
  }
}
```

Todos los campos son calculados a partir de las mismas ventanas temporales. El JSON se versiona en git junto al código del harness. La reproducción de cualquier fila del scorecard es: `git checkout <run-id> && kubectl apply -f job.yaml`.

---

## El scorecard de 3 ejes: tabla e interpretación

La siguiente tabla ilustra cómo se comparan cinco configuraciones del mismo modelo (Llama 3 70B) sobre el mismo nodo de referencia (4×H100 SXM) con el harness. Las cifras son **ilustrativas de orden de magnitud** (el banco las rellena con mediciones reales):

| Config | Motor | Precisión | CPM (€/1M) | Goodput (tok/s) | Wh/tok | TTFT P99 (ms) | ITL P95 (ms) | CO2 /1M (g, FR) |
|---|---|---|---|---|---|---|---|---|
| **A** | vLLM 0.9 | FP16 | 1,64 | 1.890 | 0,00074 | 498 | 49 | 29,6 |
| **B** | vLLM 0.9 | FP8 | 0,97 | 3.120 | 0,00044 | 487 | 42 | 17,6 |
| **C** | SGLang 0.4 | FP8 | 0,88 | 3.410 | 0,00041 | 421 | 38 | 16,4 |
| **D** | vLLM 0.9 | FP8, ISL 512 | 1,31 | 2.240 | 0,00056 | 294 | 31 | 22,4 |
| **E** | vLLM 0.9 | FP8, max-batch 128 | 0,84 | 3.590 | 0,00040 | 721 | 55 | 16,0 |

Cómo se lee la tabla:

- **B vs A**: FP8 sobre FP16 reduce el CPM un 41 % y la energía por token un 41 % por el mismo throughput más alto. Ambos cumplen el SLO (TTFT P99 < 500 ms, ITL P95 < 50 ms). B domina a A en los tres ejes: es Pareto-superior.
- **C vs B**: SGLang da goodput un 9 % mayor y CPM un 9 % menor con FP8. Ambos cumplen el SLO. C es Pareto-superior a B (si el motor es indiferente para el stack).
- **D vs B**: ISL más corto (512 tok) baja la latencia (TTFT P99 294 ms vs 487 ms) pero baja el goodput y sube el CPM. Si el caso de uso exige TTFT < 300 ms, D es el candidato; si TTFT < 500 ms es suficiente, B es mejor en coste y energía.
- **E vs C**: `max-batch 128` sube el goodput (+5 %) y baja el CPM, pero el TTFT P99 rompe el SLO (721 ms > 500 ms) y el ITL P95 también (55 ms > 50 ms). E tiene el mejor throughput bruto **pero está fuera del SLO**: no es un candidato válido para el caso de uso de chat interactivo.

La **frontera de Pareto** del scorecard, bajo el SLO declarado, incluye solo A, B, C y D (E queda fuera por SLO roto). De esas cuatro, C domina a B que domina a A. D solo entra en la frontera si el caso de uso exige TTFT P99 < 300 ms. La decisión no es un número; es la fila entera más el SLO.

---

## La frontera de Pareto multi-objetivo

Con tres métricas de minimización —CPM (€/1M tok), Wh/tok y TTFT P99 (ms)— la frontera de Pareto se define como el conjunto de configuraciones donde ninguna domina a otra en los tres ejes simultáneamente. Formalmente, la config \( i \) **domina** a la config \( j \) si:

$$\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}$$

con al menos una desigualdad estricta. El scorecard permite calcularla sobre el conjunto de corridas con una operación vectorial sencilla. En el ejemplo de la tabla, la frontera bajo SLO (con E excluido) es \(\{C, D\}\): C domina en coste/energía/goodput; D domina en latencia. Entre C y D la elección depende del SLO de TTFT del caso de uso concreto.

> **Diagrama:** Frontera de Pareto en dos ejes: CPM EUR por millon de tokens en eje Y y goodput tok por s en eje X; los puntos A B C D E marcados; E excluido por SLO roto; la curva de Pareto pasa por C y D — [verlo en la versión HTML](https://blog.lo0.es/posts/harness-reproducible-medicion-coste-rendimiento-energia/)

---

## Diseño reproducible: los doce metadatos obligatorios

El checklist que cierra el dossier de sesgo ([Sesgo y reproducibilidad](https://blog.lo0.es/posts/sesgo-medicion-reproducibilidad-bench/index.md)). Para que el JSON del harness sea auditable, los doce campos deben estar presentes en cada corrida:

| # | Campo | Ejemplo | Por qué es obligatorio |
|---|---|---|---|
| 1 | Versión del motor de serving | `vllm-0.9.1` | el mismo modelo puede rendir diferente en distintas versiones |
| 2 | Versión del modelo (commit/tag) | `meta-llama/Meta-Llama-3-70B-Instruct@sha256:abc` | evita ambigüedad entre variantes del mismo nombre |
| 3 | Precisión | `fp8` | FP16 vs FP8 cambia throughput y latencia |
| 4 | Tokenizador y versión | `meta-llama-3-tokenizer-v3` | el ISL/OSL en tokens depende del tokenizador |
| 5 | ISL (input sequence length) | `1024` tok | cambia el tiempo de prefill y el codo |
| 6 | OSL (output sequence length) | `256` tok | cambia el tiempo de decode |
| 7 | Hardware (modelo y cantidad) | `4×H100-SXM-80GB` | base de cualquier comparación |
| 8 | Concurrencia máxima probada | `32` | define el rango del sweep |
| 9 | Versión de la herramienta de bench | `guidellm-0.4.2`, `aiperf-0.2.0` | distintas versiones pueden dar métricas distintas |
| 10 | SLO declarado | `TTFT P99 < 500 ms, ITL P95 < 50 ms` | el codo depende del SLO; sin él no hay goodput |
| 11 | Duración de warmup | `60 s` | sin warmup el KV cache está frío y las primeras rondas son sesgadas |
| 12 | Versión del harness / run-id | `20260616T1200` | identifica la corrida para reproducirla con `git checkout` |

Un scorecard sin cualquiera de estos doce campos es una anécdota. Con los doce, es un dato auditable. El run-id en el nombre del Job (`bench-llama3-70b-fp8-h100x4-20260616`) incorpora implícitamente los campos 1-3 y 7, forzando que el nombre del Job cambie con cada variante — lo que hace imposible solapar corridas en el mismo namespace.

---

## Idempotencia y versionado de resultados

El Job de Kubernetes es idempotente: si se vuelve a aplicar el mismo manifiesto (mismo nombre), el Job ya existe y Kubernetes no lo relanza (política `backoffLimit: 0`). Esto evita corridas accidentales duplicadas. Para relanzar, hay que borrar el Job (`kubectl delete job <nombre>`) o cambiar el `run-id`.

Los resultados se versionan con la siguiente convención de directorios en el repositorio git del harness:

```
results/
  20260616T1200/
    guidellm-20260616T1200.json
    aiperf-20260616T1200.json
    scorecard-20260616T1200.json
  20260617T0900/
    guidellm-20260617T0900.json
    aiperf-20260617T0900.json
    scorecard-20260617T0900.json
scorecard-aggregate.csv   # todas las filas, para análisis y gráficas
```

El `scorecard-aggregate.csv` es la tabla del scorecard en formato plano: cada fila es una corrida, cada columna un campo del JSON. Se genera con:

```bash
# Genera el CSV agregado a partir de todos los JSON en results/
python scripts/aggregate_scorecards.py results/ > scorecard-aggregate.csv
```

El CSV es lo que entra en las gráficas de Pareto, en los informes y en las decisiones de arquitectura. Está versionado en git; su diff es el cambio de configuración.

---

## Cómo se conecta con los otros posts de la serie

El harness no es un artículo autónomo: es la síntesis de los veintiocho artículos. Cada herramienta tiene su deep dive:

- **Rendimiento**: [GuideLLM a fondo](https://blog.lo0.es/posts/guidellm-validacion-slo-bajo-carga/index.md) — sweep, SLO, goodput, codo; [GenAI-Perf / AIPerf a fondo](https://blog.lo0.es/posts/nvidia-genai-perf-a-fondo/index.md) — concurrencia, rate sweep, métricas de distribución.
- **Coste**: [OpenCost: cost allocation en Kubernetes](https://blog.lo0.es/posts/opencost-cost-allocation-kubernetes/index.md) — asignación por namespace/label, precios custom on-prem.
- **Energía**: [Herramientas de energía: deploy, precisión y overhead](https://blog.lo0.es/posts/herramientas-energia-deploy-precision-overhead/index.md) — DCGM vs Kepler vs Zeus, overhead de instrumentación, precisión relativa; [MLPerf Power: eficiencia energética](https://blog.lo0.es/posts/mlperf-power-eficiencia-energetica/index.md) — la referencia externa de calibración.
- **Marco teórico**: [Los tres ejes: coste, rendimiento y energía](https://blog.lo0.es/posts/tres-ejes-coste-rendimiento-energia-inferencia-llm/index.md) — la identidad CPM/energía/throughput que justifica medir juntos.
- **Sesgo**: [Sesgo y reproducibilidad en el benchmarking](https://blog.lo0.es/posts/sesgo-medicion-reproducibilidad-bench/index.md) — los doce sesgos que el harness elimina.

El harness cierra el dossier de la serie porque hace posible la promesa del artículo de apertura: "cuando alguien rebata un número de la propuesta, la respuesta no es 'lo dice un blog', sino 'este es el banco, esta la metodología, reprodúcelo'". Con el run-id y el repo git, reproducir es un comando.

---

## Flujo completo de una sesión de benchmarking

El procedimiento operativo estándar del harness, de principio a fin:

```bash
# 1. Asegurarse de que el endpoint de inferencia está activo
kubectl get svc vllm-svc -n inference

# 2. Verificar que DCGM y Kepler están scrapeando
curl -s http://prometheus.monitoring.svc.cluster.local:9090/api/v1/query \
  --data-urlencode 'query=DCGM_FI_DEV_POWER_USAGE' | jq '.data.result | length'

# 3. Registrar el counter de energía ANTES del experimento
PRE_ENERGY=$(kubectl exec -n monitoring dcgm-exporter-xxx -- \
  curl -s localhost:9400/metrics \
  | grep DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION | awk '{sum+=$2} END{print sum}')

# 4. Lanzar el Job (nombre único por corrida)
kubectl apply -f jobs/bench-llama3-70b-fp8-h100x4-20260616.yaml

# 5. Esperar a que termine
kubectl wait --for=condition=complete job/bench-llama3-70b-fp8-h100x4-20260616 \
  -n benchmark --timeout=3600s

# 6. Registrar el counter de energía DESPUÉS
POST_ENERGY=$(kubectl exec -n monitoring dcgm-exporter-xxx -- \
  curl -s localhost:9400/metrics \
  | grep DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION | awk '{sum+=$2} END{print sum}')

# 7. Recoger los resultados del volumen
kubectl cp benchmark/bench-run-pod:/results/ ./results/20260616T1200/

# 8. Calcular la energía del experimento (mJ → kWh)
python scripts/calc_energy.py \
  --pre $PRE_ENERGY --post $POST_ENERGY \
  --run-id 20260616T1200

# 9. Añadir fila al CSV agregado
python scripts/aggregate_scorecards.py results/ > scorecard-aggregate.csv

# 10. Versionar
git add results/20260616T1200/ scorecard-aggregate.csv
git commit -m "bench: llama3-70b fp8 4xH100 ISL1024 OSL256 (20260616T1200)"
```

Los pasos 6-9 son candidatos a automatizarse como un segundo Job de post-procesado que se lanza al completarse el principal (con `ownerReferences` o un CronJob de limpieza). En el estado básico del harness, los pasos manuales son precisamente los que fuerzan la revisión del resultado antes de versionarlo.

---

## Coste del experimento

Un sweep de GuideLLM de 10 rondas a 120 segundos por ronda ocupa el nodo ~22-26 minutos. El perfil de AIPerf a 4 concurrencias (200 peticiones cada una) añade ~8-12 minutos. Total por corrida: ~35-40 minutos de nodo 4×H100.

A coste amortizado de referencia (~10,8 €/h para 4×H100 on-prem):

$$\text{coste por corrida} \approx 10{,}8 \times \frac{38}{60} \approx 6{,}84 \text{ EUR}$$

A precio cloud europeo de referencia (4 × 2,73 = 10,92 €/h):

$$\text{coste por corrida (cloud)} \approx 10{,}92 \times \frac{38}{60} \approx 6{,}92 \text{ EUR}$$

Un programa de benchmarking continuo (10 configuraciones × 3 modelos × 2 sweeps/semana) suma ~415 EUR/semana. Por eso el harness incluye sweeps cortos (5 rondas, ~15 minutos) para CI y sweeps completos para releases.

---

## Lo que el harness no mide (y por qué)

| Dimensión ausente | Motivo | Dónde se cubre |
|---|---|---|
| Calidad del output (MMLU, HumanEval) | requiere banco de evaluación de calidad, no de serving | [Benchmarks de calidad LLM](https://blog.lo0.es/posts/tres-ejes-coste-rendimiento-energia-inferencia-llm/index.md) |
| Throughput de training | el harness es exclusivamente para inferencia | fuera del scope de la serie "datos" |
| Coste de red y almacenamiento | OpenCost puede atribuirlo, pero requiere configuración adicional | [OpenCost: cost allocation](https://blog.lo0.es/posts/opencost-cost-allocation-kubernetes/index.md) |
| Intensidad de carbono horaria | usar ElectricityMaps API + la energía medida | [Del vatio al carbono: PUE y mix de red](https://blog.lo0.es/posts/tres-ejes-coste-rendimiento-energia-inferencia-llm/index.md) |
| Latencia de red cliente-servidor | el Job corre dentro del cluster; latencia WAN requiere cliente externo | documentar como metadato adicional |

---

## Fuentes

- GuideLLM · GitHub (proyecto vLLM) — https://github.com/vllm-project/guidellm
- GuideLLM · PyPI — https://pypi.org/project/guidellm/
- Red Hat Developer · desplegar y benchmarkear vLLM con GuideLLM en Kubernetes — https://developers.redhat.com/articles/2025/12/24/how-deploy-and-benchmark-vllm-guidellm-kubernetes
- Red Hat Developer · GuideLLM: evaluar despliegues LLM para inferencia real — https://developers.redhat.com/articles/2025/06/20/guidellm-evaluate-llm-deployments-real-world-inference
- AIPerf · GitHub (ai-dynamo/aiperf) — https://github.com/ai-dynamo/aiperf
- GenAI-Perf · documentación NVIDIA Triton — https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/perf_analyzer/genai-perf/README.html
- NVIDIA · LLM performance benchmarking con GenAI-Perf — https://developer.nvidia.com/blog/llm-performance-benchmarking-measuring-nvidia-nim-performance-with-genai-perf
- NVIDIA NIM Benchmarking · métricas (TTFT, ITL, throughput) — https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html
- OpenCost · web oficial — https://opencost.io/
- OpenCost · GitHub (opencost/opencost) — https://github.com/opencost/opencost
- OpenCost · CNCF blog (sandbox → incubating) — https://www.cncf.io/blog/2022/12/06/opencost-a-new-cncf-sandbox-project-for-real-time-kubernetes-cost-monitoring/
- LiteLLM · web oficial — https://www.litellm.ai/
- LiteLLM · GitHub (BerriAI/litellm) — https://github.com/BerriAI/litellm
- LiteLLM · Spend Tracking docs — https://docs.litellm.ai/docs/proxy/cost_tracking
- NVIDIA DCGM Exporter · documentación oficial — https://docs.nvidia.com/datacenter/dcgm/latest/gpu-telemetry/dcgm-exporter.html
- NVIDIA DCGM Exporter · GitHub — https://github.com/NVIDIA/dcgm-exporter
- NVIDIA DCGM Exporter · métricas CSV — https://github.com/NVIDIA/dcgm-exporter/blob/main/etc/dcp-metrics-included.csv
- Kepler (Kubernetes-based Efficient Power Level Exporter) · GitHub — https://github.com/sustainable-computing-io/kepler
- Red Hat Emerging Technologies · Introducing Kepler — https://next.redhat.com/2023/08/22/introducing-kepler-efficient-power-monitoring-for-kubernetes/
- MLCommons · MLPerf Inference Datacenter benchmark — https://mlcommons.org/benchmarks/inference-datacenter/
- MLCommons · MLPerf Power presentado en IEEE HPCA 2025 — https://mlcommons.org/2025/03/ml-commons-power-hpca/
- IEEE Xplore · MLPerf Power paper (μWatts to MWatts) — https://ieeexplore.ieee.org/document/10946778/
- MLCommons · MLPerf Inference v5.1 results (septiembre 2025) — https://mlcommons.org/2025/09/mlperf-inference-v5-1-results/
- Red Hat · Efficient and reproducible LLM inference: MLPerf Inference v5.1 — https://www.redhat.com/en/blog/efficient-and-reproducible-llm-inference-red-hat-mlperf-inference-v51-results
- BentoML · Beyond tokens-per-second: cost, speed and quality in LLM inference — https://www.bentoml.com/blog/beyond-tokens-per-second-how-to-balance-speed-cost-and-quality-in-llm-inference

