Ronda de Diseño ML · Serving de modelos · ejemplos de C01
Modelo y APIPara llevar un modelo entrenado en PyTorch a un servicio rápido y medido con la documentación abierta, y para diagnosticar por qué va lento, predice distinto o no carga. El modelo se exporta a ONNX, se compara con el original, se compila con TensorRT para la GPU donde va a correr y se sirve con Triton, que junta los requests en batches; al final se mide. Cada sección trae la sintaxis como plantilla con variables, las piezas, un ejemplo real del curso, una figura, un truco para recordarla, la trampa típica y, cuando se puede sin GPU, un laboratorio opcional. En la versión web, al final hay un diagnosticador de síntomas, un revisor de config.pbtxt y un quiz de errores.
Cómo recordarlaTrampa típicavariablela cambias túEjemplos en verde
En las figuras:CPUla GPU y su trabajoespera y latenciaprecisión: FP32 · FP16 · INT8falla, se rechaza o no cargase ignora o es un ejemplo
¿Qué números, puertos y rutas conviene saberse de memoria para servir un modelo, medirlo y leer sus métricas sin abrir la documentación?
Sintaxis con variables
curl -i localhost:puerto_http/v2/health/ready¿Triton puede recibir tráfico? Si sí, la primera línea dice HTTP/1.1 200 OK; si no, 400 Bad Request. Con --strict-readiness=true (el default, que el C01 igual escribe explícito), solo da 200 si el servidor responde y todos sus modelos cargaron. /v2/health/live pregunta algo más simple: si el proceso responde.
curl -s localhost:puerto_http/v2/models/modeloLa metadata del modelo según el protocolo KServe v2 (el contrato estándar que habla Triton por HTTP y por gRPC, un protocolo binario sobre HTTP/2): nombre, versiones, plataforma y, por cada tensor, name, datatype (como FP32) y shape. El eje de batch sale como -1. Con /config al final ves la configuración que Triton usa de verdad, en JSON.
curl -s localhost:puerto_metricas/metrics | grep metricaLas métricas de Triton, en el formato de texto de Prometheus (el sistema que las junta y las guarda). Las de requests y tiempos (nv_inference_request_success, nv_inference_queue_duration_us…) son contadores: acumulan desde que arrancó Triton, como un odómetro, y lo útil es cuánto suben por segundo, que en Prometheus se calcula con rate(). Las de la GPU (nv_gpu_utilization, nv_gpu_memory_used_bytes) y nv_inference_pending_request_count son gauges: marcan el valor de ahora, como un velocímetro.
nvidia-smi --query-gpu=name,compute_cap,memory.used,memory.total,utilization.gpu --format=csv
En la máquina con GPU, no en tu Mac: qué GPU es, su compute capability (la generación de su arquitectura: 7.5 en la T4, y el engine queda atado a ella), cuánta memoria de GPU usa y qué fracción del tiempo tuvo algún kernel (una función que corre en la GPU) en marcha. Ojo: 100 % no quiere decir que esté llena.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| puerto_http | puerto HTTP de Triton (protocolo KServe v2) | 8000 |
| puerto_metricas | puerto de métricas para Prometheus | 8002 |
| modelo | nombre del modelo: su carpeta en el model repository | classifier |
| metrica | nombre de una métrica de Triton | nv_inference_queue_duration_us |
| parámetros del modelo: los números que aprendió | 24,532,532 ≈ 24.5 M (ResNet-50 con su capa final, fc, de 500 clases) | |
| bytes por valor según la precisión | FP32 4 · FP16 2 · INT8 1 | |
| batch: cuántas imágenes van juntas en una ejecución | 1 a 32 (max_batch_size: 32) | |
| lo que tarda una ejecución del modelo con ese batch | ≈ 3–6 ms (p50) con batch 1–8 en la T4 (presupuesto del curso) | |
| throughput: imágenes por segundo; X_max es el máximo medido | ≈ 1,200 img/s por T4 (supuesto del C01: se mide con perf_analyzer) | |
| tasa de llegada: requests por segundo | 250 req/s por GPU en el pico de diseño (500 entre 2 GPUs) | |
| utilización: qué fracción de la capacidad usas | ≈ 21 % por T4 en el pico; se planifica hasta ≈ 50 % | |
| W: tiempo que pasa un request adentro (cola + cómputo). L: cuántos requests hay adentro a la vez | W ≈ 5–10 ms dentro de Triton (estimación) → L ≈ 1–3 | |
| Δt: la ventana del dynamic batching, lo que Triton espera para juntar requests en un batch (max_queue_delay_microseconds). B̄: el batch promedio real | Δt = 2,000 µs = 2 ms; B̄ ≈ 1–3 en el pico y ≈ 2–4 si una GPU absorbe todo (failover) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| FP32 · 4 B | Precisión completa. Es la referencia: el checkpoint guarda los pesos en FP32 (aunque el C01 entrena con AMP, en precisión mixta), y así se evalúa y se exporta. | 98 MB de pesos; en la T4 corre en los CUDA cores: 8.1 TFLOPS |
| FP16 · 2 B | Media precisión: la mitad de bytes y acceso a los tensor cores (las unidades que multiplican matrices). Tope 65,504; cerca de 10, los valores van de a 0.008. | el engine del C01: 49 MB de pesos; 65 TFLOPS en la T4 |
| INT8 · 1 B | Enteros de 8 bits con una escala. Pide calibración (fijar las escalas con imágenes reales) y un gate por slices: medir la accuracy en cada subgrupo de datos antes de aprobarlo. | 25 MB; 130 TOPS en la T4; el C01 no lo usa porque la GPU le sobra |
| BF16 · FP8 | Formatos de 2 y 1 byte, comunes en LLMs (modelos de lenguaje grandes). La T4 no los tiene: BF16 pide una GPU Ampere o más nueva, y FP8, una Ada, Hopper o más nueva. | en una T4, un modelo entrenado en BF16 se sirve en FP32, o en FP16 si pasa la paridad: FP16 tiene menos rango (tope 65,504) |
| NVIDIA T4 | La GPU del C01, de la generación Turing (2018): 16 GB de memoria GDDR6 que se lee a ≈ 320 GB/s, 70 W, conectada por PCIe Gen3 x16 (el bus del servidor) y con compute capability 7.5 (sm_75). | g4dn.xlarge (la máquina de AWS con una T4) ≈ US$0.526/h; nvidia-smi muestra 15,360 MiB porque el ECC (corrección de errores) reserva 1/16 de la memoria |
| Tensores de entrada y salida | Lo que viaja entre el gateway (el servicio en CPU que recibe la foto y llama a Triton) y Triton: la imagen preprocesada, y de vuelta un logit (puntaje antes del softmax) por clase. | entrada: 602 KB en FP32 (150 KB en UINT8; un batch de 32 dentro de Triton, 19 MB); salida: 2 KB |
| VRAM ocupada | La memoria de la GPU en uso. Por proceso, el contexto CUDA (lo que reservan el driver y las librerías de NVIDIA al arrancar); por cada instancia del modelo, su copia del engine con los pesos y sus activaciones (los resultados intermedios de cada capa). | ≈ 1–3 GB de 16 con 2 instancias (estimación del curso) |
| 8000 · 8001 · 8002 | Puertos por defecto de Triton: HTTP/REST, gRPC y métricas. | el gateway llama al 8000; Prometheus lee el 8002 |
| /v2/health/live · /v2/health/ready | live: ¿el proceso responde? Sirve para la liveness (¿lo reinicio?). ready: ¿puede recibir tráfico? Sirve para la readiness (¿le mando requests?). | livenessProbe en /live; startupProbe y readinessProbe en /ready |
| /v2/models/modelo/ready | ¿Ese modelo está listo? Mira uno solo, no el servidor entero. | lo consulta el /readyz del gateway |
| /v2/models/modelo · /v2/models/modelo/config | La metadata (nombres, datatypes, shapes) y la configuración efectiva del modelo, en JSON. | input FP32 [-1, 3, 224, 224] → logits FP32 [-1, 500] |
| /v2/models/modelo/infer | La inferencia por HTTP (POST). Con la extensión binaria, los tensores viajan como bytes crudos detrás de un JSON corto. | el cliente async (tritonclient.http.aio) del gateway manda 602 KB binarios por request |
| nv_inference_queue_duration_us · nv_inference_compute_infer_duration_us | Microsegundos acumulados en cola y en cómputo, por modelo. Divide cuánto suben por cuánto sube nv_inference_request_success y tienes el promedio por request. | la espera en cola es la señal con la que KEDA (el autoscaler por métricas) escala Triton |
| 2 · 100 · 150 · 200 ms | La ventana del batching, el rechazo en la cola, el timeout del gateway hacia Triton y el SLO (el objetivo de latencia) del servicio. | max_queue_delay 2000 µs · REJECT a los 100 ms · INFER_TIMEOUT_S=0.15 · p99 ≤ 200 ms |
ResNet-50 del C01 (capa final de 500 clases) · 24,532,532 parámetros
FP32
4 B
98.1 MB
referencia: checkpoint y ONNXCUDA cores · 8.1 TFLOPS
FP16
2 B
49.1 MB
el engine TensorRT del C01tensor cores · 65 TFLOPS
INT8
1 B
24.5 MB
opcional: pide calibracióntensor cores · 130 TOPS
NVIDIA T4 · 16 GB de VRAM (15,360 MiB en nvidia-smi)
De qué está hecho lo que se usa (rango estimado, a escala de 0 a 1 GB)
engine (pesos FP16 + kernels elegidos)
0.05–0.15 GB
activaciones a batch 32
0.2–0.5 GB
contexto CUDA + librerías
0.3–0.8 GB
Con 2 instancias del engine (instance_group count 2), ≈ 1–3 GB en total. Es una estimación del curso: se confirma con nv_gpu_memory_used_bytes.
RESIZE_SHORT = 256 # lado corto tras el resize: el mismo número que Resize(256) en training
CROP = 224 # tamaño del center crop: el input del ResNet-50 (y del engine TensorRT)
# mean/std de ImageNet con forma (3,1,1) para hacer broadcasting sobre CHW sin copiar
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)CROP = 224 y los 3 canales RGB dan la forma de cada imagen: [3, 224, 224]. Es la misma de dims: [ 3, 224, 224 ] en el config.pbtxt y la de --minShapes=input:1x3x224x224 en trtexec (la herramienta que compila el engine).MEAN y STD son las estadísticas de ImageNet por canal. Si un lado normaliza y el otro no, el modelo recibe otra distribución y pierde accuracy sin lanzar ningún error.RESIZE_SHORT = 256 copia el Resize(256) del training. El training, la evaluación y el gateway importan este mismo archivo: es la única fuente de estos números.export_onnx.py fija el resto del contrato: NUM_CLASSES = 500 (la salida [500]), los nombres input y logits, y MAX_BATCH = 32, que es el max_batch_size de Triton y el --maxShapes de trtexec.Cómo recordarla
4 · 2 · 1 bytes por valor en FP32, FP16 e INT8: el modelo del C01 pesa 98 · 49 · 25 MB. Puertos 8000 · 8001 · 8002: HTTP, gRPC y métricas. live = ¿lo reinicio?; ready = ¿le mando tráfico?
Trampa
Creer que la VRAM la llenan los pesos: en el C01 son 49 MB de 16 GB, y lo que ocupa es el contexto CUDA, las activaciones y cada instancia extra (≈ 1–3 GB en total). Y poner /v2/health/ready en la livenessProbe (el chequeo de ¿lo reinicio?): si un modelo deja de estar listo, por ejemplo porque se descargó, el kubelet (el agente de Kubernetes en cada nodo) reinicia Triton en bucle, y reiniciar no lo arregla. Liveness va con /live; readiness, con /ready.
En el curso: Números · Bytes, dtypes y tamaños típicos M4 · T4, L4, A10G, A100, H100: cuándo usar cada una M4 · Cálculo de VRAM paso a paso M3 · Little's law: la cuenta que ordena todo M7 · NVIDIA Triton Inference Server Hoja · Formulario de estimaciones
Qué vas a provocar: Medir tú mismo los bytes de bolsillo: parámetros, MB por precisión, tamaño en disco, el tensor que viaja a Triton y los límites de FP16.
Necesitas: un venv con Python 3.13, torch 2.14.0 y torchvision 0.29.0 (las versiones del C01) e internet para instalarlos; sin GPU
python3.13 -m venv ~/venvs/lab-triton
source ~/venvs/lab-triton/bin/activate
pip install torch==2.14.0 torchvision==0.29.0Qué deberías ver: La última línea empieza con Successfully installed e incluye torch-2.14.0 y torchvision-0.29.0, junto con sus dependencias (numpy, pillow, sympy y otras). Son las versiones del C01; no necesitas GPU.
python -c "
import torchvision
m = torchvision.models.resnet50(weights=None, num_classes=500)
P = sum(p.numel() for p in m.parameters())
print(P, [round(P * b / 1e6, 1) for b in (4, 2, 1)])
"Qué deberías ver: 24532532 [98.1, 49.1, 24.5]: los parámetros del modelo del C01 y sus MB de pesos en FP32, FP16 e INT8.
python -c "
import os, torch, torchvision
m = torchvision.models.resnet50(weights=None, num_classes=500)
torch.save(m.state_dict(), '/tmp/r50_fp32.pt'); torch.save(m.half().state_dict(), '/tmp/r50_fp16.pt')
print([round(os.path.getsize(f) / 1e6, 1) for f in ('/tmp/r50_fp32.pt', '/tmp/r50_fp16.pt')])
"Qué deberías ver: [98.4, 49.3]: un poco más que 98.1 y 49.1, porque el archivo guarda también las estadísticas del BatchNorm y los nombres de cada tensor.
python -c "
import json, numpy as np
x = np.random.default_rng(0).standard_normal((1, 3, 224, 224)).astype(np.float32)
print(x.nbytes, x.astype(np.uint8).nbytes, len(json.dumps(x.ravel().tolist())))
"Qué deberías ver: 602112 150528 3105734: el tensor pesa 602 KB en FP32 y 150 KB en UINT8, pero como JSON (números en texto) son ≈ 3.1 MB, unas 5 veces más. Por eso el gateway lo manda en binario.
python -c "import numpy as np; print(np.finfo(np.float16).max, np.spacing(np.float16(10)), np.spacing(np.float32(10)), np.float16(70000))"Qué deberías ver: 6.55e+04 0.007812 9.536743e-07 inf, con un aviso RuntimeWarning: overflow encountered in cast. FP16 llega hasta 65,504; cerca de 10 solo puede moverse de a 0.0078 (FP32, de a 0.00000095), y 70,000 ya no cabe: inf. Por eso un engine FP16 mueve los logits ≈ 1e-2.
rm /tmp/r50_fp32.pt /tmp/r50_fp16.ptQué deberías ver: Nada: borra los dos archivos de prueba. El venv queda para los labs de las secciones siguientes.
Cómo arreglarlo y confirmarlo
Si un número no te da, revisa num_classes (con 1000 clases son 25.6 M parámetros y 102 MB), el dtype del array (np.random.rand da float64: 8 bytes por valor) y que estés en el venv correcto: python -c "import torch; print(torch.__version__)" debe decir 2.14.0.
Probado de verdad en un Mac, sin GPU.
¿Qué pasa, en orden, entre el checkpoint que evaluaste y una respuesta de Triton con su latencia medida, y qué revisa cada paso?
Sintaxis con variables
python model/export_onnx.py --checkpoint checkpoint --out onnx
Exporta a ONNX (un formato de archivo estándar para redes, que no depende de PyTorch) y lo verifica: onnx.checker, el opset real (la versión de los operadores que quedó escrita) y ONNX Runtime (un motor que ejecuta ONNX en CPU) con batch 1 y 32 contra PyTorch. Exit 0 = la cadena sigue.
python model/parity_check.py --checkpoint checkpoint --onnx onnx --images muestra --n 2000
Primer gate de paridad (¿predice lo mismo que PyTorch?), en CPU con ONNX Runtime. Sin --checkpoint, la referencia de PyTorch tendría pesos aleatorios y compararías contra otro modelo.
NGC_RELEASE=release EXPECTED_GPU=gpu bash model/build_engine.sh
En la máquina con la GPU de producción: TensorRT (el compilador de NVIDIA) convierte el ONNX en model.plan, el engine (el modelo compilado para un tipo de GPU y una versión de TensorRT). Las dos variables valen 24.08 y T4 por defecto, y el script aborta si la GPU no coincide.
python model/parity_check.py --backend triton --triton-url host:8000 --checkpoint checkpoint --images muestra --n 2000
Segundo gate: el engine FP16 servido por Triton contra PyTorch FP32, de a una imagen como en producción. Después viene el gate de latencia con perf_analyzer (la herramienta de Triton que genera carga y mide la latencia).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| checkpoint | el state_dict entrenado y aprobado (.pt) | resnet50_500.pt |
| onnx | el archivo ONNX que sale del export | model/classifier.onnx |
| muestra | carpeta con imágenes reales del set de evaluación | ≥ 2,000 imágenes (lo que recomienda parity_check.py) |
| release | release de NGC (las imágenes de contenedor de NVIDIA): la misma de la imagen de Triton | 24.08 (TensorRT 10.3) |
| gpu | la GPU donde correrá el engine | T4 |
| host | dónde corre Triton | localhost, en el runner de CI con la T4 |
| acuerdo del 1.º lugar y solapamiento del top-5 contra PyTorch FP32 | ≥ 99.5 % y ≥ 99 % (defaults de parity_check.py) | |
| la mayor diferencia entre los logits de los dos modelos | ≤ 0.5: atrapa exports rotos | |
| la latencia que solo el 1 % de los requests supera | perf_analyzer a 250 y 500 req/s contra el champion (el modelo en producción); el challenger es el candidato |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| checkpoint (.pt) | Los pesos entrenados, guardados con torch.save como un state_dict: un diccionario con el nombre de cada tensor de pesos (como fc.weight) y sus valores. | resnet50_500.pt: 24.5 M parámetros en FP32 |
| build_model() + eval() | Rearma la arquitectura del training, carga los pesos y deja el modelo en modo inferencia. | export_onnx.py y parity_check.py usan la misma función |
| torch.onnx.export | Recorre el modelo con una entrada de ejemplo y escribe el grafo ONNX con sus pesos. | exportador TorchScript, opset 17, batch dinámico |
| classifier.onnx | El punto de entrega entre training y serving: grafo + pesos + firma de entradas y salidas. | ≈ 98 MB; input [batch, 3, 224, 224] → logits [batch, 500] |
| onnx.checker | Revisa que el archivo sea ONNX válido; con full_check también deduce la forma de cada tensor. | dentro de verify() de export_onnx.py |
| ONNX Runtime (ORT) | Un runtime (el motor que ejecuta el grafo). Aquí corre el ONNX en CPU, sin GPU. | verify() con batch 1 y 32, y el gate de paridad en CPU |
| parity_check.py | El gate de paridad: compara top-1, top-5 y |Δlogit| contra PyTorch FP32. Exit 1 frena todo. | --backend ort en CPU; --backend triton en la T4 |
| trtexec | La herramienta de línea de comandos de TensorRT: compila el ONNX a un engine y hace un benchmark rápido. | --fp16 con perfil 1/16/32, dentro de tensorrt:24.08-py3 (TensorRT 10.3; TensorRT 11 ya no tiene --fp16) |
| model.plan (engine) | El modelo compilado para un tipo de GPU y una versión de TensorRT: corre rápido, pero solo ahí. | T4 (sm_75) + TensorRT 10.3; ≈ 50–150 MB (estimación del curso) |
| model repository | La carpeta que lee Triton: una subcarpeta por modelo con su config.pbtxt, y dentro una por versión. | classifier/config.pbtxt + classifier/1/model.plan |
| imagen de Triton | El servidor con el model repository horneado adentro: el tag de la imagen es la versión del modelo. | tritonserver:24.08-py3 + engine; tag 2026-09-01-resnet50-fp16 |
| Triton (tritonserver) | El servidor de modelos: carga el engine, encola los requests, arma batches y responde por HTTP y por gRPC (otro protocolo, binario). | READY recién después del warmup: 3 ejecuciones de prueba a batch 32 |
| gateway (el cliente) | El servicio FastAPI en CPU que preprocesa la imagen y llama a Triton con tritonclient. | POST /v1/classify → infer en :8000 con 150 ms de timeout |
| perf_analyzer | Genera carga contra Triton y mide throughput y latencias por percentil. | gate: p99 a 250 y 500 req/s ≤ champion + 10 % |
Checkpoint
resnet50_500.pt · ≈ 98 MB en FP32
registry · cualquier máquina
torch.load("resnet50_500.pt", map_location="cpu", weights_only=True)
Revisa: load_state_dict estricto: las llaves y shapes calzan con la arquitectura
ONNX
classifier.onnx · ≈ 98 MB · opset 17
CI · CPU
python model/export_onnx.py --checkpoint resnet50_500.pt --out model/classifier.onnx
Revisa: onnx.checker, opset real = 17, ORT con batch 1 y 32 ≈ PyTorch; después, paridad en CPU
Engine
model.plan · FP16 · batch 1 a 32
runner con T4 · tensorrt:24.08-py3
Solo carga enT4 · sm_75+TensorRT 10.3
bash model/build_engine.sh
Revisa: la GPU es una T4; imprime GPU, NGC_RELEASE y sha256 para el registry
Repository
classifier/config.pbtxt + 1/model.plan, horneados en la imagen
docker build · tritonserver:24.08-py3
docker build -t classifier-triton:2026-09-01-resnet50-fp16 triton/
Revisa: RUN test -f /models/classifier/1/model.plan
Servidor
Triton con el modelo READY
la misma T4
curl -i localhost:8000/v2/health/ready
Revisa: carga el engine, corre el warmup y recién ahí responde 200
Cliente
logits [500] por request y el p99 medido
la misma T4 en CI; en producción, el gateway
python model/parity_check.py --backend triton --triton-url localhost:8000 --checkpoint resnet50_500.pt --images /data/eval_sample --n 2000
Revisa: paridad FP16 vs FP32 (top-1 ≥ 99.5 %) y perf_analyzer: p99 ≤ champion + 10 %
corre en cualquier CPUcorre en la GPUprecisión de lo que sale
checkpoint .pt ──export_onnx.py──▶ classifier.onnx ──parity_check.py (ORT, CPU)──▶ ¿pasa?
└─ en la máquina con T4: build_engine.sh ──▶ model.plan ──docker build triton/──▶ imagen
└─ Triton arriba ──parity_check.py --backend triton (FP16 vs FP32)──▶ ¿pasa? ──▶ registry / canarydocker build triton/ hornea el engine en la imagen. Su RUN test -f /models/classifier/1/model.plan hace fallar el build si falta el engine: mejor ahí que un pod reiniciándose en bucle (CrashLoopBackOff).parity_check.py --backend triton compara FP16 contra FP32. Si pasa, y la latencia también, va al registry (el catálogo de versiones de modelos) como challenger, el candidato, y después a un canary: una parte chica del tráfico real.¿pasa? es un exit code: 0 sigue, 1 frena. El CI no lee los números; lee ese código.Cómo recordarla
Exportas una vez, compilas donde corre y comparas dos veces: el ONNX en CPU (paridad con ONNX Runtime), el engine en la T4, y otra paridad ya servido por Triton antes de medir la latencia.
Trampa
Correr parity_check.py sin --checkpoint: la referencia de PyTorch nace con pesos aleatorios. Contra el ONNX del checkpoint real, el gate falla siempre; si el ONNX también salió sin checkpoint, pasa y no prueba nada. Y compilar el engine en otra máquina por comodidad: por defecto, solo carga en el tipo de GPU y la versión de TensorRT con que se construyó.
En el curso: C01 · Archivos de referencia M10 · CI/CD para modelos: la pirámide de tests M8 · Cómo validar una optimización M7 · Qué hace un model server (y qué no)
Qué vas a provocar: Correr en tu Mac la mitad CPU de la cadena del C01 con sus scripts reales, ver un gate que frena y ver dónde se corta sin GPU.
Necesitas: el repo del curso (apps/ml-system-design), Python 3.13 e internet para instalar model/requirements.txt; sin GPU. El engine y Triton necesitan una máquina con T4.
cd apps/ml-system-design/reference/c01
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r model/requirements.txtQué deberías ver: Desde la raíz del repo del curso. Instala las versiones fijas del C01: torch 2.14.0, torchvision 0.29.0, onnx 1.23.0, onnxscript 0.7.2, onnxruntime 1.30.0, entre otras. Sin GPU.
python model/export_onnx.py --out /tmp/c01.onnxQué deberías ver: Primero [model] AVISO: sin --checkpoint → pesos aleatorios… y un DeprecationWarning del exportador TorchScript. Después [export] opset=17 input=input['batch', 3, 224, 224] output=logits, una línea por batch (1 y 32) con shape (1, 500) y (32, 500), max|Δlogit| del orden de 1e-5 a 1e-4 y top1_igual=100%, y al final [export] OK.
python model/parity_check.py --onnx /tmp/c01.onnx --n 64Qué deberías ver: [parity] backend=ort n=64 top1_agreement=100.00% top5_overlap=100.00%, un max|Δlogit| del orden de 1e-5 a 1e-4, y [parity] PASA. Pasa porque los dos lados usan los mismos pesos aleatorios (semilla 0): prueba el pipeline, no un modelo.
python -c "import torch, torchvision; torch.manual_seed(1); torch.save(torchvision.models.resnet50(weights=None, num_classes=500).state_dict(), '/tmp/otro.pt')"
python model/parity_check.py --onnx /tmp/c01.onnx --checkpoint /tmp/otro.pt --n 64; echo "exit=$?"Qué deberías ver: top1_agreement y top5_overlap en 0 % o casi, un max|Δlogit| del orden de 100 y [parity] NO PASA: no promuevas este artefacto, con exit=1. Es lo que pasa cuando el artefacto no viene del modelo que evaluaste.
bash model/build_engine.sh; echo "exit=$?"Qué deberías ver: model/build_engine.sh: line 19: nvidia-smi: command not found y exit=127 (127 = comando no encontrado). Es el borde de la cadena: desde aquí todo corre en una máquina con T4 y su driver de NVIDIA.
deactivate
rm /tmp/c01.onnx /tmp/otro.ptQué deberías ver: Sale del venv y borra los archivos de prueba.
Cómo arreglarlo y confirmarlo
Si export_onnx.py falla con ModuleNotFoundError: No module named 'onnx' (o 'onnxruntime'), el venv no tiene los requirements: actívalo y repite el pip install. Si el paso 4 pasa, revisa que la semilla sea 1 y no 0 (con 0 creas el mismo modelo del export).
Verificado contra la documentación; no ejecutado aquí.
¿Qué tiene que hacer un servicio alrededor de model(x) para aguantar tráfico real, y qué te da un servidor de modelos como Triton?
Sintaxis con variables
uvicorn modulo:app --host 0.0.0.0 --port puerto --workers n
Levanta una app FastAPI (un framework web de Python) con uvicorn (el servidor que atiende las conexiones). Con --workers mayor que 1 son varios procesos, cada uno con su copia del modelo y sin batches entre ellos. El C01 usa 1 proceso por contenedor y agrega pods.
resultado = await run_in_threadpool(funcion, datos)
Dentro de un async def, el trabajo de CPU (decodificar el JPEG, correr el modelo) va a un hilo aparte. Si no, bloquea el event loop (el bucle que atiende todas las requests del proceso): nadie más avanza, ni los health checks.
result = await client.infer("modelo", [entrada], outputs=[salida])Con Triton, el gateway (el servicio delante del modelo) ya no corre el modelo: manda el tensor por HTTP con el cliente async tritonclient.http.aio y espera. El await libera el loop mientras la GPU trabaja.
tritonserver --model-repository=repoArranca el servidor de modelos: lee la carpeta, carga cada modelo con su backend (el motor que lo ejecuta: TensorRT, ONNX Runtime, Python…) y abre los puertos 8000 (HTTP), 8001 (gRPC, un protocolo binario) y 8002 (métricas).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modulo | módulo de Python con la app FastAPI | app.main (el gateway del C01) |
| puerto | puerto donde escucha uvicorn | 8080 |
| n | procesos de uvicorn; cada uno carga su propia copia del modelo | 1 en el C01: se escala con pods |
| resultado, funcion, datos | lo que devuelve, la función bloqueante y sus argumentos | batch, digest = …(_decode_and_hash, data) |
| modelo | nombre del modelo en Triton | classifier |
| entrada, salida | el InferInput (nombre, shape, dtype y datos) y el InferRequestedOutput | input FP32 [1, 3, 224, 224] y logits |
| repo | carpeta del model repository | /models (horneada en la imagen) |
| procesos con su copia del modelo en la misma GPU; cada uno paga además su contexto CUDA (la memoria fija que CUDA reserva por proceso) | 4 workers en una T4 | |
| el tráfico a aguantar en el pico | 500 req/s | |
| capacidad planificada por GPU | eager a batch 1 ≈ 100 img/s; Triton + TensorRT FP16 ≈ 600 (el 50 % de 1,200) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| servir (serving) | Poner el modelo detrás de una API para que otros sistemas le pidan predicciones, con latencia, errores y costo bajo control. | POST /v1/classify con p99 ≤ 200 ms |
| online síncrono | Quien llama espera la respuesta en la misma conexión. Lo opuesto: batch (offline) o una cola asíncrona. | la persona sigue en el formulario de publicación |
| latencia p50 / p99 | La mediana y el valor que solo el 1 % de los requests supera. En el C01, el SLO (el objetivo pactado) se fija en el p99. | SLO: p99 ≤ 200 ms medido en el servidor |
| throughput | Cuántas predicciones por segundo completa el servicio. | pico de diseño: 500 req/s |
| event loop | El bucle de asyncio que atiende todas las requests de un proceso; entre un await y el siguiente, solo corre una cosa. | decodificar una foto de 12 MP sin draft mode (el decode reducido de JPEG), ≈ 40–80 ms de CPU, dentro de un async def lo frena todo ese tiempo |
| threadpool | Hilos aparte para el trabajo de CPU. PyTorch, Pillow y NumPy sueltan el GIL (el candado que deja correr un solo hilo de Python a la vez) en su parte pesada. | run_in_threadpool, con 2 decodes a la vez por proceso |
| batch 1 | Cada request corre sola. En GPU deja el acelerador casi ocioso; en CPU casi da lo mismo. | ≈ 100 img/s por proceso con PyTorch eager en una T4 (estimación del C01) |
| dynamic batching | El servidor junta requests de clientes distintos durante una ventana corta y las corre juntas. Como un bus que espera unos milisegundos a llenarse antes de salir. | ventana de 2 ms, hasta 32 imágenes |
| instancia del modelo | Una copia del modelo en ejecución dentro del servidor. Dos en la misma GPU se turnan: mientras una copia datos, la otra calcula. | instance_group count 2 en la T4 |
| servidor de modelos (model server) | Un proceso dedicado a convertir tensores en tensores: carga modelos y versiones, encola, arma batches y expone salud y métricas. | Triton (NVIDIA hoy lo llama Dynamo-Triton); no es el paquete triton de pip, el lenguaje de kernels que usa torch.compile |
| backend | El motor que ejecuta cada modelo dentro de Triton. | backend tensorrt (platform: tensorrt_plan) para el engine; onnxruntime (platform: onnxruntime_onnx) para un .onnx |
| gateway | El servicio delante del servidor de modelos. Sabe del producto: quién llama, qué bytes acepta, el top-k y qué error devolver. | FastAPI en CPU: valida, preprocesa, llama a Triton y arma el top-5 |
| readiness / liveness | ¿Le mando tráfico? / ¿Lo reinicio? Son dos preguntas distintas con dos endpoints distintos. | /readyz del gateway depende de Triton; /healthz no |
| timeout y load shedding | Cortar lo que ya no llega a tiempo y rechazar rápido lo que no cabe, en vez de encolarlo. | 150 ms a Triton → 504; más de 16 en vuelo → 503 con Retry-After |
| Necesidad | model(x) en FastAPI | Triton | En el C01 |
|---|---|---|---|
| Batching | batch 1, o una cola propia (asyncio.Queue, futures, timeouts, límites) | dynamic_batching: dos líneas de config | ventana de 2 ms, hasta 32 |
| Varias copias en la GPU | un proceso por copia (--workers), sin batches entre ellas | instance_group | 2 instancias en la T4 |
| Cola con plazo | lo que escribas | default_queue_policy (rechaza lo que vence) | REJECT a los 100 ms en cola |
| Versiones y recarga | reiniciar el proceso, o una recarga en caliente propia | version_policy y modos de control del repository | latest 1; una versión por imagen |
| Salud por modelo | un /readyz propio | /v2/health/ready y /v2/models/{modelo}/ready | readinessProbe en /v2/health/ready |
| Cola vs cómputo | histogramas propios | métricas por modelo en el puerto 8002 | KEDA (el autoscaler) escala con la espera en cola |
| Protocolo de tensores | el JSON que diseñes | KServe v2 por HTTP y gRPC, con tensores binarios | 602 KB binarios por request |
| Engine optimizado | torch.compile, o TensorRT integrado a mano | backends tensorrt, onnxruntime, python… | TensorRT FP16 en la T4 |
Contra el MVP: ≈ 100 img/s por proceso con PyTorch eager a batch 1, contra ≈ 1,200 img/s por T4 con TensorRT FP16 y dynamic batching. Son estimaciones del C01, a medir con perf_analyzer; el salto mezcla el engine y el batching.
"""Gateway HTTP del clasificador de categorías (caso c01).
Por qué existe un gateway en CPU delante de Triton: validar, decodificar y redimensionar imágenes es
trabajo de CPU; si lo hiciera Triton (python backend o DALI) competiría con la GPU cara. Así cada
capa escala por separado: gateway por CPU (HPA) y Triton por cola de GPU (KEDA).
Endpoints: POST /v1/classify?top_k=5 · GET /healthz (liveness) · GET /readyz (readiness) · GET /metrics
"""
# …
def _decode_and_hash(data: bytes) -> tuple[np.ndarray, str]:
"""CPU pura (bloqueante): corre en el threadpool. Pillow y numpy sueltan el GIL en decode y resize."""
digest = hashlib.sha256(data).hexdigest() # huella de la imagen: deduplicar y cruzar con labels
tensor = preprocess(data, max_pixels=MAX_PIXELS) # float32 [3,224,224], idéntico a training/eval
return tensor[np.newaxis], digest # batch de 1: Triton junta requests con dynamic batching
# …
batch, digest = await run_in_threadpool(_decode_and_hash, data)_decode_and_hash es CPU pura y bloqueante: por eso se llama con run_in_threadpool. Llamada directo dentro de un async def, congelaría el event loop mientras dura (el lab lo mide).tensor[np.newaxis] agrega el eje de batch: [3, 224, 224] → [1, 3, 224, 224]. El gateway manda una imagen por request; juntar requests es trabajo de Triton (dynamic batching), no del cliente.Cómo recordarla
El gateway sabe del producto; el servidor de modelos, solo de tensores. FastAPI con model(x) alcanza para un MVP; se rompe primero por el batch 1 (throughput) y después por los reinicios en cada deploy.
Trampa
Subir --workers para ganar throughput en GPU: cada worker es otro proceso con su copia de los pesos y su contexto CUDA, y siguen corriendo batches de 1 que se turnan la GPU. Y llamar a model(x) dentro de un async def: el p99 sube con la concurrencia sin que la CPU esté llena, y hasta /healthz tarda.
En el curso: M7 · Qué hace un model server (y qué no) M7 · FastAPI + PyTorch: el punto de partida M7 · NVIDIA Triton Inference Server C01 · Serving
Qué vas a provocar: Medir en tu Mac los dos límites de model(x) dentro de un proceso: el batch de 1 y el event loop bloqueado.
Necesitas: el venv del lab de Números de bolsillo (Python 3.13, torch 2.14.0, torchvision 0.29.0); sin GPU
source ~/venvs/lab-triton/bin/activate
python - <<'EOF'
import time, torch, torchvision
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
x = torch.randn(8, 3, 224, 224)
with torch.inference_mode():
for bs in (1, 8):
m(x[:bs]) # warmup de esta shape
t = time.perf_counter()
for _ in range(10):
m(x[:bs])
dt = (time.perf_counter() - t) / 10
print(f"batch {bs:d}: {dt * 1000:.0f} ms por llamada, {bs / dt:.0f} img/s")
EOFQué deberías ver: Algo como batch 1: 13 ms por llamada, 76 img/s y batch 8: 94 ms por llamada, 85 img/s (Mac con chip Apple, 4 hilos de PyTorch). En CPU, juntar 8 imágenes casi no rinde más: no hay un acelerador que llenar. En una T4, el C01 estima ≈ 100 img/s con batch 1 y ≈ 1,200 con TensorRT y batching.
python - <<'EOF'
import asyncio, time, torch, torchvision
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
x = torch.randn(8, 3, 224, 224)
def inferir():
with torch.inference_mode(): # dentro de la función: el modo es por hilo
return m(x)
async def latido(huecos, fin):
antes = time.perf_counter()
while not fin.is_set():
await asyncio.sleep(0.01)
ahora = time.perf_counter(); huecos.append(ahora - antes); antes = ahora
async def probar(en_hilo):
huecos, fin = [], asyncio.Event()
tarea = asyncio.create_task(latido(huecos, fin))
await asyncio.sleep(0.05)
if en_hilo:
await asyncio.to_thread(inferir) # como run_in_threadpool de FastAPI
else:
inferir() # CPU dentro de la corrutina: bloquea el loop
await asyncio.sleep(0.05); fin.set(); await tarea
print("en un hilo:" if en_hilo else "en el loop:", f"mayor hueco del latido {max(huecos) * 1000:.0f} ms")
inferir() # warmup
asyncio.run(probar(False)); asyncio.run(probar(True))
EOFQué deberías ver: en el loop: mayor hueco del latido ≈ 100 ms (lo que dura la inferencia: el loop no atendió nada) y en un hilo: ≈ 10–15 ms (el latido de 10 ms siguió). Medido: 96 ms contra 12 ms. asyncio.to_thread hace lo mismo que run_in_threadpool en FastAPI.
Cómo arreglarlo y confirmarlo
Si el hueco con hilo también es grande, revisa que inferir() corra entera dentro de asyncio.to_thread. Si batch 8 rinde mucho menos que batch 1, quédate con batches chicos: en CPU no conviene subirlos.
Probado de verdad en un Mac, sin GPU.
¿Qué tienes que dejar fijo en PyTorch antes de exportar, para que el modelo servido se comporte como el que evaluaste?
Sintaxis con variables
model.load_state_dict(torch.load("checkpoint", map_location="cpu", weights_only=True))Carga solo tensores y tipos simples: un .pt es un pickle (el formato de Python para guardar objetos) y podría ejecutar código al cargarse (weights_only=True ya es el default en torch 2.14, y el C01 lo escribe explícito). load_state_dict es estricto por defecto: si una llave o una shape no calza, falla aquí y no en producción.
model.eval()
Modo evaluación: BatchNorm usa sus estadísticas guardadas (running stats) y Dropout se apaga. Equivale a model.train(False). No apaga los gradientes: eso es otro interruptor.
with torch.inference_mode():
salida = model(entrada)Sin autograd (el registro de operaciones para calcular gradientes): no guarda activaciones (los resultados intermedios de cada capa) para un backward que nunca llega, y es algo más rápido que torch.no_grad(). Vale solo en el hilo donde lo activas: en un servidor, va dentro de la función que corre el modelo.
torch.manual_seed(semilla)
Fija los números aleatorios de CPU y GPU. El C01 la usa para que el export y la paridad creen el mismo modelo cuando no hay checkpoint. No garantiza resultados idénticos entre versiones de PyTorch, plataformas, ni entre CPU y GPU.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| checkpoint | archivo con el state_dict entrenado | resnet50_500.pt |
| entrada, salida | el tensor de entrada y los logits | x [N, 3, 224, 224] FP32 → [N, 500] |
| semilla | el número que fija lo aleatorio | 0 en export_onnx.py y parity_check.py |
| escala y corrimiento que BatchNorm aprende, uno por canal | 53 BatchNorm2d en el ResNet-50 | |
| running stats: media y varianza que BatchNorm guardó durante el training | las usa eval() | |
| media y varianza del batch actual | las usa train(): la salida depende de las otras imágenes | |
| momentum de la actualización y un épsilon numérico | 0.1 y 1e-5 (defaults de BatchNorm2d) | |
| valor de un píxel (0–255) y canal de color RGB | mean y std de ImageNet, en preprocess.py |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| model.eval() | Pone el modelo en modo inferencia. Solo afecta capas como BatchNorm y Dropout; equivale a train(False). | build_model() termina en return model.eval() |
| model.train() | Modo entrenamiento: BatchNorm normaliza con el batch y actualiza sus running stats; Dropout apaga neuronas al azar. | un forward en train() cambia el modelo aunque estés en no_grad |
| torch.no_grad() | No registra operaciones para el backward. Los tensores que salen se pueden usar después con autograd. | el loop de validación dentro del training |
| torch.inference_mode() | Como no_grad pero más estricto: tampoco lleva la cuenta de las vistas (tensores que comparten memoria) ni de las versiones (cuántas veces se modificó cada tensor). Lo que sale no puede entrar después a autograd. | run_torch() y verify() del C01 |
| el modo vale por hilo | inference_mode (y no_grad) solo valen en el hilo donde los activaste. | alrededor de un await run_in_threadpool no hacen nada en el hilo del pool |
| torch.load(…, weights_only=True) | Carga solo tensores y tipos simples; rechaza objetos arbitrarios del pickle. | default en torch 2.14; el C01 lo escribe explícito |
| load_state_dict (estricto) | Exige que las llaves y las shapes calcen exacto con la arquitectura. | size mismatch for fc.weight: copying a param with shape torch.Size([1000, 2048]) from checkpoint, … torch.Size([500, 2048]) |
| prefijo module. | Un checkpoint guardado desde DataParallel o DDP (entrenamiento en varias GPUs) trae module. delante de cada llave. | Missing key(s) in state_dict: "conv1.weight"…: quita el prefijo antes de cargar |
| preprocess.py compartido | Una sola función de preprocesamiento que importan el training, la evaluación y el serving. | bytes → float32 [3, 224, 224], RGB, normalizado |
| Resize(256) + CenterCrop(224) | Lado corto a 256 con interpolación bilinear, con antialias al reducir (un suavizado que evita bordes dentados), y recorte central de 224 × 224. | medido contra torchvision 0.29: diferencia 0.0 sin draft mode (el decode reducido de JPEG) |
| RGB, no BGR | El orden de canales del training. OpenCV lee en BGR; Pillow, en RGB. | convert("RGB") en decode() |
| EXIF | Metadatos de la foto; las de celular guardan ahí la rotación. | ImageOps.exif_transpose: sin esto el modelo ve la foto de lado |
| semillas | Fijan lo aleatorio: pesos iniciales, datos de ejemplo, augmentations. | torch.manual_seed(0): export y paridad crean el mismo modelo sin checkpoint |
| determinismo | Mismos números en cada corrida. Otro batch, otra versión u otro hardware cambian el orden de las sumas: diferencias de ≈ 1e-4 en los logits. | batch 8 contra batch 1 en CPU: 1.5e-4 con logits de hasta 85 (medido) |
| Modo | Qué cambia | La salida | Úsalo para |
|---|---|---|---|
| model.train() | BatchNorm usa la media y la varianza del batch y actualiza sus running stats; Dropout activo | depende de las otras imágenes del batch | entrenar |
| model.eval() | BatchNorm usa sus running stats; Dropout apagado. No toca los gradientes | la misma para cada imagen, con cualquier batch | evaluar, exportar y servir |
| torch.no_grad() | no registra operaciones para el backward | requires_grad=False; se puede usar después con autograd | la validación dentro del training |
| torch.inference_mode() | lo de no_grad, y además sin seguimiento de vistas ni de versiones | requires_grad=False e is_inference()=True; no entra a autograd | inferencia pura: servir y la referencia de la paridad |
| eval() + inference_mode() | las dos cosas: no se pisan | la misma por imagen y sin memoria de autograd | lo que hacen verify() y run_torch() en el C01 |
La documentación de PyTorch lo dice así: el modo de evaluación no es un mecanismo para apagar gradientes; es ortogonal a no_grad y a inference_mode.
def build_model(checkpoint: str | None) -> torch.nn.Module:
"""Reconstruye la arquitectura del training y carga los pesos."""
if not checkpoint:
print("[model] AVISO: sin --checkpoint → pesos aleatorios (sirve para probar el pipeline, no para servir)")
torch.manual_seed(0) # aleatorios pero reproducibles: export y parity_check crean el MISMO modelo
# weights=None: nada de descargar pesos de ImageNet; num_classes=500 crea la fc correcta
model = torchvision.models.resnet50(weights=None, num_classes=NUM_CLASSES)
if checkpoint:
# weights_only=True: carga solo tensores, no ejecuta pickle arbitrario (un .pt es código)
state = torch.load(checkpoint, map_location="cpu", weights_only=True)
# strict (por defecto): si una llave no coincide, falla AQUÍ y no en producción con pesos al azar
model.load_state_dict(state)
# eval(): BatchNorm usa sus running stats y Dropout se apaga. torch.onnx.export (2.14) ya exporta
# en eval aunque lo olvides, pero este modelo es también la referencia PyTorch de verify() y del
# gate de paridad: en modo train daría otros logits y el gate compararía contra una referencia rota.
return model.eval()weights=None evita bajar pesos de ImageNet y num_classes=500 crea la capa final con 500 salidas. Tiene que ser la misma arquitectura del training, o las llaves no calzan.weights_only=True: un .pt es un pickle, y un pickle puede ejecutar código al cargarse. Así solo entran tensores.load_state_dict es estricto por defecto: un checkpoint de 1000 clases falla con size mismatch for fc.weight. Mejor ese error aquí que un modelo a medio cargar en producción.torch.manual_seed(0) hace que el export y la paridad creen el mismo modelo aleatorio: sirve para probar el pipeline, no para servir.return model.eval(), y el comentario dice por qué importa aunque el exportador TorchScript ya exporte en eval: este mismo objeto es la referencia de PyTorch en verify() y en el gate de paridad. En modo train daría otros logits, y el gate compararía contra una referencia rota. El exportador dynamo (el default) ni siquiera cambia el modo: exporta el modelo como esté y solo avisa con un UserWarning.Cómo recordarla
eval() cambia las capas; inference_mode() apaga el autograd. Son dos interruptores distintos y para servir van los dos prendidos. Y un solo preprocess.py para training, evaluación y serving.
Trampa
Un forward en model.train(), aunque sea dentro de torch.no_grad(), actualiza las running stats del BatchNorm: el modelo cambia en silencio y deja de ser el que evaluaste. Y reescribir el preprocesamiento "parecido" en el servicio (BGR, otro resize, sin normalizar): no da ningún error, solo menos accuracy.
En el curso: M7 · FastAPI + PyTorch: el punto de partida M9 · Training-serving skew: causas, detección y prevención M10 · Reproducibilidad: los cinco ingredientes C01 · Archivos de referencia
Qué vas a provocar: Ver en números qué cambia con train(), eval(), no_grad() e inference_mode(), y cuánto ruido normal tiene FP32.
Necesitas: el venv del lab de Números de bolsillo (Python 3.13, torch 2.14.0, torchvision 0.29.0); sin GPU
source ~/venvs/lab-triton/bin/activate
python - <<'EOF'
import torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet50(weights=None, num_classes=500)
x = torch.randn(8, 3, 224, 224)
m.eval()
with torch.inference_mode():
ref = m(x)
m.train()
with torch.no_grad():
tr = m(x)
print("max|Δ| train vs eval:", round((tr - ref).abs().max().item(), 1))
print("top-1 igual:", (tr.argmax(1) == ref.argmax(1)).float().mean().item())
EOFQué deberías ver: max|Δ| train vs eval: 83.4 y top-1 igual: 0.0. Con pesos aleatorios, las running stats todavía valen 0 y 1, así que la diferencia es enorme; con un modelo entrenado es menor, pero existe.
python - <<'EOF'
import torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet50(weights=None, num_classes=500).train()
antes = m.bn1.running_mean.clone()
with torch.no_grad():
m(torch.randn(8, 3, 224, 224))
print("running_mean cambió:", not torch.equal(antes, m.bn1.running_mean), "| batches vistos:", m.bn1.num_batches_tracked.item())
EOFQué deberías ver: running_mean cambió: True | batches vistos: 1. Un solo forward en train(), sin gradientes, ya modificó el modelo.
python - <<'EOF'
import torch, torchvision
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
x = torch.randn(1, 3, 224, 224)
y0 = m(x)
with torch.no_grad():
y1 = m(x)
with torch.inference_mode():
y2 = m(x)
print(y0.requires_grad, y1.requires_grad, y2.requires_grad, y2.is_inference())
try:
(y2 * torch.ones(500, requires_grad=True)).sum().backward()
except RuntimeError as e:
print(str(e).split(".")[0])
EOFQué deberías ver: True False False True: sin nada, la salida arrastra el grafo para el backward; con no_grad e inference_mode, no. Después: Inference tensors cannot be saved for backward. Un tensor creado en inference_mode no puede entrar a autograd.
python - <<'EOF'
import threading, torch
with torch.inference_mode():
t = threading.Thread(target=lambda: print("hilo nuevo:", torch.is_inference_mode_enabled()))
t.start(); t.join()
print("hilo actual:", torch.is_inference_mode_enabled())
EOFQué deberías ver: hilo nuevo: False y hilo actual: True. El modo vale por hilo: en FastAPI, el with va dentro de la función que corre en el threadpool.
python - <<'EOF'
import torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
x = torch.randn(8, 3, 224, 224)
with torch.inference_mode():
juntos = m(x)
de_a_uno = torch.cat([m(x[i:i + 1]) for i in range(8)])
print(f"max|Δ| batch 8 vs batch 1: {(juntos - de_a_uno).abs().max().item():.1e} | max|logit|: {de_a_uno.abs().max().item():.0f}")
EOFQué deberías ver: max|Δ| batch 8 vs batch 1: 1.5e-04 | max|logit|: 85. Las mismas imágenes en otro batch cambian el orden de las sumas: ese ≈ 1e-4 es el ruido normal de FP32, el que verás también entre PyTorch y ONNX Runtime.
Cómo arreglarlo y confirmarlo
Si el paso 1 da 0.0 de diferencia, revisa que llamaste m.train() antes del segundo forward. Si el paso 3 no lanza el error, el tensor no salió de inference_mode.
Probado de verdad en un Mac, sin GPU.
¿Cómo pasas el modelo de PyTorch a un archivo ONNX que TensorRT (el compilador de NVIDIA) y ONNX Runtime entiendan, y cómo sabes que salió bien?
Sintaxis con variables
torch.onnx.export(model, (ejemplo,), "archivo", dynamo=False, opset_version=opset, input_names=["entrada"], output_names=["salida"], dynamic_axes={"entrada": {0: "batch"}, "salida": {0: "batch"}})El exportador TorchScript, el que usa el C01: ejecuta el modelo con la entrada de ejemplo y registra lo que pasó (tracing). En torch 2.14 hay que pedirlo con dynamo=False, avisa con un DeprecationWarning y necesita el paquete onnx.
torch.onnx.export(model, (ejemplo,), "archivo", dynamo=True, opset_version=18, input_names=["entrada"], output_names=["salida"], dynamic_shapes={"argumento": {0: torch.export.Dim("batch", min=1, max=max_batch)}}, external_data=False)El exportador nuevo, default desde torch 2.9: captura el programa entero con torch.export. No pone el modelo en eval (solo avisa): llama tú a model.eval() antes. El eje dinámico va en dynamic_shapes, con el nombre del argumento de forward. Necesita onnx y onnxscript. Sin external_data=False, los pesos salen en un .onnx.data aparte.
onnx.checker.check_model(onnx.load("archivo"), full_check=True)Revisa que el archivo sea ONNX válido; con full_check=True además infiere las shapes (deduce la forma de cada tensor). Si todo está bien no imprime nada; si algo falla, lanza una excepción.
ort.InferenceSession("archivo", providers=["CPUExecutionProvider"]).run(["salida"], {"entrada": x_float32})ONNX Runtime (un motor que ejecuta grafos ONNX) en CPU. Si corre con batch 1 y con el máximo, el eje dinámico funciona de verdad. La entrada tiene que ser un array de numpy float32: con float64 falla.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| ejemplo | tensor de entrada de ejemplo, con la forma real | torch.randn(2, 3, 224, 224): batch 2, no 1 |
| archivo | el archivo ONNX | classifier.onnx |
| opset | versión del conjunto de operadores ONNX | 17 con el exportador TorchScript; 18 con dynamo |
| entrada, salida | nombres de los tensores: el contrato con Triton y el gateway | input y logits |
| argumento | nombre del argumento de forward() que lleva el eje dinámico | x, porque ResNet.forward(self, x) |
| max_batch | batch máximo del eje dinámico | 32 = max_batch_size de Triton |
| x_float32 | array de numpy float32 con la forma de la entrada | torch.randn(7, 3, 224, 224).numpy() |
| pesos y bias de una convolución | las convoluciones de ResNet no tienen bias: b = 0 | |
| lo que aprendió y guardó el BatchNorm que sigue a esa convolución | 53 pares Conv + BatchNorm en el ResNet-50 | |
| parámetros que caben en un .onnx de un solo archivo (el límite de protobuf, el formato de serialización) | ≈ 500 M en FP32; el C01 usa 24.5 M |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| ONNX | Un formato de archivo estándar para redes: grafo de operadores + pesos + firma de entradas y salidas, sin depender de PyTorch. | classifier.onnx ≈ 98 MB |
| opset | La versión del conjunto de operadores. "Opset 17" = cada operador con la semántica de esa versión del estándar; quien lo lee tiene que conocerla. | el C01 usa 17; torch 2.14 escribe el 20 si no pides otro |
| dynamo= | Qué exportador usar. True (default desde 2.9): torch.export. False: TorchScript, deprecado. | export_onnx.py usa False; con --dynamo, True |
| opset_version= | El opset que se escribe. Con dynamo, si pides menos de 18 intenta convertir hacia abajo y puede fallar: el archivo queda en 18. | el C01 pide 17 con TorchScript, y 18 con --dynamo |
| input_names= / output_names= | Los nombres de los tensores dentro del grafo. Triton y el gateway los usan tal cual. | ["input"] y ["logits"] |
| dynamic_axes= | Qué ejes quedan variables, en el exportador TorchScript. Sin esto, el batch queda fijo en el del ejemplo. | {"input": {0: "batch"}, "logits": {0: "batch"}} |
| dynamic_shapes= | Lo mismo para dynamo, con torch.export.Dim; la llave es el nombre del argumento de forward. | {"x": {0: Dim("batch", min=1, max=32)}} |
| external_data= | Solo dynamo; default True: los pesos van a un .onnx.data aparte. Hace falta por encima de 2 GB. | el C01 pasa False: un solo archivo para trtexec y el registry |
| do_constant_folding= | Precalcula lo que no depende de la entrada. Es del exportador TorchScript (True por defecto); la documentación de torch 2.14 lo marca como deprecado. | el C01 lo escribe explícito |
| optimize= / verify= | Solo dynamo. optimize (True por defecto) pliega constantes y quita redundancias; verify (False por defecto) corre el modelo en ONNX Runtime al exportar. | el C01 verifica aparte, en verify() |
| la entrada de ejemplo | El tensor con que se recorre el modelo. Con batch 1, algunos tracers (lo que registra cada operación) vuelven constante ese eje. | torch.randn(2, 3, 224, 224) |
| onnx.checker.check_model | Valida la estructura, la versión del formato (IR) y el opset; full_check=True agrega la inferencia de shapes. | el primer chequeo de verify() |
| ONNX Runtime (ORT) | Un runtime de Microsoft que ejecuta el grafo con execution providers (bibliotecas por hardware). Fuera de CPU, los providers se piden explícitos. | onnxruntime 1.30.0 en el C01; la más nueva es 1.31.0 (octubre de 2026) |
| optimización de grafo de ORT | Al crear la sesión, ORT pliega constantes, quita Dropout y fusiona Conv + BatchNorm. Todo viene activado por defecto (ORT_ENABLE_ALL). | por eso el ONNX corre bien en CPU sin tocarlo |
PyTorch: ResNet-50 con capa final (fc) de 500 clases
ONNX: 122 nodos
Conv2d×53
Conv×53
con el BatchNorm plegado adentro
BatchNorm2d×53
plegado en Conv: no queda como nodo
ReLU×49
17 módulos, 49 llamadas
Relu×49
suma residual (out += identity)×16
en forward, no es un módulo
Add×16
MaxPool2d×1
MaxPool×1
AdaptiveAvgPool2d×1
GlobalAveragePool×1
torch.flatten×1
Flatten×1
Linear×1
la capa fc
Gemm×1
la capa fc
Conv2d + BatchNorm2d → Conv: export en eval (TorchScript, plegado de constantes)Entrada input · FP32 · [batch, 3, 224, 224]Salida logits · FP32 · [batch, 500]Pesos ≈ 98 MB
Con pesos aleatorios aparecen además nodos Identity: muchos bias plegados salen iguales y el exportador los comparte. Con un checkpoint entrenado no aparecen.
def export(model: torch.nn.Module, out: str, opset: int, dynamo: bool) -> None:
"""PyTorch → ONNX con eje de batch dinámico (Triton junta requests en batches de 1 a 32)."""
# Ejemplo con batch 2 y no 1: con 1 algunos tracers especializan el eje y lo vuelven constante
example = torch.randn(2, 3, 224, 224)
if dynamo:
# Exportador nuevo (torch.export). Verificado con torch 2.14: el eje dinámico se declara con
# dynamic_shapes (la clave "x" es el nombre del argumento de ResNet.forward). OJO: si le
# pides opset 17, intenta convertir hacia abajo, esa conversión falla con ReduceMean y el
# archivo queda en opset 18 (solo imprime el error). Úsalo con --opset 18.
batch = torch.export.Dim("batch", min=1, max=MAX_BATCH)
torch.onnx.export(
model, (example,), out, dynamo=True, opset_version=opset,
input_names=[INPUT_NAME], output_names=[OUTPUT_NAME],
dynamic_shapes={"x": {0: batch}},
external_data=False, # un solo archivo (~98 MB): trtexec y el registry no pierden el .data aparte
)
else:
# Exportador TorchScript (legacy). torch ≥ 2.9 usa dynamo=True POR DEFECTO, así que hay que
# pedirlo explícito: es el que produce opset 17 limpio (GlobalAveragePool) y respeta
# dynamic_axes tal cual. Está deprecado ("will be removed"): fija la versión de torch del
# job de export y planea migrar a --dynamo --opset 18.
torch.onnx.export(
model, (example,), out, dynamo=False, opset_version=opset,
input_names=[INPUT_NAME], output_names=[OUTPUT_NAME],
dynamic_axes={INPUT_NAME: {0: "batch"}, OUTPUT_NAME: {0: "batch"}}, # eje 0 = batch variable
do_constant_folding=True, # precalcula lo constante: grafo más simple para TensorRT
)torch.export.Dim, y la llave "x" es el nombre del argumento de ResNet.forward. Si le pides opset 17, intenta convertir hacia abajo, falla con ReduceMean y el archivo queda en 18: por eso el script dice que se use con --opset 18.external_data=False deja un solo archivo de ≈ 98 MB. Con el default (True), dynamo escribe los pesos en un .onnx.data aparte; si alguien copia solo el .onnx, trtexec no encuentra los pesos.dynamo=False explícito, opset 17 y dynamic_axes por nombre de tensor, para la entrada y para la salida.--checkpoint): ≈ 98 MB (97,959,677 bytes), opset 17, input['batch', 3, 224, 224] → logits['batch', 500], y ningún nodo BatchNormalization: el export los plegó dentro de cada Conv.Cómo recordarla
Modelo en eval, nombres fijos, eje de batch dinámico, opset fijo, y verificar el archivo, no la llamada: lee el opset real y las dims del .onnx, y córrelo en ONNX Runtime con batch 1 y con el máximo.
Trampa
Confiar en que el export salió como lo pediste. Con dynamo y opset 17, el archivo queda en 18 (solo imprime el error, no falla); y sin dynamic_axes, el batch queda fijo en el del ejemplo y ONNX Runtime responde Got invalid dimensions for input: input … index: 0 Got: 7 Expected: 2. Las dos cosas se atrapan leyendo el archivo exportado, como hace verify().
En el curso: M8 · ONNX y ONNX Runtime C01 · Archivos de referencia M8 · TensorRT
Qué vas a provocar: Exportar un ResNet-18 a ONNX a mano, revisarlo, correrlo en ONNX Runtime con dos tamaños de batch y provocar los dos errores más comunes.
Necesitas: el venv de los labs anteriores (torch 2.14.0 y torchvision 0.29.0) más onnx 1.23.0 y onnxruntime 1.30.0, las versiones del C01; sin GPU
source ~/venvs/lab-triton/bin/activate
pip install onnx==1.23.0 onnxruntime==1.30.0
mkdir -p ~/lab-onnx && cd ~/lab-onnxQué deberías ver: Instala las versiones del C01 en el venv de los labs anteriores, que ya tiene torch 2.14.0 y torchvision 0.29.0.
python - <<'EOF'
import torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet18(weights=None, num_classes=500).eval()
torch.onnx.export(m, (torch.randn(2, 3, 224, 224),), "r18.onnx", dynamo=False, opset_version=17,
input_names=["input"], output_names=["logits"],
dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}})
EOF
ls -lh r18.onnxQué deberías ver: Un DeprecationWarning que empieza con You are using the legacy TorchScript-based ONNX export (es el exportador viejo, pedido a propósito) y un archivo de 44M: 11.4 M parámetros × 4 B ≈ 45.7 MB, que ls -h muestra en MiB.
python - <<'EOF'
import onnx
p = onnx.load("r18.onnx")
onnx.checker.check_model(p, full_check=True)
print("opset", [o.version for o in p.opset_import if o.domain in ("", "ai.onnx")])
print(p.graph.input[0].name, [d.dim_param or d.dim_value for d in p.graph.input[0].type.tensor_type.shape.dim])
print(sorted({n.op_type for n in p.graph.node}))
EOFQué deberías ver: opset [17], input ['batch', 3, 224, 224] y los tipos de nodo ['Add', 'Conv', 'Flatten', 'Gemm', 'GlobalAveragePool', 'Identity', 'MaxPool', 'Relu']. No hay BatchNormalization: quedó plegado en cada Conv. Los Identity salen solo con pesos aleatorios (bias plegados iguales que el exportador comparte).
python - <<'EOF'
import numpy as np, onnxruntime as ort, torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet18(weights=None, num_classes=500).eval()
s = ort.InferenceSession("r18.onnx", providers=["CPUExecutionProvider"])
for bs in (1, 7):
x = torch.randn(bs, 3, 224, 224)
with torch.inference_mode():
ref = m(x).numpy()
got = s.run(["logits"], {"input": x.numpy()})[0]
print(bs, got.shape, f"max|Δ| {np.abs(ref - got).max():.1e}")
EOFQué deberías ver: 1 (1, 500) y 7 (7, 500), cada una con un max|Δ| chico, entre 1e-6 y 1e-4: el ruido normal de FP32. El eje dinámico funciona: el batch 7 nunca se vio en el export.
python - <<'EOF'
import onnxruntime as ort, torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet18(weights=None, num_classes=500).eval()
torch.onnx.export(m, (torch.randn(2, 3, 224, 224),), "r18_fijo.onnx", dynamo=False, opset_version=17,
input_names=["input"], output_names=["logits"])
s = ort.InferenceSession("r18_fijo.onnx", providers=["CPUExecutionProvider"])
print(s.get_inputs()[0].shape)
s.run(["logits"], {"input": torch.randn(7, 3, 224, 224).numpy()})
EOFQué deberías ver: [2, 3, 224, 224]: sin dynamic_axes, el batch quedó fijo en el del ejemplo. Después, un error de tres líneas (cada / es un salto de línea): [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for input: input for the following indices / index: 0 Got: 7 Expected: 2 / Please fix either the inputs/outputs or the model.
python -c "
import numpy as np, onnxruntime as ort
s = ort.InferenceSession('r18.onnx', providers=['CPUExecutionProvider'])
s.run(['logits'], {'input': np.random.rand(1, 3, 224, 224)})
"Qué deberías ver: INVALID_ARGUMENT : Unexpected input data type. Actual: (tensor(double)) , expected: (tensor(float)). numpy crea float64 por defecto y el modelo pide float32: se arregla con .astype(np.float32).
cd ~ && rm -r ~/lab-onnxQué deberías ver: Borra los archivos del lab.
Cómo arreglarlo y confirmarlo
Si el export falla con Module onnx is not installed!, te falta pip install onnx: en torch 2.14 el exportador TorchScript lo necesita. Con dynamo=True, el error es No module named 'onnxscript'. Si ONNX Runtime se queja de dimensiones o de tipo, compara tu array con s.get_inputs()[0].
Verificado contra la documentación; no ejecutado aquí.
¿Cómo pruebas que el ONNX y el engine FP16 predicen lo mismo que el PyTorch FP32 que evaluaste, y qué diferencia es normal?
Sintaxis con variables
python model/parity_check.py --checkpoint checkpoint --onnx onnx --images muestra --n n
Gate 1 (una prueba que frena la cadena si falla), en CPU: ONNX Runtime contra PyTorch FP32, las dos ramas con el mismo preprocess.py del gateway (el servicio que prepara las imágenes en producción). Atrapa exports rotos sin tocar una GPU.
python model/parity_check.py --backend triton --triton-url host:8000 --checkpoint checkpoint --images muestra --n n
Gate 2, en la T4: el engine TensorRT FP16 (el modelo ya compilado para esa GPU) servido por Triton, llamado de a una imagen como en producción. Exit 1 = no se registra.
python model/parity_check.py --checkpoint checkpoint --onnx onnx --min-top1 0.995 --min-top5 0.99 --max-abs-diff 0.5
Los tres umbrales, escritos con sus defaults. Se ajustan con datos cuando cambia el backend; nunca se bajan para que un candidato pase.
polygraphy run onnx --trt --fp16 --onnxrt
Sin Triton, en la máquina con GPU: Polygraphy (la herramienta de NVIDIA para depurar TensorRT) construye un engine FP16 y compara sus salidas con ONNX Runtime; también puede comparar capa por capa. Sus tolerancias por defecto (1e-5) son para FP32: con FP16, sube --atol y --rtol, o fallará aunque el engine esté bien. Es la alternativa que da el propio parity_check.py para el TensorRT 10 del C01.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| checkpoint | el state_dict evaluado: la referencia | resnet50_500.pt |
| onnx | el ONNX candidato | model/classifier.onnx |
| muestra | carpeta con imágenes reales del set de evaluación | una muestra del golden set (el set fijo de evaluación) |
| n | máximo de imágenes a comparar | default 256; el script recomienda ≥ 2,000 |
| host | dónde corre Triton | localhost, en el runner con la T4 |
| imágenes comparadas | 2,000 → el gate tolera 10 cambios de top-1 | |
| logits de PyTorch FP32 y del candidato, de forma [N, 500] | got: ONNX Runtime en CPU, o el engine vía Triton | |
| las 5 clases con el logit más alto: lo que ve quien publica | el orden dentro del top-5 no importa | |
| épsilon de máquina: la distancia de 1 al siguiente número que el formato puede guardar | FP16 2⁻¹⁰; FP32 2⁻²³ |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| paridad (parity) | Probar que el artefacto servido predice lo mismo que el modelo que evaluaste. | PyTorch FP32 contra ONNX Runtime, y contra el engine FP16 en Triton |
| la referencia | PyTorch FP32 en eval() y con inference_mode(): el modelo tal como se evaluó. | run_torch(), con batches de 32 |
| acuerdo top-1 | La fracción de imágenes donde el 1.º lugar coincide: ¿cambia la sugerencia principal? | ≥ 99.5 % (--min-top1 0.995) |
| solapamiento top-5 | Cuántas de las 5 sugerencias coinciden, en promedio, sin importar el orden. | ≥ 99 % (--min-top5 0.99) |
| max|Δlogit| | La mayor diferencia entre logits. Un salto de órdenes de magnitud es un bug, no redondeo. | ≤ 0.5 (--max-abs-diff): atrapa exports rotos |
| ruido de FP32 | Cambiar el orden de las sumas (otro runtime, otro batch) mueve los logits ≈ 1e-4. | 8.0e-05 en la paridad con ORT del README (512 imágenes) |
| redondeo de FP16 | FP16 guarda 10 bits de mantisa: cerca de 10, los valores van de a 0.008. | FP16 mueve los logits ≈ 1e-2 en promedio (1.3e-2 en el lab) |
| overflow de FP16 | Por encima de 65,504, FP16 da inf, y después aparecen NaN. | raro en una CNN; más común en transformers |
| margen del top-1 | La distancia entre el 1.º y el 2.º logit. Si es menor que el ruido, ese top-1 puede cambiar. | por eso el umbral es 99.5 % y no 100 % |
| el mismo tensor | Las dos ramas reciben exactamente la misma entrada, hecha con el preprocess.py del gateway. | load_images() lo importa desde gateway/app |
| imágenes reales | Una muestra del set de evaluación. Las sintéticas solo prueban que el script corre. | sin --images, gradientes + ruido codificados como JPEG |
| exit code | 0 = pasa; 1 = no pasa. Es lo que lee el CI para frenar la cadena. | [parity] NO PASA: no promuevas este artefacto |
| paridad de punta a punta | Imágenes de referencia por el gateway real, contra las predicciones del pipeline de evaluación. | atrapa el skew de preprocesamiento (preparar la imagen distinto que en training) y los labels en otro orden |
| Polygraphy | Herramienta de NVIDIA que compara TensorRT contra ONNX Runtime, también capa por capa. | para encontrar la capa que diverge y dejarla en FP32 |
La escala de max|Δlogit| (logarítmica): qué diferencia es normal
A la izquierda del tope el gate pasa; a la derecha, frena.
ONNX Runtime FP32 contra PyTorch FP32CPU
python model/parity_check.py --checkpoint resnet50_500.pt --onnx model/classifier.onnx --images /data/eval_sample --n 2000
En CPU, en cualquier máquina. Esperas top-1 ≈ 100 % y max|Δlogit| ≈ 1e-4.
Atrapa: un export roto, un opset mal convertido, un eje de batch fijo.
TensorRT FP16 en Triton contra PyTorch FP32GPU
python model/parity_check.py --backend triton --triton-url localhost:8000 --checkpoint resnet50_500.pt --images /data/eval_sample --n 2000
En la T4, de a una imagen. Esperas top-1 ≥ 99.5 %, un |Δlogit| medio ≈ 1e-2 y un máximo muy por debajo de 0.5.
Atrapa: una capa que diverge o desborda en FP16, un engine construido mal.
De punta a punta por el gatewayservicio
curl -s -H "Content-Type: image/jpeg" --data-binary @foto.jpg "localhost:8080/v1/classify?top_k=5"
En staging: imágenes de referencia por el gateway real, contra las predicciones del pipeline de evaluación.
Atrapa: skew de preprocesamiento, labels en otro orden, otro dtype.
en CPUen la GPUpor el servicio enterolo que atrapa
def compare(ref: np.ndarray, got: np.ndarray) -> dict:
"""Métricas de paridad que importan para el producto (top-k), no solo diferencias numéricas."""
top1 = float((ref.argmax(1) == got.argmax(1)).mean()) # ¿cambia la sugerencia principal?
ref5 = np.argsort(-ref, 1)[:, :5] # las 5 sugerencias que ve el vendedor
got5 = np.argsort(-got, 1)[:, :5]
overlap5 = float(np.mean([len(set(a) & set(b)) / 5 for a, b in zip(ref5, got5)])) # orden dentro del top-5 no importa
diff = np.abs(ref - got)
return {"n": len(ref), "top1_agreement": top1, "top5_overlap": overlap5,
"max_abs_diff": float(diff.max()), "mean_abs_diff": float(diff.mean())}
# …
p.add_argument("--n", type=int, default=256, help="máximo de imágenes a comparar")
p.add_argument("--min-top1", type=float, default=0.995, help="umbral del gate: acuerdo top-1 ≥ 99.5 %%")
p.add_argument("--min-top5", type=float, default=0.99, help="solapamiento medio del top-5")
# FP32 vs FP32 da ~1e-4; TensorRT FP16 da ~1e-2: el umbral depende del backend (ajústalo con datos)
p.add_argument("--max-abs-diff", type=float, default=0.5, help="tope de |Δlogit| (atrapa exports rotos)")
# …
ok = (m["top1_agreement"] >= args.min_top1 and m["top5_overlap"] >= args.min_top5
and m["max_abs_diff"] <= args.max_abs_diff)
print("[parity] PASA" if ok else "[parity] NO PASA: no promuevas este artefacto")
return 0 if ok else 1 # CI lee el exit codemax_abs_diff se reporta y tiene tope. FP32 contra FP32 da ≈ 1e-4 y TensorRT FP16 ≈ 1e-2; el tope de 0.5 queda muy arriba del redondeo y muy abajo de un export roto (los logits de otro modelo difieren en decenas).--backend ort compara el ONNX en CPU; --backend triton, el engine servido. Si un backend cambia la escala del ruido, los umbrales se ajustan con datos.--n es 256: con 99.5 % eso tolera un solo cambio de top-1. El script recomienda ≥ 2,000 imágenes (10 cambios): más resolución para decidir.Cómo recordarla
1e-4 · 1e-2 · ≥ 1: aritmética, FP16, bug. La paridad dice si es el mismo modelo; el golden set por slices dice si es un buen modelo. Son dos preguntas distintas.
Trampa
Bajar el umbral cuando el engine FP16 da 97 % de acuerdo. En una CNN, FP16 suele pasar el 99.5 % con holgura: 97 % es un bug, no redondeo. Corre primero --backend ort: si el ONNX en FP32 ya falla, el problema está en el export; si pasa, busca la capa que diverge en el engine. Y no tomes como gate una corrida sin --checkpoint o con imágenes sintéticas: solo prueba que el script corre.
En el curso: M8 · Cómo validar una optimización C01 · Evaluación offline y online M10 · El validation gate: cuándo un modelo puede reemplazar al actual M9 · Training-serving skew: causas, detección y prevención
Qué vas a provocar: Ver la escala de |Δlogit| que separa el ruido, FP16 y un bug, y por qué el gate necesita el modelo entrenado.
Necesitas: el venv del lab de Números de bolsillo (torch 2.14.0 y torchvision 0.29.0); sin GPU. La paridad real con parity_check.py necesita además onnxruntime y el checkpoint.
source ~/venvs/lab-triton/bin/activate
python - <<'EOF'
import copy, numpy as np, torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
x = torch.randn(16, 3, 224, 224)
def delta(nombre, got):
d = np.abs(ref - got.float().numpy())
print(f"{nombre:<24} max|Δ| {d.max():.1e} · media|Δ| {d.mean():.1e}")
with torch.inference_mode():
ref = m(x).numpy()
delta("FP32, de a una imagen", torch.cat([m(x[i:i + 1]) for i in range(16)]))
delta("FP16 (pesos y cálculo)", copy.deepcopy(m).half()(x.half()))
delta("BGR en vez de RGB", m(x.flip(1)))
EOFQué deberías ver: Tres líneas, una por caso: FP32, de a una imagen → max|Δ| 1.7e-04 · media|Δ| 1.9e-05; FP16 (pesos y cálculo) → max|Δ| 6.6e-02 · media|Δ| 1.3e-02; BGR en vez de RGB → max|Δ| 1.7e+00 · media|Δ| 3.3e-01. Tres escalas: aritmética, redondeo de FP16 y un error de preprocesamiento. El tope del gate (0.5) queda entre las dos últimas. Tarda unos segundos: FP16 en CPU es lento.
python - <<'EOF'
import numpy as np, torch, torchvision
torch.manual_seed(0)
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
with torch.inference_mode():
z = np.sort(m(torch.randn(64, 3, 224, 224)).numpy(), 1)
margen = z[:, -1] - z[:, -2]
print(f"1.º menos 2.º logit: mediana {np.median(margen):.2f}, mínimo {margen.min():.3f}")
EOFQué deberías ver: 1.º menos 2.º logit: mediana 0.49, mínimo 0.001. Con pesos aleatorios hay imágenes donde el 1.º y el 2.º lugar están a milésimas, y cualquier redondeo cambia el top-1. Por eso el gate de verdad corre con --checkpoint e imágenes reales, donde casi todas las imágenes tienen márgenes amplios.
python -c "print([int((1 - 0.995) * n) for n in (256, 2000)])"Qué deberías ver: [1, 10]: con el default --n 256, el gate tolera un solo cambio de top-1; con 2,000 imágenes, diez.
Cómo arreglarlo y confirmarlo
Si FP32 de a una imagen da más de 1e-3, algo cambió entre las dos llamadas (¿el modelo quedó en train()?). Si BGR da casi 0, revisa que flip(1) invierta el eje de canales y no otro.
Probado de verdad en un Mac, sin GPU.
¿Cómo convierte TensorRT tu ONNX en un engine rápido para la T4, y por qué ese engine no sirve en otro tipo de GPU ni con otra versión de TensorRT?
Sintaxis con variables
trtexec --onnx=modelo.onnx --saveEngine=engine.plan --fp16 \ --minShapes=entrada:minx3x224x224 \ --optShapes=entrada:optx3x224x224 \ --maxShapes=entrada:maxx3x224x224
TensorRT 10 (NGC 24.08: la release de agosto de 2024 de los contenedores de NVIDIA, la de C01). Corre en la GPU de destino, dentro de la imagen nvcr.io/nvidia/tensorrt, y deja un engine: el modelo compilado para ese tipo de GPU y esa versión de TensorRT. --fp16 permite FP16: TensorRT elige capa por capa y deja en FP32 lo que no conviene. Min, opt y max forman el perfil de optimización (el rango de shapes que acepta el engine).
python -m modelopt.onnx.autocast --onnx_path modelo.onnx \ --output_path modelo_fp16.onnx --keep_io_types trtexec --onnx=modelo_fp16.onnx --saveEngine=engine.plan \ --minShapes=entrada:minx3x224x224 \ --optShapes=entrada:optx3x224x224 \ --maxShapes=entrada:maxx3x224x224
TensorRT 11 (NGC 26.06 en adelante; la 26.09 trae la 11.3). Las redes son strongly typed (de tipos fijos): el tipo de cada tensor sale del ONNX y TensorRT no lo cambia. Si le pasas --fp16, --int8 o --best, trtexec sale con error. FP16 se pide antes, con ModelOpt AutoCast (una herramienta de NVIDIA que reescribe el ONNX en FP16). --keep_io_types deja la entrada y la salida en FP32; sin él también pasan a FP16 y ya no calzan con el TYPE_FP32 del config.pbtxt. INT8 y FP8 van con nodos Q/DQ (marcas de cuantización en el ONNX) que agrega ModelOpt.
trtexec --loadEngine=engine.plan --shapes=entrada:batchx3x224x224
Mide el engine solo: sin cola, sin red y sin Triton. Usa el batch que llega online (1–3 en C01), no solo el de opt. En TensorRT 10 agrega --dumpProfile --separateProfileRun para ver el tiempo de cada capa en una corrida aparte, sin frenar la medición principal; en 11 basta --dumpProfile, porque esa corrida aparte ya va siempre.
--timingCacheFile=cache # guarda y reusa las mediciones de tácticas --builderOptimizationLevel=nivel # 0 a 5, default 3: más alto = build más largo --hardwareCompatibilityLevel=ampere+ # un engine para Ampere y posteriores --versionCompatible # corre también con TensorRT más nuevos
Flags de build opcionales (trtexec de TensorRT 10.3). Los dos modos de compatibilidad pueden ser más lentos que un engine normal. ampere+ exige compute capability (el número de generación de la GPU) ≥ 8.0 y la T4 es 7.5: en C01 no sirve.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modelo.onnx | el ONNX exportado: la entrada del build | model/classifier.onnx (≈ 98 MB, opset 17) |
| engine.plan | dónde queda el engine serializado (el archivo .plan) | triton/model_repository/classifier/1/model.plan |
| entrada | nombre del tensor de entrada en el ONNX | input |
| min, opt, max | batch mínimo, el batch para el que TensorRT elige los kernels más rápidos (los programas que corren en la GPU) y el máximo | 1, 16, 32 |
| modelo_fp16.onnx | el ONNX que deja AutoCast, ya en precisión mixta (TensorRT 11) | no existe en C01: C01 usa TensorRT 10.3 |
| batch | batch con que mides el engine | 1–3 (lo que llega online); 16 en el chequeo final de build_engine.sh |
| cache | archivo donde se guardan las mediciones del build (timing cache) | t4.cache (C01 no lo usa) |
| nivel | cuánto tiempo se da el builder (el compilador de TensorRT) para probar opciones | 3, el default (C01 no lo cambia) |
| inferencias por segundo que reporta trtexec en Throughput | se mide en la T4 | |
| batch de la medición | 16 → imágenes/s = qps × 16 | |
| copia de la entrada a la GPU y de la salida de vuelta (el trtexec de TensorRT 11 no las mide por defecto; --includeDataTransfers las suma) | 602,112 B de entrada por imagen | |
| tiempo de los kernels en la GPU | ≈ 3–6 ms p50 con batch 1–8 (presupuesto de C01) | |
| el techo de batch de config.pbtxt | 32 | |
| el batch máximo del perfil del engine | 32 (--maxShapes) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| builder | La parte de TensorRT que compila: lee el ONNX, optimiza el grafo y mide kernels en tu GPU. | lo corre trtexec dentro de tensorrt:24.08 |
| engine (.plan) | El modelo ya compilado y guardado en un archivo: corre rápido, pero solo en ese tipo de GPU y con esa versión de TensorRT. | model.plan, ≈ 50–150 MB para ResNet-50 |
| parser de ONNX | Traduce cada nodo del ONNX a una capa de TensorRT. Si un operador no tiene traducción, el build falla. | el opset 17 de export_onnx.py |
| fusión de capas (layer fusion) | Junta capas seguidas en un solo kernel: el resultado intermedio no vuelve a la memoria de la GPU. | Conv + ReLU; Conv + suma del atajo + ReLU |
| BatchNorm plegado | En inferencia, BatchNorm es una escala y un corrimiento fijos: se mete en los pesos de la conv anterior. | el export de C01 ya lo hizo: el ONNX no trae nodos BatchNormalization |
| precisión | Cuántos bits usa cada número: FP32, FP16, INT8, FP8. Menos bits = más rápido y algo de error numérico. | FP16 en C01: los logits se mueven ≈ 1e-2 |
| tácticas (tactics) | Las implementaciones posibles de cada capa. El builder las cronometra en tu GPU y se queda con la más rápida. | por eso el build tarda minutos y el resultado es de esa GPU |
| perfil de optimización (optimization profile) | El rango de shapes que acepta una entrada dinámica: min, opt y max. Fuera del rango, la inferencia falla. | 1 / 16 / 32 |
| timing cache | Archivo con las mediciones de tácticas: el siguiente build en la misma GPU tarda menos. | --timingCacheFile |
| strongly typed | Red donde los tipos salen del modelo y TensorRT no elige la precisión. Obligatorio desde TensorRT 11. | en 11, --fp16 hace fallar a trtexec |
| ModelOpt AutoCast | Herramienta de NVIDIA que convierte el ONNX a precisión mixta FP16 antes del build. Con --keep_io_types, la entrada y la salida siguen en FP32. | python -m modelopt.onnx.autocast --onnx_path … --keep_io_types |
| nodos Q/DQ | Pares QuantizeLinear/DequantizeLinear en el ONNX: dicen dónde cuantizar a INT8 o FP8 y con qué escala. | los genera ModelOpt; C01 no usa INT8 |
| compute capability (sm_XX) | El número de arquitectura de la GPU. Los kernels del engine se eligen para ese número. | T4 = 7.5 (sm_75); L4 = 8.9 |
| hardware compatibility | Un engine para varias arquitecturas: ampere+ (compute capability ≥ 8.0) o, desde TensorRT 10.9, cualquier GPU con la misma compute capability. Puede ser más lento. | ampere+ no cubre la T4 |
| version compatibility | El engine lleva adentro un runtime chico (el lean runtime) y corre con TensorRT más nuevos. Puede ser más lento; Triton lo carga solo con --backend-config=tensorrt,version-compatible=true. | un engine 10 compatible corre con TensorRT 11 |
| trtexec | La herramienta de línea de comandos de TensorRT: construye engines y los mide solos. | build_engine.sh la corre en la T4 |
1073operaciones por bloque, de PyTorch al engine
PyTorch en eval()
FP32
ONNX de export_onnx.py (opset 17)
FP32
engine TensorRT en la T4
FP16 donde conviene
El atajo: la entrada del bloque salta las tres convs y entra a la suma (Add); en 4 de los 16 bloques, los que cambian de tamaño, el atajo pasa antes por su propia Conv 1×1
la entrada y la salida del engine siguen en FP32: TensorRT agrega conversiones en los bordes
Míralo en tu engine: trtexec --loadEngine=model.plan --dumpLayerInfo — los nombres de las capas fusionadas se unen con " + "
¿Dónde carga este engine?
| TensorRT | T4 (compute capability 7.5) | otra GPU (otra compute capability) |
|---|---|---|
| TensorRT 10.3 (NGC 24.08) | carga: es la del build | no carga: generated on an incompatible device |
| otro TensorRT (otra release de NGC) | no carga: not compatible with this version of TensorRT | no carga |
Salvo que lo construyas con un modo de compatibilidad (--hardwareCompatibilityLevel o --versionCompatible): opcional y más lento.
una operaciónjunta varias: plegadas en el export o fusionadas en un kernelcapa del engine, en la GPU
# trtexec viene dentro de la imagen oficial de TensorRT: no instalas nada en el host.
# --gpus all expone la GPU al contenedor (requiere NVIDIA Container Toolkit)
# --user el .plan queda con tu usuario, no como root
# --fp16 habilita kernels FP16 (Tensor Cores de la T4); TensorRT deja en FP32 lo que no convenga.
# Es TensorRT 10 (NGC 24.08). En TensorRT 11 (NGC 26.06+) ya no existe: conviertes el
# ONNX a FP16 con ModelOpt AutoCast y construyes el engine sin flags de precisión.
# --minShapes/opt/max perfil de optimización del batch dinámico: 1 ≤ batch ≤ 32, afinado para 16
# (max = max_batch_size de config.pbtxt). Ojo: online los batches son de ≈ 1–4
# (ver config.pbtxt y c01 · Serving); opt 16 es un punto de partida pensado para
# picos y batches grandes, no "el batch típico". Compara con perf_analyzer contra
# un engine con --optShapes=input:4x3x224x224 y quédate con el de mejor p99 a
# 250–500 req/s por GPU.
# --shapes batch con el que trtexec hace su benchmark rápido al terminar (sanity check;
# la medición seria es perf_analyzer contra Triton)
docker run --rm --gpus all \
--user "$(id -u):$(id -g)" \
-v "$ROOT:/work" -w /work \
"nvcr.io/nvidia/tensorrt:${NGC_RELEASE}-py3" \
trtexec \
--onnx="$ONNX" \
--saveEngine="$PLAN_DIR/model.plan" \
--fp16 \
--minShapes=input:1x3x224x224 \
--optShapes=input:16x3x224x224 \
--maxShapes=input:32x3x224x224 \
--shapes=input:16x3x224x224--fp16 (TensorRT 10) deja que el builder use FP16 donde rinde. La entrada y la salida del engine siguen en FP32: por eso config.pbtxt declara TYPE_FP32 y el gateway manda FP32. Los logits cambian ≈ 1e-2 frente a PyTorch FP32; el gate de paridad (sección 4) lo controla.max_batch_size: 32 de Triton. Online llegan batches de ≈ 1–3, así que el script propone comparar con un engine de --optShapes=input:4x3x224x224 usando perf_analyzer.--shapes=input:16x3x224x224 solo fija el batch del benchmark corto que trtexec corre al terminar. Su Throughput viene en qps: con batch 16, imágenes/s = qps × 16. La medición que decide es perf_analyzer contra Triton (sección 9).nvcr.io/nvidia/tensorrt:${NGC_RELEASE}-py3 con NGC_RELEASE=24.08 trae TensorRT 10.3, la misma versión que tritonserver:24.08 (Triton 2.49.0 con TensorRT 10.3.0.26). --gpus all le da la GPU al contenedor y --user deja el .plan a tu nombre.--fp16. El ONNX pasa antes por AutoCast con --keep_io_types (así la entrada y la salida siguen en FP32, como pide config.pbtxt), el engine se construye sin flags de precisión y la paridad FP16 se repite.Cómo recordarla
TensorRT es un compilador: el ONNX es el código fuente y el engine, el binario para una GPU (T4, sm_75) y una versión de TensorRT (10.3). Se compila donde va a correr, con el rango de batch que va a ver, y se mide con el batch real.
Trampa
subir solo la imagen de Triton (por ejemplo, de 24.08 a 26.09 por parches de seguridad) sin reconstruir el engine. La 26.09 trae TensorRT 11.3 y, por defecto, un engine solo carga con la versión de TensorRT que lo construyó: el modelo no carga y, con --exit-on-error=true, Triton se cierra y el pod entra en CrashLoopBackOff (se reinicia una y otra vez). Y en TensorRT 11 el mismo build_engine.sh falla, porque --fp16 ya no existe. Las imágenes tensorrt y tritonserver suben juntas, con el mismo YY.MM.
En el curso: M8 · TensorRT M8 · Cómo validar una optimización M4 · T4, L4, A10G, A100, H100: cuándo usar cada una C01 · Archivos de referencia Números · Optimización y serving: rangos y umbrales
Sin laboratorio: necesita una GPU NVIDIA.
¿Qué carpetas y qué configuración necesita Triton para cargar tu modelo, y qué pasa cuando algo no coincide?
Sintaxis con variables
repositorio/
└── modelo/
├── config.pbtxt
└── versión/
└── archivoEl model repository es una carpeta (local o en la nube: s3://, gs:// o as://). Cada modelo tiene su carpeta y cada versión, una subcarpeta con número. Triton ignora las subcarpetas que no son números o que empiezan con 0. Nombres por defecto: model.plan (TensorRT), model.onnx (ONNX Runtime), model.pt (PyTorch), model.py (Python).
name: "modelo"
platform: "plataforma"
max_batch_size: max_batch
input [ { name: "entrada", data_type: tipo, dims: [ dims ] } ]
output [ { name: "salida", data_type: tipo, dims: [ dims ] } ]Lo mínimo: quién lo ejecuta (platform o backend) y el contrato de tensores (nombre, tipo y shape de cada entrada y salida), con los nombres del ONNX o del engine. Con max_batch_size mayor que 0, dims va sin el eje de batch: Triton antepone -1. Con 0 no hay batching y dims es la shape completa.
tritonserver --model-repository=ruta \ --model-control-mode=modo \ --disable-auto-complete-config \ --strict-readiness=true --exit-on-error=true
Modos: none (default) carga todo al arrancar y no vuelve a mirar la carpeta; poll recarga lo que cambia (la doc no lo recomienda en producción); explicit carga solo lo que pidas: con --load-model al arrancar o por la API de control de modelos. --disable-auto-complete-config: Triton usa tu config tal cual, sin completarlo leyendo el modelo.
curl -s host:8000/v2/models/modelo/config curl -s -X POST host:8000/v2/repository/index
/config devuelve, en JSON, el config que Triton cargó de verdad, con los defaults que agregó (por ejemplo, instance_group). El índice lista cada modelo con su versión, su estado (READY o UNAVAILABLE) y el motivo.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| repositorio | carpeta raíz que recibe --model-repository | /models dentro de la imagen |
| modelo | nombre del modelo = nombre de su carpeta | classifier |
| versión | subcarpeta numérica de la versión | 1 |
| archivo | el modelo, con su nombre por defecto | model.plan (lo genera build_engine.sh; no va en git) |
| plataforma | quién lo ejecuta: platform (o backend) | tensorrt_plan |
| max_batch | techo del batch; 0 = sin batching | 32 (= max del perfil del engine) |
| entrada, salida | nombres de los tensores del modelo | input, logits |
| tipo | tipo de dato del config | TYPE_FP32 (en el cliente se escribe FP32) |
| dims | shape de una muestra, sin el batch | 3, 224, 224 y 500 |
| ruta | ruta o URL del repository | /models (o s3://bucket/models) |
| modo | modo de control de modelos | none |
| host | dónde escucha Triton | localhost con kubectl port-forward |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| model repository | La carpeta de modelos que lee Triton, con un modelo por subcarpeta. | triton/model_repository/, copiada a /models |
| carpeta de versión | Subcarpeta con número. Las que no son números o empiezan con 0 se ignoran. | classifier/1/ |
| config.pbtxt | La configuración del modelo, en el formato de texto de protobuf (campos con dos puntos, llaves y listas) que define el esquema ModelConfig: contrato, batching, instancias y warmup. | el de C01, 77 líneas comentadas |
| name | Opcional. Si falta, Triton usa el nombre de la carpeta; si está, tiene que ser igual a la carpeta. | classifier |
| platform / backend | Quién ejecuta el modelo. TensorRT: platform tensorrt_plan o backend tensorrt. ONNX: onnxruntime_onnx u onnxruntime. Python: backend python. | platform: "tensorrt_plan" |
| default_model_filename | Cambia el nombre del archivo que busca el backend. | C01 usa el default, model.plan |
| max_batch_size | El batch más grande que acepta el modelo. Mayor que 0: Triton antepone el eje de batch. 0: sin batching. | 32 |
| input / output | El contrato de cada tensor: name, data_type y dims. Tiene que coincidir con el modelo y con el cliente. | input FP32 [3, 224, 224] → logits FP32 [500] |
| data_type | El tipo en el config: TYPE_FP32, TYPE_INT64, TYPE_STRING… El cliente usa el nombre del protocolo: FP32, INT64, BYTES. | TYPE_FP32 |
| reshape | Para cuando la shape que manda el cliente y la que espera el modelo difieren. | C01 no lo necesita |
| version_policy | Qué versiones de la carpeta se cargan: latest (default, la más alta), all o specific. | latest { num_versions: 1 } |
| model_warmup | Requests de prueba que Triton corre al cargar. El modelo no queda listo hasta que terminan bien. | batch 32, 3 veces, con ceros |
| --disable-auto-complete-config | Usa el config tal cual, sin leer el modelo para completarlo. Igual agrega instance_group si falta. | en el CMD de triton/Dockerfile |
| --model-control-mode | Cuándo lee Triton el repository: none (al arrancar), poll (cada --repository-poll-secs) o explicit (con --load-model o por la API). | none: un modelo nuevo llega en una imagen nueva |
| --strict-readiness / --exit-on-error | /v2/health/ready da 200 solo con todos los modelos cargados / el proceso termina si algo falla al arrancar. Los dos son true por defecto. | C01 los escribe igual, para que se lean |
| UNAVAILABLE | Estado de un modelo que no cargó. El motivo sale en el log y en el índice del repository. | un engine de otra versión de TensorRT |
/modelstriton/model_repository/ en el repo; COPY model_repository /models en triton/Dockerfile
classifier/
el nombre de la carpeta es el name del config
config.pbtxt
lo escribes tú: contrato, batching, instancias y warmup
1/
la única versión; latest { num_versions: 1 }
model.planengine
engine FP16 para la T4: lo genera build_engine.sh y no va en git
detector/ejemplo
ejemplo: otro modelo en el mismo Triton, con otro backend
config.pbtxt
backend: "onnxruntime"
1/
model.onnx
nombre por defecto de ONNX Runtime
viejo/ignorada
Triton la ignora: no es un número
02/ignorada
Triton la ignora: empieza con 0
Cómo lo carga Triton
el engine: atado a la GPUTriton la ignoraejemplo
# Configuración del modelo "classifier" en Triton Inference Server (caso c01).
# Formato: protobuf en texto (mensaje ModelConfig). Triton arranca con
# --disable-auto-complete-config: no "adivina" nada del engine; todo lo que el gateway asume
# (nombres, dtypes, shapes) está escrito y revisado aquí.
# El engine (1/model.plan) lo genera model/build_engine.sh y NO se versiona en git.
name: "classifier" # = nombre del directorio; el gateway lo manda en MODEL_NAME
platform: "tensorrt_plan" # backend TensorRT: carga 1/model.plan (nombre por defecto del archivo)
max_batch_size: 32 # hasta 32 requests en un batch; debe ser ≤ --maxShapes del engine (32)
# Solo la versión más nueva del directorio. Con la imagen "horneada" hay una sola versión; el
# rollback es volver a desplegar la imagen anterior, no cargar dos versiones en la misma GPU.
version_policy: { latest: { num_versions: 1 } }
input [
{
name: "input" # = input_names del export ONNX y InferInput("input") del gateway
data_type: TYPE_FP32 # el gateway manda FP32; TensorRT convierte a FP16 dentro del engine
dims: [ 3, 224, 224 ] # SIN el eje de batch: con max_batch_size > 0 Triton antepone [-1]
}
]
output [
{
name: "logits" # logits crudos: softmax y top-k los hace el gateway (así se puede calibrar)
data_type: TYPE_FP32
dims: [ 500 ] # una salida por categoría hoja
}
]--disable-auto-complete-config (CMD de triton/Dockerfile): no completa nada leyendo el engine. Cada nombre, tipo y shape que asume el gateway está escrito aquí, a la vista.platform: "tensorrt_plan" sin default_model_filename: el backend busca 1/model.plan. El Dockerfile hace RUN test -f /models/classifier/1/model.plan para que el build de la imagen falle si el engine no está.max_batch_size: 32 con dims: [ 3, 224, 224 ]: Triton ve la entrada como [-1, 3, 224, 224], igual que el engine. Si escribieras el -1 en dims, serían 5 dimensiones y la carga falla con the model expects 4 dimensions … but the model configuration specifies 5 dimensions.version_policy con latest 1: una sola versión en la GPU. El rollback es volver a la imagen anterior, no tener dos engines cargados, cada uno con su VRAM.input y logits salen de export_onnx.py y los usa el gateway: si cambias uno, tienes que cambiarlo en los tres archivos. Las líneas 60–77 agregan el warmup (batch 32, 3 veces): de paso prueban al cargar que el engine acepta el batch máximo.Cómo recordarla
carpeta = nombre, subcarpeta = versión, config.pbtxt = contrato. Nombres, tipos, shapes y batch están tres veces: en el engine, en el config y en el cliente. Las tres copias tienen que coincidir.
Trampa
copiar la shape del ONNX tal cual, dims: [ -1, 3, 224, 224 ], con max_batch_size: 32. Triton antepone otro -1 y le pide al engine 5 dimensiones: el modelo queda UNAVAILABLE y el error habla de an initial batch dimension because max_batch_size > 0. Con batching, dims describe una sola muestra.
En el curso: M7 · NVIDIA Triton Inference Server C01 · Archivos de referencia C01 · Serving Docker y kubectl · GPUs en Docker
Qué vas a provocar: Armar el repository de C01 en tu Mac y validar su config.pbtxt contra el mensaje ModelConfig real de Triton (sin GPU), para ver qué errores atrapa el parser y cuáles no.
Necesitas: Python 3.13, como en los demás labs (como mínimo 3.10: tritonclient[grpc] 2.73 pide grpcio 1.81.1 o más nuevo, que ya no tiene versión para Python 3.9). Internet para pip (tritonclient, grpcio y protobuf). No necesitas GPU ni Docker.
mkdir -p ~/lab-repositorio && cd ~/lab-repositorio
python3.13 -m venv .venv && . .venv/bin/activate
pip install "tritonclient[grpc]==2.73.0"
mkdir -p model_repository/classifier/1Qué deberías ver: pip termina con Successfully installed y, en la lista, tritonclient-2.73.0, grpcio y protobuf (una 6.x: tritonclient 2.73 pide protobuf menor que 7). tritonclient es Python puro y grpcio trae wheels para macOS: se instala igual en un Mac con Apple Silicon.
cat > model_repository/classifier/config.pbtxt <<'EOF'
name: "classifier"
platform: "tensorrt_plan"
max_batch_size: 32
version_policy: { latest: { num_versions: 1 } }
input [ { name: "input", data_type: TYPE_FP32, dims: [ 3, 224, 224 ] } ]
output [ { name: "logits", data_type: TYPE_FP32, dims: [ 500 ] } ]
dynamic_batching {
max_queue_delay_microseconds: 2000
default_queue_policy { timeout_action: REJECT, default_timeout_microseconds: 100000 }
}
instance_group [ { count: 2, kind: KIND_GPU } ]
model_warmup [ { name: "warmup_max_batch", batch_size: 32, count: 3,
inputs { key: "input", value: { data_type: TYPE_FP32, dims: [ 3, 224, 224 ], zero_data: true } } } ]
EOF
find model_repositoryQué deberías ver: find lista model_repository, model_repository/classifier, la carpeta 1 y el config.pbtxt. La carpeta 1 queda vacía: el engine solo se construye en una T4.
cat > valida.py <<'EOF'
import sys
from google.protobuf import text_format
from tritonclient.grpc import model_config_pb2 as mc
cfg = text_format.Parse(open(sys.argv[1]).read(), mc.ModelConfig())
print(cfg.name, cfg.platform, cfg.max_batch_size, list(cfg.input[0].dims), cfg.instance_group[0].count)
EOF
python valida.py model_repository/classifier/config.pbtxtQué deberías ver: classifier tensorrt_plan 32 [3, 224, 224] 2. El archivo es protobuf válido para ModelConfig: los mismos nombres de campo y de enum que lee Triton.
sed -i.bak 's/name: "input", data_type: TYPE_FP32/name: "input", data_type: FP32/' model_repository/classifier/config.pbtxt
python valida.py model_repository/classifier/config.pbtxtQué deberías ver: Falla con google.protobuf.text_format.ParseError: la línea 5 y el texto Enum type "inference.DataType" has no value named FP32. FP32 es el nombre del protocolo (el de tritonclient), no el del config.
mv model_repository/classifier/config.pbtxt.bak model_repository/classifier/config.pbtxt
sed -i.bak 's/max_batch_size/max_batch_sise/' model_repository/classifier/config.pbtxt
python valida.py model_repository/classifier/config.pbtxtQué deberías ver: Otro ParseError: Message type "inference.ModelConfig" has no field named "max_batch_sise". Un campo mal escrito también impide la carga.
mv model_repository/classifier/config.pbtxt.bak model_repository/classifier/config.pbtxt
sed -i.bak 's/kind: KIND_GPU/kind: KIND_CPU/' model_repository/classifier/config.pbtxt
python valida.py model_repository/classifier/config.pbtxt
cat model_repository/classifier/config.pbtxtQué deberías ver: valida.py imprime lo mismo que en el paso 3: el protobuf es válido. Pero pega el config en el revisor de esta página: marca tensorrt-en-cpu. Triton tampoco lo cargaría (TensorRT backend supports only GPU device). El parser revisa nombres; el sentido lo revisan el revisor y Triton.
deactivate && cd ~ && rm -r ~/lab-repositorioQué deberías ver: Sin salida: borraste el venv y el repository de prueba.
Cómo arreglarlo y confirmarlo
Si pip dice que no encuentra una versión de grpcio, el venv quedó con Python 3.9 (el python3 que trae macOS): bórralo con rm -r .venv y créalo con python3.13. Si el import falla con The installation does not include grpc support, instalaste tritonclient sin el extra [grpc]: repite el pip install con las comillas. Si pip no encuentra 2.73.0, usa la última 2.7x. En una máquina con GPU, el paso siguiente es poner el model.plan en la carpeta 1 y arrancar tritonserver con este repository.
Verificado contra la documentación; no ejecutado aquí. Verificado: tritonclient 2.73.0 es un wheel py3-none-any y su extra [grpc] pide grpcio ≥ 1.81.1 (wheels solo para Python ≥ 3.10) y protobuf ≥ 6.30 y < 7; tritonclient.grpc importa model_config_pb2 y, sin grpc, lanza The installation does not include grpc support; el paquete del proto es inference; los textos de los ParseError salen de text_format.py de protobuf (iguales en la 6.33 y en la 7.36, donde se probaron sobre un mensaje incluido en protobuf); las tres ediciones con sed (BSD) se probaron sobre el archivo; el config es el de M07, que el curso parseó contra el ModelConfig de tritonclient 2.72.
¿Cómo junta Triton requests sueltos en un batch, cuántas copias del modelo conviene correr y cuánto cuesta eso en latencia?
Sintaxis con variables
max_batch_size: max_batch
dynamic_batching {
max_queue_delay_microseconds: espera_us
preferred_batch_size: [ tamaños ]
}El dynamic batcher junta requests de clientes distintos del lado del servidor. Un batch sale cuando llega a max_batch_size (o a un tamaño preferido) o cuando el primero lleva max_queue_delay_microseconds esperando; default de la espera: 0. preferred_batch_size casi nunca: la doc lo reserva para tamaños que rinden mucho mejor (varios perfiles de TensorRT).
dynamic_batching {
default_queue_policy {
timeout_action: REJECT
default_timeout_microseconds: timeout_us
max_queue_size: cola_max
}
priority_levels: niveles
default_priority_level: nivel_default
}La política de la cola. default_timeout_microseconds 0 = sin timeout; max_queue_size 0 = cola sin tope. Al vencer, REJECT (default) devuelve error y DELAY lo atiende después de los que siguen a tiempo. Con prioridades, 1 es la más alta y el default tiene que estar entre 1 y priority_levels.
instance_group [
{ count: n, kind: KIND_GPU, gpus: [ ids ] }
]count copias en cada GPU de gpus (o en todas, si no lo pones). Sin instance_group: 1 instancia por GPU. KIND_CPU corre en CPU (en ONNX Runtime, 2 instancias si no pones count); TensorRT solo acepta GPU.
sum(rate(nv_inference_count{model="modelo"}[5m]))
/
sum(rate(nv_inference_exec_count{model="modelo"}[5m]))El batch promedio que de verdad se forma: inferencias ÷ ejecuciones del modelo. Es la consulta del serving de C01; la corres en Prometheus (el sistema que junta las métricas), que las lee del puerto 8002.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| max_batch | techo del batch; no puede pasar el máximo del perfil del engine | 32 |
| espera_us | espera máxima para juntar requests, en µs | 2000 (2 ms) |
| tamaños | tamaños de batch preferidos | no se usan: el engine tiene un solo perfil, 1–32 |
| timeout_us | cuánto puede esperar un request en la cola, en µs | 100000 (100 ms, menos que los 150 ms del gateway) |
| cola_max | requests máximos en la cola; 0 = sin tope | no se fija |
| niveles | cuántas prioridades hay; 1 es la más alta | 2, en la propuesta del serving de C01 (el config.pbtxt no las trae) |
| nivel_default | prioridad de los requests que no mandan una | 2: la app manda priority=1 y los scripts quedan detrás |
| n | instancias por GPU | 2 |
| ids | GPUs donde van las copias; si falta, todas | no se pone: el pod ve una sola T4 |
| modelo | label model de las métricas | classifier |
| requests que llegan por segundo a cada GPU | 250 en el pico de diseño; 500 si cae una GPU | |
| tiempo promedio dentro de Triton: cola + copia + cómputo | ≈ 8 ms (supuesto de M03) | |
| requests dentro de Triton en promedio | ≈ 2: dos instancias alcanzan | |
| max_queue_delay_microseconds, en segundos | 0.002 | |
| tiempo para juntar un batch lleno | 128 ms, más de la mitad del p99 de 200 ms: por eso el batch no espera a llenarse y sale a los 2 ms | |
| batch promedio real | ≈ 1–3 a 250 req/s; ≈ 2–4 en failover |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| dynamic batcher | El scheduler (la parte de Triton que decide qué request corre y cuándo) que junta requests sueltos de varios clientes en un batch, del lado del servidor. | dynamic_batching { … } en config.pbtxt |
| scheduler por defecto | Si no hay dynamic_batching ni otro scheduler, cada request se ejecuta sola, aunque max_batch_size sea 32. | con --disable-auto-complete-config nadie lo agrega por ti |
| max_queue_delay_microseconds | La espera máxima para juntar compañía. Con 0 no espera: igual arma el batch con lo que se acumuló mientras las instancias estaban ocupadas. | 2000 en C01 |
| batch real vs max_batch_size | max_batch_size es un techo. El batch que se forma depende de cuántos requests llegan. | techo 32, real ≈ 1–3 |
| preferred_batch_size | Tamaños con los que el batch sale de inmediato. La doc lo desaconseja para la mayoría de los modelos. | C01 no los pone |
| default_queue_policy | Qué hace la cola: timeout, acción al vencer y tope de tamaño. | REJECT a los 100 ms |
| timeout_action | REJECT (default): devuelve error. DELAY: lo atiende después de los que siguen a tiempo. | REJECT en C01 |
| allow_timeout_override | Deja que cada request pida un timeout más corto (el parámetro timeout de tritonclient, en µs). | no se usa en C01 |
| priority_levels | Varias colas por prioridad: la 1 se atiende primero. | propuesta del serving de C01: app en 1, scripts en 2 |
| preserve_ordering | Devuelve las respuestas en el orden de llegada. No se permite con más de un nivel de prioridad. | no se usa |
| instance_group | Cuántas copias del modelo corren y dónde. Cada copia es un contexto de ejecución con su propia memoria. | count: 2, kind: KIND_GPU |
| kind | KIND_GPU, KIND_CPU, KIND_MODEL o KIND_AUTO (el default: GPU si hay). | KIND_GPU |
| rate limiter | Opcional (--rate-limit=execution_count): reparte la ejecución entre instancias según recursos y prioridad. | C01 no lo usa |
| sequence batcher | Para modelos con estado: manda los requests de una misma secuencia a la misma instancia. | no aplica a un clasificador |
| client-side batching | El cliente junta imágenes antes de mandarlas. Online casi nunca conviene: suma latencia y Triton ya junta. | C01: una imagen por request |
Cuándo sale un batch
1Carga normal de C01: ≈ 250 req/s por GPU
6 requests en 4 ejecuciones: batch promedio 1.5; cada request espera 2 ms o menos en la cola
2Ráfaga: 6 requests en 1.5 ms (un pico o un failover)
una ejecución de ≈ 7.7 ms atiende a los 6: el batch crece solo cuando hay tráfico
llega un requestespera en la cola (hasta 2 ms)el batch corre en la GPU: cuántos requests van juntoseje en ms, a escala
# Dynamic batching: junta requests individuales en un batch para usar bien la GPU (1 imagen por
# llamada deja la T4 casi ociosa). Espera como máximo 2 ms a que lleguen más requests. Con 2 GPUs,
# cada Triton recibe ≈ 175–250 req/s en el pico (350–500 req/s en total): en 2 ms llegan ≈ 0.35–0.5
# requests más y el batch promedio queda en ≈ 1–3 (≈ 2–4 si una GPU absorbe los 500 req/s del
# failover). Cuesta como mucho 2 ms del p99 de 200 ms; el batch crece solo en picos y failover.
# Mídelo, no lo supongas: rate(nv_inference_count) / rate(nv_inference_exec_count) = batch promedio.
dynamic_batching {
max_queue_delay_microseconds: 2000
# Sin preferred_batch_size a propósito: con un solo perfil de optimización (1–32) Triton arma el
# batch más grande posible dentro de la espera; fijar tamaños solo tiene sentido con varios
# perfiles/engines por tamaño, y mal elegidos agregan espera.
default_queue_policy {
# Protección ante sobrecarga: un request que lleva 100 ms en cola ya no llega a tiempo al
# timeout del gateway (150 ms). Mejor rechazarlo y liberar GPU para requests que siguen vivos
# que calcular una respuesta que nadie espera.
timeout_action: REJECT
default_timeout_microseconds: 100000
}
}
# 2 instancias del engine en la MISMA GPU: mientras una copia datos o espera, la otra computa.
# Cada instancia reserva su propia memoria de activaciones; con ResNet-50 FP16 sobra en 16 GB.
instance_group [
{
count: 2
kind: KIND_GPU
}
]preferred_batch_size: con un solo perfil (1–32), lo mejor es el batch más grande que se forme dentro de la espera. Fijar tamaños tiene sentido con varios perfiles o engines por tamaño.default_queue_policy con REJECT a los 100 ms: un request que ya esperó eso no llega al timeout de 150 ms del gateway. Triton lo rechaza (se cuenta en nv_inference_request_failure con reason="REJECTED") y el gateway responde 503 con Retry-After.count: 2 en la misma T4: mientras una instancia copia datos o espera, la otra calcula. Cada una reserva su propia memoria de activaciones; con ResNet-50 FP16 son unos pocos GB en total, de 16.Cómo recordarla
el dynamic batcher es un bus que espera como mucho 2 ms a que suban más pasajeros. Si se llena (32 asientos), sale antes; si no, sale cuando vence la espera. Las instancias son buses en paralelo. Con poco tráfico el bus sale casi vacío y la espera es el costo; en un pico se llena solo.
Trampa
poner max_batch_size: 32 y creer que ya hay batching. Sin el bloque dynamic_batching, Triton usa el scheduler por defecto y cada request va sola a la GPU. Con el autocompletado activo, el backend de TensorRT agrega el bloque si el batch máximo es mayor que 1; el de ONNX Runtime, solo si además tiene que completar input y output. Con --disable-auto-complete-config, como C01, nadie lo agrega. Escríbelo siempre y mídelo con inferencias ÷ ejecuciones.
En el curso: M3 · Batching: throughput a cambio de latencia M3 · Little's law: la cuenta que ordena todo C01 · Serving M7 · NVIDIA Triton Inference Server M4 · Cálculo de VRAM paso a paso
Qué vas a provocar: Ver cómo cambian el batch y la espera con la tasa de llegada y con la ventana, en una simulación en Python de la regla del dynamic batcher con los números de C01. No corre Triton: imita su regla.
Necesitas: Python 3.9 o más nuevo, sin paquetes extra. Ni GPU ni Docker.
mkdir -p ~/lab-batching && cd ~/lab-batching
cat > batcher.py <<'EOF'
# batcher.py: la regla del dynamic batcher de Triton en pocas líneas (simulación, no es Triton)
import math, random, sys
def simular(tasa, ventana_ms, max_batch=32, n=50000):
random.seed(1) # misma semilla = mismos números en cualquier Python 3
t, llegadas = 0.0, []
for _ in range(n): # llegadas al azar (Poisson): `tasa` requests por segundo
t += -math.log(1.0 - random.random()) / tasa * 1000.0
llegadas.append(t)
gpu_libre, i, lotes, esperas, totales = 0.0, 0, [], [], []
while i < n:
corte = max(llegadas[i] + ventana_ms, gpu_libre) # el primero espera compañía hasta aquí
j = i
while j < n and j - i < max_batch and llegadas[j] <= corte:
j += 1 # suben al bus los que llegaron antes del corte
sale = max(llegadas[j - 1], gpu_libre) if j - i == max_batch else corte # lleno: sale ya
computo = 3.25 + 0.74 * (j - i) # ms en la GPU, ilustrativo (≈ tabla de M03)
gpu_libre = sale + computo
lotes.append(j - i)
esperas += [sale - x for x in llegadas[i:j]]
totales += [sale + computo - x for x in llegadas[i:j]]
i = j
totales.sort()
return sum(lotes) / len(lotes), sum(esperas) / len(esperas), totales[int(0.99 * n)]
tasa = float(sys.argv[1]) if len(sys.argv) > 1 else 250
for ventana in (0, 2, 10):
b, espera, p99 = simular(tasa, ventana)
print(f"{tasa:.0f} req/s ventana {ventana} ms batch medio {b:.1f} espera media {espera:.1f} ms p99 {p99:.1f} ms")
EOF
python3 batcher.py 250Qué deberías ver: Tres líneas, una por ventana: 250 req/s ventana 0 ms batch medio 1.4 espera media 1.7 ms p99 10.8 ms; con 2 ms: batch 1.7, espera 2.2 ms, p99 10.8 ms; con 10 ms: batch 3.5, espera 6.4 ms, p99 18.4 ms. La ventana de 2 ms casi no cambia el p99. La de 10 ms duplica el batch, pero sube el p99 de 10.8 a 18.4 ms: a esta carga, la GPU no necesita batches más grandes.
python3 batcher.py 500Qué deberías ver: El failover de C01 (toda la carga en una GPU): batch medio 2.7 con ventana 0 y 2.9 con 2 ms; p99 15.3 ms. El batch sube solo con el tráfico, dentro del ≈ 2–4 que estima C01.
python3 batcher.py 1000Qué deberías ver: Batch medio 12.2 aunque la ventana sea 0 ms: con carga, los requests se acumulan mientras la GPU trabaja y el batcher se los lleva juntos. p99 ≈ 40 ms.
python3 batcher.py 1200Qué deberías ver: Espera media ≈ 164 ms y p99 ≈ 285 ms con cualquier ventana: pasaste el codo (con estos tiempos, la GPU da ≈ 1,190 img/s con batch 32). La cola crece mientras dure la carga; en C01, la política de la cola rechazaría a los 100 ms.
cd ~ && rm -r ~/lab-batchingQué deberías ver: Sin salida.
Cómo arreglarlo y confirmarlo
Si tus números no coinciden, revisa que copiaste el script entero: con la semilla fija, cualquier Python 3 da exactamente esos valores. Para jugar, cambia la tasa (el argumento) o la fórmula de la línea computo. Es una simulación de la regla, no Triton: deja afuera la segunda instancia de C01 (y su solape de copias), la red y los timeouts de la cola. Los números de verdad salen de perf_analyzer contra Triton y del cociente inferencias ÷ ejecuciones en sus métricas.
Probado de verdad en macOS (Apple Silicon), sin GPU, con Python 3.9.6 y 3.14.3. Salida idéntica, con las cifras de arriba. Simula la regla documentada del dynamic batcher (sale lleno o al vencer la espera, cuando la GPU queda libre) con tiempos de cómputo ilustrativos de M03.
¿Cómo le habla tu servicio a Triton: qué endpoints usa, qué viaja por la red y qué cliente de Python eliges?
Sintaxis con variables
GET /v2/health/live GET /v2/health/ready GET /v2/models/modelo/ready GET /v2/models/modelo GET /v2/models/modelo/config POST /v2/models/modelo[/versions/v]/infer
El protocolo KServe v2 (también llamado Open Inference Protocol: las rutas y el JSON estándar para hablarle a un servidor de modelos) por HTTP, en el puerto 8000. Health: 200 = sí, 400 = no, sin body. Dos casos más: mientras los modelos cargan al arrancar (con --model-control-mode=none) el servidor HTTP todavía no acepta conexiones, y al apagarse responde 503. /v2/models/modelo es la metadata (nombres, tipos y shapes); /config es una extensión de Triton.
import tritonclient.http as httpclient
client = httpclient.InferenceServerClient(url="host:8000")
inp = httpclient.InferInput("entrada", [shape], "tipo")
inp.set_data_from_numpy(arreglo, binary_data=True)
out = httpclient.InferRequestedOutput("salida", binary_data=True)
res = client.infer("modelo", [inp], outputs=[out])
y = res.as_numpy("salida")La URL va sin http://: si lo pones, el cliente falla con url should not include the scheme. El tipo es el nombre del protocolo (FP32, no TYPE_FP32) y la shape incluye el batch. La versión async es tritonclient.http.aio, la de C01.
import tritonclient.grpc as grpcclient
client = grpcclient.InferenceServerClient(url="host:8001")
inp = grpcclient.InferInput("entrada", [shape], "tipo")
inp.set_data_from_numpy(arreglo)
res = client.infer("modelo", [inp], client_timeout=segundos)gRPC (llamadas a funciones remotas sobre HTTP/2), en el puerto 8001: el tensor viaja como bytes crudos dentro de un mensaje protobuf (el formato binario de Google). client_timeout sí es un timeout del cliente, en segundos: si vence, lanza InferenceServerException con Deadline Exceeded. En los dos clientes, timeout es otra cosa: µs que Triton aplica en su cola.
curl -i host:8000/v2/health/ready curl -s host:8000/v2/models/modelo
Para mirar a mano. La primera muestra HTTP/1.1 200 OK o 400 Bad Request. La metadata es un JSON con name, versions, platform, inputs y outputs (nombre, datatype y shape, con -1 en el eje de batch).
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modelo | nombre del modelo en el repository | classifier |
| v | versión explícita (opcional) | no se usa: hay una sola |
| host | dónde escucha Triton, sin http:// | triton.classifier.svc.cluster.local en EKS; triton en compose |
| entrada, salida | nombres de los tensores, los del config | input, logits |
| shape | shape del request, con el batch | 1, 3, 224, 224 |
| tipo | datatype del protocolo | FP32 |
| arreglo | arreglo de NumPy con ese dtype y esa shape | float32 [1, 3, 224, 224] de preprocess.py |
| segundos | timeout del cliente gRPC | C01 usa HTTP: su tope es asyncio.wait_for, 150 ms |
| bytes del tensor en binario | 602,112 | |
| cantidad de valores del tensor | 150,528 | |
| el mismo tensor con los números escritos como texto (≈ 20 B por número; M07 midió 20.6 con json.dumps) | ≈ 3 MB (3.1 MB en M07) | |
| requests por segundo | 500 en el pico de diseño | |
| el presupuesto total del request en el gateway | 250 ms desde que llegó |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| KServe v2 (Open Inference Protocol) | Un contrato estándar de endpoints y JSON para servidores de modelos: Triton, KServe, MLServer, OpenVINO Model Server. | el gateway habla v2 por HTTP |
| extensión binaria | Header JSON corto + los bytes crudos del tensor. El header Inference-Header-Content-Length dice dónde termina el JSON. | binary_data=True en tritonclient |
| datatype del protocolo | BOOL, UINT8…, FP16, FP32, FP64, BYTES, BF16. En el config se escriben TYPE_*, y el texto es TYPE_STRING allá y BYTES aquí. | FP32 |
| /v2/health/live | ¿El proceso responde? Para la livenessProbe (el chequeo con que Kubernetes decide si reiniciar el contenedor). | livenessProbe de k8s/triton.yaml |
| /v2/health/ready | ¿Puede atender? Con --strict-readiness, solo si todos los modelos están cargados. | startupProbe y readinessProbe |
| /v2/models/modelo/ready | ¿Ese modelo está cargado? Si no, 400 con un error en JSON. | is_model_ready() en el /readyz del gateway |
| metadata y config | Metadata: nombres, tipos y shapes. Config: el config.pbtxt cargado, en JSON (extensión de Triton). | get_model_metadata() y get_model_config() |
| gRPC | El mismo modelo de datos sobre HTTP/2: el servicio inference.GRPCInferenceService, en el puerto 8001. | perf_analyzer -i grpc |
| tritonclient | La librería oficial de Python: tritonclient.http, .http.aio, .grpc y .grpc.aio. Se instala con pip install "tritonclient[http]" o "[grpc]". | tritonclient[http]==2.72.0 en C01 |
| InferInput / InferRequestedOutput | El tensor que mandas (nombre, shape, datatype) y la salida que pides. El cliente revisa el dtype y la shape del arreglo antes de mandar. | InferInput("input", [1, 3, 224, 224], "FP32") |
| InferenceServerException | El error que devuelve Triton (modelo no cargado, request rechazado); .message() trae el texto. | el gateway lo traduce a 503 |
| timeout de infer() | No es del cliente: son µs que Triton aplica en su cola, y solo si el modelo tiene dynamic batching. | por eso C01 usa asyncio.wait_for |
| memoria compartida | Si cliente y Triton están en la misma máquina, el tensor pasa por memoria (del sistema o CUDA), sin red. | perf_analyzer --shared-memory system |
| gRPC y el balanceo de Kubernetes | Una conexión HTTP/2 lleva todos los requests, y el Service de Kubernetes reparte conexiones, no requests (balanceo L4): todo termina en un solo pod. | por eso C01 usa HTTP con un pool de conexiones |
gateway: tritonclient.http.aio, un cliente por procesoTriton, en el pod con la T4
8000HTTP/REST, KServe v2
gateway y probes del kubelet
kubelet
GET/v2/health/live
200 vivo · 400 no
En el C01: livenessProbe cada 10 s
kubelet
GET/v2/health/ready
200 si todos los modelos están listos · 400 si no
En el C01: startupProbe hasta 5 min; readinessProbe cada 5 s
gateway (/readyz)
GET/v2/models/classifier/ready
200 · 400 {"error": "Model version not ready"}
En el C01: is_model_ready(), tope de 0.5 s
tú, al depurar
GET/v2/models/classifier
input FP32 [-1, 3, 224, 224] · logits FP32 [-1, 500]
En el C01: get_model_metadata()
gateway
POST/v2/models/classifier/infer
header JSON + 2,000 bytes de logits (500 × 4 B)
En el C01: header JSON + 602,112 bytes del tensor
↑ sube602,112 B
↓ baja2,000 B
8001gRPC
perf_analyzer o clientes que lo prefieran; la NetworkPolicy de C01 no lo abre al gateway
8002HTTP /metrics, formato Prometheus
Prometheus (cada 15 s) y, a través de él, KEDA
Prometheus
GET:8002/metrics
texto con nv_inference_* y nv_gpu_*
En el C01: scrape cada 15 s
async def _infer(client, batch: np.ndarray, request_id: str, timeout: float = INFER_TIMEOUT_S) -> np.ndarray:
"""Llama a Triton y traduce sus fallas a códigos HTTP útiles para el cliente."""
inp = triton_http.InferInput("input", list(batch.shape), "FP32") # nombre, shape y dtype = config.pbtxt
# binary tensor extension: 602 KB crudos detrás del header JSON, en vez de 150k floats como texto
inp.set_data_from_numpy(batch, binary_data=True)
out = triton_http.InferRequestedOutput("logits", binary_data=True) # la respuesta también en binario
try:
# Verificado en tritonclient 2.72: el parámetro `timeout` de infer() NO es un timeout del
# cliente, es un parámetro en microsegundos que Triton aplica en SU cola (solo con dynamic
# batching). El único timeout del cliente aio es conn_timeout, global para toda la sesión.
# Por eso el presupuesto por request se impone con asyncio.wait_for, que además cancela la
# petición HTTP en vuelo.
result = await asyncio.wait_for(
client.infer(MODEL_NAME, [inp], outputs=[out], request_id=request_id),
timeout=timeout, # ≤ INFER_TIMEOUT_S: lo que quede del presupuesto del request
)
except TimeoutError: # asyncio.TimeoutError es alias de TimeoutError desde Python 3.11
# 504: Triton está vivo pero lento (cola llena, GPU saturada); el cliente cae a su fallback
raise HTTPException(504, "timeout de inferencia") from None
except aiohttp.ClientError: # conexión rechazada/reseteada, DNS: Triton caído o reiniciando
raise HTTPException(503, "Triton no disponible", headers={"Retry-After": "1"}) from None
except InferenceServerException as exc: # Triton respondió error: modelo no cargado, request rechazado
detail = f"Triton rechazó la inferencia: {exc.message()}"
raise HTTPException(503, detail, headers={"Retry-After": "1"}) from None
return result.as_numpy("logits")[0] # [500] logits del único elemento del batchInferInput("input", list(batch.shape), "FP32"): nombre, shape y tipo calcan el config (TYPE_FP32 allá, FP32 aquí). La shape es la completa, [1, 3, 224, 224]: el batch de 1 lo agrega _decode_and_hash.binary_data=True en la entrada y en la salida: viajan 602,112 bytes crudos detrás del header, no 150,528 números como texto (≈ 3 MB).timeout de infer() no corta nada en el cliente (comentario de las líneas 154–158). El presupuesto se impone con asyncio.wait_for, que además cancela la petición en vuelo; el conn_timeout=1.0 del cliente (línea 108) queda de respaldo.conn_limit=64: reusa las conexiones keep-alive. El /readyz del gateway llama is_model_ready(MODEL_NAME) con un tope de 0.5 s.Cómo recordarla
dos preguntas de salud y una de trabajo: ¿vive? (live), ¿puede atender? (ready) e infer. El tensor viaja en binario: un header JSON corto y 602,112 bytes. Y el tipo cambia de nombre: TYPE_FP32 en el config, FP32 en el cable.
Trampa
pasarle timeout= a infer() creyendo que el cliente dejará de esperar. Ese parámetro son microsegundos que Triton aplica en su cola, y solo si el modelo tiene dynamic batching; el cliente sigue esperando. Con HTTP, C01 usa asyncio.wait_for; con gRPC existe client_timeout, en segundos.
En el curso: M7 · Protocolos: REST, gRPC, KServe v2 y OpenAI-compatible C01 · Serving M3 · Timeouts, reintentos y hedging C01 · Archivos de referencia
Qué vas a provocar: Ver sin servidor qué viaja en un request KServe v2: el tensor de C01 en binario y en JSON, y el header que arma tritonclient.
Necesitas: Python 3.13, como en los demás labs, e internet para pip (tritonclient y numpy). No necesitas GPU, Docker ni Triton.
mkdir -p ~/lab-cliente && cd ~/lab-cliente
python3.13 -m venv .venv && . .venv/bin/activate
pip install "tritonclient[http]==2.73.0" numpyQué deberías ver: Successfully installed y, en la lista, tritonclient-2.73.0 y numpy. No hace falta Triton: el body del request se arma en tu máquina.
cat > cuerpo.py <<'EOF'
import numpy as np
import tritonclient.http as httpclient
x = np.random.default_rng(0).standard_normal((1, 3, 224, 224), dtype=np.float32) # como el tensor del gateway
for binario in (True, False):
inp = httpclient.InferInput("input", list(x.shape), "FP32")
inp.set_data_from_numpy(x, binary_data=binario)
out = httpclient.InferRequestedOutput("logits", binary_data=binario)
body, header = httpclient.InferenceServerClient.generate_request_body([inp], outputs=[out])
print("binario" if binario else "JSON", len(body), "bytes; header JSON:", header)
EOF
python cuerpo.pyQué deberías ver: Dos líneas. Binario: el body mide el header (≈ 175 bytes) más los 602,112 bytes del tensor. JSON: ≈ 3,000,000 bytes y header None, porque todo el body es JSON: unas 5 veces más.
python - <<'EOF'
import numpy as np
import tritonclient.http as httpclient
inp = httpclient.InferInput("input", [1, 3, 224, 224], "FP32")
inp.set_data_from_numpy(np.zeros((1, 3, 224, 224), dtype=np.float32))
body, header = httpclient.InferenceServerClient.generate_request_body([inp])
print(body[:header].decode())
EOFQué deberías ver: El header JSON: inputs con name input, shape [1,3,224,224], datatype FP32 y parameters con binary_data_size 602112. Como no pediste salidas, el cliente agrega binary_data_output: true para recibirlas todas en binario.
python - <<'EOF'
import numpy as np
import tritonclient.http as httpclient
inp = httpclient.InferInput("input", [1, 3, 224, 224], "FP32")
inp.set_data_from_numpy(np.zeros((1, 224, 224, 3), dtype=np.float32))
EOFQué deberías ver: InferenceServerException: got unexpected numpy array shape [1, 224, 224, 3], expected [1, 3, 224, 224]. El cliente revisa la shape (y el dtype) antes de mandar nada: una imagen en HWC no llega a Triton.
deactivate && cd ~ && rm -r ~/lab-clienteQué deberías ver: Sin salida.
Cómo arreglarlo y confirmarlo
Si el import de numpy o de tritonclient falla, revisa que activaste el venv (. .venv/bin/activate) antes de correr python. Con un Triton arriba (en una máquina con GPU), el mismo InferInput va en client.infer("classifier", [inp]) y la respuesta se lee con as_numpy("logits").
Verificado contra la documentación; no ejecutado aquí. Verificado en el código de tritonclient: generate_request_body es estático y devuelve el body y el tamaño del header (None si todo es JSON); el JSON se arma con rapidjson, sin espacios; sin outputs agrega binary_data_output; el error de shape usa got unexpected numpy array shape. Los tamaños se calcularon aparte con la misma estructura y los mismos floats: header de 174 bytes y body JSON de ≈ 2.95 MB (≈ 19.6 B por número; M07 midió 20.6 con json.dumps, que agrega un espacio tras cada coma).
¿Cuánto aguanta una GPU, dónde se va la latencia y cómo sabes si el batching de verdad está pasando?
Sintaxis con variables
perf_analyzer -m modelo -u host:8000 -i http \ --concurrency-range inicio:fin:paso \ --input-data datos.json --percentile=99 \ --measurement-interval ms
Carga cerrada: cada cliente virtual espera su respuesta antes de mandar otra. Sirve para encontrar el throughput máximo. Sin --input-data manda datos al azar; -b es el batch de cada request (1, como el gateway). Viene en la imagen nvcr.io/nvidia/tritonserver:release-py3-sdk (o con pip install perf-analyzer, que solo trae versiones para Linux).
perf_analyzer -m modelo -u host:8000 \ --request-rate-range tasa --request-distribution poisson \ --input-data datos.json --percentile=99
Carga abierta: manda una tasa fija de requests aunque el servidor se frene, como el tráfico de verdad (siempre que no se le acaben los hilos: usa --async o sube --max-threads). Es la que dice el p99 a esa tasa. La distribución por defecto es constant; poisson se parece más al tráfico real.
curl -s host:8002/metrics | grep 'model="modelo"' \ | grep -E 'nv_inference_(count|exec_count|request_success|queue_duration_us|compute_infer_duration_us)'
Estas cinco son contadores (counters) acumulados desde que arrancó Triton: lo que importa es cuánto suben en un intervalo (rate en Prometheus), no el valor suelto. Las de GPU (nv_gpu_utilization, nv_gpu_memory_used_bytes) y nv_inference_pending_request_count son gauges: valen lo que marcan en ese momento y se leen directo, sin rate.
sum(rate(nv_inference_queue_duration_us{model="modelo"}[5m]))
/ sum(rate(nv_inference_request_success{model="modelo"}[5m]))
/ 1000Espera media en la cola, en ms: la alerta TritonQueueTimeHigh de C01 salta a 20 ms y KEDA (el autoscaler que agrega pods de Triton) escala con un objetivo de 10 ms. Incluye la ventana de batching: el request ya está en la cola mientras espera compañía.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modelo | modelo a medir (-m) | classifier |
| host | Triton, sin http:// | localhost con kubectl port-forward, o triton en compose |
| inicio:fin:paso | clientes concurrentes: desde, hasta, de a cuánto | 1:32:2 (M03) |
| datos.json | entradas reales, en el formato JSON de perf_analyzer | tensores_reales.json: fotos ya preprocesadas |
| ms | largo de cada ventana de medición, en ms (default 5000) | 10000 |
| tasa | requests por segundo, en carga abierta | 250 (pico de diseño por GPU) y 500 (failover) |
| release | release de NGC de la imagen SDK | 24.08, la misma que el servidor |
| throughput: requests atendidos por segundo | el máximo de una T4 en C01 es un supuesto: ≈ 1,200 img/s | |
| clientes concurrentes de perf_analyzer | 8 | |
| latencia media de cada request | 10 ms | |
| espera media en la cola de Triton | ≈ 1–3 ms normal; más de 20 ms sostenidos dispara la alerta | |
| carga de diseño | 500 req/s | |
| el codo de la rampa, medido con perf_analyzer | ≈ 1,200 img/s → 600 planeadas (50 %) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| perf_analyzer | Genera carga contra Triton (HTTP o gRPC) y reporta throughput y latencias por percentil, del cliente y del servidor. | la rampa contra una T4 |
| carga cerrada (--concurrency-range) | N clientes que esperan su respuesta antes de mandar otra. Encuentra el máximo, pero esconde la cola. | 1:32:2 |
| carga abierta (--request-rate-range) | Tasa fija aunque el servidor se frene (con --async o hilos de sobra): mide la latencia a esa tasa sin el sesgo de la carga cerrada. | 250 y 500 req/s, el gate de latencia de C01 |
| --input-data | Qué datos manda: random (default), zero o un JSON con tensores reales. | fotos reales ya preprocesadas |
| --percentile | Usa ese percentil, en vez del promedio, para decidir que la medición ya es estable. | --percentile=99 |
| desglose del servidor | perf_analyzer muestra cuánto se fue en overhead, queue, compute input, compute infer y compute output. | queue alto con compute normal = falta capacidad |
| model_analyzer | Barre configuraciones (instancias, max batch, dynamic batching) usando perf_analyzer y elige la mejor bajo una restricción de latencia. | model-analyzer profile --model-repository … --profile-models classifier |
| trtexec, perf_analyzer, Locust o k6 | El motor solo; el servidor; el servicio completo con el gateway. | cada uno responde una pregunta distinta |
| nv_inference_count / nv_inference_exec_count | Inferencias y ejecuciones del modelo: su cociente es el batch promedio. | ≈ 1–3 en C01 |
| nv_inference_queue_duration_us | µs acumulados en la cola, incluida la ventana de batching. | la señal de escalado de KEDA |
| nv_inference_compute_infer_duration_us | µs acumulados de cómputo, contados por request. Dividido por los requests exitosos da el cómputo medio: si ese promedio sube sin que suba la cola, cambió el modelo, el batch o la GPU. | tablero de Triton |
| nv_inference_pending_request_count | Requests que esperan a un backend en este momento: es un gauge, sube y baja. | alternativa para escalar |
| nv_inference_request_failure | Fallas por motivo, en el label reason. REJECTED = venció el timeout de la cola. | los rechazos a los 100 ms |
| nv_gpu_utilization | Fracción del tiempo con al menos un kernel corriendo (de 0.0 a 1.0), no la capacidad usada. Es un gauge. | alerta GPUSaturated: más de 0.85 durante 15 min |
| nv_gpu_memory_used_bytes | VRAM usada en cada GPU, en bytes (un gauge). | unos pocos GB de 16 en C01 |
| /v2/models/modelo/stats | Las mismas estadísticas en JSON: cola y cómputo acumulados, y ejecuciones por tamaño de batch (batch_stats). | curl localhost:8000/v2/models/classifier/stats |
throughput logradosi todo lo que llega salierase rechazap99p50referencias
Fuente: M03, load testing: rampa ilustrativa
pasado el codo, la latencia se estanca en ≈ 130 ms porque la cola rechaza a los 100 ms; lo que sobra se rechaza (≈ 7 % a 1,300 y ≈ 14 % a 1,400)
# Tiempo medio en la cola de Triton (µs acumulados / requests exitosos → ms).
# Con max_queue_delay de 2 ms, > 20 ms significa GPU saturada: KEDA debería estar escalando.
- alert: TritonQueueTimeHigh
expr: |
(
sum(rate(nv_inference_queue_duration_us{model="classifier"}[5m]))
/
sum(rate(nv_inference_request_success{model="classifier"}[5m]))
) / 1000 > 20
for: 10m
labels:
severity: warning
annotations:
summary: "Cola de Triton sobre 20 ms en promedio"
description: "Espera media en cola = {{ $value | printf \"%.1f\" }} ms. Si KEDA ya está en maxReplicaCount (4), falta capacidad de GPU."
runbook_url: "https://runbooks.example.com/classifier/triton-queue-time-high"rate), y ÷ 1000 para pasar a ms. Es la misma cuenta que usa KEDA en k8s/keda-triton.yaml, con un objetivo de 10 ms.max_queue_delay de 2 ms, lo normal es ≈ 1–3 ms en la cola, porque la ventana cuenta como espera. Más de 20 ms durante 10 minutos (for: 10m) es una GPU saturada.maxReplicaCount (4), falta capacidad de GPU y hay que subir el techo o mejorar el engine.nv_gpu_utilization > 0.85 por 15 min), mide tiempo con kernels corriendo, no capacidad: con batches de 1 la utilización sube aunque el throughput esté lejos del máximo. Por eso C01 escala por la cola, no por la utilización.classifier_request_latency_seconds). Y antes de producción, perf_analyzer mide el p99 de una GPU a 250 y 500 req/s (el gate de latencia de C01) y su capacidad máxima con la rampa.Cómo recordarla
trtexec mide el motor, perf_analyzer el servidor, Locust o k6 el servicio. En producción, dos cocientes: inferencias ÷ ejecuciones = batch promedio; µs en cola ÷ requests = espera media.
Trampa
medir con carga cerrada y quedarte con su p99. Cuando el servidor se frena, los clientes virtuales también: los peores momentos casi no se miden y el p99 sale mejor que el real (coordinated omission). El máximo se busca con --concurrency-range; la latencia a una tasa, con --request-rate-range.
En el curso: M3 · Load testing: cómo se mide de verdad M3 · Utilización y colas: por qué no se planea al 100 % C01 · Monitoreo, alertas y reentrenamiento M12 · Prometheus, Grafana y alertas por SLO Números · Throughput: qué medir y con qué Hoja · Formulario de estimaciones
Sin laboratorio: necesita una GPU NVIDIA.
¿Dónde conviertes los bytes de la foto en el tensor que espera el modelo, y los logits (los puntajes crudos del modelo) en la respuesta, sin que el servicio vea algo distinto de lo que vio el entrenamiento?
Sintaxis con variables
from app.preprocess import preprocess # el MISMO archivo en entrenamiento, eval y servicio
batch = preprocess(bytes, max_pixels=tope)[np.newaxis] # float32 [1, 3, 224, 224]
inp = InferInput("entrada", list(batch.shape), "FP32")
inp.set_data_from_numpy(batch, binary_data=True) # bytes crudos, no floats en JSONPreprocesamiento (preprocessing: convertir los bytes de la foto en el tensor que espera el modelo). En C01 vive en un solo archivo, preprocess.py, que importan el entrenamiento, la evaluación y el gateway. Nombre, shape y dtype (el tipo de número) tienen que coincidir con config.pbtxt.
logits = result.as_numpy("salida")[0] # [500]: logits crudos, sin softmax
probs = _softmax(logits) # en float64 y restando el máximo: no desborda
top = np.argsort(probs)[::-1][:k] # los k índices más probables
preds = [{"label": labels[i], "score": round(float(probs[i]), 4)} for i in top]Postprocesamiento (postprocessing: convertir la salida del modelo en la respuesta del producto). C01 devuelve logits y hace en el gateway el softmax (logits → probabilidades), el top-k (las k más altas) y los nombres: así puede calibrar scores o cambiar el top-k sin tocar el engine.
name: "pipeline"
platform: "ensemble"
max_batch_size: n
input [ { name: "IMAGE", data_type: TYPE_UINT8, dims: [ -1 ] } ] # los bytes del JPEG
output [ { name: "LOGITS", data_type: TYPE_FP32, dims: [ clases ] } ]
ensemble_scheduling {
step [
{ model_name: "preproceso"
model_version: -1
input_map { key: "RAW" value: "IMAGE" }
output_map { key: "TENSOR" value: "preprocessed" } },
{ model_name: "modelo"
model_version: -1
input_map { key: "input" value: "preprocessed" }
output_map { key: "logits" value: "LOGITS" } }
]
}Un ensemble (un modelo de Triton que no ejecuta nada: encadena otros modelos) recibe el JPEG y devuelve logits en una sola llamada. En cada input_map/output_map, la key es el tensor del paso y el value, el tensor dentro del ensemble. No lleva instance_group: cada paso escala con el suyo y conserva su propia cola.
import numpy as np
import triton_python_backend_utils as pb_utils
from app.preprocess import preprocess # el mismo archivo que usa el entrenamiento
class TritonPythonModel:
def execute(self, requests):
responses = []
for request in requests:
raw = pb_utils.get_input_tensor_by_name(request, "RAW").as_numpy() # [1, n] UINT8
x = preprocess(raw[0].tobytes())[np.newaxis] # [1, 3, 224, 224] FP32
responses.append(pb_utils.InferenceResponse(output_tensors=[pb_utils.Tensor("TENSOR", x)]))
return responsesEl backend de Python (Triton corre tu model.py; cada instancia corre en un proceso aparte y le pasa los tensores a Triton por memoria compartida, /dev/shm) sería el paso preproceso del ensemble, con backend: "python" en su config. Corre en la CPU del nodo con GPU, y lo que importe (Pillow, tu paquete) tiene que existir en su entorno.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| bytes | los bytes crudos de la foto, tal como llegan en el body | ≈ 150–250 KB desde el app móvil |
| tope | máximo de píxeles que aceptas, leído del header antes de decodificar | 40_000_000 (40 MP) |
| entrada · salida | nombres de los tensores: los fija el export y los repite config.pbtxt | input · logits |
| k | cuántas sugerencias devuelves | 5 (máximo 20 en C01) |
| pipeline · preproceso · modelo | nombres de modelos (carpetas) del model repository | pipeline · preprocess · classifier |
| n | max_batch_size del ensemble | 32 |
| clases | largo del vector de salida | 500 |
| valor de un píxel, de 0 a 255 | 200 | |
| media y desvío de ImageNet del canal c, en orden RGB | R: 0.485 y 0.229 | |
| requests por segundo | 500 en el pico de diseño | |
| CPU por imagen: decode, resize y normalizar | ≈ 6 ms con la mezcla de fotos de C01 | |
| bytes del tensor que viaja del gateway a Triton | 602 KB en FP32 | |
| logit y probabilidad de la clase i | restar el máximo evita que exp() desborde |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| Gateway en CPU (C01) | Un servicio aparte (el gateway: la API en FastAPI que va delante de Triton), en nodos baratos, valida y decodifica la foto y arma el tensor; Triton solo ve tensores. Escala por CPU con su HPA (el autoscaler de pods de Kubernetes). | FastAPI + preprocess.py, HPA al 60 % de CPU |
| Ensemble + backend de Python | Triton recibe el JPEG y lo convierte con tu model.py: una sola llamada, pero el decode usa la CPU del nodo con GPU. | g4dn.xlarge: 4 vCPU, y Triton ya pide 2 |
| Ensemble + DALI | DALI (la librería de NVIDIA para cargar imágenes en la GPU) decodifica y hace el resize en la GPU (backend: "dali", archivo model.dali). Libera CPU, pero es otro decoder. | decoder con device="mixed" |
| Todo en un proceso | El MVP (la primera versión, la más simple): decode y modelo en el mismo proceso de FastAPI + PyTorch. CPU y GPU escalan juntas. | el MVP de C01, una VM g4dn |
| Normalización dentro del grafo | El modelo recibe UINT8 de 0 a 255 y normaliza adentro: 4× menos bytes por la red. Obliga a reexportar y repetir la paridad. | 602 KB → 151 KB por foto |
| Training-serving skew | El servicio prepara la entrada distinto que el entrenamiento: el modelo ve otra distribución y pierde accuracy, sin ningún error. | accuracy online menor que la offline |
| RGB vs BGR | OpenCV lee en BGR; Pillow y torchvision, en RGB. Los canales al revés no dan error. | cv2.imread entrega BGR |
| Resize + center crop | Resize(256) lleva el lado corto a 256 y CenterCrop(224) recorta el centro; un resize directo a 224 × 224 deforma la foto. | Resize((224, 224)) ≠ Resize(256) + CenterCrop(224) |
| Antialias | Al achicar, Pillow suaviza siempre; F.interpolate no, salvo antialias=True. En torchvision 0.29, Resize trae antialias=True por defecto. | F.interpolate(..., mode="bilinear") |
| EXIF | Las fotos de celular guardan la rotación aparte, en sus metadatos EXIF: sin exif_transpose, el modelo las ve de lado. | ImageOps.exif_transpose |
| HWC vs CHW | Una imagen de Pillow u OpenCV pasada a NumPy queda en alto × ancho × canales (HWC); el modelo espera los canales primero (CHW). Este sí falla con un error. | x.transpose(2, 0, 1) |
| Draft mode de JPEG | El decoder reduce la resolución mientras decodifica: mucho menos CPU y píxeles casi iguales. | solo si el lado corto > 1024 px, sin bajar de 512 |
| Softmax y top-k | El postproceso: logits → probabilidades → las k más altas, con su nombre. | labels.json: 500 nombres, índice = id de clase |
| labels.json desalineado | Si el orden de los nombres no es el del entrenamiento, todo responde 200 con la categoría equivocada. | una falla silenciosa de C01 |
Gateway en CPUelegido en C01
Viaja a Triton: tensor FP32: 602 KB por foto
A favor: Escala por CPU con nodos baratos (HPA al 60 %); aísla los bytes no confiables lejos de la GPU; usa el mismo preprocess.py que el entrenamiento.
En contra: Viaja el tensor, 3× el JPEG del celular: obliga a mandar cada request al Triton de su zona.
Ensemble + backend de Pythondepende
Viaja a Triton: el JPEG: ≈ 150–250 KB desde el celular
A favor: Una sola llamada a Triton; viaja el JPEG; preproceso y modelo se versionan juntos.
En contra: Usa la CPU del nodo con GPU: ≈ 3 cores de decode en el pico contra 4 vCPU, con Triton ya pidiendo 2.
Ensemble + DALIa 10× el tráfico, o al reclasificar el catálogo (backfill)
Viaja a Triton: el JPEG
A favor: Libera CPU: el JPEG se decodifica en la GPU; rinde a throughput muy alto.
En contra: Otro decoder, distinto del de entrenamiento (riesgo de skew), y tiempo de GPU.
Todo en un proceso (MVP)el MVP de C01
Viaja a Triton: nada: no hay salto de red
A favor: Lo más simple: sin red ni serialización entre servicios.
En contra: CPU y GPU escalan juntas; el decode compite con el proceso que alimenta la GPU.
Gateway + normalización en el grafocuando la red lo pida
Viaja a Triton: tensor UINT8: 151 KB por foto
A favor: 4× menos bytes por la red que en FP32.
En contra: Reexportar el modelo con la normalización adentro y repetir la paridad.
KB que viajan por la red hasta Triton, a escalala que eligió el C01
# gateway/app/preprocess.py (extracto)
RESIZE_SHORT = 256 # lado corto tras el resize: el mismo número que Resize(256) en training
CROP = 224 # tamaño del center crop: el input del ResNet-50 (y del engine TensorRT)
# mean/std de ImageNet con forma (3,1,1) para hacer broadcasting sobre CHW sin copiar
MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(3, 1, 1)
STD = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(3, 1, 1)
# …
def to_chw_normalized(img: Image.Image) -> np.ndarray:
"""Imagen RGB 224×224 → float32 [3,224,224] normalizado (ToTensor + Normalize)."""
x = np.asarray(img, dtype=np.float32) / 255.0 # HWC en [0, 1], igual que ToTensor
x = x.transpose(2, 0, 1) # HWC → CHW: el layout que espera el modelo de PyTorch/TensorRT
x = (x - MEAN) / STD # normalización por canal con las estadísticas de ImageNet
# contiguous: el cliente de Triton serializa el buffer tal cual; un view transpuesto se copiaría
return np.ascontiguousarray(x, dtype=np.float32)
# gateway/app/main.py (extracto)
def _decode_and_hash(data: bytes) -> tuple[np.ndarray, str]:
"""CPU pura (bloqueante): corre en el threadpool. Pillow y numpy sueltan el GIL en decode y resize."""
digest = hashlib.sha256(data).hexdigest() # huella de la imagen: deduplicar y cruzar con labels
tensor = preprocess(data, max_pixels=MAX_PIXELS) # float32 [3,224,224], idéntico a training/eval
return tensor[np.newaxis], digest # batch de 1: Triton junta requests con dynamic batchingpreprocess.py para entrenamiento, evaluación y gateway: el skew no se evita con cuidado, se evita con un solo archivo. Contra torchvision 0.29 con Pillow 12.3 da una diferencia de 0.0 cuando no se activa el draft mode (lo miden el docstring del archivo y el lab de abajo).config.pbtxt: input, FP32, [3, 224, 224]. El gateway agrega el eje de batch ([np.newaxis]) y manda una foto por request: el batching lo hace Triton.np.ascontiguousarray: el transpose no mueve datos; deja una vista (view), los mismos números leídos en otro orden. tritonclient la copiaría igual al serializar (tobytes()); hacer la copia aquí, contigua y en FP32 explícito, deja el tensor listo y con el tipo del contrato.PREPROCESS_CONCURRENCY). Es CPU pura: dentro del event loop (el hilo que atiende todos los requests del proceso) bloquearía a los demás.Cómo recordarla
un solo preprocess.py, y lo de CPU en CPU barata. El preproceso se escribe una vez y lo importan entrenamiento, evaluación y servicio; vive donde sobra CPU (el gateway), y Triton solo ve tensores.
Trampa
reescribir el preproceso en el servicio «igual que en el notebook». BGR en vez de RGB, un resize directo a 224 × 224, F.interpolate sin antialias o la normalización aplicada dos veces no lanzan ningún error: todo responde 200 y la accuracy online cae. La paridad del modelo (PyTorch contra el engine, con el mismo tensor) tampoco lo ve: para eso está la paridad end-to-end, con fotos de referencia por el gateway real.
En el curso: C01 · Serving C01 · Estimaciones back-of-the-envelope M7 · Protocolos: REST, gRPC, KServe v2 y OpenAI-compatible M7 · NVIDIA Triton Inference Server M9 · Training-serving skew: causas, detección y prevención C01 · Modos de falla y degradación
Qué vas a provocar: Comparar el preproceso de C01 contra el de torchvision y contra cuatro versiones «casi iguales», y ver que el skew cambia la entrada y la salida del modelo sin lanzar un solo error.
Necesitas: Tu Mac (CPU) y el venv de model/requirements.txt de C01 (Python 3.13); los pasos se corren desde la raíz del repo. El ResNet-50 usa pesos al azar: no descarga pesos.
cd apps/ml-system-design/reference/c01 && python3.13 -m venv .venv && . .venv/bin/activate && pip install -r model/requirements.txtQué deberías ver: Termina con Successfully installed … (torch 2.14.0, torchvision 0.29.0, pillow 12.3.0, numpy 2.5.3, onnx, onnxruntime, entre otras). Es el entorno del job de export y paridad de C01; sirve también para los labs de optimizar y de errores.
mkdir -p ~/lab-triton && cat > ~/lab-triton/skew_lab.py <<'EOF'
import io, sys
import numpy as np, torch, torch.nn.functional as F, torchvision
from PIL import Image
from torchvision import transforms as T
sys.path.insert(0, sys.argv[1]) # la carpeta gateway/ de C01
from app.preprocess import preprocess, MEAN, STD # el preproceso real de C01
tv = T.Compose([T.Resize(256), T.CenterCrop(224), T.ToTensor(),
T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])]) # el del notebook
rgb = lambda b: Image.open(io.BytesIO(b)).convert("RGB")
def chw(a): return ((np.asarray(a, np.float32) / 255).transpose(2, 0, 1) - MEAN) / STD
def interp(b): # resize a mano con F.interpolate (antialias=False)
x = torch.from_numpy(np.asarray(rgb(b), np.float32) / 255).permute(2, 0, 1)
h, w = x.shape[1:]; nh, nw = (int(256 * h / w), 256) if w <= h else (256, int(256 * w / h))
x = F.interpolate(x[None], size=(nh, nw), mode="bilinear")[0]
t, l = int(round((nh - 224) / 2)), int(round((nw - 224) / 2))
return (x[:, t:t + 224, l:l + 224].numpy() - MEAN) / STD
variantes = {"preprocess.py de C01": preprocess,
"BGR en vez de RGB": lambda b: preprocess(b)[::-1].copy(),
"Resize((224, 224)) directo": lambda b: chw(rgb(b).resize((224, 224), Image.Resampling.BILINEAR)),
"F.interpolate sin antialias": interp,
"sin Normalize": lambda b: preprocess(b) * STD + MEAN}
def foto(h, w, rng): # JPEG sintético, como parity_check.py
y, x = np.mgrid[0:h, 0:w]
img = np.stack([x / w, y / h, (x + y) / (w + h)], -1) * 255 * rng.random(3) + rng.normal(0, 25, (h, w, 3))
buf = io.BytesIO(); Image.fromarray(np.clip(img, 0, 255).astype(np.uint8)).save(buf, "JPEG", quality=90)
return buf.getvalue()
rng = np.random.default_rng(0)
fotos = [foto(*rng.integers(300, 1025, size=2), rng) for _ in range(32)] # lado corto ≤ 1024: sin draft
torch.manual_seed(0)
model = torchvision.models.resnet50(weights=None, num_classes=500).eval() # pesos al azar: sin descargas
ref = np.stack([tv(rgb(b)).numpy() for b in fotos])
with torch.inference_mode():
ref_logits = model(torch.from_numpy(ref)).numpy()
print(f"{'32 fotos de hasta 1024 px':<28}{'max|Δx|':>9}{'media|Δx|':>11}{'max|Δlogit|':>13}")
for nombre, f in variantes.items():
x = np.stack([f(b) for b in fotos]).astype(np.float32)
with torch.inference_mode():
d = np.abs(model(torch.from_numpy(x)).numpy() - ref_logits).max()
print(f"{nombre:<28}{np.abs(x - ref).max():>9.3f}{np.abs(x - ref).mean():>11.4f}{d:>13.3f}")
g = foto(3000, 4000, rng) # 12 MP: preprocess.py activa el draft mode
dx = np.abs(preprocess(g) - tv(rgb(g)).numpy())
print(f"foto de 12 MP con draft mode: max|Δx| {dx.max():.3f} · media|Δx| {dx.mean():.4f}")
EOFQué deberías ver: Sin salida: el script queda en ~/lab-triton/skew_lab.py. Genera 32 JPEG sintéticos de hasta 1024 px y uno de 12 MP; no descarga nada.
python ~/lab-triton/skew_lab.py gatewayQué deberías ver: Una tabla. preprocess.py de C01: 0.000 · 0.0000 · 0.000 (idéntico a torchvision). BGR en vez de RGB: ≈ 3.553 · 0.5914 · 12.956. Resize((224, 224)) directo: ≈ 1.882 · 0.1803 · 8.630. F.interpolate sin antialias: ≈ 1.060 · 0.0871 · 6.675. sin Normalize: ≈ 2.118 · 1.1401 · 49.126. Y la foto de 12 MP con draft mode: max|Δx| ≈ 0.035 · media ≈ 0.0055. Ninguna variante lanza un error. Tarda ≈ 15 s en una Mac M4.
python -c "import torch, torchvision; torchvision.models.resnet50(num_classes=500).eval()(torch.zeros(1, 224, 224, 3))"Qué deberías ver: RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[1, 224, 224, 3] to have 3 channels, but got 224 channels instead. El layout HWC sí falla con un error; los cuatro skews de la tabla, no.
Cómo arreglarlo y confirmarlo
Si tu preproceso no da 0.000 en la primera fila contra el transform del entrenamiento, compáralo paso a paso (orden de canales, resize, crop, normalización, dtype) hasta que dé 0. Con pesos al azar, casi todas las fotos caen en la misma clase y el top-1 no significa nada: mira |Δlogit|. Con tu checkpoint, mide el acuerdo top-1 con parity_check.py.
Probado de verdad en un Mac, sin GPU.
Tu engine ya sirve: ¿qué optimizas primero, cuánto compra cada paso y cuándo conviene no usar una GPU?
Sintaxis con variables
histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[5m])))
histogram_quantile(0.99, sum by (le) (rate(classifier_inference_seconds_bucket[5m])))
sum(rate(nv_inference_queue_duration_us{model="modelo"}[5m])) / sum(rate(nv_inference_request_success{model="modelo"}[5m]))
sum(rate(nv_inference_compute_infer_duration_us{model="modelo"}[5m])) / sum(rate(nv_inference_request_success{model="modelo"}[5m]))Mide antes de optimizar. Las dos primeras son el p99 (la latencia que solo supera el 1 % de los requests) del preproceso y de la llamada a Triton, medidos en el gateway; las dos últimas, la espera en cola y el cómputo promedio por request dentro de Triton, en µs. Optimizar el modelo solo mueve el último número.
# TensorRT 10 (NGC 24.08, la de C01) trtexec --onnx=onnx --saveEngine=model.plan --fp16 \ --minShapes=input:1x3x224x224 --optShapes=input:optx3x224x224 --maxShapes=input:32x3x224x224 # TensorRT 11 (NGC 26.06 en adelante): la precisión va en el ONNX python -m modelopt.onnx.autocast --onnx_path onnx --output_path onnx_fp16 --keep_io_types
El paso de más impacto en C01: TensorRT (un compilador que convierte el ONNX en un engine para una GPU concreta) en FP16 da varias veces el throughput de PyTorch eager (el modo normal, operación por operación) en FP32 en la T4, casi sin tocar la calidad. Cada release de NGC (las imágenes de NVIDIA, YY.MM) trae su TensorRT. En TensorRT 11 ya no existe --fp16: AutoCast, de NVIDIA ModelOpt, convierte el ONNX a precisión mixta (FP16 donde se puede) y --keep_io_types deja la entrada y la salida en FP32, como pide config.pbtxt.
quant_pre_process("onnx", "onnx_pre")
quantize_static("onnx_pre", "onnx_int8", lector,
quant_format=QuantFormat.QDQ, activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8, per_channel=True,
calibrate_method=CalibrationMethod.Percentile)INT8 (enteros de 8 bits con una escala por tensor o por canal) con ONNX Runtime: ≈ 4× menos bytes que FP32; el ONNX de C01 baja de 98 MB a ≈ 25 MB. El lector de calibración (calibration reader) entrega fotos reales pasadas por el mismo preprocess.py. TensorRT solo acepta INT8 simétrico (el 0 real se guarda como 0): con ONNX Runtime agrega extra_options={"ActivationSymmetric": True}, o cuantiza con ModelOpt.
opciones = ort.SessionOptions()
opciones.intra_op_num_threads = vcpus # los vCPU del pod, no los cores del nodo
opciones.inter_op_num_threads = 1
sesion = ort.InferenceSession("onnx_int8", opciones, providers=["CPUExecutionProvider"])
logits = sesion.run(["logits"], {"input": batch})[0]La alternativa sin GPU: el mismo ONNX en ONNX Runtime (ORT), en CPU. Fija los hilos con el límite de CPU del pod: si no, ORT usa tantos hilos como cores físicos ve en el nodo (no lee el límite del contenedor) y pelea por tu cuota.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modelo | nombre del modelo en Triton (el label model de las métricas) | classifier |
| onnx · onnx_fp16 · onnx_pre · onnx_int8 | el ONNX FP32 y sus variantes | model/classifier.onnx · model/classifier_int8.onnx |
| opt | batch para el que TensorRT afina el engine | 16 en C01; compáralo con 4 |
| lector | tu CalibrationDataReader: entrega una foto preprocesada por llamada | ≈ 500–1,000 fotos reales |
| vcpus | vCPU del pod (su límite), no los cores del nodo | 4 |
| fracción del tiempo total que es cómputo del modelo | ≈ 0.05–0.10 en el p99 de C01 | |
| cuántas veces más rápido queda el modelo | 2 | |
| cuántas veces más rápido queda el request completo | ≈ 1.03–1.05: el p99 baja ≈ 3–5 % | |
| requests por segundo a atender | 500 en el pico de diseño | |
| CPU por imagen, medida en la instancia real | INT8 ≈ 8–25 ms; FP32 ≈ 40–80 ms (estimaciones de C01) | |
| utilización objetivo del HPA (el autoscaler de pods) | 0.6 | |
| máximo medido por GPU con perf_analyzer (la prueba de carga contra Triton) | ≈ 1,200 img/s por T4 (supuesto de C01, a medir) |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| TensorRT FP16 | Compila el ONNX para la T4 en FP16, que usa sus tensor cores (las unidades que multiplican matrices en baja precisión): varias veces el throughput de PyTorch eager FP32. Riesgo: algún top-1 cambia en casos límite. | sí en C01, con paridad (top-1 ≥ 99.5 %) |
| INT8 | ≈ 1.5–2× más sobre FP16 en GPU. Necesita calibración con datos representativos y pierde más en el long tail (las clases con pocas fotos). | no en C01: la GPU trabaja al ≈ 5–20 %; revisar a 10× |
| Engine construido en CI sobre T4 | Construirlo en el pipeline de CI (el que corre en cada cambio), en una T4, no acelera nada: evita que Triton no pueda cargar el modelo en producción. | misma GPU (sm_75: la arquitectura de la T4) y misma release de NGC (24.08) |
| Downscale en el cliente (≤ 1024 px) | El celular achica la foto antes de subirla: menos bytes y menos CPU de decode; sin riesgo si el lado corto sigue ≥ 256. | el app móvil manda ≈ 150–250 KB |
| JPEG draft mode conservador | El decoder reduce la foto mientras decodifica: un JPEG de 12 MP baja de ≈ 40 a ≈ 18 ms de CPU (medido en una laptop). | solo si el lado corto > 1024 px, sin bajar de 512 |
| Threadpool para el trabajo de CPU | El event loop (el hilo que atiende todos los requests) sigue con la red mientras otro hilo decodifica. Es corrección, no optimización. | run_in_threadpool + 2 decodes por proceso |
| Entrada FP16 o UINT8 con normalización en el grafo | 2× o 4× menos bytes entre el gateway y Triton. Obliga a reexportar y a repetir la paridad. | después, si la red o el costo entre zonas lo pide |
| ONNX Runtime en CPU (FP32) | El mismo ONNX sin GPU: un servicio menos, sin engine atado a la T4 ni la espera de levantar un nodo GPU (cold start). | ≈ 40–80 ms por imagen por vCPU: ≈ 20–40 vCPU ocupados en el pico |
| INT8 en CPU | Pesos y activaciones en INT8 con ONNX Runtime u OpenVINO: el candidato serio de C01 para quitar la GPU. | ≈ 8–25 ms por imagen por vCPU, a medir en la semana 1 |
| VNNI · AMX | Instrucciones de CPU que aceleran INT8: VNNI desde Cascade Lake (la c6i, Ice Lake, lo trae) y AMX desde Sapphire Rapids (c7i). Sin ellas, INT8 puede ser más lento que FP32. | en Arm, las de producto punto (Graviton2 en adelante) |
| intra_op_num_threads | Hilos de ORT dentro de cada operador. Con 0, ORT usa uno por core físico que ve: los del nodo, no los de tu límite. | fíjalo al límite de CPU del pod |
| Procesos × hilos | Más procesos dan más throughput total; más hilos por proceso, menos latencia por request. | FP32 en la Mac M4 del curso: 4 × 1 hilo ≈ 86 img/s; 1 × 4 hilos ≈ 61 img/s |
Medir dónde se va el tiempo
histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[5m])))
Si el cómputo es el 5–10 % del p99, un modelo 2× más rápido baja el p99 ≈ 3–5 %.
Menos trabajo por request
img.draft("RGB", (DRAFT_MIN_SHORT, DRAFT_MIN_SHORT))
Caché, cascadas (un modelo barato primero y el caro solo si hace falta), menos píxeles. En C01, el draft mode ahorra en una foto web tanto como todo el cómputo de la GPU.
Batching e instancias
dynamic_batching { max_queue_delay_microseconds: 2000 }
Sin tocar el modelo ni la calidad. Rinde cuando hay tráfico para llenar los batches; en C01 llegan ≈ 1–3 por batch.
Runtime compilado, misma precisión
trtexec --onnx=model/classifier.onnx --saveEngine=model.plan
Fusiona capas y elige kernels (las rutinas que corren en la GPU) para esa GPU. El riesgo es un bug de export: lo atrapa la paridad.
Menos precisión
trtexec … --fp16 (TensorRT 10) · python -m modelopt.onnx.autocast --onnx_path model/classifier.onnx --keep_io_types (TensorRT 11)
FP16 casi nunca cambia una CNN. INT8 necesita calibración y un gate por slices.
Un modelo más chico
torchvision.models.mobilenet_v3_large(num_classes=500)
Destilación (un modelo chico que aprende a imitar al grande) o pruning (quitar pesos): la ganancia más grande y el costo más alto (entrenar de nuevo y mantenerlo).
Aparte: sin GPU (un servicio menos)
ort.InferenceSession("classifier_int8.onnx", opciones, providers=["CPUExecutionProvider"])
Si INT8 en CPU da ≈ 15 ms por imagen por vCPU sin perder más de 0.5 puntos de accuracy, son ≈ 13 vCPU en el pico y desaparece el servicio con GPU.
Cómputo GPU en el p99 8–15 ms de ≈ 150 ms (5–10 %)
Modelo 2× más rápido p99 ≈ 142–146 ms: ≈ 3–5 % menos
GPUs hoy (FP16, 600 img/s plan) ⌈500 / 600⌉ + 1 = 2 T4
GPUs con INT8 (900–1,200 plan) ⌈500 / 900⌉ + 1 = 2 T4 → ahorro US$0
A 10× (5,000 req/s), FP16 ⌈5,000 / 600⌉ + 1 = 10 T4
A 10×, INT8 6–7 T4 → ≈ US$1,150–1,540/mes menos
Sin GPU: INT8 en CPU (HPA al 60 %) ≈ 7–21 vCPU en el pico; ≈ US$125–250/mes contra ≈ US$768 de 2 T4Cómo recordarla
mide, después lo barato, al final lo arriesgado. Presupuesto por tramo → menos trabajo → batching → runtime compilado → menos precisión → modelo más chico. Optimizas el modelo cuando cambia el número de máquinas, elimina un servicio o hace que algo quepa.
Trampa
«le pongo INT8 para bajar la latencia» en un sistema donde el modelo es el 10 % del p99 y las GPUs están al 5 %. No baja el p99, no ahorra una GPU y agrega riesgo en el long tail. Y no midas la ganancia con trtexec a batch 32 si online llegan batches de 1–3: el número que decide sale de perf_analyzer contra Triton, con la carga real.
En el curso: C01 · Serving C01 · Estimaciones back-of-the-envelope M8 · Por qué y cuándo optimizar M8 · El mapa de técnicas M8 · Cuantización: FP16, INT8, FP8 e INT4 M8 · Inferencia en CPU: ONNX Runtime y OpenVINO M8 · Cómo validar una optimización M4 · CPU vs GPU vs TPU vs aceleradores
Qué vas a provocar: Exportar el ResNet-50 de C01, cuantizarlo a INT8 con ONNX Runtime y medir ms por imagen en CPU, FP32 contra INT8, para convertirlo en vCPU con la fórmula.
Necesitas: Tu Mac (CPU), Python 3.13 y el venv de model/requirements.txt de C01 (torch 2.14, onnx 1.23, onnxscript 0.7.2, onnxruntime 1.30): unos 2 GB de disco; los pasos se corren desde la raíz del repo. Pesos al azar: mide velocidad y tamaño, no calidad.
cd apps/ml-system-design/reference/c01 && . .venv/bin/activateQué deberías ver: Queda activo el venv de C01, el del lab de pre y postprocesamiento. Si no lo hiciste: python3.13 -m venv .venv && . .venv/bin/activate && pip install -r model/requirements.txt.
python model/export_onnx.py --out model/classifier.onnxQué deberías ver: [model] AVISO: sin --checkpoint → pesos aleatorios…, [export] opset=17 input=input['batch', 3, 224, 224] output=logits, dos líneas de verificación (batch= 1 y batch=32, max|Δlogit| del orden de 1e-4, top1_igual=100%) y [export] OK. El archivo pesa ≈ 98 MB.
mkdir -p ~/lab-triton && cat > ~/lab-triton/cuantizar.py <<'EOF'
import sys
sys.path.insert(0, "model")
from onnxruntime.quantization import (CalibrationDataReader, CalibrationMethod, QuantFormat,
QuantType, quantize_static)
from onnxruntime.quantization.shape_inference import quant_pre_process
from parity_check import load_images # JPEG sintéticos pasados por el preprocess.py de C01
class Lector(CalibrationDataReader):
def __init__(self):
self.fotos = iter(load_images(None, 64)) # para practicar; en serio: ≈ 500–1,000 fotos reales
def get_next(self):
x = next(self.fotos, None)
return None if x is None else {"input": x[None]} # [1, 3, 224, 224] float32
quant_pre_process("model/classifier.onnx", "model/classifier_pre.onnx")
quantize_static("model/classifier_pre.onnx", "model/classifier_int8.onnx", Lector(),
quant_format=QuantFormat.QDQ, activation_type=QuantType.QInt8,
weight_type=QuantType.QInt8, per_channel=True,
calibrate_method=CalibrationMethod.Percentile)
EOF
python ~/lab-triton/cuantizar.py && ls -lh model/classifier.onnx model/classifier_int8.onnxQué deberías ver: Puede imprimir advertencias de ONNX Runtime durante la calibración. ls -lh cuenta en MiB: ≈ 94M para classifier.onnx (≈ 98 MB) y ≈ 24M para classifier_int8.onnx (≈ 25 MB; 25.1 MB en la medición del curso): 4× menos bytes.
cat > ~/lab-triton/bench.py <<'EOF'
import time
import numpy as np
import onnxruntime as ort
x = np.random.default_rng(0).standard_normal((1, 3, 224, 224), dtype=np.float32)
for f in ("model/classifier.onnx", "model/classifier_int8.onnx"):
for hilos in (1, 4):
o = ort.SessionOptions()
o.intra_op_num_threads, o.inter_op_num_threads = hilos, 1
s = ort.InferenceSession(f, o, providers=["CPUExecutionProvider"])
for _ in range(10):
s.run(["logits"], {"input": x}) # warmup
t0 = time.perf_counter()
for _ in range(100):
s.run(["logits"], {"input": x})
ms = (time.perf_counter() - t0) * 10 # 100 corridas → ms por imagen
vcpu = 500 * ms * hilos / 1000 / 0.6 # CPU por imagen = hilos × ms; HPA al 60 %
print(f"{f:<28} {hilos} hilo(s): {ms:5.1f} ms/img · vCPU a 500 req/s: {vcpu:5.1f}")
EOF
python ~/lab-triton/bench.pyQué deberías ver: Cuatro líneas. En la Mac M4 del curso (ORT 1.30): FP32 ≈ 20 ms con 1 hilo y ≈ 15–18 ms con 4; INT8 ≈ 21 ms con 1 hilo y ≈ 8 ms con 4. Con 1 hilo, INT8 no gana: ahí el FP32 usa la unidad de matrices de la M4 (SME) y el INT8 no. La columna vCPU es la fórmula de arriba con la CPU por imagen (hilos × ms).
Cómo arreglarlo y confirmarlo
Si INT8 no gana, no es un error del lab: la razón INT8/FP32 depende de la CPU. Para decidir C01 se mide en una c6i o c7i con el límite de CPU del pod, y la calidad, con el checkpoint real por slices del golden set (el set de evaluación fijo de C01; con pesos al azar no hay calidad que medir).
Verificado contra la documentación; no ejecutado aquí.
Triton no carga, responde con error, va lento o predice distinto: ¿qué miras primero, qué después, y cómo partes el problema en dos?
Sintaxis con variables
curl -fsS host:8000/v2/health/live && echo vivo curl -fsS host:8000/v2/health/ready && echo listo curl -fsS host:8000/v2/models/modelo/ready && echo modelo listo
¿Vive? ¿Está listo? Triton responde 200 si sí y 400 si no (503 mientras se apaga); con -f, curl falla con el error 22 en vez de imprimir el echo. Si ni siquiera conecta (error 7), Triton todavía no escucha: con --model-control-mode=none (C01) abre los puertos recién después de cargar los modelos. Con --strict-readiness=true (C01), el ready del servidor exige que todos los modelos estén listos; el del modelo dice si esa versión cargó y terminó su warmup (las inferencias de prueba).
kubectl logs pod -n ns --previous | grep -E "failed to load|UNAVAILABLE|error: creating server" curl -s -X POST host:8000/v2/repository/index curl -s -d '{"log_verbose_level":1}' -X POST host:8000/v2/logging
¿Qué no cargó y por qué? Con --exit-on-error=true (C01), Triton sale si un modelo no carga (queda UNAVAILABLE): el motivo queda en el log del intento anterior. Si Triton sigue arriba, el índice del repository (POST /v2/repository/index) da state y reason por modelo, y la extensión de logging sube el detalle en caliente (o arranca con --log-verbose=1).
curl -s host:8000/v2/models/modelo # nombres, datatype y shape de entradas y salidas curl -s host:8000/v2/models/modelo/config # el config.pbtxt que cargó, en JSON
¿El contrato coincide? Compara lo que manda el cliente (nombre, shape con el eje de batch, dtype: el tipo de número) con la metadata (la descripción de entradas y salidas que publica Triton). Casi todos los 400 de inferencia salen de aquí.
curl -s host:8000/v2/models/modelo/stats # cola, cómputo y ejecuciones por tamaño de batch curl -s host:8002/metrics | grep -E "^nv_inference_(queue|compute_infer)_duration_us" nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total --format=csv -l 1
¿Dónde se va el tiempo? Cola contra cómputo en Triton, preproceso contra inferencia en el gateway, y la GPU. Si el problema son las predicciones y no la velocidad, el paso es la paridad: ONNX Runtime contra Triton con parity_check.py.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| host | dónde escucha Triton; en el cluster, a través de un port-forward (un túnel de kubectl a tu máquina) | localhost |
| modelo | nombre del modelo (la carpeta del repository) | classifier |
| pod · ns | pod de Triton y su namespace de Kubernetes | triton-5f6b…-7tq2m · classifier |
| preproceso y postproceso en el gateway | preprocess_ms en el log JSON de C01 | |
| ida y vuelta de la red y la serialización | ≈ 2–4 ms en p50 (la mediana) para 602 KB binarios | |
| espera en la cola de Triton y ejecución del modelo | ≤ 2 ms de ventana · 3–6 ms en p50 | |
| aumento de nv_inference_queue_duration_us (µs acumulados en cola) | rate(…[5m]) | |
| aumento de nv_inference_request_success | rate(…[5m]) | |
| aumento de nv_inference_count (inferencias) y nv_inference_exec_count (ejecuciones) | su cociente es el batch promedio | |
| lo que nvidia-smi llama utilización: tiempo con kernels (programas de la GPU) corriendo, no capacidad usada | con batches de 1 marca mucho más que la carga: C01 espera 10–35 % con ≈ 5 % de carga |
Fórmulas
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| CrashLoopBackOff al arrancar Triton | Un modelo no cargó (engine de otra versión o de otra GPU, config que no coincide, ninguna GPU visible) y --exit-on-error=true cerró el servidor con exit 1. Kubernetes lo reinicia una y otra vez, cada vez esperando más. | error: creating server: Internal - failed to load all models |
| Running pero READY 0/1 | Todavía carga el engine (el modelo compilado) o hace el warmup, y Triton aún no abre el puerto; o, con --exit-on-error=false, un modelo quedó UNAVAILABLE y el ready da 400. | Startup probe failed: … connection refused |
| 400 en /infer | El request no coincide con el contrato: nombre, shape, dtype o batch. | unexpected shape for input 'input' |
| 503 de Triton | La cola rechazó por tiempo o por tamaño, el modelo no está listo, o el servidor se está apagando. | Request timeout expired |
| 504 del gateway | Triton tardó más que el timeout del cliente (150 ms en C01): cola llena o GPU saturada. | timeout de inferencia |
| p99 alto con cómputo estable | El p99 (la latencia del 1 % más lento) sube, pero el tiempo está fuera del modelo: preproceso, red u overhead de Python. | classifier_preprocess_seconds sube |
| La cola de Triton crece | La GPU está cerca de saturarse: faltan réplicas o un modelo más barato. | alerta TritonQueueTimeHigh |
| GPU al 100 % con poco throughput | nvidia-smi cuenta tiempo con kernels, no capacidad: batches de 1, o la T4 bajando su frecuencia (clocks). | batch promedio ≈ 1 |
| Predicciones distintas a PyTorch | Bug de export, del engine o del preproceso: falla sin errores. | [parity] NO PASA |
| Primer request lento | Inicialización pendiente: sin warmup, la paga el primer request real. | model_warmup ausente |
| CUDA out of memory | VRAM llena: instancias, batch máximo, versiones cargadas o procesos de más. | nvidia-smi --query-compute-apps |
| «Ayer funcionaba» | Cambió algo debajo: la release de NGC (la imagen de NVIDIA, con otro TensorRT), el tipo de GPU o el driver. | The engine plan file is not compatible… |
| Batch promedio ≈ 1 | No hay dynamic_batching, o con la carga actual no llega nadie más dentro de la ventana. | rate(nv_inference_count) / rate(nv_inference_exec_count) |
1¿Vive?2¿Está listo?3¿Qué no cargó y por qué?4¿El contrato coincide?5¿Una inferencia sola funciona?6¿Predice lo mismo?7¿Dónde se va el tiempo?8¿Y la GPU?
¿Vive?
curl -fsS localhost:8000/v2/health/live && echo vivo
Si curl no conecta: el proceso no corre, o todavía carga los modelos (con --model-control-mode=none abre el puerto al final). Mira el pod y el log del intento anterior.
¿Está listo?
curl -fsS localhost:8000/v2/models/classifier/ready && echo listo
400: Triton escucha, pero el modelo no está listo (no cargó con --exit-on-error=false, o se está recargando). 503: Triton se está apagando.
¿Qué no cargó y por qué?
kubectl logs POD -n classifier --previous | grep -E "failed to load|UNAVAILABLE"
La tabla de modelos y la línea failed to load traen el motivo; la línea de TensorRT, el porqué.
¿El contrato coincide?
curl -s localhost:8000/v2/models/classifier
Nombre, datatype y shape (con el -1 del batch) contra lo que manda el cliente.
¿Una inferencia sola funciona?
perf_analyzer -m classifier -u localhost:8000 --concurrency-range 1 --input-data zero
Si el request falla, ves el error que devolvió Triton; si pasa, ya tienes una latencia base.
¿Predice lo mismo?
python model/parity_check.py --backend triton --triton-url localhost:8000 --checkpoint resnet50_500.pt --images /ruta/muestra_eval
Si ONNX Runtime pasa y Triton no, es el engine; si ONNX Runtime también falla, es el export.
¿Dónde se va el tiempo?
curl -s localhost:8000/v2/models/classifier/stats
Cola contra cómputo y ejecuciones por tamaño de batch; en el gateway, preproceso contra inferencia.
¿Y la GPU?
nvidia-smi -q -d PERFORMANCE,CLOCK
Clocks y motivos de baja (potencia, temperatura): la T4 es una tarjeta pasiva de 70 W y baja clocks bajo carga sostenida.
$ kubectl logs -n classifier triton-7c4d…-p2x8k --previous | grep -E "TensorRT|failed to load|UNAVAILABLE|error:"
… The engine plan file is not compatible with this version of TensorRT, expecting library version … got …
… failed to load 'classifier' version 1: Internal: unable to create TensorRT engine: …
| classifier | 1 | UNAVAILABLE: Internal: unable to create TensorRT engine: … |
error: creating server: Internal - failed to load all models
# En el contenedor de TensorRT con el que se construyó el engine:
$ trtexec --getPlanVersionOnly --loadEngine=model.planerror: creating server solo dice que algo falló; la tabla dice qué modelo; failed to load, en qué paso (crear el engine); la línea de TensorRT, por qué.model.plan se construyó con la anterior. En C01, build_engine.sh y triton/Dockerfile fijan el mismo YY.MM (24.08, TensorRT 10.3).trtexec --getPlanVersionOnly imprime con qué versión de TensorRT se creó el plan, sin cargarlo (deserializarlo). Según la versión de TensorRT, el mismo problema también sale como Serialization assertion … Version tag does not match; si el plan es de otra GPU, dice generated on an incompatible device.maxUnavailable: 0, los Triton viejos siguen sirviendo. Arreglo: reconstruir el engine con la misma release que la imagen (o volver al tag anterior) y repetir paridad y perf_analyzer (la prueba de carga contra Triton).--fp16 ya no existe y la precisión va en el ONNX, con AutoCast (de NVIDIA ModelOpt).Cómo recordarla
vive → listo → qué cargó → contrato → una inferencia → paridad → tiempo → GPU. Cada paso es un comando barato que parte el problema en dos; no saltes al último porque es el más interesante.
Trampa
reiniciar el pod o subir réplicas para «arreglar» un modelo que no carga. El pod nuevo trae el mismo engine y falla igual, y el reinicio se lleva el log del intento que murió: primero kubectl logs --previous. La otra: leer nvidia-smi al 100 % como «GPU saturada»; es tiempo con kernels corriendo, no capacidad usada.
En el curso: C01 · Modos de falla y degradación Docker y kubectl · Depurar pods: síntoma → comandos → causa típica M7 · NVIDIA Triton Inference Server M8 · Cómo validar una optimización M3 · Presupuesto de latencia end-to-end M12 · Dashboards, runbooks y on-call
Qué vas a provocar: Practicar el método desde afuera con el gateway de C01 y sin Triton: ver la diferencia entre vivo y listo, un 503 de dependencia caída, un 415 y un 400, y leerlos en el log y en las métricas.
Necesitas: Tu Mac, Python 3.13 y un venv con gateway/requirements.txt de C01, desde la raíz del repo; no hacen falta Triton ni GPU. Dos terminales.
cd apps/ml-system-design/reference/c01/gateway && python3.13 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txtQué deberías ver: Termina con Successfully installed … (fastapi 0.141.1, uvicorn 0.54.0, tritonclient 2.72.0, pillow 12.3.0, entre otras): las versiones fijadas del gateway.
uvicorn app.main:app --port 8080Qué deberías ver: Uvicorn running on http://127.0.0.1:8080. Déjalo corriendo y abre otra terminal en gateway/ con el venv activado. No hay Triton en localhost:8000: es a propósito.
curl -i localhost:8080/healthz; echo; curl -i localhost:8080/readyzQué deberías ver: HTTP/1.1 200 OK con {"status":"ok"} y HTTP/1.1 503 Service Unavailable con {"detail":"el modelo no está listo en Triton"}. Vive pero no está listo: en Kubernetes saldría del Service sin que lo reinicien.
python -c "from PIL import Image; Image.effect_noise((640, 480), 64).convert('RGB').save('foto.jpg')" && curl -i -H "Content-Type: image/jpeg" --data-binary @foto.jpg "localhost:8080/v1/classify?top_k=5"Qué deberías ver: HTTP/1.1 503 con los headers retry-after: 1 y x-request-id, y {"detail":"Triton no disponible"}: la conexión a Triton se rechazó al instante. En la terminal de uvicorn, una línea JSON con "preprocess_ms" (el decode sí corrió), "error": "Triton no disponible" y "status": 503.
curl -i -H "Content-Type: image/gif" --data-binary @foto.jpg localhost:8080/v1/classify; echo; head -c 20000 foto.jpg > rota.jpg && curl -i -H "Content-Type: image/jpeg" --data-binary @rota.jpg localhost:8080/v1/classifyQué deberías ver: 415 con {"detail":"Content-Type debe ser image/jpeg, image/png o image/webp"} y 400 con {"detail":"no se pudo decodificar la imagen"} (adentro, Pillow dijo image file is truncated). Los 4xx son del request: reintentar no sirve.
curl -s localhost:8080/metrics | grep -E '^classifier_requests_total'Qué deberías ver: Una serie por código: status="503", "415" y "400", con source="other" (no mandaste x-client-source) y model_version="dev".
rm foto.jpg rota.jpgQué deberías ver: Sin salida. Después, Ctrl+C en la terminal de uvicorn lo apaga.
Cómo arreglarlo y confirmarlo
Lo que viste es lo mismo que con Triton caído: /healthz en 200 (no reinicies el gateway), /readyz en 503 (sale del balanceador) y 503 rápidos con retry-after. Un 504, en cambio, diría que Triton está vivo pero lento: ahí miras la cola.
Verificado contra la documentación; no ejecutado aquí.
Lees el mensaje: ¿quién lo dijo (PyTorch, el exportador, ONNX Runtime, TensorRT, Triton, el cliente, CUDA), qué significa y qué corres primero?
Sintaxis con variables
failed to load 'modelo' version v: estado: motivo error: creating server: Internal - failed to load all models
Lo dijo el core de Triton (el núcleo del servidor) al cargar: el modelo queda UNAVAILABLE (no disponible) con ese motivo y, con --exit-on-error=true, el proceso sale con exit 1. El estado (Internal, Invalid argument, Not found…) y el motivo dicen qué paso falló.
[http] [request id: id] mensaje [StatusCode.grpc] mensaje
Un request que Triton rechazó, visto desde tritonclient: por HTTP, el código entre corchetes (400 contrato, 404 o 503 estado del modelo, 503 cola); por gRPC, el código de gRPC. Muchos mensajes traen el request id que mandaste (<id_unknown> si no mandaste uno); otros, como Request timeout expired, no.
unable to create TensorRT engine: motivo_trt
Lo dijo el backend de TensorRT de Triton al cargar (deserializar) el plan; el motivo es el último error de TensorRT: casi siempre versión, tipo de GPU o memoria.
excepcion: mensaje [ONNXRuntimeError] : n : codigo : mensaje
Lo dijo Python: el nombre de la excepción delata la librería (torch, onnxruntime, PIL, tritonclient) y la última línea del traceback dice el porqué. ONNX Runtime antepone su código: 2 : INVALID_ARGUMENT es tu entrada.
Variables
| Variable | Qué es | Ejemplo |
|---|---|---|
| modelo · v | modelo y versión que Triton intentó cargar | classifier · 1 |
| estado | el código de estado de Triton, en palabras | Internal, Invalid argument, Not found, Unavailable |
| motivo | el porqué que agregó el backend o el core | unable to create TensorRT engine: … |
| http | el código HTTP que respondió Triton | 400, 404, 503 |
| id | el request_id que mandó el cliente | el X-Request-ID del gateway de C01 |
| grpc | el código de gRPC | UNAVAILABLE, INVALID_ARGUMENT |
| motivo_trt | la última línea de error de TensorRT | The engine plan file is not compatible… |
| mensaje | el texto que explica el código | unexpected shape for input 'input'… |
| excepcion | el tipo de error de Python | RuntimeError, TorchExportError, InferenceServerException |
| n · codigo | el número y el nombre del código de ONNX Runtime | 2 · INVALID_ARGUMENT |
| Pieza | Qué hace | Ejemplo |
|---|---|---|
| failed to load '…' version … | El core de Triton no pudo cargar esa versión: queda UNAVAILABLE con el motivo. | Internal: unable to create TensorRT engine: … |
| error: creating server: … | Triton no arrancó; con --exit-on-error=true basta un modelo que falle. Exit 1. | Internal - failed to load all models |
| [request id: …] | Triton rechazó un request; el id es el que mandó el cliente (<id_unknown> si no hay). | unexpected shape for input 'input' |
| [400] … · [503] … | tritonclient por HTTP: el código que respondió Triton, entre corchetes. | [400] … data-type is 'FP64' |
| [StatusCode.…] | tritonclient por gRPC: el código de gRPC. | [StatusCode.UNAVAILABLE] failed to connect to all addresses |
| The engine plan file … | TensorRT, al cargar un plan: versión o tipo de GPU equivocados. | … not compatible with this version of TensorRT |
| [ONNXRuntimeError] : 2 : INVALID_ARGUMENT : … | ONNX Runtime rechazó la entrada: nombre, shape o dtype (tipo de número). | Got invalid dimensions for input: input |
| Failed to export the model with torch.export | El exportador dynamo (el default desde torch 2.9) no pudo capturar el modelo: paso 1 de 3. | Could not guard on data-dependent expression |
| Exporting the operator '…' to ONNX opset version … | El exportador TorchScript (dynamo=False, el viejo, el de C01) no sabe convertir esa operación en ese opset (la versión del conjunto de operadores de ONNX). | aten::… |
| TracerWarning | Una advertencia del exportador TorchScript: congeló un valor que depende de los datos. No falla. | Converting a tensor to a Python boolean might cause the trace to be incorrect |
| CUDA out of memory | PyTorch no consiguió VRAM: torch.OutOfMemoryError (torch.cuda.OutOfMemoryError es el mismo tipo). | Tried to allocate 2.00 GiB |
| DecompressionBombError · UnidentifiedImageError | Pillow, en el preproceso: una imagen demasiado grande, o bytes que no son una imagen. | 413 y 400 en el gateway de C01 |
| [parity] NO PASA | El gate de C01: el artefacto no predice como PyTorch FP32. Exit 1: no se promueve. | top1_agreement < 99.5 % |
| Failed to obtain stable measurement | perf_analyzer (la herramienta de carga de Triton): la medición no se estabilizó en 10 ventanas; esos números no valen. | --measurement-interval |
Quiz · un mensaje a la vez
Mensaje 1 de 26
Error(s) in loading state_dict for ResNet: size mismatch for fc.weight: copying a param with shape torch.Size([1000, 2048]) from checkpoint, the shape in current model is torch.Size([500, 2048]).
26 de 26
Error(s) in loading state_dict for ResNet: size mismatch for fc.weight: copying a param with shape torch.Size([1000, 2048]) from checkpoint, the shape in current model is torch.Size([500, 2048]).
El checkpoint no es de esta arquitectura: aquí, una capa final de 1000 clases (ImageNet) contra el modelo de C01, de 500. load_state_dict es estricto por defecto y falla al cargar, no en producción con pesos al azar. Si dice Missing key(s) o Unexpected key(s), los nombres no coinciden (por ejemplo, el prefijo module. de DataParallel).
Primero: python -c "import torch; sd = torch.load('resnet50_500.pt', map_location='cpu', weights_only=True); print(sd['fc.weight'].shape)"
Weights only load failed. … In PyTorch 2.6, we changed the default value of the `weights_only` argument in `torch.load` from `False` to `True`.
Desde torch 2.6, torch.load solo acepta tensores y tipos permitidos, y el archivo trae otro objeto de Python (un modelo entero, un optimizador, una clase propia). Cargarlo con weights_only=False ejecuta pickle, es decir, código: solo si confías en el origen. Lo sano es guardar y cargar el state_dict, como C01.
Primero: python -c "import torch; print(torch.serialization.get_unsafe_globals_in_checkpoint('resnet50_500.pt'))"
Exporting the operator 'aten::…' to ONNX opset version 17 is not supported
El exportador TorchScript (dynamo=False, el que usa C01) no sabe convertir esa operación de PyTorch a ONNX en ese opset (la versión del conjunto de operadores). Si el mensaje agrega Support for this operator was added in version N, alcanza con subir el opset; si no, reescribe esa parte con operaciones estándar, déjala fuera del grafo (como el NMS de un detector) o registra un custom op.
Primero: python model/export_onnx.py --out model/classifier.onnx --dynamo --opset 18
→ Sección 3 · Exportar a ONNX: dynamo, opset, ejes dinámicos, checker y ONNX Runtime
Failed to export the model with torch.export. This is step 1/3 of exporting the model to ONNX.
El exportador dynamo (el default desde torch 2.9) no pudo capturar el modelo con torch.export. El motivo real está abajo, en el resumen del traceback; el típico es Could not guard on data-dependent expression: un if que depende de los valores del tensor. TorchScript lo habría congelado sin avisar; dynamo falla explícito. Exprésalo con torch.cond o sácalo del modelo.
Primero: cd model && python -c "import torch; from export_onnx import build_model; torch.export.export(build_model(None), (torch.randn(2, 3, 224, 224),))"
→ Sección 3 · Exportar a ONNX: dynamo, opset, ejes dinámicos, checker y ONNX Runtime
Failed to convert the model to the target version 17 using the ONNX C API. The model was not modified
La trampa del exportador dynamo que documenta C01 (onnxscript 0.7.2): pediste opset 17, intentó convertir hacia abajo, la conversión falló (en ResNet-50, por ReduceMean) y solo lo avisó: el archivo quedó en opset 18. Lo atrapa el verify() de export_onnx.py, que lee el opset real del archivo.
Primero: python -c "import onnx; print(onnx.load('model/classifier.onnx').opset_import)"
→ Sección 3 · Exportar a ONNX: dynamo, opset, ejes dinámicos, checker y ONNX Runtime
[ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for input: input for the following indices index: 0 Got: 7 Expected: 2 Please fix either the inputs/outputs or the model.
ONNX Runtime esperaba un tamaño fijo en ese eje: el eje de batch quedó constante (2, el del ejemplo del export) porque no se declaró dinámico. Funciona con batch 2 y falla con cualquier otro. Se arregla en el export: dynamic_axes (TorchScript) o dynamic_shapes (dynamo).
Primero: python -c "import onnx; m = onnx.load('model/classifier.onnx'); print([d.dim_param or d.dim_value for d in m.graph.input[0].type.tensor_type.shape.dim])"
→ Sección 3 · Exportar a ONNX: dynamo, opset, ejes dinámicos, checker y ONNX Runtime
[parity] NO PASA: no promuevas este artefacto
El gate de C01 (parity_check.py) encontró que el artefacto no predice como PyTorch FP32: acuerdo top-1 bajo 99.5 %, solapamiento top-5 bajo 99 % o algún |Δlogit| sobre 0.5. Exit 1: el pipeline se detiene. Un FP16 bien hecho pasa con holgura (diferencia media ≈ 1e-2; 1.3e-2 en el lab de paridad); 97 % de acuerdo es un bug, no redondeo.
Primero: python model/parity_check.py --checkpoint resnet50_500.pt --onnx model/classifier.onnx --images /ruta/muestra_eval --n 2000
→ Sección 4 · Paridad: el modelo servido no es el evaluado (top-1, Δlogit, FP16)
FAILED | Output: 'logits' | Difference exceeds tolerance (rel=1e-05, abs=1e-05)
Polygraphy (la herramienta de NVIDIA para comparar runtimes) comparó TensorRT contra ONNX Runtime con sus tolerancias por defecto, 1e-5, pensadas para FP32. Con --fp16 los logits se mueven ≈ 1e-2: falla aunque el engine esté bien. Sube --atol y --rtol a lo que FP16 justifica y decide con métricas de producto, como el acuerdo top-1 de parity_check.py.
Primero: polygraphy run model/classifier.onnx --trt --fp16 --onnxrt --trt-outputs mark all --onnx-outputs mark all
→ Sección 4 · Paridad: el modelo servido no es el evaluado (top-1, Δlogit, FP16)
The engine plan file is not compatible with this version of TensorRT, expecting library version … got …
El model.plan se construyó con otra versión de TensorRT que la que corre: típicamente, Triton se actualizó de release de NGC y el engine no. Un engine solo sirve en la versión con que se construyó, salvo que lo compiles en modo version-compatible (opcional, algo más lento, y Triton solo lo carga con --backend-config=tensorrt,version-compatible=true). En el log de Triton aparece junto a failed to load 'classifier' version 1: Internal: unable to create TensorRT engine. La puntuación cambia entre versiones, y algunas lo dicen como Serialization assertion … Version tag does not match.
Primero: trtexec --getPlanVersionOnly --loadEngine=model.plan
→ Sección 5 · TensorRT: qué es, trtexec, precisión, perfiles de optimización, portabilidad del engine
The engine plan file is generated on an incompatible device, expecting compute … got compute …
El engine se compiló para otra arquitectura de GPU (compute capability): por ejemplo, en una L4 (8.9) y se carga en la T4 de C01 (7.5), o al revés. TensorRT eligió sus kernels midiendo en esa GPU. Se reconstruye en el tipo de GPU de destino; el modo de compatibilidad ampere+ no cubre la T4, que es Turing.
Primero: nvidia-smi --query-gpu=name,driver_version --format=csv,noheader
→ Sección 5 · TensorRT: qué es, trtexec, precisión, perfiles de optimización, portabilidad del engine
Plugin not found, are the plugin name, version, and namespace correct?
El parser de ONNX de TensorRT no sabe importar ese operador, lo busca como plugin (una capa propia registrada en TensorRT) y no hay ninguno con ese nombre. Más arriba, el log nombra el operador: No importer registered for op: X (No checker registered for op: X en TensorRT 10.3) y Cannot find plugin: X, version: 1. Típico de operaciones propias o de detección (NMS, deformable conv). Opciones: reescribir con operadores estándar, registrar un plugin, o dejar esa parte fuera del engine (en el gateway o en otro paso del ensemble).
Primero: trtexec --onnx=model/classifier.onnx --verbose
→ Sección 5 · TensorRT: qué es, trtexec, precisión, perfiles de optimización, portabilidad del engine
model 'classifier', tensor 'input': the model expects 4 dimensions (shape [-1,3,224,224]) but the model configuration specifies 5 dimensions (an initial batch dimension because max_batch_size > 0 followed by the explicit tensor shape, making complete shape [-1,-1,3,224,224])
Pusiste el batch en dims: con max_batch_size > 0, Triton antepone el -1 del batch, así que dims: [ -1, 3, 224, 224 ] se vuelve un tensor de 5 dimensiones que el engine no tiene. Va dims: [ 3, 224, 224 ], como en C01. El modelo no carga y, con --exit-on-error=true, Triton sale. Es el backend de TensorRT; el de ONNX Runtime avisa parecido.
Primero: grep -n "dims" triton/model_repository/classifier/config.pbtxt
unable to find backend library for backend '…', try specifying runtime on the model configuration.
El backend que pide config.pbtxt no existe en la imagen: un typo (onnxruntime mal escrito) o un backend que esa imagen no trae (el de TensorFlow dejó de venir en los contenedores de NGC desde la 25.03). Para TensorRT y ONNX alcanza con platform (tensorrt_plan, como C01, u onnxruntime_onnx); si escribes backend, tiene que ser el nombre de una carpeta de /opt/tritonserver/backends.
Primero: docker run --rm --entrypoint ls nvcr.io/nvidia/tritonserver:24.08-py3 /opt/tritonserver/backends
instance group classifier_0 of model classifier has kind KIND_GPU but no GPUs are available
El config pide KIND_GPU (como C01) y el proceso de Triton no ve ninguna GPU: el contenedor arrancó sin --gpus, el pod no pidió nvidia.com/gpu, o cayó en un nodo sin GPU o sin driver. Con KIND_GPU no hay plan B: el modelo no carga, y el backend de TensorRT solo corre en GPU. Es un problema del entorno, no del config: al arrancar, Triton suele avisar antes con pinned memory pool will not be available.
Primero: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].resources.limits}'
→ Sección 12 · Diagnóstico: el método y la tabla de síntomas
error: creating server: Internal - failed to load all models
Triton no arrancó: con --exit-on-error=true (C01) alcanza con que un modelo no cargue para que el proceso salga con exit 1, y el pod queda en CrashLoopBackOff. Este mensaje no trae la causa: búscala arriba, en la tabla de modelos y en la línea failed to load '…' version ….
Primero: kubectl logs POD -n classifier --previous | grep -E "failed to load|UNAVAILABLE"
→ Sección 12 · Diagnóstico: el método y la tabla de síntomas
Exceeds maximum queue size
La cola del dynamic batcher está llena: default_queue_policy tiene un max_queue_size y ya hay esa cantidad de requests esperando. Triton rechaza el nuevo al instante, sin encolarlo, y llega como 503. C01 no pone tope de tamaño (0 = cola sin tope) y corta por tiempo, a los 100 ms. Si aparece seguido, la GPU no da abasto en tus picos: faltan réplicas, o el tope es más chico que un pico normal.
Primero: curl -s localhost:8000/v2/models/classifier/config
Request timeout expired
La cola del dynamic batcher rechazó un request que esperó más de default_timeout_microseconds (100 ms en C01, con timeout_action: REJECT). Llega al cliente como 503 y cuenta en nv_inference_request_failure con reason="REJECTED" (esa etiqueta existe desde Triton 24.07). Es a propósito: no gastar GPU en una respuesta que el gateway (150 ms) ya no va a esperar. Si sale seguido, la GPU está saturada: faltan réplicas.
Primero: curl -s localhost:8002/metrics | grep nv_inference_request_failure
unexpected shape for input 'input' for model 'classifier'. Expected [-1,3,224,224], got [3,224,224]. NOTE: Setting a non-zero max_batch_size in the model config requires a batch dimension to be prepended to each input shape.
El cliente declaró una shape que no es la del modelo: aquí, sin el eje de batch. Con max_batch_size > 0, la shape completa es [-1] + dims, así que una foto va como [1, 3, 224, 224] (x[np.newaxis] en C01). Otro clásico: [1, 224, 224, 3], HWC en vez de CHW. Triton responde 400; el NOTE propone max_batch_size: 0, pero eso apaga el batching.
Primero: curl -s localhost:8000/v2/models/classifier
→ Sección 8 · Clientes: HTTP, gRPC, protocolo v2, health y metadata, tritonclient
inference input 'input' data-type is 'FP64', but model 'classifier' expects 'FP32'
El request declara FP64: NumPy crea float64 por defecto (np.random.rand, np.zeros) y np_to_triton_dtype lo traduce a FP64. Convierte con astype(np.float32). Si en cambio declaraste "FP32" y pasaste un float64, el error sale antes, en tritonclient: got unexpected datatype FP64 from numpy array, expected FP32.
Primero: python -c "import numpy as np; print(np.zeros((1, 3, 224, 224)).dtype)"
→ Sección 8 · Clientes: HTTP, gRPC, protocolo v2, health y metadata, tritonclient
Request for unknown model: 'classifier' version 1 is not at ready state
El modelo existe, pero esa versión no está lista: falló al cargar (con --exit-on-error=false) o se está cargando o descargando (con --model-control-mode=explicit o poll). Llega como 503. Sale cuando pides una versión concreta; sin versión, el mismo caso dice … has no available versions (404). Y … 'classifier' is not found (404) es que ese nombre no existe: revisa MODEL_NAME.
Primero: curl -s -X POST localhost:8000/v2/repository/index
→ Sección 8 · Clientes: HTTP, gRPC, protocolo v2, health y metadata, tritonclient
Failed to obtain stable measurement within 10 measurement windows for concurrency 1. Please try to increase the --measurement-interval.
perf_analyzer considera estable una medición cuando las 3 últimas ventanas varían menos de --stability-percentage (10 %) en throughput y en latencia; no lo logró en --max-trials (10) ventanas de --measurement-interval (5000 ms). Pocos requests por ventana, una GPU que baja clocks u otro tráfico en el mismo Triton. Esos números no valen: alarga la ventana o aísla el servidor.
Primero: perf_analyzer -m classifier -u localhost:8000 --concurrency-range 1 --measurement-interval 10000
→ Sección 9 · Medir: perf_analyzer, métricas de Triton, uso de GPU
[WARNING] Perf Analyzer was not able to keep up with the desired request rate. … % of the requests were delayed.
En modo de tasa fija (--request-rate-range, como el gate de latencia de C01 a 250 y 500 req/s), perf_analyzer mandó tarde más del 1 % de los requests: la carga real fue menor que la pedida y ese p99 no sirve para el gate. Por defecto manda en modo síncrono con 4 hilos (--max-threads), y cada hilo espera su respuesta: si la latencia sube, no alcanzan. Sube --max-threads o usa --async, y mira con top que a la máquina que mide le sobre CPU.
Primero: perf_analyzer -m classifier -u localhost:8000 --request-rate-range 500 --max-threads 16
→ Sección 9 · Medir: perf_analyzer, métricas de Triton, uso de GPU
Image size (… pixels) exceeds limit of … pixels, could be decompression bomb DOS attack.
Pillow se niega a abrir una imagen con más del doble de Image.MAX_IMAGE_PIXELS (DecompressionBombError; entre una y dos veces el límite, solo avisa con un warning). Un PNG de 50 KB puede declarar 30,000 × 30,000 px. C01 fija MAX_IMAGE_PIXELS en 40 MP como segunda red y, antes, lee solo el header y rechaza más de 40 MP (413) o una relación de aspecto mayor que 1:8 (400).
Primero: python -c "from PIL import Image; Image.MAX_IMAGE_PIXELS = None; im = Image.open('foto.png'); print(im.format, im.size)"
Failed to increase the shared memory pool size … If you are running Triton inside docker, use '--shm-size' flag to control the shared memory region size.
El backend de Python (por ejemplo, el paso de preproceso de un ensemble) pasa los tensores por memoria compartida (/dev/shm) y no pudo agrandar su región: Docker da 64 MB por defecto, y Kubernetes también, salvo que montes un emptyDir en memoria como C01 (1 GiB). En 24.08 el texto dice … pool size for key '…' to … bytes; desde la 25.07, sin for key. Si falla ya al arrancar, el mensaje es otro: Unable to initialize shared memory key … Each Python backend model instance requires at least 1 MB of shared memory.
Primero: kubectl exec -n classifier deploy/triton -- df -h /dev/shm
CUDA out of memory. Tried to allocate … GPU 0 has a total capacity of … of which … is free.
PyTorch no consiguió VRAM (torch.OutOfMemoryError; torch.cuda.OutOfMemoryError es el mismo tipo). En serving: batch o resolución demasiado grandes, varios procesos con su copia del modelo (uvicorn --workers) o inferencia sin torch.inference_mode, que guarda activaciones. Si hay mucha memoria reservada sin usar, el mensaje sugiere PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True (torch 2.14). No es un OOMKilled: subir limits.memory no ayuda.
Primero: nvidia-smi --query-compute-apps=pid,used_memory --format=csv
→ Sección 12 · Diagnóstico: el método y la tabla de síntomas
Unable to allocate pinned system memory, pinned memory pool will not be available: CUDA driver version is insufficient for CUDA runtime version
Una advertencia de Triton al arrancar: CUDA no funciona en ese contenedor. O no hay GPU ni driver (corriste la imagen sin --gpus, o en tu Mac), o el driver del host es más viejo que el CUDA de la imagen (24.08 trae CUDA 12.6: driver 560 o, en GPUs de data center como la T4, ramas compatibles desde 470.57). Triton sigue arrancando, y después falla el modelo con KIND_GPU.
Primero: nvidia-smi
→ Sección 12 · Diagnóstico: el método y la tabla de síntomas
Ningún mensaje tiene ese texto. Prueba con una palabra del mensaje (por ejemplo TensorRT o shape).
$ python cliente.py # InferInput("input", [3, 224, 224], "FP32") con x de shape [3, 224, 224]
tritonclient.utils.InferenceServerException: [400] [request id: demo-1] unexpected shape for input 'input' for model 'classifier'. Expected [-1,3,224,224], got [3,224,224]. NOTE: Setting a non-zero max_batch_size in the model config requires a batch dimension to be prepended to each input shape. If you want to specify the full shape including the batch dim in your input dims config, try setting max_batch_size to zero. See the model configuration docs for more info on max_batch_size.
$ curl -s localhost:8000/v2/models/classifier
{"name":"classifier","versions":["1"],"platform":"tensorrt_plan","inputs":[{"name":"input","datatype":"FP32","shape":[-1,3,224,224]}],"outputs":[{"name":"logits","datatype":"FP32","shape":[-1,500]}]}[400] es el código HTTP (lo agrega tritonclient); [request id: demo-1], el id que mandaste; después, el motivo. Un 400 es tu request, no el servidor: reintentar no sirve.Expected [-1,3,224,224]: con max_batch_size > 0, Triton antepone -1 (el batch) a los dims del config. got [3,224,224]: faltó el eje de batch. Arreglo: x[np.newaxis], como el gateway de C01.max_batch_size: 0. No en C01: apagaría el dynamic batching. Se arregla el cliente.got unexpected numpy array shape … o got unexpected datatype FP64 from numpy array, expected FP32), lo dijo tritonclient al armar el request: el array no coincide con lo que declaraste en InferInput.Request timeout expired, Exceeds maximum queue size, … is not at ready state): son de capacidad o de estado, no de contrato. El gateway de C01 convierte cualquier error de Triton en 503 con Retry-After (el header que dice en cuántos segundos reintentar).Cómo recordarla
el prefijo dice quién habla. failed to load y error: creating server son Triton cargando; [request id: …] es Triton rechazando un request; The engine plan file y unable to create TensorRT engine, TensorRT; [ONNXRuntimeError], ONNX Runtime; el resto, una excepción de Python con su librería en el nombre.
Trampa
leer solo la última línea. error: creating server: Internal - failed to load all models no dice nada: el porqué está arriba, en failed to load y en la línea de TensorRT. Y los textos cambian entre versiones: TensorRT cambia la puntuación de sus mensajes y el backend de Python de Triton quitó for key de su mensaje de memoria compartida en la 25.07. Busca el núcleo estable del mensaje, no la línea exacta.
En el curso: C01 · Modos de falla y degradación Docker y kubectl · Depurar pods: síntoma → comandos → causa típica M8 · ONNX y ONNX Runtime M8 · TensorRT M7 · NVIDIA Triton Inference Server M7 · Protocolos: REST, gRPC, KServe v2 y OpenAI-compatible
Qué vas a provocar: Reconocer los 26 mensajes en 5 minutos y provocar cinco en tu Mac, sin GPU: un checkpoint de otra arquitectura, una foto en HWC, un tensor sin eje de batch, bytes que no son imagen y una imagen enorme.
Necesitas: Una terminal de macOS para la ronda; para el paso 3, desde la raíz del repo, el venv de C01 del lab de pre y postprocesamiento (torch, torchvision, Pillow).
ronda() { printf '%s\n' 'Error(s) in loading state_dict for ResNet: size mismatch for fc.weight' 'Weights only load failed' "Exporting the operator 'aten::…' to ONNX opset version 17 is not supported" 'Failed to export the model with torch.export. This is step 1/3' 'Failed to convert the model to the target version 17 using the ONNX C API' '[ONNXRuntimeError] : 2 : INVALID_ARGUMENT : Got invalid dimensions for input: input' '[parity] NO PASA: no promuevas este artefacto' "FAILED | Output: 'logits' | Difference exceeds tolerance (rel=1e-05, abs=1e-05)" 'The engine plan file is not compatible with this version of TensorRT' 'The engine plan file is generated on an incompatible device' 'Plugin not found, are the plugin name, version, and namespace correct?' "tensor 'input': the model expects 4 dimensions … but the model configuration specifies 5 dimensions" "unable to find backend library for backend '…'" 'instance group classifier_0 of model classifier has kind KIND_GPU but no GPUs are available' 'error: creating server: Internal - failed to load all models' 'Exceeds maximum queue size' 'Request timeout expired' "unexpected shape for input 'input' for model 'classifier'. Expected [-1,3,224,224], got [3,224,224]" "inference input 'input' data-type is 'FP64', but model 'classifier' expects 'FP32'" "Request for unknown model: 'classifier' version 1 is not at ready state" 'Failed to obtain stable measurement within 10 measurement windows for concurrency 1' 'Perf Analyzer was not able to keep up with the desired request rate' 'Image size (… pixels) exceeds limit of … pixels, could be decompression bomb DOS attack.' "Failed to increase the shared memory pool size … use '--shm-size'" 'CUDA out of memory. Tried to allocate …' 'pinned memory pool will not be available: CUDA driver version is insufficient for CUDA runtime version' | sort -R | head -5; }; rondaQué deberías ver: Cinco mensajes al azar. Para cada uno, en menos de 30 s y en voz alta: quién lo dijo (PyTorch, el exportador, ONNX Runtime, el gate de paridad de C01, Polygraphy, TensorRT, Triton, el cliente, perf_analyzer, Pillow o CUDA), la causa probable y el primer comando. Después compáralo con la lista.
rondaQué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.
cd apps/ml-system-design/reference/c01 && . .venv/bin/activate && python - <<'EOF'
import io, torch, torchvision
from PIL import Image
m = torchvision.models.resnet50(weights=None, num_classes=500).eval()
buf = io.BytesIO(); Image.new("RGB", (640, 480)).save(buf, "PNG")
def bomba():
Image.MAX_IMAGE_PIXELS = 100_000
Image.open(io.BytesIO(buf.getvalue()))
for nombre, f in [
("checkpoint de 1000 clases", lambda: m.load_state_dict(torchvision.models.resnet50(weights=None).state_dict())),
("foto en HWC", lambda: m(torch.zeros(1, 224, 224, 3))),
("sin eje de batch", lambda: m(torch.zeros(3, 224, 224))),
("bytes que no son imagen", lambda: Image.open(io.BytesIO(b"hola"))),
("imagen enorme", bomba),
]:
try:
with torch.inference_mode():
f()
except Exception as e:
print(f"{nombre} -> {type(e).__name__}: {' '.join(str(e).split())[:230]}")
EOFQué deberías ver: Cinco líneas: RuntimeError: Error(s) in loading state_dict for ResNet: size mismatch for fc.weight: copying a param with shape torch.Size([1000, 2048]) from checkpoint, the shape in current model is torch.Size([500, 2048]) …; RuntimeError: Given groups=1, weight of size [64, 3, 7, 7], expected input[1, 224, 224, 3] to have 3 channels, but got 224 channels instead; ValueError: expected 4D input (got 3D input); UnidentifiedImageError: cannot identify image file <_io.BytesIO object at 0x…>; DecompressionBombError: Image size (307200 pixels) exceeds limit of 200000 pixels, could be decompression bomb DOS attack.
Cómo arreglarlo y confirmarlo
Los que fallaste: abre la sección de su tema (el enlace de la lista) y haz su laboratorio. Repite la ronda otro día: la meta es 5 de 5 en menos de 2 minutos.
Probado de verdad en un Mac, sin GPU.
Del síntoma al primer comando, y una revisión rápida de tu config.pbtxt antes de construir la imagen.
Elige lo que ves. Te dice qué correr primero y las causas típicas, cada una con cómo comprobarla y cómo arreglarla.
Elige un síntoma para ver las causas.
Triton no carga el modelo: el pod reinicia en CrashLoopBackOff, o el modelo queda UNAVAILABLE.
Corre primero
kubectl logs POD -n classifier --previous | grep -E "failed to load|UNAVAILABLE|error: creating server"
El engine no es de esta GPU o de esta versión de TensorRT (otra release de NGC, otro tipo de GPU): unable to create TensorRT engine, con la línea de TensorRT not compatible with this version of TensorRT o generated on an incompatible device.
Compruébalo: trtexec --getPlanVersionOnly --loadEngine=model.plan · nvidia-smi --query-gpu=name,driver_version --format=csv,noheader · el tag de tritonserver en triton/Dockerfile contra NGC_RELEASE en model/build_engine.sh
Arreglo: Reconstruye el engine en el mismo tipo de GPU y con la misma release de NGC que la imagen de Triton (C01: T4 y 24.08), y repite paridad y perf_analyzer.
config.pbtxt no coincide con el engine, y C01 no deja que Triton lo complete solo (--disable-auto-complete-config): el batch metido en dims (the model expects 4 dimensions … but the model configuration specifies 5) u otro data_type (unexpected datatype TYPE_FP16 for inference input 'input', expecting TYPE_FP32).
Compruébalo: grep -n -E "dims|data_type|max_batch_size" triton/model_repository/classifier/config.pbtxt
Arreglo: dims sin el eje de batch ([ 3, 224, 224 ]), los mismos nombres y tipos que el engine, y max_batch_size no mayor que el batch de --maxShapes (32 y 32 en C01).
Triton no ve la GPU: instance group classifier_0 of model classifier has kind KIND_GPU but no GPUs are available (contenedor sin --gpus, pod sin nvidia.com/gpu, nodo sin driver).
Compruébalo: kubectl get pod POD -n classifier -o jsonpath='{.spec.containers[0].resources.limits}'
Arreglo: nvidia.com/gpu: 1 en limits, nodeSelector workload=gpu-t4 y la toleration del taint (el permiso para entrar a los nodos GPU, marcados para alejar a otros pods), como en k8s/triton.yaml; en local, docker run --gpus all o la reserva de GPU de Compose.
Falta un archivo o el backend: no hay 1/model.plan en la imagen (unable to find '…' for model instance), platform con un typo (unexpected platform type) o un backend que la imagen no trae (unable to find backend library for backend …).
Compruébalo: docker run --rm --entrypoint ls IMAGEN -R /models · docker run --rm --entrypoint ls IMAGEN /opt/tritonserver/backends
Arreglo: La estructura es /models/classifier/config.pbtxt y /models/classifier/1/model.plan; C01 hace fallar el build si falta el plan (RUN test -f …). platform: "tensorrt_plan", sin typos.
El modelo no está listo: /v2/health/ready o el ready del modelo no dan 200 (no conecta, 400 o 503), y el gateway responde 503 (Triton no disponible, o el modelo no está listo en Triton).
Corre primero
curl -sS -i localhost:8000/v2/health/ready
Todavía está cargando. Con --model-control-mode=none (C01), Triton carga el engine y hace el warmup (3 ejecuciones a batch 32) antes de abrir el puerto 8000: curl no conecta y la startupProbe dice connection refused. En un nodo GPU nuevo, el pod tarda minutos (nodo, imagen de varios GB, engine).
Compruébalo: kubectl logs POD -n classifier | grep -E "loading:|successfully loaded|Started HTTPService" · kubectl describe pod POD -n classifier (eventos de la startupProbe)
Arreglo: Esperar es lo correcto: la startupProbe de C01 da hasta 5 min (60 × 5 s). Para picos, mínimo caliente de 2 réplicas e imágenes pre-descargadas.
No cargó. Con --exit-on-error=true (C01), Triton sale y el pod entra en CrashLoopBackOff (ver «Triton no carga el modelo»); con --exit-on-error=false sigue arriba, el modelo queda UNAVAILABLE y, por --strict-readiness=true, /v2/health/ready da 400.
Compruébalo: curl -s -X POST localhost:8000/v2/repository/index (state y reason de cada modelo)
Arreglo: Arregla el modelo que falla; no apagues strict readiness para que pase la probe.
El cliente pide otro nombre o una versión que no está lista: Request for unknown model: '…' is not found o … has no available versions. Triton 24.08 responde 404 a los dos (hasta la 23.12, 400).
Compruébalo: kubectl get deploy gateway -n classifier -o jsonpath='{.spec.template.spec.containers[0].env}' (MODEL_NAME) contra POST /v2/repository/index
Arreglo: MODEL_NAME = nombre de la carpeta = name en config.pbtxt (classifier). Sin versión fija en el cliente, Triton usa la más reciente cargada.
El servidor se está apagando (rollout o scale-down): /v2/health/ready da 503 (Server exiting) y los requests nuevos reciben Server is stopping, scheduler for model has stopped accepting new inference requests.
Compruébalo: kubectl get pods -n classifier -l app.kubernetes.io/name=triton -w (pods en Terminating)
Arreglo: El preStop de C01 (espera 10 s antes de la señal de apagado, SIGTERM) deja que el Service saque el pod primero, y --exit-timeout-secs=30 termina lo que estaba en vuelo; revisa esos valores contra terminationGracePeriodSeconds (60 s).
Las inferencias fallan con 400: unexpected shape, data-type o unexpected inference input.
Corre primero
curl -s localhost:8000/v2/models/classifier
Falta el eje de batch: con max_batch_size > 0, la shape es [-1] + dims, y el cliente mandó [3, 224, 224].
Compruébalo: print(batch.shape) en el cliente, contra "shape":[-1,3,224,224] de la metadata
Arreglo: batch = x[np.newaxis] → [1, 3, 224, 224], como _decode_and_hash en el gateway de C01.
Layout HWC en vez de CHW ([1, 224, 224, 3]): una imagen de Pillow u OpenCV sin transpose.
Compruébalo: print(batch.shape, batch.flags['C_CONTIGUOUS'])
Arreglo: x.transpose(2, 0, 1) y np.ascontiguousarray, como to_chw_normalized en preprocess.py.
FP64 en vez de FP32: NumPy crea float64 por defecto. Si declaraste FP32, tritonclient lo frena antes (got unexpected datatype FP64 from numpy array, expected FP32); si declaraste FP64, lo rechaza Triton.
Compruébalo: print(batch.dtype)
Arreglo: batch.astype(np.float32) y "FP32" en InferInput, igual que config.pbtxt.
Un nombre de tensor equivocado (unexpected inference input 'INPUT__0' for model 'classifier'): nombres de otro ejemplo, o los que el export pone por defecto.
Compruébalo: curl -s localhost:8000/v2/models/classifier (inputs[].name y outputs[].name)
Arreglo: Los nombres los fija el export (input_names, output_names) y los repiten config.pbtxt y el cliente: input y logits en C01.
Las predicciones del servicio no coinciden con PyTorch, o la accuracy online cae sin ningún error.
Corre primero
python model/parity_check.py --checkpoint resnet50_500.pt --onnx model/classifier.onnx --images /ruta/muestra_eval --n 2000
Un bug de export: un if que el tracer congeló, el eje de batch fijo, un opset mal convertido o el modelo exportado en modo train.
Compruébalo: parity_check.py con --backend ort (CPU): si ya falla aquí, el problema es el ONNX
Arreglo: model.eval() antes de exportar, batch dinámico verificado con 1 y 32, opset leído del archivo; o el exportador dynamo, que falla en vez de congelar.
El engine diverge (ORT pasa, Triton no): una capa en FP16 que desborda o pierde precisión, o un plugin con un bug.
Compruébalo: polygraphy run model/classifier.onnx --trt --fp16 --onnxrt --trt-outputs mark all --onnx-outputs mark all (con TensorRT 10; Polygraphy 0.50 o más nueva también acepta '*')
Arreglo: Esa capa en FP32 (TensorRT 10: --precisionConstraints=obey --layerPrecisions=CAPA:fp32; TensorRT 11: AutoCast con --nodes_to_exclude) y repite la paridad.
Skew de preproceso: el servicio no prepara la foto como el entrenamiento (BGR, otro resize, normalizar dos veces, sin EXIF). La paridad del modelo no lo ve, porque PyTorch y el engine reciben el mismo tensor.
Compruébalo: Paridad end-to-end: fotos de referencia por POST /v1/classify contra las predicciones del pipeline de evaluación; y preprocess.py contra el transform de torchvision (el lab de pre y postprocesamiento)
Arreglo: Un solo preprocess.py que importan entrenamiento, evaluación y gateway, con tensores de referencia en CI.
labels.json en otro orden que las clases del entrenamiento: todo responde 200, el score se ve normal y la categoría es la de otra clase.
Compruébalo: Cantidad y orden de labels.json contra el class_to_idx del dataset de entrenamiento (500 labels = 500 logits)
Arreglo: Publica los labels junto con el modelo y verifica versión y cantidad en la readiness, como propone C01 para producción.
El p99 está alto, pero la GPU trabaja poco y el cómputo de Triton está estable.
Corre primero
histogram_quantile(0.99, sum by (le) (rate(classifier_preprocess_seconds_bucket[5m])))
Preproceso en CPU: fotos web grandes sin draft mode, o el decode dentro de un async def que bloquea el event loop (el p99 crece con la concurrencia sin que la CPU esté llena).
Compruébalo: preprocess_ms contra inference_ms en el log JSON del gateway; classifier_preprocess_seconds contra classifier_inference_seconds
Arreglo: run_in_threadpool con un semáforo (2 decodes por proceso en C01), draft mode conservador y downscale en el cliente.
Espera en la cola de Triton aunque la GPU esté libre: una ventana de batching (max_queue_delay_microseconds) más grande que el presupuesto.
Compruébalo: sum(rate(nv_inference_queue_duration_us{model="classifier"}[5m])) / sum(rate(nv_inference_request_success{model="classifier"}[5m]))
Arreglo: max_queue_delay_microseconds dentro del presupuesto (2 ms en C01), subida de a poco con perf_analyzer.
Red y serialización: tensores en JSON (≈ 5× más bytes y ≈ 50 ms de CPU por request en Python) o clientes que no reusan conexiones.
Compruébalo: perf_analyzer -m classifier -u localhost:8000 --input-tensor-format=json, y lo mismo con --input-tensor-format=binary
Arreglo: binary_data=True en InferInput y en InferRequestedOutput; un cliente por proceso con keep-alive (C01: conn_limit=64).
CPU throttling (Kubernetes frena el contenedor que gastó su cuota): un limits.cpu apretado corta el pod en ventanas de 100 ms y mete picos de p99 aunque el promedio sea bajo.
Compruébalo: rate(container_cpu_cfs_throttled_periods_total{namespace="classifier"}[5m]) / rate(container_cpu_cfs_periods_total{namespace="classifier"}[5m])
Arreglo: Sin límite de CPU en el gateway y en Triton (como C01), o uno holgado; y los hilos de ORT o PyTorch fijados a los vCPU del pod.
nvidia-smi muestra la GPU cerca del 100 %, pero el throughput es bajo.
Corre primero
nvidia-smi -q -d PERFORMANCE,CLOCK
La utilización de nvidia-smi es el porcentaje de tiempo con al menos un kernel corriendo, no la capacidad usada: con batches de 1 la GPU está «ocupada» con poco trabajo por kernel.
Compruébalo: sum(rate(nv_inference_count{model="classifier"}[5m])) / sum(rate(nv_inference_exec_count{model="classifier"}[5m])) (batch promedio)
Arreglo: Dynamic batching activo y ajustado con perf_analyzer; escala por tiempo en cola, no por utilización de GPU (C01).
La T4 baja clocks: es una tarjeta pasiva de 70 W y, bajo carga sostenida, llega a su tope de potencia o de temperatura.
Compruébalo: nvidia-smi -q -d PERFORMANCE,CLOCK (Clocks Event Reasons: SW Power Cap, HW Slowdown, SW Thermal Slowdown; antes del driver 530 se llamaba Clocks Throttle Reasons)
Arreglo: Planea al 50 % del máximo medido (C01), mide con carga sostenida y revisa la refrigeración del nodo.
El engine no es el que crees: construido sin FP16 (TensorRT 10 sin --fp16) o con un perfil afinado para batch 16 cuando llegan batches de 1–3.
Compruébalo: trtexec --loadEngine=model.plan --shapes=input:2x3x224x224 --noDataTransfers, contra el engine anterior; y las huellas del build en el registry
Arreglo: Reconstruye con la precisión y el perfil correctos; compara opt 16 contra opt 4 con perf_analyzer a 250–500 req/s (lo que pide build_engine.sh).
Otro proceso o modelo usa la misma GPU: un backfill (el job que reclasifica el catálogo), o un segundo modelo en el mismo Triton.
Compruébalo: nvidia-smi --query-compute-apps=pid,used_memory --format=csv · nv_inference_compute_infer_duration_us por modelo
Arreglo: El batch por su propio Triton o su propio nodo; varios modelos en una GPU solo con colas y métricas separadas por modelo.
CUDA out of memory en PyTorch, o un error de memoria de GPU al cargar el engine en Triton.
Corre primero
nvidia-smi --query-compute-apps=pid,used_memory --format=csv
Varias copias del modelo: uvicorn --workers N (un contexto CUDA y una copia de pesos por proceso), o un proceso viejo que dejó la GPU tomada.
Compruébalo: nvidia-smi --query-compute-apps=pid,used_memory --format=csv
Arreglo: Un proceso por contenedor y una sola copia del modelo; con Triton, instancias en vez de procesos.
Inferencia sin torch.inference_mode (autograd guarda activaciones), o un batch o una resolución demasiado grandes.
Compruébalo: torch.cuda.max_memory_allocated() y torch.cuda.memory_reserved() alrededor de una inferencia
Arreglo: with torch.inference_mode(): dentro de la función que corre el modelo, y un batch máximo medido, no supuesto.
Fragmentación del allocator de PyTorch (el que reparte la VRAM): mucha memoria reservada pero sin asignar (el propio mensaje lo dice).
Compruébalo: print(torch.cuda.memory_summary())
Arreglo: PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, la sugerencia del mensaje.
En Triton, demasiadas instancias, versiones o modelos en la GPU: cada instancia reserva sus activaciones, y una version_policy con varias versiones las carga todas. TensorRT lo dice como Requested amount of GPU memory (… bytes) could not be allocated o Error Code 2: OutOfMemory, y Triton, como unable to create TensorRT engine (o context): …
Compruébalo: curl -s localhost:8002/metrics | grep nv_gpu_memory_used_bytes
Arreglo: Menos instancias o un batch máximo más chico (decídelo con model_analyzer), latest { num_versions: 1 } y VRAM medida antes de sumar modelos.
Un engine que funcionaba ayer hoy no carga, sin cambios en el modelo.
Corre primero
kubectl logs POD -n classifier --previous | grep -E "TensorRT|failed to load"
Se actualizó la imagen de Triton a otra release de NGC (otro TensorRT): The engine plan file is not compatible with this version of TensorRT.
Compruébalo: trtexec --getPlanVersionOnly --loadEngine=model.plan, contra el TensorRT de la release de Triton (24.08 = TensorRT 10.3.0.26; 26.09 = TensorRT 11.3)
Arreglo: Fija el tag (o el digest) de la imagen y reconstruye el engine con la misma release. La excepción opcional: un engine --versionCompatible, algo más lento, que Triton carga con --backend-config=tensorrt,version-compatible=true.
El pod cayó en otro tipo de GPU (un node group nuevo con L4, o sin nodeSelector): The engine plan file is generated on an incompatible device.
Compruébalo: kubectl get pod POD -n classifier -o wide y kubectl get node NODO -L node.kubernetes.io/instance-type
Arreglo: Un engine y una imagen por tipo de GPU, y un nodeSelector por node pool (workload=gpu-t4 en C01).
Cambió el driver del nodo (otra AMI) y es más viejo que el CUDA de la imagen: al arrancar, Triton avisa pinned memory pool will not be available: CUDA driver version is insufficient for CUDA runtime version.
Compruébalo: kubectl debug node/NODO -it --image=ubuntu --profile=sysadmin y, adentro, chroot /host nvidia-smi (Driver Version)
Arreglo: 24.08 (CUDA 12.6) pide driver 560 o, en GPUs de data center como la T4, 470.57+, 525.85+, 535.86+ o 545.23+ de esas ramas; actualiza la AMI o vuelve a la anterior.
La GPU falló: errores Xid del driver (por ejemplo, Xid 79: GPU has fallen off the bus).
Compruébalo: kubectl debug node/NODO -it --image=ubuntu --profile=sysadmin y, adentro, chroot /host dmesg | grep -i xid
Arreglo: Drena el nodo y deja que el autoscaler lo reemplace; con 2 réplicas en 2 zonas, la otra T4 sigue sirviendo.
El export a ONNX falla, o termina pero verify() lo rechaza ([export] FALLÓ la verificación).
Corre primero
python model/export_onnx.py --out model/classifier.onnx
Una operación sin equivalente en ese opset: Exporting the operator '…' to ONNX opset version 17 is not supported.
Compruébalo: El nombre del operador en el mensaje (aten::…) y si agrega Support for this operator was added in version N
Arreglo: Sube el opset si alcanza; si no, reescribe con operaciones estándar, deja esa parte fuera del grafo o prueba el exportador dynamo (--dynamo --opset 18).
Control de flujo que depende de los datos: dynamo falla en el paso 1/3 (Could not guard on data-dependent expression); TorchScript lo congela con un TracerWarning y sigue.
Compruébalo: torch.export.export(model, (x,)) directo, para ver el error completo sin el envoltorio del export
Arreglo: torch.cond, o sacar esa lógica del modelo (al gateway o a un paso aparte).
El opset del archivo no es el pedido (dynamo con opset 17 deja 18: Failed to convert the model to the target version 17), o el eje de batch quedó fijo.
Compruébalo: python -c "import onnx; m = onnx.load('model/classifier.onnx'); print(m.opset_import, [d.dim_param or d.dim_value for d in m.graph.input[0].type.tensor_type.shape.dim])"
Arreglo: TorchScript con opset 17 (lo que usa C01) o dynamo con --opset 18; dynamic_axes o dynamic_shapes en el eje 0.
Falta una dependencia: el exportador dynamo (el default desde torch 2.9) necesita onnxscript, además de onnx.
Compruébalo: pip show onnx onnxscript onnxruntime
Arreglo: Las versiones fijadas de model/requirements.txt (onnx 1.23.0, onnxscript 0.7.2, onnxruntime 1.30.0).
El primer request (o los primeros) después de levantar un pod tarda mucho más que el resto.
Corre primero
grep -n -A12 model_warmup triton/model_repository/classifier/config.pbtxt
Sin model_warmup: la primera ejecución paga reservas de memoria e inicialización, y la readiness pasa con el modelo frío.
Compruébalo: curl -s localhost:8000/v2/models/classifier/config | grep -o model_warmup
Arreglo: model_warmup con el batch máximo (C01: batch 32, 3 veces, zero_data): Triton no marca el modelo listo hasta terminarlo.
Un engine que se compila al arrancar: ONNX Runtime con el provider de TensorRT sin caché, o torch.compile en el proceso (≈ 17 s la primera llamada en la medición del curso, en CPU).
Compruébalo: El tiempo entre el arranque y READY en los logs; con ORT, si trt_engine_cache_enable está en las opciones del provider
Arreglo: Construye el engine en CI y llévalo en la imagen; con torch.compile, warmup de todas las shapes y la caché de compilación en la imagen.
El cold start del pod: nodo GPU nuevo (≈ 2–5 min), imagen de varios GB (≈ 2–5 min) y carga del engine. Un spike corto lo absorbe el margen, no el autoscaler.
Compruébalo: kubectl describe pod POD -n classifier (Events: Pulling, Pulled, Started) y kubectl get nodes
Arreglo: Mínimo caliente de 2, imágenes pre-descargadas, overprovisioning (pods de relleno que mantienen un nodo GPU de reserva) o escalado programado antes del pico.
Sin Triton (el MVP con PyTorch en FastAPI): el primer forward carga los kernels de CUDA y reserva memoria, y con torch.backends.cudnn.benchmark=True prueba algoritmos para cada shape nueva. Si la readiness pasa antes, lo paga un usuario.
Compruébalo: El tiempo del primer y del segundo model(x) al arrancar (time.perf_counter con torch.cuda.synchronize())
Arreglo: Warmup en el lifespan de FastAPI: unas inferencias con las shapes reales antes de responder ready.
El batching no aparece: casi todas las ejecuciones en Triton son de batch 1.
Corre primero
curl -s localhost:8000/v2/models/classifier/stats
No hay bloque dynamic_batching. Con --disable-auto-complete-config (C01), Triton no lo agrega solo: ejecuta cada request tal como llega.
Compruébalo: curl -s localhost:8000/v2/models/classifier/config | grep -o dynamic_batching
Arreglo: dynamic_batching { max_queue_delay_microseconds: … } con max_batch_size > 0.
La carga no da para juntar: a 250 req/s llegan ≈ 0.5 requests en una ventana de 2 ms. Un batch de ≈ 1–3 es lo esperado en C01, no un error.
Compruébalo: batch_stats de /v2/models/classifier/stats (ejecuciones por tamaño de batch), o rate(nv_inference_count) / rate(nv_inference_exec_count)
Arreglo: Nada que arreglar: el batching rinde en picos, en failover y en el backfill. Subir la ventana solo agrega latencia.
max_queue_delay_microseconds en 0 (el default): el batcher solo junta lo que ya está esperando en la cola.
Compruébalo: curl -s localhost:8000/v2/models/classifier/config
Arreglo: Sube la ventana de a poco con perf_analyzer, hasta el borde del presupuesto de latencia (la receta de la guía de Triton).
max_batch_size: 0 (sin batching), o instancias de más que se reparten los pocos requests.
Compruébalo: curl -s localhost:8000/v2/models/classifier/config (max_batch_size e instance_group)
Arreglo: max_batch_size > 0, y el count de instancias decidido con model_analyzer (la herramienta de Triton que prueba configuraciones).
gRPC (o HTTP) rinde peor de lo esperado entre el gateway y Triton.
Corre primero
perf_analyzer -m classifier -i grpc -u localhost:8001 --concurrency-range 8
HTTP en JSON: sin binary_data=True, el tensor de 602 KB viaja como ≈ 3.1 MB de texto, y serializarlo cuesta ≈ 50 ms de CPU en Python.
Compruébalo: perf_analyzer -m classifier -i http -u localhost:8000 --concurrency-range 8 --input-tensor-format=json, contra --input-tensor-format=binary (y contra el resultado de gRPC)
Arreglo: binary_data=True en InferInput y en InferRequestedOutput (C01), o gRPC.
gRPC detrás de un Service de Kubernetes: una sola conexión HTTP/2 por cliente, y el Service reparte conexiones, no requests (balanceo L4). Todo el tráfico de un gateway va a un pod de Triton, y el pod que agrega KEDA (el autoscaler de Triton en C01) no recibe nada.
Compruébalo: sum by (pod) (rate(nv_inference_request_success{model="classifier"}[5m]))
Arreglo: HTTP con un pool de conexiones (C01), o gRPC con balanceo del lado del cliente (un headless service le da la IP de cada pod) o por request (un service mesh, con un proxy junto a cada pod).
Un cliente nuevo por request: handshake TCP (y TLS) en cada llamada, sin keep-alive.
Compruébalo: Dónde se crea el InferenceServerClient
Arreglo: Un cliente por proceso, reutilizado: C01 lo crea en el lifespan.
Compresión activada en una red rápida: gzip o deflate cuestan CPU en los dos lados.
Compruébalo: perf_analyzer -m classifier -i grpc -u localhost:8001 --grpc-compression-algorithm=gzip, contra =none
Arreglo: Sin compresión dentro de la VPC; para mandar menos bytes, entrada UINT8 (÷ 4).
El gateway se satura de CPU antes que la GPU: 503 gateway saturado (CPU), o el HPA del gateway en su máximo.
Corre primero
kubectl top pod -n classifier -l app.kubernetes.io/name=gateway
Fotos web grandes (12 MP) sin draft mode: ≈ 40–80 ms de CPU cada una, contra ≈ 6 ms del promedio.
Compruébalo: classifier_image_bytes y classifier_preprocess_seconds en Prometheus
Arreglo: Draft mode conservador (ya está en preprocess.py) y downscale a ≤ 1024 px en el cliente.
Pocos slots de decode o pocas réplicas: PREPROCESS_CONCURRENCY=2 por proceso, y si no hay slot en 50 ms, 503.
Compruébalo: kubectl get hpa -n classifier (¿llegó a 12 réplicas?)
Arreglo: Más réplicas (el HPA por CPU al 60 %), no más hilos por pod: el semáforo también acota la RAM (≈ 320 MB por decode de 40 MP).
Trabajo de CPU en el event loop: un async def que decodifica bloquea a todos los requests del proceso.
Compruébalo: py-spy dump --pid PID (en local; en el cluster necesita la capability SYS_PTRACE)
Arreglo: run_in_threadpool para la CPU; await solo para I/O.
A 10× el tráfico, el decode pasa a ser el cuello del sistema.
Compruébalo: CPU por imagen × req/s: 500 × 6 ms = 3 cores hoy
Arreglo: Medir DALI (decode en GPU) contra más gateway, sabiendo que DALI es otro decoder: repite la paridad end-to-end.
La accuracy cae solo con el engine FP16 o INT8; el ONNX en FP32 pasa la paridad.
Corre primero
python model/parity_check.py --backend triton --triton-url localhost:8000 --checkpoint resnet50_500.pt --images /ruta/muestra_eval --n 2000
Una capa desborda o pierde precisión en FP16 (el máximo de FP16 es 65,504). Pasa más en transformers que en una CNN como ResNet-50, donde la diferencia media de logits ronda 1e-2 (1.3e-2 en el lab de paridad).
Compruébalo: polygraphy run model/classifier.onnx --trt --fp16 --onnxrt --trt-outputs mark all --onnx-outputs mark all (TensorRT 10)
Arreglo: Esa capa en FP32: --precisionConstraints=obey --layerPrecisions=CAPA:fp32 (TensorRT 10) o AutoCast con --nodes_to_exclude (TensorRT 11).
INT8 sin calibración real: trtexec --int8 sin datos usa escalas de relleno (sirve para medir velocidad, no calidad), o calibraste con ruido o con las augmentations del entrenamiento (sus transformaciones al azar).
Compruébalo: De dónde salieron las escalas: el cache de calibración, o los nodos Q/DQ del ONNX y los datos que los generaron
Arreglo: Calibrar con ≈ 500–1,000 fotos de tráfico real, pasadas por el mismo preprocess.py y estratificadas por slice.
INT8 pierde más en el long tail (las clases con pocas fotos): el promedio global cae 0.3 puntos y una categoría chica, 5 puntos.
Compruébalo: Recall por clase, head contra long tail, y los slices del golden set; no solo el top-1 global
Arreglo: Escalas por canal, calibración por percentil, capas sensibles en FP16 o QAT (reentrenar simulando INT8); y si no compra nada (C01 hoy), no lances INT8.
Tolerancias de FP32 aplicadas a FP16: Polygraphy falla con su default (1e-5) aunque el engine esté bien.
Compruébalo: La línea FAILED | Output: 'logits' | Difference exceeds tolerance (rel=1e-05, abs=1e-05)
Arreglo: Decide con métricas de producto (top-1 ≥ 99.5 %, top-5 ≥ 99 %, |Δlogit| ≤ 0.5 en C01) y tolerancias que FP16 justifique, no con 1e-5.
Pega el config.pbtxt de un modelo de Triton. Revisa 18 reglas típicas en tu navegador: el contrato de tensores (dims con el batch, tipos), el batching y la cola, las instancias, las versiones y el warmup. Cada hallazgo dice el campo y la línea. No reemplaza la validación de Triton, que corre al cargar el modelo.
Lo lee un parser de protobuf en texto escrito para esta página: no baja nada y funciona sin conexión.