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) 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 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:

CapaHerramienta(s)Métrica primariaProtocolo de export
RendimientoGuideLLM (sweep SLO) + AIPerfTTFT P99, ITL P99, goodput (tok/s)JSON/CSV nativo + /metrics OpenMetrics
CosteOpenCost + LiteLLM proxyCPM (€/1M tok), coste/peticiónAPI REST + Prometheus scrape
Energía (GPU)DCGM ExporterDCGM_FI_DEV_POWER_USAGE (W), DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTION (mJ)DaemonSet Prometheus
Energía (pod)Keplerkepler_container_joules_total, energía por podDaemonSet 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.

Namespace: benchmarkJob benchmark-runGuideLLM sweep (SLO)AIPerf concurrencia fijaLiteLLM proxytoken counting + CPMDaemonSet DCGMPOWER_USAGE, ENERGYDaemonSet KeplerInference endpointvLLM / SGLangmodelo pinneado, FP8/FP16OpenCost€/GPU-h por pod/nsPrometheusscrape 15 sretención 30 díasScorecard exporterPromQL → JSON/CSVversionado en gituna fila por (modelo, config, hardware)

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.

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). 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étricaUmbralPercentil
TTFT500 msP99
ITL (TPOT)50 ms/tokP95
Tasa de error0,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.

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). 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.

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

# 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) asigna coste de Kubernetes a namespace, label, pod y contenedor en tiempo real (GitHub 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:

# 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 detalla la configuración de precios y la asignación por label de GPU.

LiteLLM — el token counter por petición

LiteLLM (litellm.ai, GitHub 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).

# 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, docs) 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 DCGMTipoUnidadUso en el harness
DCGM_FI_DEV_POWER_USAGEgaugeWpotencia instantánea por GPU
DCGM_FI_DEV_TOTAL_ENERGY_CONSUMPTIONcountermJenergí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:

# 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) 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). 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:

# 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.

Referencia MLPerf Power

MLPerf Power (MLCommons, 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 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:

# 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:

{
  "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):

ConfigMotorPrecisiónCPM (€/1M)Goodput (tok/s)Wh/tokTTFT P99 (ms)ITL P95 (ms)CO2 /1M (g, FR)
AvLLM 0.9FP161,641.8900,000744984929,6
BvLLM 0.9FP80,973.1200,000444874217,6
CSGLang 0.4FP80,883.4100,000414213816,4
DvLLM 0.9FP8, ISL 5121,312.2400,000562943122,4
EvLLM 0.9FP8, max-batch 1280,843.5900,000407215516,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.

CPM (EUR/1M tok) ↑ peorgoodput (tok/s) → mejor1,701,401,100,900,841.8002.2003.1003.4003.600A (FP16)B (FP8)C (SGLang FP8) ★D (ISL 512)E (SLO roto)frontera Pareto(A domina solo en latencia; C+D en la frontera bajo SLO)

Diseño reproducible: los doce metadatos obligatorios

El checklist que cierra el dossier de sesgo (Sesgo y reproducibilidad). Para que el JSON del harness sea auditable, los doce campos deben estar presentes en cada corrida:

#CampoEjemploPor qué es obligatorio
1Versión del motor de servingvllm-0.9.1el mismo modelo puede rendir diferente en distintas versiones
2Versión del modelo (commit/tag)meta-llama/Meta-Llama-3-70B-Instruct@sha256:abcevita ambigüedad entre variantes del mismo nombre
3Precisiónfp8FP16 vs FP8 cambia throughput y latencia
4Tokenizador y versiónmeta-llama-3-tokenizer-v3el ISL/OSL en tokens depende del tokenizador
5ISL (input sequence length)1024 tokcambia el tiempo de prefill y el codo
6OSL (output sequence length)256 tokcambia el tiempo de decode
7Hardware (modelo y cantidad)4×H100-SXM-80GBbase de cualquier comparación
8Concurrencia máxima probada32define el rango del sweep
9Versión de la herramienta de benchguidellm-0.4.2, aiperf-0.2.0distintas versiones pueden dar métricas distintas
10SLO declaradoTTFT P99 < 500 ms, ITL P95 < 50 msel codo depende del SLO; sin él no hay goodput
11Duración de warmup60 ssin warmup el KV cache está frío y las primeras rondas son sesgadas
12Versión del harness / run-id20260616T1200identifica 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:

# 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:

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:

# 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 ausenteMotivoDónde se cubre
Calidad del output (MMLU, HumanEval)requiere banco de evaluación de calidad, no de servingBenchmarks de calidad LLM
Throughput de trainingel harness es exclusivamente para inferenciafuera del scope de la serie “datos”
Coste de red y almacenamientoOpenCost puede atribuirlo, pero requiere configuración adicionalOpenCost: cost allocation
Intensidad de carbono horariausar ElectricityMaps API + la energía medidaDel vatio al carbono: PUE y mix de red
Latencia de red cliente-servidorel Job corre dentro del cluster; latencia WAN requiere cliente externodocumentar como metadato adicional

Fuentes