FP8 end-to-end: activar, medir calidad y decidir con datos
TL;DR
FP8 es el cambio de configuración con mayor impacto por esfuerzo disponible en hardware H100 y Ada Lovelace. En H100, activa tensor cores FP8 nativos: +40-60% throughput en decode y ×2 VRAM disponible para KV cache. En RTX 4090 y L40, el beneficio de compute es menor pero el ×2 VRAM es real y se traduce directamente en el doble de concurrencia. El riesgo es la degradación de calidad, que en modelos modernos bien calibrados es <0.5% en benchmarks estándar pero puede ser mayor en razonamiento formal. El workflow correcto no es activar y rezar: es activar en staging, correr la eval suite, correlacionar calidad con throughput en OTel, y decidir con datos.
La analogía
Un fotógrafo que trabaja con negativos de 35 mm y pasa a digital. Las fotos digitales ocupan menos espacio y se procesan más rápido. Pero una foto de baja resolución de un paisaje puede ser indistinguible de la de alta resolución para el ojo humano, mientras que una foto de texto en baja resolución pierde letras. El mismo trade-off exacto aplica a FP8: para tareas donde la imprecisión numérica se promedía sobre miles de activaciones (conversación, resumen, RAG), es prácticamente invisible. Para tareas donde una sola multiplicación errónea propaga una respuesta incorrecta (matemáticas formales, código crítico), puede ser determinante.
Las tres capas de FP8 en vLLM
FP8 no es un único flag: son tres capas independientes que se activan por separado y tienen beneficios distintos.
Capa 1 — Pesos del modelo (--quantization fp8):
Los pesos del modelo se almacenan y se calculan en FP8 E4M3. Los modelos deben estar pre-cuantizados (disponibles en HuggingFace con sufijo -FP8 o -fp8) o cuantizarse en tiempo de carga con calibración. El beneficio: el modelo ocupa la mitad de VRAM y los matmuls de pesos son 2× más rápidos en H100.
# Modelo pre-cuantizado (recomendado para producción)
vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8 \
--quantization fp8
# O cuantización on-the-fly (sin archivos adicionales, algo más lento en primeros tokens)
vllm serve meta-llama/Meta-Llama-3.1-70B-Instruct \
--quantization fp8 \
--kv-cache-dtype auto
Capa 2 — KV cache (--kv-cache-dtype fp8):
Los tensores K y V del KV cache se almacenan en FP8 en vez de BF16. Reduce el tamaño del KV cache a la mitad, duplicando el número de tokens que caben en VRAM. No afecta a los pesos del modelo.
vllm serve mi-modelo \
--kv-cache-dtype fp8 \
--calculate-kv-scales # calibración dinámica, obligatorio para minimizar degradación
Capa 3 — Activaciones (automático en H100): En GPUs Hopper, vLLM activa automáticamente FP8 para las activaciones intermedias cuando ambas capas anteriores están activas. No requiere flag adicional.
Configuración completa para producción:
vllm serve neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8 \
--quantization fp8 \
--kv-cache-dtype fp8 \
--calculate-kv-scales \
--gpu-memory-utilization 0.92 \
--max-model-len 16384
El impacto medible por hardware
H100 SXM (Hopper, tensor cores FP8 nativos)
| Métrica | BF16 baseline | FP8 activado | Delta |
|---|---|---|---|
| Throughput decode (tok/s, 70B, batch 32) | ~1.800 | ~2.700 | +50% |
| VRAM modelo (70B) | 140 GB | 70 GB | −50% |
| VRAM KV cache disponible (en 4×H100) | 180 GB | 250 GB | +39% |
| Concurrencia máxima (ctx 8K) | ~22.500 tok | ~31.250 tok | +39% |
Esto equivale a una réplica adicional gratis en términos de capacidad de KV cache.
RTX 4090 (Ada Lovelace, FP8 CUDA pero sin tensor cores dedicados)
| Métrica | BF16/Q4 baseline | FP8 KV cache añadido | Delta |
|---|---|---|---|
| Throughput decode (tok/s, 14B Q4) | ~45 | ~47 | +4% |
| VRAM KV cache disponible | 15 GB | 15 GB (modelo igual) | — |
| Tokens totales de cache (ctx 8K) | ~46.000 | ~92.000 | +100% |
| Concurrencia máxima (ctx 8K) | ~5 usuarios | ~11 usuarios | +120% |
En Ada, el beneficio de compute es menor (los tensor cores FP8 no tienen el mismo ancho que en Hopper), pero el ×2 en capacidad de KV cache es completamente real y se traduce en el doble de usuarios concurrentes posibles.
El workflow correcto: activar, medir, decidir
Activar FP8 directamente en producción sin validar calidad es inadecuado. El workflow correcto tiene cuatro pasos.
Paso 1: baseline en staging
Antes de activar FP8, registrar las métricas de calidad del modelo BF16 actual. La forma más reproducible es correr una eval suite sobre un dataset fijo y guardar los resultados:
# Instalar lm-evaluation-harness
pip install lm-eval
# Baseline BF16
lm_eval --model vllm \
--model_args pretrained=meta-llama/Meta-Llama-3.1-70B-Instruct,dtype=bfloat16 \
--tasks mmlu,hellaswag,gsm8k \
--num_fewshot 5 \
--output_path ./results/baseline_bf16.json
Paso 2: activar FP8 y correr la misma eval suite
# FP8
lm_eval --model vllm \
--model_args pretrained=neuralmagic/Meta-Llama-3.1-70B-Instruct-FP8,quantization=fp8,kv_cache_dtype=fp8,calculate_kv_scales=true \
--tasks mmlu,hellaswag,gsm8k \
--num_fewshot 5 \
--output_path ./results/fp8_full.json
Paso 3: calcular la degradación
# compare_eval.py
import json
with open("results/baseline_bf16.json") as f:
baseline = json.load(f)
with open("results/fp8_full.json") as f:
fp8 = json.load(f)
tasks = ["mmlu", "hellaswag", "gsm8k"]
print(f"{'Task':<15} {'BF16':>8} {'FP8':>8} {'Delta':>8} {'OK?':>6}")
print("-" * 50)
for task in tasks:
b = baseline["results"][task]["acc,none"]
f = fp8["results"][task]["acc,none"]
delta = (f - b) / b * 100
ok = "✓" if abs(delta) < 1.0 else "✗ REVISAR"
print(f"{task:<15} {b:>8.3f} {f:>8.3f} {delta:>+7.1f}% {ok:>6}")
Umbrales de decisión documentados en MLPerf Inference 2025:
- < 0.5% degradación: activar en producción sin restricciones.
- 0.5% – 1.5%: activar con monitorización activa de calidad via LLM-as-judge.
- > 1.5%: investigar antes de activar — posible problema de calibración o modelo incompatible.
Paso 4: eval de dominio con LLM-as-judge
Los benchmarks académicos miden lo que miden. Tu caso de uso puede ser diferente. Añadir 200 muestras representativas de tu dominio evaluadas por un juez LLM cierra el gap:
# domain_eval.py
from langfuse import Langfuse
from openai import OpenAI
client = Langfuse()
judge = OpenAI(base_url="http://judge-llm:8000/v1", api_key="token")
# Cargar las 200 muestras de producción curadas (prompt + respuesta esperada)
samples = load_domain_samples("eval_dataset_200.json")
scores_bf16, scores_fp8 = [], []
for sample in samples:
for model_type, endpoint in [("bf16", "http://staging-bf16:8000"), ("fp8", "http://staging-fp8:8000")]:
response = call_model(endpoint, sample["prompt"])
score = judge.chat.completions.create(
model="Qwen/Qwen2.5-72B-Instruct",
messages=[{
"role": "user",
"content": f"Evalúa esta respuesta del 1 al 5 según precisión y completitud.\nPregunta: {sample['prompt']}\nRespuesta esperada: {sample['expected']}\nRespuesta modelo: {response}\n\nResponde solo con un número del 1 al 5."
}]
).choices[0].message.content.strip()
if model_type == "bf16":
scores_bf16.append(int(score))
else:
scores_fp8.append(int(score))
import numpy as np
print(f"Score medio BF16: {np.mean(scores_bf16):.2f}")
print(f"Score medio FP8: {np.mean(scores_fp8):.2f}")
print(f"Degradación: {(np.mean(scores_fp8)-np.mean(scores_bf16))/np.mean(scores_bf16)*100:.1f}%")
Correlación OTel + Langfuse: el dashboard que decide
El momento de la decisión se apoya en un único dashboard con dos señales en el mismo eje temporal:
Señal 1 — Throughput (Prometheus):
rate(vllm:generation_tokens_total[5m])
Señal 2 — Calidad media (Langfuse → Prometheus via exporter):
# Si has configurado Langfuse con scores exportados via OTel
langfuse_score_value{name="llm_judge_domain"}
El patrón esperado después de activar FP8: el throughput sube un 40-60% y la calidad se mantiene dentro de ±0.1 puntos. Si la calidad cae más de 0.3 puntos y permanece baja, hay un problema real.
# Alerta: calidad cae más de 0.2 puntos sostenidos tras el cambio
ALERT FP8CalidadDegradada
IF avg_over_time(langfuse_score_value{name="llm_judge_domain"}[30m])
< (avg_over_time(langfuse_score_value{name="llm_judge_domain"}[1d] offset 2h) - 0.2)
FOR 15m
LABELS { severity = "warning" }
ANNOTATIONS { summary = "Posible degradación de calidad tras cambio de configuración FP8" }
Cuándo NO activar FP8
FP8 no es siempre la respuesta correcta. Los casos donde la degradación supera el umbral aceptable:
Razonamiento matemático formal: GSM8K y MATH son los benchmarks más sensibles a FP8. Si tu caso de uso es resolución de problemas matemáticos o cálculo financiero preciso, medir específicamente en estos benchmarks antes de activar.
Código crítico con tests: la precisión numérica afecta a la probabilidad de los tokens en posiciones clave de una función. El riesgo no es que el código “parezca” malo, sino que pase tests superficiales pero tenga bugs sutiles.
Contextos muy largos sin --calculate-kv-scales: sin calibración dinámica de escalas, el error numérico acumulado en el KV cache crece con el contexto. Con --calculate-kv-scales activo, el impacto es mínimo hasta 32K tokens.
Modelos pequeños (<7B): el overhead de conversión FP8 puede superar el beneficio de throughput. El punto de equilibrio está alrededor de 7B parámetros.
Ver también
- https://blog.lo0.es/posts/quantization-fundamentos-inferencia/ — la matemática de FP8 E4M3: qué es el exponente de 4 bits y la mantisa de 3 bits, y por qué este formato específico fue elegido sobre INT8
- https://blog.lo0.es/posts/kv-cache-fundamentos/ — la fórmula del tamaño del KV cache: por qué pasar a FP8 lo divide exactamente por dos
- https://blog.lo0.es/posts/decode-optimizaciones-vllm/ —
--kv-cache-dtype fp8y--calculate-kv-scalesen el contexto del tuning completo del decode - https://blog.lo0.es/posts/vllm-otel-instrumentacion-optimizaciones/ — cómo configurar la correlación Langfuse + Prometheus en un solo dashboard para el before/after de FP8
- https://blog.lo0.es/posts/evals-llm-la-capa-despues-de-tracing/ — la eval suite completa: cómo construir el dataset de dominio de 200 muestras y el juez LLM que verifica la calidad