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:

  1. 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.
  2. 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.
  3. 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.compile y 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 .mlmodel o .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

  1. (Fácil) Mide la latencia p50 para resnet18, mobilenet_v3_small, efficientnet_v2_s y convnext_tiny en 224x224 en CPU. Reporta la tabla e identifica qué arquitectura tiene la mejor exactitud-por-ms.
  2. (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.
  3. (Difícil) Exporta convnext_tiny a ONNX, córrelo a través de onnxruntime con el CPUExecutionProvider y 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

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).