Ronda de Diseño ML · Serving de modelos · ejemplos de C01

Modelo y API

De PyTorch a Triton

Para 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

0

Números de bolsillo: bytes, VRAM, la T4 y las rutas de Triton

¿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/modelo

La 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 metrica

Las 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

VariableQué esEjemplo
puerto_httppuerto HTTP de Triton (protocolo KServe v2)8000
puerto_metricaspuerto de métricas para Prometheus8002
modelonombre del modelo: su carpeta en el model repositoryclassifier
metricanombre de una métrica de Tritonnv_inference_queue_duration_us
PPparámetros del modelo: los números que aprendió24,532,532 ≈ 24.5 M (ResNet-50 con su capa final, fc, de 500 clases)
bbbytes por valor según la precisiónFP32 4 · FP16 2 · INT8 1
BBbatch: cuántas imágenes van juntas en una ejecución1 a 32 (max_batch_size: 32)
texec(B)t_{\text{exec}}(B)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)
X, XmaxX,\ X_{\text{max}}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)
λ\lambdatasa de llegada: requests por segundo250 req/s por GPU en el pico de diseño (500 entre 2 GPUs)
ρ\rhoutilización: qué fracción de la capacidad usas≈ 21 % por T4 en el pico; se planifica hasta ≈ 50 %
W, LW,\ LW: tiempo que pasa un request adentro (cola + cómputo). L: cuántos requests hay adentro a la vezW ≈ 5–10 ms dentro de Triton (estimación) → L ≈ 1–3
Δt, Bˉ\Delta t,\ \bar{B}Δ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

VRAM  ≈  P×b⏟pesos  +  activaciones  +  contexto CUDA  +  margenP×b=24.5 M×2 B≈49 MB (FP16)\text{VRAM} \;\approx\; \underbrace{P \times b}_{\text{pesos}} \;+\; \text{activaciones} \;+\; \text{contexto CUDA} \;+\; \text{margen} \qquad P \times b = 24.5\,\text{M} \times 2\,\text{B} \approx 49\,\text{MB (FP16)}
tensor=3×224×224×4 B=602,112 B≈602 KBlogits=500×4 B=2 KB\text{tensor} = 3 \times 224 \times 224 \times 4\,\text{B} = 602{,}112\,\text{B} \approx 602\,\text{KB} \qquad \text{logits} = 500 \times 4\,\text{B} = 2\,\text{KB}
X=Btexec(B)ρ=λXmax⁡=2501,200≈21 %X = \frac{B}{t_{\text{exec}}(B)} \qquad \rho = \frac{\lambda}{X_{\max}} = \frac{250}{1{,}200} \approx 21\,\%
L=λ W250 reqs×(0.005–0.010) s≈1–3 requests dentro de cada TritonL = \lambda\,W \qquad 250\ \tfrac{\text{req}}{\text{s}} \times (0.005\text{–}0.010)\ \text{s} \approx 1\text{–}3\ \text{requests dentro de cada Triton}
Bˉ=Δ nv_inference_countΔ nv_inference_exec_countBˉ≳1+λ Δt=1+250×0.002=1.5\bar{B} = \frac{\Delta\,\texttt{nv\_inference\_count}}{\Delta\,\texttt{nv\_inference\_exec\_count}} \qquad \bar{B} \gtrsim 1 + \lambda\,\Delta t = 1 + 250 \times 0.002 = 1.5

Las piezas

PiezaQué haceEjemplo
FP32 · 4 BPrecisió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 BMedia 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 BEnteros 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 · FP8Formatos 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 T4La 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 salidaLo 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 ocupadaLa 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 · 8002Puertos por defecto de Triton: HTTP/REST, gRPC y métricas.el gateway llama al 8000; Prometheus lee el 8002
/v2/health/live · /v2/health/readylive: ¿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/configLa 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/inferLa 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_usMicrosegundos 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 msLa 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)

  • en uso con 2 instancias: ≈ 1–3 GB (rango estimado)
  • los pesos FP16: 49.1 MB, el 0.3 % de la tarjeta
  • libre

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.

Bytes por parámetro y peso de los pesos del modelo del C01 (24,532,532 parámetros), a escala. Al lado, la T4: los pesos son una rebanada mínima de sus 16 GB; lo que ocupa memoria es el runtime y las activaciones.

Ejemplo real · El contrato de entrada del C01: los números del preprocesamiento compartido

Pythonreference/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)
  • 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).
  • En FP32 son 3 × 224 × 224 × 4 B = 602,112 B: los ≈ 602 KB que el gateway manda a Triton en cada request (≈ 2.4 Gb/s a 500 req/s).
  • 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.
  • Del lado del modelo, 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.

Laboratorio en tu Mac, sin GPU (opcional)6 pasos

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

  1. Paso 1
    python3.13 -m venv ~/venvs/lab-triton
    source ~/venvs/lab-triton/bin/activate
    pip install torch==2.14.0 torchvision==0.29.0

    Qué 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.

  2. Paso 2
    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.

  3. Paso 3
    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.

  4. Paso 4
    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.

  5. Paso 5
    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.

  6. Paso 6
    rm /tmp/r50_fp32.pt /tmp/r50_fp16.pt

    Qué 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.

↳

La cadena: del checkpoint a una respuesta medida

¿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

VariableQué esEjemplo
checkpointel state_dict entrenado y aprobado (.pt)resnet50_500.pt
onnxel archivo ONNX que sale del exportmodel/classifier.onnx
muestracarpeta con imágenes reales del set de evaluación≥ 2,000 imágenes (lo que recomienda parity_check.py)
releaserelease de NGC (las imágenes de contenedor de NVIDIA): la misma de la imagen de Triton24.08 (TensorRT 10.3)
gpula GPU donde correrá el engineT4
hostdónde corre Tritonlocalhost, en el runner de CI con la T4
top1, top5\text{top1},\ \text{top5}acuerdo del 1.º lugar y solapamiento del top-5 contra PyTorch FP32≥ 99.5 % y ≥ 99 % (defaults de parity_check.py)
max⁡∣Δlogit∣\max|\Delta \text{logit}|la mayor diferencia entre los logits de los dos modelos≤ 0.5: atrapa exports rotos
p99\text{p99}la latencia que solo el 1 % de los requests superaperf_analyzer a 250 y 500 req/s contra el champion (el modelo en producción); el challenger es el candidato

Fórmulas

registrar como challenger  ⟺  top1≥0.995  ∧  top5≥0.99  ∧  max⁡∣Δlogit∣≤0.5⏟paridad, en CPU y en la T4  ∧  p99nuevo≤1.1⋅p99champion⏟latencia\text{registrar como challenger} \iff \underbrace{\text{top1} \ge 0.995 \;\wedge\; \text{top5} \ge 0.99 \;\wedge\; \max|\Delta\text{logit}| \le 0.5}_{\text{paridad, en CPU y en la T4}} \;\wedge\; \underbrace{p99_{\text{nuevo}} \le 1.1 \cdot p99_{\text{champion}}}_{\text{latencia}}

Las piezas

PiezaQué haceEjemplo
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.exportRecorre el modelo con una entrada de ejemplo y escribe el grafo ONNX con sus pesos.exportador TorchScript, opset 17, batch dinámico
classifier.onnxEl 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.checkerRevisa 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.pyEl 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
trtexecLa 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 repositoryLa 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 TritonEl 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_analyzerGenera carga contra Triton y mide throughput y latencias por percentil.gate: p99 a 250 y 500 req/s ≤ champion + 10 %
  1. 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

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

  3. desde aquí, en una T4 con NGC 24.08
  4. 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

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

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

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

Seis eslabones del checkpoint a una respuesta medida. Los dos primeros corren en cualquier CPU; desde el engine, todo corre en una T4 con la release 24.08 de NGC. Cada eslabón tiene su chequeo, y un exit code distinto de 0 frena la cadena.

Ejemplo real · La cadena tal como la dibuja el README del C01

textoreference/c01/README.md (Flujo: del checkpoint a producción)
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 / canary
  • La primera línea corre en cualquier máquina, también en tu Mac: export y paridad en CPU con ONNX Runtime. Valida el export sin tocar una GPU.
  • La segunda corre en la máquina con la T4 (una g4dn.xlarge): el engine se compila donde va a correr, con la misma release de NGC (24.08) que la imagen de Triton.
  • docker 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).
  • La tercera es el gate final: Triton arriba con ese engine, y 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.
  • Cada ¿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ó.

