6 min de lectura
🇪🇸 Español | 🇺🇸 Leer en inglés
El parche que registraba “patched” y nunca se ejecutó
6 min de lectura
Dos incidentes. La misma causa raíz. El primero paralizó la empresa durante tres horas en julio. El segundo reveló que el fix instalado era un placebo con un log reconfortante: el código estaba allí, registraba una confirmación, y el arena de memoria seguía creciendo sin límite.
Aquí cuento cómo la BFCArena de ONNX Runtime creció en silencio hasta bloquear todo con un OOM, por qué el primer parche no funcionó a pesar de imprimir “patched” en los logs, y qué métrica demuestra que el fix correcto funcionó.
Síntoma medido
El 16 de julio de 2026, a las 18h, el servicio de orquestación de agentes de IA comenzó a tener una fuga de memoria. La tasa alcanzó los 737 MB por minuto.
El servicio es el pipeline que se ejecuta en el puerto 4090: recibe solicitudes de despacho, ejecuta los agentes y gestiona el estado de cada ejecución en curso. El punto de agotamiento fue la ruta memory_recall_v2, responsable de la búsqueda semántica en el índice de hechos de la empresa. Cada llamada a esta ruta activaba la biblioteca fastembed para generar el vector de consulta y compararlo con el índice.
En menos de 20 minutos desde las primeras alertas, el kernel activó el OOM killer. El proceso del pipeline murió. Todos los agentes con tareas abiertas perdieron la conexión con el dispatcher. El sistema de despacho quedó ciego: sin el proceso, ningún agente podía devolver resultados, actualizar estados o crear tareas hijas. La empresa se paralizó.
La hipótesis equivocada
La hipótesis operativa era clara y estaba equivocada: el log de inicio decía "[brain_server] onnxruntime patched: BFCArena bounded", por lo que el arena estaba deshabilitada. El servidor se había reiniciado de forma limpia y se había mantenido estable en las primeras pruebas.
La hipótesis era equivocada porque el parche verificaba if so is None antes de crear un SessionOptions con el arena deshabilitada. El fastembed nunca pasa sess_options=None: entrega su propio SessionOptions con el arena habilitada. La condición nunca era verdadera, el _Bounded se instanciaba, el log aparecía, y el SessionOptions del fastembed llegaba intacto al runtime. El arena crecía normalmente.
Causa raíz
El onnxruntime utiliza un arena de asignación interna llamada BFCArena (Best-Fit with Coalescing). El comportamiento predeterminado tiene dos aspectos que, combinados, son fatales en procesos de larga duración:
Primero, el arena crece en potencias de dos a medida que aumenta la demanda: 256 MB, 512 MB, 1 GB, 2 GB. Cada salto es irreversible.
Segundo, el arena nunca devuelve al sistema operativo lo que ha asignado. Si una inferencia necesita 1 GB de arena, ese gigabyte queda reservado para el proceso por el resto de su vida, independientemente de cuántas otras inferencias necesiten menos.
Con llamadas continuas a memory_recall_v2, el arena crecía en escalones exponenciales. Cada llamada que necesitaba un bloque más grande forzaba un nuevo salto de potencia de dos. El bloque anterior quedaba reservado. El siguiente salto reservaba el doble. En menos de 20 minutos, 12 GB agotados.
El parche que registraba “patched” y nunca se ejecutaba
Una vez identificada la BFCArena como la culpable, apliqué un monkey-patch en onnxruntime antes de la importación de fastembed. La idea era reemplazar la InferenceSession por una subclase que forzara el SessionOptions con el arena deshabilitada:
import onnxruntime as _ort
_orig = _ort.InferenceSession
class _Bounded(_orig):
def __init__(self, *args, **kwargs):
so = kwargs.get("sess_options")
if so is None: # o bug estava aqui
so = _ort.SessionOptions()
so.enable_cpu_mem_arena = False
so.enable_mem_pattern = False
kwargs["sess_options"] = so
super().__init__(*args, **kwargs)
_ort.InferenceSession = _Bounded
El log de inicio lo confirmaba: "[brain_server] onnxruntime patched: BFCArena bounded". El servidor se reinició de forma limpia. El servicio se mantuvo estable en las primeras pruebas.
Siete días después, la fuga de memoria volvió a aparecer.
Medí el VmRSS del proceso ejecutando embeddings sin carga externa: 902 MB creciendo a 2,57 GB en 5 minutos. El parche estaba instalado, registraba “patched”, y el arena nunca se había deshabilitado en ninguna inferencia real. A lo largo de este período, el servicio acumuló 308 reinicios.
La razón era la condición if so is None. El fastembed nunca pasa sess_options=None: entrega su propio SessionOptions con el arena habilitada. El parche interceptaba la llamada, veía que so no era None y se saltaba por completo la lógica de deshabilitación.
Véase también: Sobre Igor Caique.
El fix correcto
El fix correcto elimina la condición y fuerza la desactivación del arena independientemente de lo que haya pasado el caller:
class _Bounded(_orig):
def __init__(self, *args, **kwargs):
so = kwargs.get("sess_options")
if so is None:
so = _ort.SessionOptions()
# força SEMPRE, ignorando o que o fastembed passou
so.enable_cpu_mem_arena = False
so.enable_mem_pattern = False
so.intra_op_num_threads = 1
so.inter_op_num_threads = 1
kwargs["sess_options"] = so
super().__init__(*args, **kwargs)
Junto con el parche corregido, activé un kill-switch a través de una variable de entorno: PEI_DISABLE_LOCAL_EMBED=1 configurado en el servicio de systemd. Con el kill-switch activo, la ruta de recall deshabilita el embedding local y degrada a una búsqueda FTS5 pura. Tenemos 316 mil hechos indexados en FTS5 y la búsqueda textual es suficiente para la operación normal mientras el embedding remoto no está disponible.
Se eligió el monkey-patch en lugar de un subproceso aislado porque el subproceso añadiría latencia de inicialización por worker, lo cual es inaceptable para búsquedas síncronas con SLA de respuesta. El kill-switch (PEI_DISABLE_LOCAL_EMBED=1) es el plan B: cuando el embedding local está deshabilitado, el sistema degrada a FTS5 pura sin pérdida de funcionalidad para los 316 mil hechos indexados.
La métrica que lo demuestra
La señal es el VmRSS del proceso. Sin medir esto en tiempo de ejecución, el parche “funciona” en teoría y ninguna prueba detecta el fallo. El log "patched" no es una señal: es texto que confirma que una función se ejecutó, no que el efecto se produjo.
Métrica de aceptación: ejecutar al menos 200 embeddings reales y medir el VmRSS en tres puntos espaciados en el tiempo. Criterio de aprobación: el delta entre el punto intermedio y el punto final debe ser menor a 100 MB. Un RSS creciente con el parche instalado significa que la condición no se está ejecutando.
for i in 1 2 3; do
grep VmRSS /proc/$(pgrep -f brain_server)/status
sleep 30
done
Con el parche equivocado: crecimiento lineal en los tres puntos, de 902 MB a 2,57 GB.
Con el parche correcto: estabilización entre el segundo y el tercer punto, con un delta por debajo de 80 MB.
La pregunta que cierra cualquier prueba: “si el parche estuviera roto, ¿lo habría detectado mi prueba?” Con VmRSS en tres puntos y 200 embeddings reales, la respuesta es sí. Con solo el log “patched”, la respuesta es no.
Referencias
- ONNX Runtime: ajuste de rendimiento y threads: documentación oficial sobre
SessionOptions, incluyendoenable_cpu_mem_arena. - onnxruntime/bfc_arena.cc: implementación de la BFCArena en el repositorio oficial.
- qdrant/fastembed: biblioteca que instancia
InferenceSessioncon su propioSessionOptions. - proc(5): manual de Linux: referencia de
VmRSSen/proc/PID/status. - tensorflow/bfc_allocator.h: origen del patrón BFCAllocator, del cual ONNX Runtime derivó la implementación.
Newsletter
Uma decisão técnica por post. Nada mais.
Bastidores reais de quem constrói empresa operada por IA. Sem hype.
Dupla confirmação. Sem spam. Cancele quando quiser. Dados usados exclusivamente para esta newsletter (LGPD).