Wrappers Python para ONNX Runtime en 2026: qué merece encapsularse
Envolver un InferenceSession en una clase con __call__, un par de type hints y un try/except genérico es la parte más visible y menos decisiva de servir un modelo ONNX: no aporta nada que onnxruntime no resuelva ya por sí mismo. Ese fue el contenido de la versión anterior de este artículo. La pregunta relevante en 2026 es qué decisiones concretas de un wrapper cambian el comportamiento real en producción: selección y partición de execution provider, coste de las copias CPU↔GPU, y si el modelo que estás sirviendo sigue siendo terreno de ONNX Runtime o ya debería vivir en otro runtime.
Este artículo cubre esas cuatro decisiones: cuándo ONNX Runtime sigue siendo la pieza correcta, cómo exportar un modelo desde PyTorch con el exporter que es default desde hace varias versiones, qué debe contener un wrapper que sí aporte algo (selección de provider verificada, SessionOptions, IOBinding) y por qué, si el modelo es un LLM generativo, casi nunca conviene escribir ese wrapper a mano.
¿Sigue teniendo sentido ONNX Runtime en 2026?
ONNX Runtime sigue con desarrollo activo: Microsoft publica en cadencia mensual y la página de releases confirma la versión estable v1.28.0 (25 de julio de 2026), con cambios como hacer opcionales cuDNN/cuFFT en tiempo de ejecución para el execution provider de CUDA y separar CUDA 13 en un paquete propio. Las notas de esa misma release documentan además el EP de WebGPU convertido en plugin independiente, versionado aparte del core, y la retirada de los kernels fused de atención causal de TensorRT dentro del EP de CUDA. No es un proyecto en mantenimiento residual: es un proyecto que sigue recortando superficie legacy mientras añade soporte nuevo.
Su terreno, sin embargo, se ha reordenado. La lista de execution providers sigue creciendo justo en edge y NPU: Qualcomm QNN para Snapdragon, Arm Compute Library, Rockchip RKNPU, Apple CoreML, Intel OpenVINO, además de los clásicos CUDA, TensorRT y DirectML. Ese es el nicho donde ONNX Runtime gana frente a alternativas: un mismo grafo .onnx corriendo sin cambios sobre GPU de servidor, NPU de un portátil Snapdragon y CPU de un contenedor, seleccionando el provider en tiempo de ejecución. Microsoft ha reforzado esa apuesta con Windows ML, que alcanzó disponibilidad general en septiembre de 2025 descrito como la copia de ONNX Runtime mantenida por el propio Windows, con los execution providers (QNN, OpenVINO, TensorRT-RTX) entregados vía Windows Update en lugar de empaquetados por cada aplicación.
Donde ONNX Runtime ya no es la respuesta por defecto es en servir LLMs grandes a muchos usuarios concurrentes sobre GPU de servidor: ese espacio se ha consolidado alrededor de motores especializados en attention paginada y scheduling de batches, pensados específicamente para ese escenario de forma que un runtime de propósito general no puede igualar sin reimplementar buena parte de esa lógica. Si el wrapper existe para servir un modelo de visión, un encoder de embeddings, un modelo de árboles exportado desde scikit-learn o cualquier cosa destinada a edge, NPU o CPU, el terreno sigue siendo el correcto. Si existe para servir un LLM generativo, la sección sobre onnxruntime-genai más abajo importa más que cualquier wrapper artesanal.
Exportar el modelo: el exporter basado en dynamo ya es el default
Si vienes de PyTorch, el punto de partida cambió. Desde PyTorch 2.9 el parámetro dynamo de torch.onnx.export es True por defecto, y este exporter basado en torch.export.ExportedProgram es, según la propia documentación oficial, la vía recomendada y por defecto para exportar a ONNX. Desde PyTorch 2.11 además desapareció el fallback automático al exporter legado basado en TorchScript: si el exporter dynamo falla, ya no cae en silencio al camino antiguo, hay que decidirlo explícitamente con dynamo=False.
El ejemplo mínimo de la documentación actual ya no distingue entre torch.onnx.export y un dynamo_export separado: es una sola llamada.
import torch
class MyModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv1 = torch.nn.Conv2d(1, 128, 5)
def forward(self, x):
return torch.relu(self.conv1(x))
input_tensor = torch.rand((1, 1, 128, 128), dtype=torch.float32)
model = MyModel().eval()
torch.onnx.export(
model,
(input_tensor,),
"my_model.onnx",
input_names=["input"],
dynamo=True, # default desde PyTorch 2.9
)
Para modelos con control de flujo en Python o formas dinámicas, el exporter dynamo se apoya en torch.export, así que las restricciones que ya conocías de esa API (nada de ramas condicionales sobre tensores sin marcar, formas dinámicas declaradas explícitamente) aplican también aquí. No es un detalle menor si tu pipeline de exportación asumía el comportamiento permisivo del trazador basado en TorchScript.
El wrapper que aporta algo: providers explícitos, no __call__
Un wrapper de InferenceSession tiene sentido cuando resuelve algo que la clase base no resuelve: la política de selección de provider y la verificación de qué quedó activo de verdad, la configuración de sesión que depende del entorno de despliegue (threads, nivel de optimización de grafo), o necesidades operativas legítimas como métricas de latencia por request, observabilidad, validación de contratos de entrada, warm-up antes de servir tráfico real, o agrupar peticiones en batches. Lo que no justifica por sí solo una capa de abstracción dedicada es envolver predict() solo para loggear genérico o validar tipos: eso es código de aplicación normal.
El ejemplo siguiente se simplifica a un modelo de una sola entrada y una sola salida, el caso típico de visión o embeddings; para grafos con múltiples inputs u outputs conviene iterar sobre get_inputs()/get_outputs() en vez de indexar [0].
import logging
from typing import Sequence
import numpy as np
import onnxruntime as ort
logger = logging.getLogger(__name__)
class OnnxModel:
"""InferenceSession con selección de provider verificada tras crear la sesión."""
def __init__(
self,
model_path: str,
preferred_providers: Sequence[str] = ("CUDAExecutionProvider", "CPUExecutionProvider"),
intra_op_threads: int | None = None,
fail_fast_on_degradation: bool = True,
) -> None:
available = set(ort.get_available_providers())
providers = [p for p in preferred_providers if p in available]
if not providers:
raise RuntimeError(
f"Ninguno de {list(preferred_providers)} está disponible "
f"en este build de onnxruntime ({sorted(available)})"
)
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
if intra_op_threads:
sess_options.intra_op_num_threads = intra_op_threads
self.session = ort.InferenceSession(
model_path, sess_options=sess_options, providers=providers
)
# get_available_providers() solo dice qué EPs trae el build; el provider
# que de verdad quedó registrado se confirma después de crear la sesión.
active = self.session.get_providers()
if active[0] != providers[0]:
message = f"Provider activo {active[0]!r}, se pidió {providers[0]!r}"
if fail_fast_on_degradation:
raise RuntimeError(message)
logger.warning(message)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [o.name for o in self.session.get_outputs()]
def predict(self, input_array: np.ndarray) -> np.ndarray:
return self.session.run(self.output_names, {self.input_name: input_array})[0]
providers no es un failover limpio ante un fallo del dispositivo: es una lista de precedencia que ONNX Runtime usa para particionar el grafo nodo a nodo. Si un operador no tiene kernel en CUDAExecutionProvider, ese nodo concreto se ejecuta en CPU aunque el resto del grafo corra en GPU; la referencia de la API de Python lo describe así: cuando hay un kernel disponible en el execution provider de CUDA, ONNX Runtime lo ejecuta en GPU, y si no lo hay, ese kernel se ejecuta en CPU. Es partición por operador, no una alternativa completa si la GPU deja de estar disponible.
Tampoco get_available_providers() garantiza que el provider vaya a funcionar: solo confirma qué EPs incluye el build instalado, no que la GPU esté presente, el driver sea compatible o haya memoria libre en tiempo de ejecución. Por eso el wrapper anterior comprueba session.get_providers() justo después de crear la sesión, que sí refleja qué quedó registrado, y decide una política fail-fast si el resultado se degradó a CPU sin que nadie lo autorizara. Además, desde ONNX Runtime 1.10 el comportamiento por defecto cambió: la misma referencia señala que ejecutar en CPU es el único caso en que la API permite no fijar el parámetro providers explícitamente, y que cualquier EP distinto de CPU hay que pedirlo a propósito. Omitir providers no arriesga un intento silencioso de CUDA, arriesga quedarse en CPU sin que se note hasta que alguien mida la latencia.
IOBinding: la optimización que sí cambia el rendimiento en GPU
Si el provider es CUDA, TensorRT o cualquier EP que no sea CPU, cada llamada a session.run() con un numpy.ndarray implica copiar la entrada a memoria de dispositivo y el resultado de vuelta a CPU. La propia guía de tuning de rendimiento de ONNX Runtime es explícita sobre cuándo importa esto: "when working with non-CPU execution providers, it's most efficient to have inputs (and/or outputs) arranged on the target device" antes de ejecutar la sesión. El beneficio depende de dónde vive el dato al entrar y al salir: crear el tensor de entrada desde un numpy.ndarray en cada llamada y copiar la salida de vuelta a CPU al final paga las dos transferencias que IOBinding existe para evitar. La ganancia aparece cuando el input ya está en GPU antes de llamar a run_with_iobinding y la salida se queda ahí, lista para encadenarse sin volver a CPU.
import numpy as np
import onnxruntime as ort
import torch
session = ort.InferenceSession("model.onnx", providers=["CUDAExecutionProvider"])
binding = session.io_binding()
def run_on_gpu(x: torch.Tensor, output_shape: tuple[int, ...]) -> torch.Tensor:
# x ya vive en GPU (viene de un preprocesado con PyTorch): no se copia desde CPU
x = x.contiguous()
binding.bind_input(
name=session.get_inputs()[0].name,
device_type="cuda",
device_id=0,
element_type=np.float32,
shape=tuple(x.shape),
buffer_ptr=x.data_ptr(),
)
# Salida preasignada en GPU y reutilizada entre llamadas: tampoco vuelve a CPU
y = torch.empty(output_shape, dtype=torch.float32, device="cuda:0").contiguous()
binding.bind_output(
name=session.get_outputs()[0].name,
device_type="cuda",
device_id=0,
element_type=np.float32,
shape=tuple(y.shape),
buffer_ptr=y.data_ptr(),
)
session.run_with_iobinding(binding)
# y sigue en GPU: puede pasar directo como input del siguiente paso del
# pipeline, o convertirse a CPU solo si algo del lado de la aplicación
# lo necesita de verdad.
return y
Esto sí es una razón legítima para envolverlo: decidir, según el provider activo y dónde vive el dato en el pipeline, si conviene mantener buffers en GPU entre llamadas o si el run() simple basta porque el dato entra y sale por CPU de todas formas. Tiene sentido meterlo en un wrapper cuando esa decisión se repite en varios puntos del código; hacerlo por rutina en un modelo que corre en CPU, o cuando de todos modos hay que tocar CPU antes y después, no añade nada.
Si el modelo es un LLM, casi nunca conviene escribir el wrapper a mano
Aplicar el mismo patrón de wrapper genérico a un modelo generativo suele salir caro. Servir un LLM en ONNX no es solo llamar a session.run(): hace falta gestionar KV-cache entre tokens, sampling, streaming de tokens y, cada vez más, tool calling con grammar constraints. Microsoft ya resolvió buena parte de eso en onnxruntime-genai, que implementa el bucle generativo completo para modelos ONNX: preprocesado, inferencia, logits processing, sampling y gestión de KV-cache, incluida especificación de grammar para tool calling. No es un proyecto secundario: el propio repositorio indica que da soporte a Foundry Local, Windows ML y el AI Toolkit de Visual Studio Code, y su release v0.15.2 (6 de agosto de 2026) añade soporte para arquitecturas MoE recientes sobre el execution provider TensorRT-RTX. Sigue, eso sí, en versión 0.x: no hay compromiso de compatibilidad entre versiones menores, así que conviene fijar la versión exacta en producción y revisar el changelog antes de actualizar.
import onnxruntime_genai as og
model = og.Model("phi-3-mini-4k-instruct-onnx/cpu_and_mobile/cpu-int4-rtn-block-32-acc-level-4")
tokenizer = og.Tokenizer(model)
stream = tokenizer.create_stream()
params = og.GeneratorParams(model)
params.set_search_options(max_length=2048, batch_size=1) # longitud total: prompt + generación
prompt = "<|user|>\nResume qué hace IOBinding en ONNX Runtime.<|end|>\n<|assistant|>"
input_tokens = tokenizer.encode(prompt)
generator = og.Generator(model, params)
generator.append_tokens(input_tokens)
while not generator.is_done():
generator.generate_next_token()
new_token = generator.get_next_tokens()[0]
print(stream.decode(new_token), end="", flush=True)
tokenizer.create_stream() importa: decodificar cada token por separado con tokenizer.decode([token]) puede cortar a la mitad un carácter multibyte cuando el token no coincide con un límite de codificación UTF-8, algo frecuente fuera del inglés. max_length limita la longitud total de la secuencia, prompt incluido, no solo los tokens nuevos generados: conviene reservar margen para el prompt al fijarlo.
Esto ya cubre lo que un wrapper artesanal tendría que reimplementar: gestión de KV-cache, control de longitud, streaming token a token respetando límites de codificación. Reimplementarlo a mano sobre InferenceSession puro solo tiene sentido si necesitas un control fino que onnxruntime-genai todavía no expone; para el caso general, es mantener código que el propio proyecto ya resuelve y sigue actualizando activamente.
Checklist antes de escribir una sola línea de wrapper
- Modelo no generativo (visión, embeddings, tabular, audio) destinado a CPU, edge o NPU multiplataforma: ONNX Runtime sigue siendo razonable; envuelve solo la selección de provider y, si aplica, IOBinding.
- Modelo generativo (LLM o SLM) para ejecución local, on-device o en Windows/Foundry Local: usa
onnxruntime-genaien lugar de construir el loop de generación a mano. - LLM grande servido a muchos usuarios concurrentes en GPU de servidor: ONNX Runtime deja de ser la pieza central; evalúa motores especializados en ese escenario antes de invertir en exportación a ONNX.
- Exportando desde PyTorch: usa
dynamo=True(default desde 2.9) y valida que el modelo no dependa del comportamiento permisivo del trazador legado, porque desde 2.11 ya no hay fallback silencioso a él. - Antes de envolver
InferenceSession: si la única lógica añadida es logging o validación de tipos, no es un wrapper, es código de aplicación que no necesita una clase dedicada.