Laboratorio en tu Mac, sin GPU (opcional)6 pasos

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.

  1. Paso 1
    cd apps/ml-system-design/reference/c01
    python3.13 -m venv .venv && source .venv/bin/activate
    pip install -r model/requirements.txt

    Qué 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.

  2. Paso 2
    python model/export_onnx.py --out /tmp/c01.onnx

    Qué 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.

  3. Paso 3
    python model/parity_check.py --onnx /tmp/c01.onnx --n 64

    Qué 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.

  4. Paso 4
    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.

  5. Paso 5
    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.

  6. Paso 6
    deactivate
    rm /tmp/c01.onnx /tmp/otro.pt

    Qué 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í.

1

Qué es servir un modelo (y por qué model(x) dentro de FastAPI no basta)

¿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=repo

Arranca 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

VariableQué esEjemplo
modulomódulo de Python con la app FastAPIapp.main (el gateway del C01)
puertopuerto donde escucha uvicorn8080
nprocesos de uvicorn; cada uno carga su propia copia del modelo1 en el C01: se escala con pods
resultado, funcion, datoslo que devuelve, la función bloqueante y sus argumentosbatch, digest = …(_decode_and_hash, data)
modelonombre del modelo en Tritonclassifier
entrada, salidael InferInput (nombre, shape, dtype y datos) y el InferRequestedOutputinput FP32 [1, 3, 224, 224] y logits
repocarpeta del model repository/models (horneada en la imagen)
nnprocesos 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
λdisen˜o\lambda_{\text{diseño}}el tráfico a aguantar en el pico500 req/s
XXcapacidad planificada por GPUeager a batch 1 ≈ 100 img/s; Triton + TensorRT FP16 ≈ 600 (el 50 % de 1,200)

Fórmulas

VRAMworkers≈n×(pesos+contexto CUDA)=4×(0.1+0.3–1 GB)≈1.6–4.4 GB\text{VRAM}_{\text{workers}} \approx n \times (\text{pesos} + \text{contexto CUDA}) = 4 \times (0.1 + 0.3\text{–}1\ \text{GB}) \approx 1.6\text{–}4.4\ \text{GB}
NGPU=⌈λdisen˜oX⌉eager, batch 1: ⌈500100⌉=5Triton + TensorRT: ⌈500600⌉=1 (+1 de repuesto: N+1)N_{\text{GPU}} = \left\lceil \frac{\lambda_{\text{diseño}}}{X} \right\rceil \qquad \text{eager, batch 1: } \left\lceil \tfrac{500}{100} \right\rceil = 5 \qquad \text{Triton + TensorRT: } \left\lceil \tfrac{500}{600} \right\rceil = 1\ (+1\ \text{de repuesto: N+1})

Las piezas

PiezaQué haceEjemplo
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íncronoQuien 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 / p99La 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
throughputCuántas predicciones por segundo completa el servicio.pico de diseño: 500 req/s
event loopEl 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
threadpoolHilos 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 1Cada 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 batchingEl 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 modeloUna 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
backendEl motor que ejecuta cada modelo dentro de Triton.backend tensorrt (platform: tensorrt_plan) para el engine; onnxruntime (platform: onnxruntime_onnx) para un .onnx
gatewayEl 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 sheddingCortar 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
Necesidadmodel(x) en FastAPITritonEn el C01
Batchingbatch 1, o una cola propia (asyncio.Queue, futures, timeouts, límites)dynamic_batching: dos líneas de configventana de 2 ms, hasta 32
Varias copias en la GPUun proceso por copia (--workers), sin batches entre ellasinstance_group2 instancias en la T4
Cola con plazolo que escribasdefault_queue_policy (rechaza lo que vence)REJECT a los 100 ms en cola
Versiones y recargareiniciar el proceso, o una recarga en caliente propiaversion_policy y modos de control del repositorylatest 1; una versión por imagen
Salud por modeloun /readyz propio/v2/health/ready y /v2/models/{modelo}/readyreadinessProbe en /v2/health/ready
Cola vs cómputohistogramas propiosmétricas por modelo en el puerto 8002KEDA (el autoscaler) escala con la espera en cola
Protocolo de tensoresel JSON que diseñesKServe v2 por HTTP y gRPC, con tensores binarios602 KB binarios por request
Engine optimizadotorch.compile, o TensorRT integrado a manobackends 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.

Lo que tienes que construir tú si sirves con model(x) dentro de FastAPI, contra lo que Triton ya trae, y cómo lo usa el C01.

Ejemplo real · El corte del C01: el gateway prepara una imagen y Triton hace el resto

