Phase 04 - Lesson 15
Visión en Tiempo Real — Despliegue en el Borde (Edge)
La inferencia en el borde es la disciplina de lograr que un modelo con 90 de exactitud corra a 30 fps en un dispositivo con 2 GB de RAM. Cada punto porcentual de exactitud se canjea por milisegundos de latencia.
Tipo: Aprender + Construir Lenguajes: Python Prerrequisitos: Fase 4 Lección 04 (Clasificación de Imágenes), Fase 10 Lección 11 (Cuantización) Tiempo: ~75 minutos
Objetivos de Aprendizaje
- Medir la latencia de inferencia, el pico de memoria y el throughput para cualquier modelo PyTorch, y leer el trade-off entre FLOPs / parámetros / latencia
- Cuantizar un modelo de visión a INT8 usando la cuantización post-entrenamiento de PyTorch y verificar una pérdida de exactitud < 1%
- Exportar a ONNX y compilar con ONNX Runtime o TensorRT; nombrar las tres fallas de exportación más comunes y sus soluciones
- Explicar cuándo elegir MobileNetV3, EfficientNet-Lite, ConvNeXt-Tiny o MobileViT para una restricción de borde
El Problema
Un modelo de visión en tiempo de entrenamiento es un monstruo de punto flotante. 100M de parámetros, 10 GFLOPs por pase hacia adelante, 2 GB de VRAM. Nada de eso cabe en un teléfono, en la unidad de entretenimiento de un auto, en una cámara industrial o en un dron. Entregar un sistema de visión significa encajar las mismas predicciones en un presupuesto 100x más pequeño.
Tres perillas hacen la mayor parte del trabajo: la elección del modelo (una arquitectura más pequeña con la misma receta), la cuantización (INT8 en lugar de FP32) y el runtime de inferencia (ONNX Runtime, TensorRT, Core ML, TFLite). Acertarlas es la diferencia entre un demo que corre en una estación de trabajo y un producto que se despliega en un módulo de cámara de $30.
Esta lección establece primero la disciplina de medición (no puedes optimizar lo que no puedes medir), y luego recorre las tres perillas. El objetivo no es aprender cada runtime de borde, sino saber qué palancas existen y cómo verificar que cada una hace lo que crees.
El Concepto
Los tres presupuestos
flowchart LR
M["Modelo"] --> LAT["Latencia<br/>ms por imagen"]
M --> MEM["Memoria<br/>pico MB"]
M --> PWR["Energía<br/>mJ por inferencia"]
LAT --> SHIP["Decisión de<br/>entregar / no entregar"]
MEM --> SHIP
PWR --> SHIP
style LAT fill:#fecaca,stroke:#dc2626
style MEM fill:#fef3c7,stroke:#d97706
style PWR fill:#dbeafe,stroke:#2563eb
- Latencia: p50, p95, p99. Promediar solo el p50 oculta el comportamiento de cola que importa en sistemas de tiempo real.
- Pico de memoria: el máximo que el dispositivo llega a ver, no el promedio en estado estacionario. Importa porque los OOM son fatales en objetivos embebidos.
- Energía / consumo: milijulios por inferencia en un dispositivo alimentado por batería. A menudo se aproxima por la utilización de CPU/GPU * tiempo.
Una tabla de (modelo, latencia, memoria, exactitud) es la base de una decisión de borde. Cada celda se mide en el dispositivo objetivo, no en la estación de trabajo.
Disciplina de medición
Tres reglas que todo perfil de borde debe seguir:
- Calienta el modelo con 5-10 pases hacia adelante ficticios antes de medir. Las cachés frías y la compilación JIT producen primeros números no representativos.
- Sincroniza las cargas de trabajo de GPU con
torch.cuda.synchronize()antes y después del bloque cronometrado. Sin esto mides el despacho del kernel, no su ejecución. - Fija los tamaños de entrada a la resolución de producción. La latencia en 224x224 no es la latencia en 512x512.
FLOPs como proxy
FLOPs (operaciones de punto flotante por inferencia) es un proxy barato e independiente del dispositivo para la latencia. Útil para comparar arquitecturas, engañoso como wall-clock absoluto. Un modelo con 10% más FLOPs puede ser 2x más rápido en la práctica porque usa operaciones amigables con el hardware (las convoluciones depthwise compilan bien, las convoluciones grandes 7x7 no).
Regla: usa FLOPs para la búsqueda de arquitecturas, usa la latencia en el dispositivo para decisiones de despliegue.
Cuantización en un párrafo
Reemplaza los pesos y activaciones FP32 por INT8. El tamaño del modelo cae 4x, el ancho de banda de memoria cae 4x, el cómputo cae 2-4x en hardware que tiene kernels INT8 (todo SoC móvil moderno, toda GPU NVIDIA con Tensor Cores). La pérdida de exactitud en tareas de visión es típicamente de 0,1-1 punto porcentual con cuantización estática post-entrenamiento.
Tipos:
- Dinámica — cuantiza los pesos a INT8, las activaciones se calculan en FP. Fácil, aceleración pequeña.
- Estática (post-entrenamiento) — cuantiza los pesos + calibra los rangos de activación en un pequeño conjunto de calibración. Mucho más rápida que la dinámica.
- Entrenamiento consciente de cuantización (QAT) — simula la cuantización durante el entrenamiento para que el modelo aprenda en torno a ella. Mejor exactitud, necesita datos etiquetados.
Para visión, la cuantización estática post-entrenamiento entrega el 95% del beneficio con el 5% del esfuerzo. Usa QAT solo cuando la pérdida de exactitud del PTQ sea inaceptable.
Pruning y destilación
- Pruning — elimina pesos sin importancia (basado en magnitud) o canales (estructurado). Funciona bien en modelos sobreparametrizados; menos útil en arquitecturas ya compactas.
- Destilación — entrena un estudiante pequeño para imitar los logits de un profesor grande. A menudo recupera la mayor parte de la exactitud perdida al encoger el modelo. Es el estándar para modelos de borde en producción.
Los runtimes de inferencia
- PyTorch eager — lento, no sirve para despliegue. Úsalo solo para desarrollo.
- TorchScript — heredado. Superado por
torch.compiley la exportación a ONNX. - ONNX Runtime — el runtime neutral. CPU, CUDA, CoreML, TensorRT, OpenVINO tienen todos providers ONNX. Empieza por aquí.
- TensorRT — el compilador de NVIDIA. Mejor latencia en GPUs NVIDIA (estación de trabajo y Jetson). Se integra con ONNX Runtime o standalone.
- Core ML — el runtime de Apple para iOS/macOS. Necesita
.mlmodelo.mlpackage. - TFLite — el runtime de Google para Android/ARM. Necesita
.tflite. - OpenVINO — el runtime de Intel para CPU/VPU. Necesita
.xml+.bin.
En la práctica: exporta PyTorch -> ONNX -> elige el runtime para el objetivo. ONNX es la lengua franca.
Selector de arquitectura de borde
| Presupuesto | Modelo | Por qué |
|---|---|---|
| < 3M params | MobileNetV3-Small | Compila en todas partes, buena baseline |
| 3-10M | EfficientNet-Lite-B0 | Mejor exactitud por param en TFLite |
| 10-20M | ConvNeXt-Tiny | Mejor exactitud-por-param, amigable con CPU |
| 20-30M | MobileViT-S o EfficientViT | Transformer con exactitud ImageNet |
| 30-80M | Swin-V2-Tiny | Si el stack soporta window attention |
Cuantiza todos estos a INT8 a menos que tengas una razón específica para no hacerlo.
Constrúyelo
Paso 1: Mide la latencia correctamente
import time
import torch
def measure_latency(model, input_shape, device="cpu", warmup=10, iters=50):
model = model.to(device).eval()
x = torch.randn(input_shape, device=device)
with torch.no_grad():
for _ in range(warmup):
model(x)
if device == "cuda":
torch.cuda.synchronize()
times = []
for _ in range(iters):
if device == "cuda":
torch.cuda.synchronize()
t0 = time.perf_counter()
model(x)
if device == "cuda":
torch.cuda.synchronize()
times.append((time.perf_counter() - t0) * 1000)
times.sort()
return {
"p50_ms": times[len(times) // 2],
"p95_ms": times[int(len(times) * 0.95)],
"p99_ms": times[int(len(times) * 0.99)],
"mean_ms": sum(times) / len(times),
}
Calienta, sincroniza, usa time.perf_counter(). Reporta percentiles, no solo la media.
Paso 2: Conteo de parámetros y FLOPs
def parameter_count(model):
return sum(p.numel() for p in model.parameters())
def flops_estimate(model, input_shape):
"""
Rough FLOP count for a conv/linear-only model. For production use `fvcore` or `ptflops`.
"""
total = 0
def conv_hook(m, inp, out):
nonlocal total
c_out, c_in, kh, kw = m.weight.shape
h, w = out.shape[-2:]
total += 2 * c_in * c_out * kh * kw * h * w
def linear_hook(m, inp, out):
nonlocal total
total += 2 * m.in_features * m.out_features
hooks = []
for m in model.modules():
if isinstance(m, torch.nn.Conv2d):
hooks.append(m.register_forward_hook(conv_hook))
elif isinstance(m, torch.nn.Linear):
hooks.append(m.register_forward_hook(linear_hook))
model.eval()
with torch.no_grad():
model(torch.randn(input_shape))
for h in hooks:
h.remove()
return total
Para proyectos reales usa fvcore.nn.FlopCountAnalysis o ptflops; manejan correctamente cada tipo de módulo.
Paso 3: Cuantización estática post-entrenamiento
def quantise_ptq(model, calibration_loader, backend="x86"):
import torch.ao.quantization as tq
model = model.eval().cpu()
model.qconfig = tq.get_default_qconfig(backend)
tq.prepare(model, inplace=True)
with torch.no_grad():
for x, _ in calibration_loader:
model(x)
tq.convert(model, inplace=True)
return model
Tres pasos: configurar, preparar (insertar observers), calibrar con datos reales, convertir (fusionar + cuantizar). Requiere que el modelo esté fusionado (Conv -> BN -> ReLU -> ConvBnReLU), lo cual torch.ao.quantization.fuse_modules resuelve.
Paso 4: Exportar a ONNX
def export_onnx(model, sample_input, path="model.onnx"):
model = model.eval()
torch.onnx.export(
model,
sample_input,
path,
input_names=["input"],
output_names=["output"],
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}},
opset_version=17,
)
return path
opset_version=17 es el valor predeterminado seguro en 2026. dynamic_axes permite correr el modelo ONNX con un tamaño de batch arbitrario.
Paso 5: Haz benchmark y compara los regímenes
import torch.nn as nn
from torchvision.models import mobilenet_v3_small
def compare_regimes():
model = mobilenet_v3_small(weights=None, num_classes=10)
params = parameter_count(model)
flops = flops_estimate(model, (1, 3, 224, 224))
lat_fp32 = measure_latency(model, (1, 3, 224, 224), device="cpu")
print(f"FP32 MobileNetV3-Small: {params:,} params {flops/1e9:.2f} GFLOPs "
f"p50={lat_fp32['p50_ms']:.2f}ms p95={lat_fp32['p95_ms']:.2f}ms")
Corre la misma función para resnet50, efficientnet_v2_s y convnext_tiny y tendrás la tabla comparativa que necesitas para una decisión de despliegue.
Úsalo
Los stacks de producción convergen en uno de tres caminos:
- Web / serverless: PyTorch -> ONNX -> ONNX Runtime (provider de CPU o CUDA). El más fácil, suficientemente bueno para la mayoría.
- Borde NVIDIA (Jetson, servidor GPU): PyTorch -> ONNX -> TensorRT. Mejor latencia, mayor esfuerzo de ingeniería.
- Móvil: PyTorch -> ONNX -> Core ML (iOS) o TFLite (Android). Cuantiza antes de exportar.
Para la medición, torch-tb-profiler, nvprof / nsys e Instruments en macOS dan desgloses capa por capa. benchmark_app (OpenVINO) y trtexec (TensorRT) dan números standalone vía CLI.
Entrégalo
Esta lección produce:
outputs/prompt-edge-deployment-planner.md— un prompt que elige backbone, estrategia de cuantización y runtime dados el dispositivo objetivo y el SLA de latencia.outputs/skill-latency-profiler.md— una skill que escribe un script completo de benchmark de latencia con warmup, sincronización, percentiles y rastreo de memoria.
Ejercicios
- (Fácil) Mide la latencia p50 para
resnet18,mobilenet_v3_small,efficientnet_v2_syconvnext_tinyen 224x224 en CPU. Reporta la tabla e identifica qué arquitectura tiene la mejor exactitud-por-ms. - (Medio) Aplica cuantización estática post-entrenamiento a
mobilenet_v3_small. Reporta la latencia FP32 vs INT8 y la pérdida de exactitud en un subconjunto de validación de CIFAR-10 o similar. - (Difícil) Exporta
convnext_tinya ONNX, córrelo a través deonnxruntimecon elCPUExecutionProvidery compara la latencia con la baseline eager de PyTorch. Identifica la primera capa en la que ONNX Runtime es más rápido y explica por qué.
Términos Clave
| Término | Lo que dice la gente | Lo que realmente significa |
|---|---|---|
| Latencia | "Qué tan rápido" | Tiempo de la entrada a la salida; percentiles p50/p95/p99, no la media |
| FLOPs | "Tamaño del modelo" | Operaciones de punto flotante por pase hacia adelante; proxy aproximado del costo de cómputo |
| Cuantización INT8 | "8 bits" | Reemplazar pesos/activaciones FP32 por enteros de 8 bits; ~4x más pequeño, 2-4x más rápido |
| PTQ | "Cuantización post-entrenamiento" | Cuantizar un modelo entrenado sin reentrenar; fácil, generalmente suficiente |
| QAT | "Entrenamiento consciente de cuantización" | Simular la cuantización durante el entrenamiento; mejor exactitud, requiere datos etiquetados |
| ONNX | "El formato neutral" | Formato de intercambio de modelos soportado por todo runtime de inferencia mainstream |
| TensorRT | "Compilador de NVIDIA" | Compila ONNX en un engine optimizado para GPUs NVIDIA |
| Destilación | "Profesor -> estudiante" | Entrenar un modelo pequeño para imitar los logits de un modelo grande; recupera la mayor parte de la exactitud perdida |
Lectura Adicional
- EfficientNet (Tan & Le, 2019) — escalado compuesto para arquitecturas eficientes
- MobileNetV3 (Howard et al., 2019) — arquitectura mobile-first con h-swish y squeeze-excite
- A Practical Guide to TensorRT Optimization (NVIDIA) — cómo obtener realmente los números de throughput del paper
- ONNX Runtime docs — cuantización, optimización de grafo, selección de provider