Pythonreference/c01/gateway/app/main.py (extractos; # … marca líneas omitidas)
"""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)
  • El gateway (FastAPI, en nodos de CPU) hace lo que depende del producto y de la imagen; Triton (en la T4) solo convierte tensores en tensores. Cada uno escala con su propia señal: CPU para el gateway, tiempo en cola para Triton.
  • _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.
  • En el MVP del C01 (una VM g4dn y un solo proceso con PyTorch eager), todo esto vive en el mismo proceso. Se rompe primero por throughput (cada request es un batch de 1) y después por los deploys con reinicio: ahí la inferencia pasa a Triton y FastAPI se queda como gateway.
  • Pillow y NumPy sueltan el GIL en el decode y el resize: por eso los 2 hilos de decode pueden usar 2 cores a la vez (el pod pide 1 core y no tiene límite de CPU, así que aprovecha ráfagas).

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.

Laboratorio en tu Mac, sin GPU (opcional)2 pasos

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

  1. Paso 1
    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")
    EOF

    Qué 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.

  2. Paso 2
    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))
    EOF

    Qué 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.

2

Preparar el modelo en PyTorch: eval, inference_mode, preprocesamiento idéntico y determinismo

¿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

VariableQué esEjemplo
checkpointarchivo con el state_dict entrenadoresnet50_500.pt
entrada, salidael tensor de entrada y los logitsx [N, 3, 224, 224] FP32 → [N, 500]
semillael número que fija lo aleatorio0 en export_onnx.py y parity_check.py
γ, β\gamma,\ \betaescala y corrimiento que BatchNorm aprende, uno por canal53 BatchNorm2d en el ResNet-50
μ^, σ^2\hat{\mu},\ \hat{\sigma}^{2}running stats: media y varianza que BatchNorm guardó durante el traininglas usa eval()
μB, σB2\mu_{B},\ \sigma^{2}_{B}media y varianza del batch actuallas usa train(): la salida depende de las otras imágenes
m, εm,\ \varepsilonmomentum de la actualización y un épsilon numérico0.1 y 1e-5 (defaults de BatchNorm2d)
p, cp,\ cvalor de un píxel (0–255) y canal de color RGBmean y std de ImageNet, en preprocess.py

Fórmulas

eval: y=γ x−μ^σ^2+ε+βtrain: y=γ x−μBσB2+ε+β,μ^←(1−m) μ^+m μB\text{eval: } y = \gamma\,\frac{x - \hat\mu}{\sqrt{\hat\sigma^2 + \varepsilon}} + \beta \qquad \text{train: } y = \gamma\,\frac{x - \mu_B}{\sqrt{\sigma_B^2 + \varepsilon}} + \beta, \quad \hat\mu \leftarrow (1 - m)\,\hat\mu + m\,\mu_B
xc=p/255−meancstdcmean=(0.485, 0.456, 0.406)std=(0.229, 0.224, 0.225)x_c = \frac{p / 255 - \text{mean}_c}{\text{std}_c} \qquad \text{mean} = (0.485,\ 0.456,\ 0.406) \qquad \text{std} = (0.229,\ 0.224,\ 0.225)

Las piezas

PiezaQué haceEjemplo
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 hiloinference_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 compartidoUna 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 BGREl orden de canales del training. OpenCV lee en BGR; Pillow, en RGB.convert("RGB") en decode()
EXIFMetadatos de la foto; las de celular guardan ahí la rotación.ImageOps.exif_transpose: sin esto el modelo ve la foto de lado
semillasFijan lo aleatorio: pesos iniciales, datos de ejemplo, augmentations.torch.manual_seed(0): export y paridad crean el mismo modelo sin checkpoint
determinismoMismos 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)
ModoQué cambiaLa salidaÚsalo para
model.train()BatchNorm usa la media y la varianza del batch y actualiza sus running stats; Dropout activodepende de las otras imágenes del batchentrenar
model.eval()BatchNorm usa sus running stats; Dropout apagado. No toca los gradientesla misma para cada imagen, con cualquier batchevaluar, exportar y servir
torch.no_grad()no registra operaciones para el backwardrequires_grad=False; se puede usar después con autogradla validación dentro del training
torch.inference_mode()lo de no_grad, y además sin seguimiento de vistas ni de versionesrequires_grad=False e is_inference()=True; no entra a autogradinferencia pura: servir y la referencia de la paridad
eval() + inference_mode()las dos cosas: no se pisanla misma por imagen y sin memoria de autogradlo 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.

Los dos interruptores de PyTorch: el modo del modelo (train/eval) y el de los gradientes (no_grad/inference_mode). Son independientes, y para servir van los dos.

Ejemplo real · build_model(): el modelo que todo el pipeline usa como referencia

Pythonreference/c01/model/export_onnx.py (build_model completo)
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.
  • Sin checkpoint, 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.

Laboratorio en tu Mac, sin GPU (opcional)5 pasos

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

  1. Paso 1
    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())
    EOF

    Qué 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.

  2. Paso 2
    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())
    EOF

    Qué deberías ver: running_mean cambió: True | batches vistos: 1. Un solo forward en train(), sin gradientes, ya modificó el modelo.

  3. Paso 3
    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])
    EOF

    Qué 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.

  4. Paso 4
    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())
    EOF

    Qué 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.

  5. Paso 5
    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}")
    EOF

    Qué 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.

3

Exportar a ONNX: dynamo, opset, ejes dinámicos, checker y ONNX Runtime

¿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

VariableQué esEjemplo
ejemplotensor de entrada de ejemplo, con la forma realtorch.randn(2, 3, 224, 224): batch 2, no 1
archivoel archivo ONNXclassifier.onnx
opsetversión del conjunto de operadores ONNX17 con el exportador TorchScript; 18 con dynamo
entrada, salidanombres de los tensores: el contrato con Triton y el gatewayinput y logits
argumentonombre del argumento de forward() que lleva el eje dinámicox, porque ResNet.forward(self, x)
max_batchbatch máximo del eje dinámico32 = max_batch_size de Triton
x_float32array de numpy float32 con la forma de la entradatorch.randn(7, 3, 224, 224).numpy()
W, bW,\ bpesos y bias de una convoluciónlas convoluciones de ResNet no tienen bias: b = 0
γ, β, μ^, σ^2, ε\gamma,\ \beta,\ \hat{\mu},\ \hat{\sigma}^{2},\ \varepsilonlo que aprendió y guardó el BatchNorm que sigue a esa convolución53 pares Conv + BatchNorm en el ResNet-50
PmaxP_{\text{max}}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

W′=W⋅γσ^2+εb′=β+(b−μ^) γσ^2+ε(Conv+BatchNorm  →  un Conv)W' = W \cdot \frac{\gamma}{\sqrt{\hat\sigma^2 + \varepsilon}} \qquad b' = \beta + (b - \hat\mu)\,\frac{\gamma}{\sqrt{\hat\sigma^2 + \varepsilon}} \qquad (\text{Conv} + \text{BatchNorm} \;\to\; \text{un Conv})
un solo .onnx≤2 GB  ⇒  Pmax⁡≈2 GB4 B≈500 M paraˊmetros en FP32C01: 24.5 M×4 B≈98 MB\text{un solo .onnx} \le 2\ \text{GB} \;\Rightarrow\; P_{\max} \approx \frac{2\ \text{GB}}{4\ \text{B}} \approx 500\,\text{M parámetros en FP32} \qquad \text{C01: } 24.5\,\text{M} \times 4\,\text{B} \approx 98\,\text{MB}

Las piezas

PiezaQué haceEjemplo
ONNXUn formato de archivo estándar para redes: grafo de operadores + pesos + firma de entradas y salidas, sin depender de PyTorch.classifier.onnx ≈ 98 MB
opsetLa 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 ejemploEl 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_modelValida 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 ORTAl 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

  • Fusión
  • 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.

Qué hay dentro del classifier.onnx del C01, contado en un export real con torch 2.14 (exportador TorchScript, opset 17, en eval, con estadísticas de BatchNorm como las de un modelo entrenado). Los 53 BatchNorm desaparecen: el export los plegó en cada Conv. TensorRT fusiona más todavía (sección de TensorRT).

Ejemplo real · export(): los dos exportadores, con sus trampas anotadas

Pythonreference/c01/model/export_onnx.py (export completo)
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
        )
  • El ejemplo es de batch 2 a propósito: con batch 1, algunos tracers (lo que registra cada operación) fijan el eje como constante.
  • Rama dynamo: el eje se declara con 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.
  • Rama TorchScript, la que corre el script por defecto: dynamo=False explícito, opset 17 y dynamic_axes por nombre de tensor, para la entrada y para la salida.
  • Exportado con torch 2.14 y los pesos aleatorios del script (sin --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().

Laboratorio en tu Mac, sin GPU (opcional)7 pasos

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

  1. Paso 1
    source ~/venvs/lab-triton/bin/activate
    pip install onnx==1.23.0 onnxruntime==1.30.0
    mkdir -p ~/lab-onnx && cd ~/lab-onnx

    Qué 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.

  2. Paso 2
    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.onnx

    Qué 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.

  3. Paso 3
    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}))
    EOF

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

  4. Paso 4
    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}")
    EOF

    Qué 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.

  5. Paso 5
    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()})
    EOF

    Qué 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.

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

  7. Paso 7
    cd ~ && rm -r ~/lab-onnx

    Qué 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í.

4

Paridad: el modelo servido no es el evaluado (top-1, Δlogit, FP16)

¿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

VariableQué esEjemplo
checkpointel state_dict evaluado: la referenciaresnet50_500.pt
onnxel ONNX candidatomodel/classifier.onnx
muestracarpeta con imágenes reales del set de evaluaciónuna muestra del golden set (el set fijo de evaluación)
nmáximo de imágenes a comparardefault 256; el script recomienda ≥ 2,000
hostdónde corre Tritonlocalhost, en el runner con la T4
NNimágenes comparadas2,000 → el gate tolera 10 cambios de top-1
ref, got\text{ref},\ \text{got}logits de PyTorch FP32 y del candidato, de forma [N, 500]got: ONNX Runtime en CPU, o el engine vía Triton
T5T_{5}las 5 clases con el logit más alto: lo que ve quien publicael orden dentro del top-5 no importa
ε\varepsilonépsilon de máquina: la distancia de 1 al siguiente número que el formato puede guardarFP16 2⁻¹⁰; FP32 2⁻²³

Fórmulas

top1=1N∑i=1N1 ⁣[arg⁡max⁡refi=arg⁡max⁡goti]top5=1N∑i=1N∣T5(refi)∩T5(goti)∣5\text{top1} = \frac{1}{N}\sum_{i=1}^{N} \mathbf{1}\!\left[\arg\max \text{ref}_i = \arg\max \text{got}_i\right] \qquad \text{top5} = \frac{1}{N}\sum_{i=1}^{N} \frac{|T_5(\text{ref}_i) \cap T_5(\text{got}_i)|}{5}
max⁡∣Δ∣=max⁡i,k ∣refik−gotik∣FP32: ≈10−4FP16: 10−2–10−1bug: ≳1\max|\Delta| = \max_{i,k}\, |\text{ref}_{ik} - \text{got}_{ik}| \qquad \text{FP32: } \approx 10^{-4} \qquad \text{FP16: } 10^{-2}\text{–}10^{-1} \qquad \text{bug: } \gtrsim 1
cambios de top-1 tolerados=⌊(1−0.995) N⌋N=256⇒1N=2,000⇒10\text{cambios de top-1 tolerados} = \lfloor (1 - 0.995)\,N \rfloor \qquad N = 256 \Rightarrow 1 \qquad N = 2{,}000 \Rightarrow 10
εFP16=2−10≈9.8×10−4εFP32=2−23≈1.2×10−7paso de FP16 entre 8 y 16=2−7≈0.008\varepsilon_{\text{FP16}} = 2^{-10} \approx 9.8 \times 10^{-4} \qquad \varepsilon_{\text{FP32}} = 2^{-23} \approx 1.2 \times 10^{-7} \qquad \text{paso de FP16 entre 8 y 16} = 2^{-7} \approx 0.008

Las piezas

PiezaQué haceEjemplo
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 referenciaPyTorch FP32 en eval() y con inference_mode(): el modelo tal como se evaluó.run_torch(), con batches de 32
acuerdo top-1La fracción de imágenes donde el 1.º lugar coincide: ¿cambia la sugerencia principal?≥ 99.5 % (--min-top1 0.995)
solapamiento top-5Cuá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 FP32Cambiar 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 FP16FP16 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 FP16Por 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-1La 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 tensorLas dos ramas reciben exactamente la misma entrada, hecha con el preprocess.py del gateway.load_images() lo importa desde gateway/app
imágenes realesUna muestra del set de evaluación. Las sintéticas solo prueban que el script corre.sin --images, gradientes + ruido codificados como JPEG
exit code0 = 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 puntaImá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
PolygraphyHerramienta 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.

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

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

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

Tres pruebas en orden, de la más barata a la más completa; cada una atrapa algo que la anterior no ve. La escala de |Δlogit| dice qué pasó: ≈ 1e-4 es aritmética, ≈ 1e-2 es FP16 y ≳ 1 es un bug.

Ejemplo real · compare() y el gate: las métricas, los umbrales y el exit code

Pythonreference/c01/model/parity_check.py (extractos; # … marca líneas omitidas)
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 code
  • El acuerdo top-1 y el solapamiento del top-5 miden lo que ve el producto: la sugerencia principal y las 5 que se muestran. El top-5 se compara como conjunto: el orden de adentro no importa.
  • max_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).
  • Un mismo script para los dos gates: --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.
  • El default de --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.
  • El exit code es la interfaz con el CI: 1 frena la cadena antes del registry. Un 97 % en FP16 no se arregla bajando el umbral: en una CNN es un bug, no redondeo.

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.

Laboratorio en tu Mac, sin GPU (opcional)3 pasos

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.

  1. Paso 1
    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)))
    EOF

    Qué 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.

  2. Paso 2
    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}")
    EOF

    Qué 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.

  3. Paso 3
    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.

5

TensorRT: qué es, trtexec, precisión, perfiles de optimización, portabilidad del engine

¿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

VariableQué esEjemplo
modelo.onnxel ONNX exportado: la entrada del buildmodel/classifier.onnx (≈ 98 MB, opset 17)
engine.plandónde queda el engine serializado (el archivo .plan)triton/model_repository/classifier/1/model.plan
entradanombre del tensor de entrada en el ONNXinput
min, opt, maxbatch 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áximo1, 16, 32
modelo_fp16.onnxel ONNX que deja AutoCast, ya en precisión mixta (TensorRT 11)no existe en C01: C01 usa TensorRT 10.3
batchbatch con que mides el engine1–3 (lo que llega online); 16 en el chequeo final de build_engine.sh
cachearchivo donde se guardan las mediciones del build (timing cache)t4.cache (C01 no lo usa)
nivelcuánto tiempo se da el builder (el compilador de TensorRT) para probar opciones3, el default (C01 no lo cambia)
qps\text{qps}inferencias por segundo que reporta trtexec en Throughputse mide en la T4
bbbatch de la medición16 → imágenes/s = qps × 16
H2D, D2H\text{H2D},\ \text{D2H}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
GPU Compute\text{GPU Compute}tiempo de los kernels en la GPU≈ 3–6 ms p50 con batch 1–8 (presupuesto de C01)
max_batch_size\texttt{max\_batch\_size}el techo de batch de config.pbtxt32
maxperfil\text{max}_{\text{perfil}}el batch máximo del perfil del engine32 (--maxShapes)

Fórmulas

imaˊgenes/s=qps×b(trtexec cuenta inferencias: cada una es un batch de b)\text{imágenes/s} = \text{qps} \times b \qquad (\text{trtexec cuenta inferencias: cada una es un batch de } b)
Latency=H2D+GPU Compute+D2H\text{Latency} = \text{H2D} + \text{GPU Compute} + \text{D2H}
min≤opt≤maxmax_batch_size≤maxperfil(1≤16≤32, 32≤32)\text{min} \le \text{opt} \le \text{max} \qquad \texttt{max\_batch\_size} \le \text{max}_{\text{perfil}} \qquad (1 \le 16 \le 32,\ 32 \le 32)

Las piezas

PiezaQué haceEjemplo
builderLa 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 ONNXTraduce 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 plegadoEn 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ónCuá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 cacheArchivo con las mediciones de tácticas: el siguiente build en la misma GPU tarda menos.--timingCacheFile
strongly typedRed 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 AutoCastHerramienta 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/DQPares 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 compatibilityUn 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 compatibilityEl 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
trtexecLa 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

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

  • PyTorch en eval(): 10 operaciones
  • ONNX de export_onnx.py (opset 17): 7 nodos: el export plegó cada BatchNorm en los pesos de su Conv
  • engine TensorRT en la T4: 3 capas: cada conv lleva su activación y la última también la suma del atajo, en un solo kernel

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?

TensorRTT4 (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

Un bloque bottleneck de ResNet-50 (hay 16) en PyTorch, en el ONNX de C01 y en el engine. Ilustrativo: qué fusiona exactamente depende de la versión de TensorRT y de la GPU; lo confirmas con trtexec --dumpLayerInfo.

Ejemplo real · build_engine.sh: el engine FP16 se compila en la T4, con la misma release que Triton

bash · zshreference/c01/model/build_engine.sh (líneas 27–52)
# 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.
  • El perfil 1 / 16 / 32: el engine acepta batches de 1 a 32 y afina los kernels para 16. El máximo coincide con 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).
  • La imagen 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.
  • El comentario de TensorRT 11 es la migración pendiente: con NGC 26.06 o más nueva, este comando falla en --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.

Sin laboratorio: necesita una GPU NVIDIA.

6

El model repository y config.pbtxt

¿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/
        └── archivo

El 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

VariableQué esEjemplo
repositoriocarpeta raíz que recibe --model-repository/models dentro de la imagen
modelonombre del modelo = nombre de su carpetaclassifier
versiónsubcarpeta numérica de la versión1
archivoel modelo, con su nombre por defectomodel.plan (lo genera build_engine.sh; no va en git)
plataformaquién lo ejecuta: platform (o backend)tensorrt_plan
max_batchtecho del batch; 0 = sin batching32 (= max del perfil del engine)
entrada, salidanombres de los tensores del modeloinput, logits
tipotipo de dato del configTYPE_FP32 (en el cliente se escribe FP32)
dimsshape de una muestra, sin el batch3, 224, 224 y 500
rutaruta o URL del repository/models (o s3://bucket/models)
modomodo de control de modelosnone
hostdónde escucha Tritonlocalhost con kubectl port-forward

Fórmulas

shape completa={[−1] ∥ dimsmax_batch_size>0dimsmax_batch_size=0[−1] ∥ [3,224,224]=[−1,3,224,224]\text{shape completa} = \begin{cases} [-1] \,\Vert\, \texttt{dims} & \texttt{max\_batch\_size} > 0 \\ \texttt{dims} & \texttt{max\_batch\_size} = 0 \end{cases} \qquad [-1] \,\Vert\, [3, 224, 224] = [-1, 3, 224, 224]
bytes por imagen=3×224×224×4 B=602,112 B≈602 KB\text{bytes por imagen} = 3 \times 224 \times 224 \times 4\ \text{B} = 602{,}112\ \text{B} \approx 602\ \text{KB}
entrada a batch maˊximo=32×602 KB≈19 MB\text{entrada a batch máximo} = 32 \times 602\ \text{KB} \approx 19\ \text{MB}

Las piezas

PiezaQué haceEjemplo
model repositoryLa carpeta de modelos que lee Triton, con un modelo por subcarpeta.triton/model_repository/, copiada a /models
carpeta de versiónSubcarpeta con número. Las que no son números o empiezan con 0 se ignoran.classifier/1/
config.pbtxtLa 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
nameOpcional. Si falta, Triton usa el nombre de la carpeta; si está, tiene que ser igual a la carpeta.classifier
platform / backendQuién ejecuta el modelo. TensorRT: platform tensorrt_plan o backend tensorrt. ONNX: onnxruntime_onnx u onnxruntime. Python: backend python.platform: "tensorrt_plan"
default_model_filenameCambia el nombre del archivo que busca el backend.C01 usa el default, model.plan
max_batch_sizeEl batch más grande que acepta el modelo. Mayor que 0: Triton antepone el eje de batch. 0: sin batching.32
input / outputEl 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_typeEl tipo en el config: TYPE_FP32, TYPE_INT64, TYPE_STRING… El cliente usa el nombre del protocolo: FP32, INT64, BYTES.TYPE_FP32
reshapePara cuando la shape que manda el cliente y la que espera el modelo difieren.C01 no lo necesita
version_policyQué versiones de la carpeta se cargan: latest (default, la más alta), all o specific.latest { num_versions: 1 }
model_warmupRequests 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-configUsa el config tal cual, sin leer el modelo para completarlo. Igual agrega instance_group si falta.en el CMD de triton/Dockerfile
--model-control-modeCuá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
UNAVAILABLEEstado 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

  1. Triton arranca (modo none)
  2. lee cada carpeta y su config.pbtxt
  3. el backend carga el archivo de la versión
  4. corre model_warmup
  5. READYo UNAVAILABLE con el motivo

el engine: atado a la GPUTriton la ignoraejemplo

El repository de C01 (arriba, horneado en la imagen) y un segundo modelo de ejemplo (abajo) para ver las reglas de nombres y de versiones.

Ejemplo real · config.pbtxt de C01: el contrato del modelo

protobuf (texto)reference/c01/triton/model_repository/classifier/config.pbtxt (líneas 1–29)
# 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
  }
]
  • Triton arranca con --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.
  • Los nombres 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.

Laboratorio en tu Mac, sin GPU (opcional)7 pasos

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.

  1. Paso 1
    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/1

    Qué 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.

  2. Paso 2
    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_repository

    Qué 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.

  3. Paso 3
    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.pbtxt

    Qué 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.

  4. Paso 4
    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.pbtxt

    Qué 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.

  5. Paso 5
    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.pbtxt

    Qué 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.

  6. Paso 6
    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.pbtxt

    Qué 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.

  7. Paso 7
    deactivate && cd ~ && rm -r ~/lab-repositorio

    Qué 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.

7

Dynamic batching, instancias y concurrencia

¿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

VariableQué esEjemplo
max_batchtecho del batch; no puede pasar el máximo del perfil del engine32
espera_usespera máxima para juntar requests, en µs2000 (2 ms)
tamañostamaños de batch preferidosno se usan: el engine tiene un solo perfil, 1–32
timeout_uscuánto puede esperar un request en la cola, en µs100000 (100 ms, menos que los 150 ms del gateway)
cola_maxrequests máximos en la cola; 0 = sin topeno se fija
nivelescuántas prioridades hay; 1 es la más alta2, en la propuesta del serving de C01 (el config.pbtxt no las trae)
nivel_defaultprioridad de los requests que no mandan una2: la app manda priority=1 y los scripts quedan detrás
ninstancias por GPU2
idsGPUs donde van las copias; si falta, todasno se pone: el pod ve una sola T4
modelolabel model de las métricasclassifier
λ\lambdarequests que llegan por segundo a cada GPU250 en el pico de diseño; 500 si cae una GPU
WWtiempo promedio dentro de Triton: cola + copia + cómputo≈ 8 ms (supuesto de M03)
LLrequests dentro de Triton en promedio≈ 2: dos instancias alcanzan
tventanat_{\text{ventana}}max_queue_delay_microseconds, en segundos0.002
tllenart_{\text{llenar}}tiempo para juntar un batch lleno128 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
bˉ\bar{b}batch promedio real≈ 1–3 a 250 req/s; ≈ 2–4 en failover

Fórmulas

L=λ⋅W250 req/s×0.008 s≈2 requests dentro de TritonL = \lambda \cdot W \qquad 250\ \text{req/s} \times 0.008\ \text{s} \approx 2 \text{ requests dentro de Triton}
llegadas en la ventana=λ⋅tventana=250×0.002=0.5\text{llegadas en la ventana} = \lambda \cdot t_{\text{ventana}} = 250 \times 0.002 = 0.5
tllenar=max_batch_sizeλ=32250 req/s=128 mst_{\text{llenar}} = \frac{\texttt{max\_batch\_size}}{\lambda} = \frac{32}{250\ \text{req/s}} = 128\ \text{ms}
bˉ=Δ nv_inference_countΔ nv_inference_exec_count\bar{b} = \frac{\Delta\, \texttt{nv\_inference\_count}}{\Delta\, \texttt{nv\_inference\_exec\_count}}

Las piezas

PiezaQué haceEjemplo
dynamic batcherEl 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 defectoSi 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_microsecondsLa 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_sizemax_batch_size es un techo. El batch que se forma depende de cuántos requests llegan.techo 32, real ≈ 1–3
preferred_batch_sizeTamañ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_policyQué hace la cola: timeout, acción al vencer y tope de tamaño.REJECT a los 100 ms
timeout_actionREJECT (default): devuelve error. DELAY: lo atiende después de los que siguen a tiempo.REJECT en C01
allow_timeout_overrideDeja que cada request pida un timeout más corto (el parámetro timeout de tritonclient, en µs).no se usa en C01
priority_levelsVarias colas por prioridad: la 1 se atiende primero.propuesta del serving de C01: app en 1, scripts en 2
preserve_orderingDevuelve las respuestas en el orden de llegada. No se permite con más de un nivel de prioridad.no se usa
instance_groupCuá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
kindKIND_GPU, KIND_CPU, KIND_MODEL o KIND_AUTO (el default: GPU si hay).KIND_GPU
rate limiterOpcional (--rate-limit=execution_count): reparte la ejecución entre instancias según recursos y prioridad.C01 no lo usa
sequence batcherPara modelos con estado: manda los requests de una misma secuencia a la misma instancia.no aplica a un clasificador
client-side batchingEl 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

  1. el batch sale cuando junta max_batch_size (32) o cuando el primero lleva 2 ms esperando
  2. lo toma una instancia libre; si las dos están ocupadas, sigue juntando
  3. con poco tráfico el costo es la espera: hasta 2 ms por request

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

El bus que espera 2 ms: llegadas, ventana y batch despachado a la instancia libre, a escala. Ilustrativo, con los números de C01: un request cada ≈ 4 ms y un cómputo de ≈ 3.3 + 0.74 × b ms (≈ la tabla de M03).

Ejemplo real · config.pbtxt de C01: batching, cola e instancias

protobuf (texto)reference/c01/triton/model_repository/classifier/config.pbtxt (líneas 31–58)
# 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
  }
]
  • La cuenta de las líneas 32–35: con ≈ 250 req/s por GPU, en 2 ms llegan 250 × 0.002 = 0.5 requests más. El batch promedio queda en ≈ 1–3 (≈ 2–4 en failover) y la ventana cuesta como mucho 2 ms del p99 de 200 ms.
  • Sin 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.
  • Little lo confirma: a 250 req/s y ≈ 8 ms dentro de Triton hay ≈ 2 requests adentro. Más instancias que requests no ayudan; el número final se elige midiendo con model_analyzer.

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.

Laboratorio en tu Mac, sin GPU (opcional)5 pasos

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.

  1. Paso 1
    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 250

    Qué 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.

  2. Paso 2
    python3 batcher.py 500

    Qué 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.

  3. Paso 3
    python3 batcher.py 1000

    Qué 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.

  4. Paso 4
    python3 batcher.py 1200

    Qué 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.

  5. Paso 5
    cd ~ && rm -r ~/lab-batching

    Qué 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.

8

Clientes: HTTP, gRPC, protocolo v2, health y metadata, tritonclient

¿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

VariableQué esEjemplo
modelonombre del modelo en el repositoryclassifier
vversión explícita (opcional)no se usa: hay una sola
hostdónde escucha Triton, sin http://triton.classifier.svc.cluster.local en EKS; triton en compose
entrada, salidanombres de los tensores, los del configinput, logits
shapeshape del request, con el batch1, 3, 224, 224
tipodatatype del protocoloFP32
arregloarreglo de NumPy con ese dtype y esa shapefloat32 [1, 3, 224, 224] de preprocess.py
segundostimeout del cliente gRPCC01 usa HTTP: su tope es asyncio.wait_for, 150 ms
BBbytes del tensor en binario602,112
nncantidad de valores del tensor150,528
BJSONB_{\text{JSON}}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)
λ\lambdarequests por segundo500 en el pico de diseño
deadline\text{deadline}el presupuesto total del request en el gateway250 ms desde que llegó

Fórmulas

B=∏ishapei×bytes del tipo=1×3×224×224×4=602,112 BB = \prod_i \text{shape}_i \times \text{bytes del tipo} = 1 \times 3 \times 224 \times 224 \times 4 = 602{,}112\ \text{B}
BJSON≈n×20 B=150,528×20 B≈3 MB  (≈5×)B_{\text{JSON}} \approx n \times 20\ \text{B} = 150{,}528 \times 20\ \text{B} \approx 3\ \text{MB} \;(\approx 5\times)
red=B×λ×8=602 KB×500 req/s×8≈2.4 Gb/s\text{red} = B \times \lambda \times 8 = 602\ \text{KB} \times 500\ \text{req/s} \times 8 \approx 2.4\ \text{Gb/s}
tTriton=min⁡(150 ms, deadline−ahora)t_{\text{Triton}} = \min(150\ \text{ms},\ \text{deadline} - \text{ahora})

Las piezas

PiezaQué haceEjemplo
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 binariaHeader 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 protocoloBOOL, 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 configMetadata: nombres, tipos y shapes. Config: el config.pbtxt cargado, en JSON (extensión de Triton).get_model_metadata() y get_model_config()
gRPCEl mismo modelo de datos sobre HTTP/2: el servicio inference.GRPCInferenceService, en el puerto 8001.perf_analyzer -i grpc
tritonclientLa 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 / InferRequestedOutputEl 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")
InferenceServerExceptionEl 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 compartidaSi 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 KubernetesUna 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

  • Salud
  • 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

  • Metadata
  • tú, al depurar

    GET/v2/models/classifier

    input FP32 [-1, 3, 224, 224] · logits FP32 [-1, 500]

    En el C01: get_model_metadata()

  • Inferencia
  • 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

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

  • Métricas
  • Prometheus

    GET:8002/metrics

    texto con nv_inference_* y nv_gpu_*

    En el C01: scrape cada 15 s

Quién llama a qué en C01: el kubelet (el agente de Kubernetes en cada nodo) pregunta por la salud, el gateway por el modelo y la inferencia, y Prometheus lee las métricas. gRPC (8001) ofrece las mismas operaciones como llamadas RPC.

Ejemplo real · main.py: la llamada a Triton, con su presupuesto y sus errores

Pythonreference/c01/gateway/app/main.py (líneas 147–171)
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 batch
  • InferInput("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).
  • El 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.
  • Cada falla tiene su código: TimeoutError → 504 (Triton lento), aiohttp.ClientError → 503 (Triton caído o reiniciando), InferenceServerException → 503 con el mensaje de Triton, por ejemplo un rechazo de la cola.
  • Un solo cliente por proceso, creado en el lifespan con 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.

Laboratorio en tu Mac, sin GPU (opcional)5 pasos

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.

  1. Paso 1
    mkdir -p ~/lab-cliente && cd ~/lab-cliente
    python3.13 -m venv .venv && . .venv/bin/activate
    pip install "tritonclient[http]==2.73.0" numpy

    Qué 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.

  2. Paso 2
    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.py

    Qué 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.

  3. Paso 3
    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())
    EOF

    Qué 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.

  4. Paso 4
    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))
    EOF

    Qué 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.

  5. Paso 5
    deactivate && cd ~ && rm -r ~/lab-cliente

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

9

Medir: perf_analyzer, métricas de Triton, uso de GPU

¿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]))
  / 1000

Espera 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

VariableQué esEjemplo
modelomodelo a medir (-m)classifier
hostTriton, sin http://localhost con kubectl port-forward, o triton en compose
inicio:fin:pasoclientes concurrentes: desde, hasta, de a cuánto1:32:2 (M03)
datos.jsonentradas reales, en el formato JSON de perf_analyzertensores_reales.json: fotos ya preprocesadas
mslargo de cada ventana de medición, en ms (default 5000)10000
tasarequests por segundo, en carga abierta250 (pico de diseño por GPU) y 500 (failover)
releaserelease de NGC de la imagen SDK24.08, la misma que el servidor
XXthroughput: requests atendidos por segundoel máximo de una T4 en C01 es un supuesto: ≈ 1,200 img/s
NNclientes concurrentes de perf_analyzer8
WWlatencia media de cada request10 ms
tˉcola\bar{t}_{\text{cola}}espera media en la cola de Triton≈ 1–3 ms normal; más de 20 ms sostenidos dispara la alerta
pico\text{pico}carga de diseño500 req/s
maˊx. medido\text{máx. medido}el codo de la rampa, medido con perf_analyzer≈ 1,200 img/s → 600 planeadas (50 %)

Fórmulas

X=NW8 clientes / 10 ms=800 req/s(carga cerrada, Little)X = \frac{N}{W} \qquad 8 \text{ clientes} \,/\, 10\ \text{ms} = 800\ \text{req/s} \quad (\text{carga cerrada, Little})
tˉcola=Δ nv_inference_queue_duration_usΔ nv_inference_request_success\bar{t}_{\text{cola}} = \frac{\Delta\, \texttt{nv\_inference\_queue\_duration\_us}}{\Delta\, \texttt{nv\_inference\_request\_success}}
GPUs=⌈pico0.5×maˊx. medido⌉+1=⌈500600⌉+1=2\text{GPUs} = \left\lceil \frac{\text{pico}}{0.5 \times \text{máx. medido}} \right\rceil + 1 = \left\lceil \frac{500}{600} \right\rceil + 1 = 2

Las piezas

PiezaQué haceEjemplo
perf_analyzerGenera 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-dataQué datos manda: random (default), zero o un JSON con tensores reales.fotos reales ya preprocesadas
--percentileUsa ese percentil, en vez del promedio, para decidir que la medición ya es estable.--percentile=99
desglose del servidorperf_analyzer muestra cuánto se fue en overhead, queue, compute input, compute infer y compute output.queue alto con compute normal = falta capacidad
model_analyzerBarre 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 k6El motor solo; el servidor; el servicio completo con el gateway.cada uno responde una pregunta distinta
nv_inference_count / nv_inference_exec_countInferencias 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_countRequests que esperan a un backend en este momento: es un gauge, sube y baja.alternativa para escalar
nv_inference_request_failureFallas por motivo, en el label reason. REJECTED = venció el timeout de la cola.los rechazos a los 100 ms
nv_gpu_utilizationFracció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_bytesVRAM usada en cada GPU, en bytes (un gauge).unos pocos GB de 16 en C01
/v2/models/modelo/statsLas 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
pico de diseño por GPUfailover: una GPU con todocapacidad planeada (50 %)codo ≈ 1,200: el máximo medidothroughput logrado (img/s)02505007501,0001,2501,500ILUSTRATIVOlatencia (ms)050100150p99p5002505007501,0001,2501,500carga ofrecida (img/s), perf_analyzer --request-rate-range
pico de diseño por GPUfailover: una GPU con todocapacidad planeada (50 %)codo ≈ 1,200: el máximo medidothroughput logrado (img/s)05001,0001,500ILUSTRATIVOlatencia (ms)050100150p99p5005001,0001,500carga ofrecida (img/s), perf_analyzer --request-rate-range

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)

Rampa contra una T4 con perf_analyzer (números ILUSTRATIVOS de M03): hasta el codo el throughput sigue a la carga y la latencia casi no cambia; después, el throughput se aplana y la cola de C01 corta a los 100 ms. La forma es la que vas a ver; tus números se miden.

Ejemplo real · alerts.yml: la cola de Triton como señal de saturación

YAMLreference/c01/monitoring/alerts.yml (líneas 78–93)
      # 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"
  • Espera media = µs acumulados en la cola ÷ requests exitosos (los dos como 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.
  • Con 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.
  • La descripción dice qué mirar: si KEDA ya está en maxReplicaCount (4), falta capacidad de GPU y hay que subir el techo o mejorar el engine.
  • La regla vecina, GPUSaturated (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.
  • Lo que no está aquí: el p99 del servicio se mide en el gateway (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.

Sin laboratorio: necesita una GPU NVIDIA.

10

Pre y postprocesamiento: dónde va (gateway, ensemble, backend de Python) y el riesgo de skew

¿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 JSON

Preprocesamiento (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 responses

El 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

VariableQué esEjemplo
byteslos bytes crudos de la foto, tal como llegan en el body≈ 150–250 KB desde el app móvil
topemáximo de píxeles que aceptas, leído del header antes de decodificar40_000_000 (40 MP)
entrada · salidanombres de los tensores: los fija el export y los repite config.pbtxtinput · logits
kcuántas sugerencias devuelves5 (máximo 20 en C01)
pipeline · preproceso · modelonombres de modelos (carpetas) del model repositorypipeline · preprocess · classifier
nmax_batch_size del ensemble32
claseslargo del vector de salida500
xxvalor de un píxel, de 0 a 255200
μc, σc\mu_{c},\ \sigma_{c}media y desvío de ImageNet del canal c, en orden RGBR: 0.485 y 0.229
λ\lambdarequests por segundo500 en el pico de diseño
tCPUt_{\text{CPU}}CPU por imagen: decode, resize y normalizar≈ 6 ms con la mezcla de fotos de C01
BBbytes del tensor que viaja del gateway a Triton602 KB en FP32
zi, piz_{i},\ p_{i}logit y probabilidad de la clase irestar el máximo evita que exp() desborde

Fórmulas

x′=x/255−μcσcμ=(0.485, 0.456, 0.406)σ=(0.229, 0.224, 0.225)x' = \dfrac{x/255 - \mu_c}{\sigma_c} \qquad \mu = (0.485,\ 0.456,\ 0.406) \qquad \sigma = (0.229,\ 0.224,\ 0.225)
B=3×224×224×4 B=602,112 B≈602 KBen UINT8: 150,528 B≈151 KBB = 3 \times 224 \times 224 \times 4\ \text{B} = 602{,}112\ \text{B} \approx 602\ \text{KB} \qquad \text{en UINT8: } 150{,}528\ \text{B} \approx 151\ \text{KB}
cores=λ⋅tCPU=500 reqs×6 ms=3red=λ⋅B≈2.4 Gb/s\text{cores} = \lambda \cdot t_{\text{CPU}} = 500\ \tfrac{\text{req}}{\text{s}} \times 6\ \text{ms} = 3 \qquad \text{red} = \lambda \cdot B \approx 2.4\ \text{Gb/s}
pi=e zi−max⁡jzj∑ke zk−max⁡jzjp_i = \dfrac{e^{\,z_i - \max_j z_j}}{\sum_k e^{\,z_k - \max_j z_j}}

Las piezas

PiezaQué haceEjemplo
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 PythonTriton 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 + DALIDALI (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 procesoEl 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 grafoEl 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 skewEl 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 BGROpenCV lee en BGR; Pillow y torchvision, en RGB. Los canales al revés no dan error.cv2.imread entrega BGR
Resize + center cropResize(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)
AntialiasAl achicar, Pillow suaviza siempre; F.interpolate no, salvo antialias=True. En torchvision 0.29, Resize trae antialias=True por defecto.F.interpolate(..., mode="bilinear")
EXIFLas 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 CHWUna 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 JPEGEl 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-kEl postproceso: logits → probabilidades → las k más altas, con su nombre.labels.json: 500 nombres, índice = id de clase
labels.json desalineadoSi el orden de los nombres no es el del entrenamiento, todo responde 200 con la categoría equivocada.una falla silenciosa de C01
  • Gateway (nodo de CPU, c6i.xlarge)
  • CPU del nodo con GPU (g4dn.xlarge, 4 vCPU)
  • GPU T4
  1. Decode + EXIF + RGB
  2. Resize 256 + crop 224
  3. Normalizar → tensor
  4. Modelo (engine TensorRT)
  5. Softmax + top-k + labels

Gateway en CPUelegido en C01

GatewayGatewayGatewayGPU T4Gateway

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

CPU del nodo con GPUCPU del nodo con GPUCPU del nodo con GPUGPU T4Gateway

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)

GPU T4GPU T4GPU T4GPU T4Gateway

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

CPU del nodo con GPUCPU del nodo con GPUCPU del nodo con GPUGPU T4CPU del nodo con GPU

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

GatewayGatewayGPU T4GPU T4Gateway

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

Las mismas cinco etapas en cinco lugares posibles. Cada celda dice dónde corre esa etapa; abajo, qué viaja por la red hasta Triton. C01 eligió la primera fila: la CPU barata hace el trabajo de CPU y la T4 solo recibe tensores.

Ejemplo real · C01: el preprocesamiento compartido y la llamada que lo usa

Pythonreference/c01/gateway/app/preprocess.py y main.py (extractos; # … marca líneas omitidas)
# 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 batching
  • Un solo preprocess.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).
  • La salida es exactamente el contrato de 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.
  • El decode corre en un threadpool (hilos aparte), y un semáforo (un contador de turnos) deja pasar 2 decodes a la vez por proceso (PREPROCESS_CONCURRENCY). Es CPU pura: dentro del event loop (el hilo que atiende todos los requests del proceso) bloquearía a los demás.
  • ¿Por qué en el gateway y no en Triton? A 500 req/s son ≈ 3 cores de decode; la g4dn.xlarge tiene 4 vCPU y el pod de Triton ya pide 2. En un ensemble con backend de Python, para tener más CPU tendrías que comprar más GPU.
  • El precio: viaja el tensor (602 KB) y no el JPEG del celular (≈ 200 KB), 3× más red. Por eso C01 manda cada request al Triton de su misma zona (cruzar de zona se cobra por GB) y, al crecer, pasaría a entrada UINT8 con la normalización dentro del grafo.

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.

Laboratorio en tu Mac, sin GPU (opcional)4 pasos

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.

  1. Paso 1
    cd apps/ml-system-design/reference/c01 && python3.13 -m venv .venv && . .venv/bin/activate && pip install -r model/requirements.txt

    Qué 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.

  2. Paso 2
    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}")
    EOF

    Qué 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.

  3. Paso 3
    python ~/lab-triton/skew_lab.py gateway

    Qué 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.

  4. Paso 4
    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.

11

Optimizaciones en orden de impacto, y la alternativa sin GPU (ONNX Runtime en CPU)

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

VariableQué esEjemplo
modelonombre del modelo en Triton (el label model de las métricas)classifier
onnx · onnx_fp16 · onnx_pre · onnx_int8el ONNX FP32 y sus variantesmodel/classifier.onnx · model/classifier_int8.onnx
optbatch para el que TensorRT afina el engine16 en C01; compáralo con 4
lectortu CalibrationDataReader: entrega una foto preprocesada por llamada≈ 500–1,000 fotos reales
vcpusvCPU del pod (su límite), no los cores del nodo4
ppfracción del tiempo total que es cómputo del modelo≈ 0.05–0.10 en el p99 de C01
sscuántas veces más rápido queda el modelo2
StotalS_{\text{total}}cuántas veces más rápido queda el request completo≈ 1.03–1.05: el p99 baja ≈ 3–5 %
λ\lambdarequests por segundo a atender500 en el pico de diseño
timgt_{\text{img}}CPU por imagen, medida en la instancia realINT8 ≈ 8–25 ms; FP32 ≈ 40–80 ms (estimaciones de C01)
UobjU_{\text{obj}}utilización objetivo del HPA (el autoscaler de pods)0.6
XmaxX_{\text{max}}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

Stotal=1(1−p)+p/sp=10150≈0.067,  s=2 ⇒ S≈1.03S_{\text{total}} = \dfrac{1}{(1 - p) + p/s} \qquad p = \tfrac{10}{150} \approx 0.067,\ \ s = 2 \ \Rightarrow\ S \approx 1.03
vCPU=λ⋅timgUobj=500×0.015 s0.6=12.5\text{vCPU} = \dfrac{\lambda \cdot t_{\text{img}}}{U_{\text{obj}}} = \dfrac{500 \times 0.015\ \text{s}}{0.6} = 12.5
NGPU=⌈λ0.5 Xmax⁡⌉+1=⌈500600⌉+1=2N_{\text{GPU}} = \left\lceil \dfrac{\lambda}{0.5\, X_{\max}} \right\rceil + 1 = \left\lceil \dfrac{500}{600} \right\rceil + 1 = 2

Las piezas

PiezaQué haceEjemplo
TensorRT FP16Compila 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 T4Construirlo 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 conservadorEl 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 CPUEl 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 grafo2× 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 CPUPesos 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 · AMXInstrucciones 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_threadsHilos 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 × hilosMá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
  1. 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 %.

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

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

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

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

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

La escalera, de lo más barato y seguro a lo más caro (M08). Cada peldaño se mide antes de subir al siguiente. C01 usa los cinco primeros (con FP16, sin INT8), deja el sexto al equipo de modelado y mide el último en la semana 1.

Ejemplo real · C01: ¿qué compra cada optimización? (las cuentas del curso)

textoCuentas del curso: M08 (por qué optimizar) y C01 (estimaciones). Son estimaciones con supuestos, no benchmarks: el máximo por T4 y los ms por vCPU se miden.
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 T4
  • Ley de Amdahl (lo que no aceleras pone el techo) con los números de C01: si el cómputo es el 5–10 % del p99, ningún truco del modelo mueve el p99 más que eso. Primero el presupuesto por tramo; después, la técnica que mueve el número que importa.
  • Las 2 T4 están por disponibilidad (N+1: una de sobra, una por zona), no por throughput: hoy INT8 no ahorra ni una GPU y agrega riesgo en el long tail.
  • TensorRT FP16 igual se usa: es casi gratis en calidad (la paridad lo confirma) y da margen para picos y failover sin escalar.
  • A 10× el tráfico, INT8 sí paga 3–4 T4, si pasa el gate por slices (grupos como móvil, web o poca luz); y queda por debajo de pasar a 5 L4 en FP16 (≈ US$2,938/mes).
  • Sin GPU decide el benchmark de la semana 1. En x86 con VNNI o AMX se espera INT8 ≈ 3–5× sobre FP32, pero no está medido para este stack; en la Mac M4 del curso dio ≈ 1–2×. Si en la instancia real da ≈ 1.5–2×, el ahorro contra las dos T4 deja de ser claro.

Có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.

Laboratorio en tu Mac, sin GPU (opcional)4 pasos

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.

  1. Paso 1
    cd apps/ml-system-design/reference/c01 && . .venv/bin/activate

    Qué 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.

  2. Paso 2
    python model/export_onnx.py --out model/classifier.onnx

    Qué 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.

  3. Paso 3
    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.onnx

    Qué 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.

  4. Paso 4
    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.py

    Qué 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í.

12

Diagnóstico: el método y la tabla de síntomas

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

VariableQué esEjemplo
hostdónde escucha Triton; en el cluster, a través de un port-forward (un túnel de kubectl a tu máquina)localhost
modelonombre del modelo (la carpeta del repository)classifier
pod · nspod de Triton y su namespace de Kubernetestriton-5f6b…-7tq2m · classifier
tpre, tpostt_{\text{pre}},\ t_{\text{post}}preproceso y postproceso en el gatewaypreprocess_ms en el log JSON de C01
tredt_{\text{red}}ida y vuelta de la red y la serialización≈ 2–4 ms en p50 (la mediana) para 602 KB binarios
tcola, tcoˊmputot_{\text{cola}},\ t_{\text{cómputo}}espera en la cola de Triton y ejecución del modelo≤ 2 ms de ventana · 3–6 ms en p50
ΔQ\Delta Qaumento de nv_inference_queue_duration_us (µs acumulados en cola)rate(…[5m])
ΔNok\Delta N_{\text{ok}}aumento de nv_inference_request_successrate(…[5m])
ΔNinf, ΔNexec\Delta N_{\text{inf}},\ \Delta N_{\text{exec}}aumento de nv_inference_count (inferencias) y nv_inference_exec_count (ejecuciones)su cociente es el batch promedio
UGPUU_{\text{GPU}}lo que nvidia-smi llama utilización: tiempo con kernels (programas de la GPU) corriendo, no capacidad usadacon batches de 1 marca mucho más que la carga: C01 espera 10–35 % con ≈ 5 % de carga

Fórmulas

ttotal≈tpre+tred+tcola+tcoˊmputo+tpostt_{\text{total}} \approx t_{\text{pre}} + t_{\text{red}} + t_{\text{cola}} + t_{\text{cómputo}} + t_{\text{post}}
tˉcola=ΔQΔNokbˉ=ΔNinfΔNexec\bar{t}_{\text{cola}} = \dfrac{\Delta Q}{\Delta N_{\text{ok}}} \qquad \bar{b} = \dfrac{\Delta N_{\text{inf}}}{\Delta N_{\text{exec}}}
UGPU=tcon al menos un kerneltmuestraU_{\text{GPU}} = \dfrac{t_{\text{con al menos un kernel}}}{t_{\text{muestra}}}

Las piezas

PiezaQué haceEjemplo
CrashLoopBackOff al arrancar TritonUn 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/1Todaví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 /inferEl request no coincide con el contrato: nombre, shape, dtype o batch.unexpected shape for input 'input'
503 de TritonLa cola rechazó por tiempo o por tamaño, el modelo no está listo, o el servidor se está apagando.Request timeout expired
504 del gatewayTriton 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 estableEl 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 creceLa GPU está cerca de saturarse: faltan réplicas o un modelo más barato.alerta TritonQueueTimeHigh
GPU al 100 % con poco throughputnvidia-smi cuenta tiempo con kernels, no capacidad: batches de 1, o la T4 bajando su frecuencia (clocks).batch promedio ≈ 1
Predicciones distintas a PyTorchBug de export, del engine o del preproceso: falla sin errores.[parity] NO PASA
Primer request lentoInicialización pendiente: sin warmup, la paga el primer request real.model_warmup ausente
CUDA out of memoryVRAM 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 ≈ 1No 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?

  1. ¿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.

  2. ¿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.

  3. ¿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é.

  4. ¿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.

  5. ¿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.

  6. ¿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.

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

  8. ¿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.

De afuera hacia adentro y de lo barato a lo caro: cada paso descarta una capa. Si un paso falla, ahí está el problema; si pasa, sigues con el siguiente.

Ejemplo real · C01: actualizan la imagen de Triton y el pod nuevo no levanta

textoForma típica del log de Triton (prefijos de cada línea omitidos). Textos del core y del backend de TensorRT de Triton (24.08 y la rama actual), y del manual de errores de TensorRT; --getPlanVersionOnly existe en trtexec desde 8.6 (también en 10.x y 11.x)
$ 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.plan
  • Léelo de abajo hacia arriba: error: 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é.
  • La causa: la imagen nueva trae otra release de NGC (otro TensorRT) y el 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.
  • No es un problema del pod: con 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).
  • Si la imagen saltó de TensorRT 10 a 11 (NGC 26.06 en adelante; 26.09 trae TensorRT 11.3), también cambia el build: --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.

Laboratorio en tu Mac, sin GPU (opcional)7 pasos

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.

  1. Paso 1
    cd apps/ml-system-design/reference/c01/gateway && python3.13 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt

    Qué 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.

  2. Paso 2
    uvicorn app.main:app --port 8080

    Qué 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.

  3. Paso 3
    curl -i localhost:8080/healthz; echo; curl -i localhost:8080/readyz

    Qué 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.

  4. Paso 4
    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.

  5. Paso 5
    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/classify

    Qué 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.

  6. Paso 6
    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".

  7. Paso 7
    rm foto.jpg rota.jpg

    Qué 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í.

13

Errores que debes reconocer rápido (export, TensorRT, Triton, CUDA)

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

VariableQué esEjemplo
modelo · vmodelo y versión que Triton intentó cargarclassifier · 1
estadoel código de estado de Triton, en palabrasInternal, Invalid argument, Not found, Unavailable
motivoel porqué que agregó el backend o el coreunable to create TensorRT engine: …
httpel código HTTP que respondió Triton400, 404, 503
idel request_id que mandó el clienteel X-Request-ID del gateway de C01
grpcel código de gRPCUNAVAILABLE, INVALID_ARGUMENT
motivo_trtla última línea de error de TensorRTThe engine plan file is not compatible…
mensajeel texto que explica el códigounexpected shape for input 'input'…
excepcionel tipo de error de PythonRuntimeError, TorchExportError, InferenceServerException
n · codigoel número y el nombre del código de ONNX Runtime2 · INVALID_ARGUMENT

Las piezas

PiezaQué haceEjemplo
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.exportEl 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::…
TracerWarningUna 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 memoryPyTorch no consiguió VRAM: torch.OutOfMemoryError (torch.cuda.OutOfMemoryError es el mismo tipo).Tried to allocate 2.00 GiB
DecompressionBombError · UnidentifiedImageErrorPillow, en el preproceso: una imagen demasiado grande, o bytes que no son una imagen.413 y 400 en el gateway de C01
[parity] NO PASAEl gate de C01: el artefacto no predice como PyTorch FP32. Exit 1: no se promueve.top1_agreement < 99.5 %
Failed to obtain stable measurementperf_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]).
Quiz: el mensaje tal como lo ves; responde quién lo dijo, qué significa, qué corres primero y en qué sección está.

Los 26 mensajes

26 de 26

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

    → Sección 2 · Preparar el modelo en PyTorch: eval, inference_mode, preprocesamiento idéntico y determinismo

  2. 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'))"

    → Sección 2 · Preparar el modelo en PyTorch: eval, inference_mode, preprocesamiento idéntico y determinismo

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

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

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

  6. [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

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

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

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

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

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

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

    → Sección 6 · El model repository y config.pbtxt

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

    → Sección 6 · El model repository y config.pbtxt

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

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

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

    → Sección 7 · Dynamic batching, instancias y concurrencia

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

    → Sección 7 · Dynamic batching, instancias y concurrencia

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

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

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

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

  22. [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

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

    → Sección 10 · Pre y postprocesamiento: dónde va (gateway, ensemble, backend de Python) y el riesgo de skew

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

    → Sección 10 · Pre y postprocesamiento: dónde va (gateway, ensemble, backend de Python) y el riesgo de skew

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

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

Ejemplo real · Cómo leer un error de inferencia: el cliente se olvidó del eje de batch

textoForma típica con Triton 24.08 y tritonclient 2.72: los textos salen del código del core de Triton (infer_request.cc, igual en 24.08 y en la rama actual) y del cliente; la metadata sigue el protocolo KServe v2
$ 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]}]}
  • Léelo por partes: [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.
  • El NOTE sugiere max_batch_size: 0. No en C01: apagaría el dynamic batching. Se arregla el cliente.
  • Si el error sale antes de llegar a Triton (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.
  • Los 503 también vienen de Triton (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.

Laboratorio en tu Mac, sin GPU (opcional)3 pasos

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

  1. Paso 1
    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; }; ronda

    Qué 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.

  2. Paso 2
    ronda

    Qué deberías ver: Otra ronda de cinco. Repite hasta completar 5 minutos; la meta es 5 de 5 sin mirar.

  3. Paso 3
    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]}")
    EOF

    Qué 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.

⚙

Herramientas

Del síntoma al primer comando, y una revisión rápida de tu config.pbtxt antes de construir la imagen.

Diagnosticador

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.

Revisor de config.pbtxt

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.