Ir al contenido principal
Inteligencia Artificial · 8 min

Laya en CPU, sin GPU: lo que medí en tres máquinas antes de ponerlo en producción

38 ms era con GPU. En CPU, de 94 a 848 ms según la máquina. Benchmarks reales, la trampa de los hilos, ONNX frente a PyTorch y cómo quedó el despliegue en un VPS.

Pablo Fesa
Pablo Fesa Software Architect & Tech Lead

En el artículo anterior sobre Laya di una cifra: 38 ms por decisión. Era la cifra publicada por el proyecto, y es cierta, pero con una GPU. Yo quería saber qué pasa en una máquina sin GPU, que es donde corre casi todo lo que mantengo. Así que lo medí en tres máquinas, lo desplegué en una de ellas y lo puse detrás de mi gateway de LLM. Este artículo son los números, incluidos los que no me gustaron.

Resumen para quien tenga prisa: en CPU, una decisión tarda entre 94 ms y 848 ms según la máquina y el checkpoint. La precisión zero-shot en español es del 84 %. Y hay una trampa de configuración que multiplica la latencia por 30 si no la conoces.

El banco de pruebas

25 casos en español, escritos a mano, repartidos en 5 suites que se parecen a lo que un backend de atención al cliente decide cada día:

SuiteTipoCasosEjemplo
departamentoelegir entre 46”Me habéis cobrado dos veces la cuota” → facturación
intención sobre una subastaelegir entre 45”¿Hasta cuándo tengo para hacer la transferencia?” → pago
¿es spam?sí/no6”Crypto investment opportunity, 300% returns” → sí
¿está enfadado?sí/no4”Si mañana no está solucionado os denuncio” → sí
¿contiene datos personales?sí/no4”Domiciliad el pago en ES91 2100…” → sí

Cada caso se ejecuta 3 veces, sin ningún entrenamiento previo (zero-shot). Se mide la mediana (p50) y el percentil 95 de latencia por decisión, y los aciertos sobre 25.

Laya publica tres checkpoints. Probé los tres:

  • english: ModernBERT-large, 421M de parámetros. Pese al nombre, entiende español.
  • typed-decisions: 421M, afinado para decisiones tipadas.
  • multilingual: mmBERT-base, 322M. Más pequeño, más rápido.

Tres máquinas, una tabla

Mismo banco de pruebas, misma versión de Laya (0.3.23), mejor configuración encontrada para cada máquina.

MáquinaCosteRuntimetyped-decisions p50 / p95multilingual p50 / p95Aciertos td / ml
Portátil, i5-7300HQ (4 núcleos, 2017)ya lo teníaPyTorch489 / 752 ms173 / 291 ms21/25 · 18/25
VPS ARM, 2 OCPU, 11 GB0 €/mes (capa gratuita)ONNX fp32848 / 1.593 ms297 / 375 ms21/25 · 18/25
VPS x86, 16 vCPU EPYC, 62 GBVPS de uso generalONNX fp32, 4 hilos326 / 680 ms94 / 243 ms21/25 · 18/25

Tres cosas saltan a la vista.

Los aciertos no cambian. 21/25 con typed-decisions y 18/25 con multilingual, en las tres máquinas, con PyTorch o con ONNX. El modelo es determinista. Lo único que cambia con el hardware es el tiempo.

multilingual es 3 a 4 veces más rápido, y 12 puntos peor. 72 % frente a 84 %. Y falla cosas que un humano no fallaría: manda “Quiero darme de baja” al departamento técnico y no detecta un DNI ni un IBAN escritos tal cual. En el artículo anterior escribí “multilingüe sin degradación”. Medido, no es así.

El VPS gratuito de 2 núcleos ARM sirve. Casi un segundo por decisión con typed-decisions, pero sirve. Para clasificar un email al llegar, marcar spam en un formulario o etiquetar en un cron, un segundo da igual. Para una respuesta síncrona en una web, no.

La trampa de los hilos

Esto es lo más útil del artículo, y no está en ninguna documentación.

El VPS de 16 vCPU dio, en la primera prueba, un p50 de 12.134 ms. Doce segundos. Doce veces peor que el VPS gratuito de 2 núcleos. La máquina estaba al 60 % de ocio y sin robo de CPU del hipervisor.

La causa: por defecto, PyTorch y ONNX Runtime usan tantos hilos como núcleos vean. Con 16 hilos repartiendo operaciones pequeñas, el coste de sincronizarlos supera al cálculo. Medido con el checkpoint multilingual y PyTorch:

Hilos por procesop50p95
1266 ms403 ms
2190 ms514 ms
4170 ms779 ms
81.051 ms3.228 ms
16 (por defecto)5.097 ms10.291 ms

El punto óptimo está entre 2 y 4 hilos por proceso. Los 16 núcleos se aprovechan con varios procesos de pocos hilos, no con un proceso de muchos. En el despliegue final: 4 workers de 4 hilos cada uno.

Si ejecutas Laya (o cualquier modelo tipo BERT) en una máquina con muchos núcleos y te va lento, antes de tocar nada fija OMP_NUM_THREADS o intra_op_num_threads a 2 o 4.

ONNX sí, int8 no

Exporté los checkpoints a ONNX (opset 18) y probé fp32 e int8 (cuantización dinámica de los MatMul).

ONNX fp32 frente a PyTorch, mismos aciertos:

  • En el x86 EPYC: entre 2,4 y 2,6 veces más rápido. Es la diferencia entre 795 ms y 326 ms por decisión.
  • En ARM: un 10-12 % más rápido. Modesto, pero sin los picos de p95.
  • En el i5: nada. Igual que PyTorch y con picos de p95 de casi 3 segundos cuando ONNX Runtime reajusta memoria con una forma de entrada nueva.

int8: más lento y pierde 3 aciertos (15/25). Marca como spam mensajes normales y ve datos personales donde no los hay. En ARM, además, la cuantización se quedó sin memoria dentro del límite de 4 GB del contenedor. Descartado.

Y un detalle que contradice la intuición: hacer 5 preguntas sobre el mismo texto en una sola llamada es más lento que hacer 5 llamadas con una pregunta cada una (2.579 ms frente a 2.182 ms con 4 hilos). La secuencia de entrada crece con cada pregunta y la atención cuesta más que repetir un estado corto. Si quieres paralelismo, usa varios workers, no una petición gorda.

Precisión: lo que acierta y lo que no

84 % con typed-decisions sobre 25 casos. Por suite:

Suitetyped-decisionsmultilingual
departamento (6)54
intención subasta (5)44
spam (6)56
enfadado (4)42
datos personales (4)32

Los fallos de typed-decisions son criterios ambiguos, no errores tontos: “¿Cuánto costaría el plan anual?” lo manda a facturación en vez de a comercial, y “¿hacéis envíos a Portugal?” a pago en vez de a recogida. Se corrigen afinando la redacción de los criterios de cada opción, antes de pensar en entrenar nada.

Para DNI, IBAN, emails o teléfonos: una expresión regular acierta siempre y el modelo no. No uses un modelo para datos con formato fijo.

Sobre la calibración. En el artículo anterior hablé de “fiabilidad matemática rigurosa”. Matizo. Los 4 fallos del banco de pruebas tienen una confianza entre 0,34 y 0,67: con un umbral de 0,7, los cuatro habrían ido a revisión humana en vez de automatizarse. Eso está bien. Pero en una batería más amplia de casos nuevos, el modelo también se equivoca a veces con 0,9 o más. Un ejemplo: “¿Me recomiendas una película?” clasificado como product_info con 0,95. La confianza reduce el error; no lo elimina. El umbral hay que medirlo sobre datos propios, no copiarlo.

Cómo quedó desplegado

                 ┌──────────────────────────────────────┐
                 │  Contenedor  python:3.12-slim        │
                 │  laya[onnx,serve] 0.3.23             │
                 │  checkpoint: typed-decisions (ONNX)  │
                 │  4 workers × 4 hilos  ·  ~7 GB RAM   │
                 │  TLS autofirmado  ·  API key         │
                 └──────────────────┬───────────────────┘
                                    │ :8000
                 ┌──────────────────┴───────────────────┐
                 │  UFW: solo IPs conocidas + VPN       │
                 └──────────────────┬───────────────────┘
                                    │
              ┌─────────────────────┴─────────────────────┐
              ▼                                           ▼
   [Sistemas propios, directo]            [Gateway de LLM en Rust]
                                           modelo `laya-decide`
                                           misma API que el resto
                                           de modelos; +~240 ms

Decisiones del despliegue:

  • Imagen propia. python:3.12-slim, torch solo para exportar el ONNX la primera vez, usuario sin privilegios, healthcheck contra /health. El modelo y el .onnx viven en un volumen; sobreviven a reinicios y no se descargan de nuevo.
  • 4 × 4. Cuatro workers de uvicorn, cuatro hilos de ONNX Runtime cada uno. Cubre los 16 vCPU sin la trampa de los hilos. Límite de memoria de 16 GB, consumo real en torno a 7 GB.
  • TLS autofirmado con certificado fijado. Los clientes llevan el certificado como única CA de confianza. Desactivar la verificación (-k) sigue cifrando, pero deja al servidor suplantable.
  • Firewall por IP. Desde cualquier IP no autorizada, el puerto no responde. Ni 401, ni 403: timeout.
  • Detrás del gateway. Mi gateway de LLM en Rust expone Laya como un modelo más, laya-decide, con la misma API y la misma API key que el resto. Los sistemas no saben si detrás hay un LLM o un clasificador. El gateway añade unos 240 ms. Medido de punta a punta: 21/25 aciertos (los mismos), p50 864 ms, y con 8 llamadas en paralelo, 75 de 75 correctas a 2,8 decisiones por segundo.

Ese 2,8 es menos de lo que calculé (5 a 10). Con 8 peticiones simultáneas, 4 workers de 4 hilos saturan los 16 vCPU. Si hiciera falta más, la palanca es 8 workers de 2 hilos. Por ahora no hace falta.

Para qué sirve y para qué no

Con datos propios de otra batería de pruebas (holdout: casos nuevos escritos después de ajustar las preguntas, sin volver a tocar nada):

Fiable, se puede automatizar: ¿el cliente quiere hablar con una persona? (92 %), ¿hay datos personales? (92 %), ¿la acción es destructiva? (90 %), ¿es urgente? (90 %).

Útil como primera capa, con humano detrás: spam (90 %), amenazas (90 %), riesgo de baja (70 %), tono grosero (80 %), qué equipo de soporte (80 %).

Solo como señal: intención entre 9 opciones (50 %), si hace falta herramientas (58 %), guardrails de inyección de prompt (67 %). Aquí sirve para priorizar, no para decidir solo.

Y lo que no es: no genera texto, no resume, no responde preguntas de conocimiento. Y no vale para una respuesta síncrona por debajo de 100 ms con typed-decisions en CPU. Para eso, multilingual a 94 ms aceptando la pérdida de precisión, o una GPU.

Una regla que salió de medir variantes de preguntas y que vale para cualquier modelo de este tipo: pregunta por el problema, no por la virtud. “¿Es el tono adecuado?” acierta el 58 %. “¿Es grosero, sarcástico o despectivo?” acierta el 88 %. Y prefiere sí/no a escalas: “prioridad de 1 a 4” acierta el 33 %; “¿necesita atención hoy porque algo está roto o costando dinero?” el 83 %.

Dónde está hoy

Hoy lo consumen, a través del gateway, los flujos de atención al cliente de un partner de reparación de una aseguradora de garantías: si el cliente quiere hablar con una persona, qué pide el mensaje antes de entrar al flujo, y validaciones sí/no en workers en segundo plano. Ninguna de esas decisiones es irreversible; las que importan las confirma una persona.

Lo barato de Laya no es la latencia. Es que una decisión cuesta 0,000016 € a través del gateway, 0 € en directo, y el texto del cliente no sale de la red. Para lo que hace, en la máquina donde lo hace, 326 ms están bien.

Temas: #Open Source #System One #Python #ONNX #Benchmarks
Sigue leyendo

Artículos Relacionados

¿Necesitas diseñar sistemas resistentes sin sobrecostes de infraestructura?

Hablemos 15 minutos en una sesión de diagnóstico técnico con Pablo para auditar tus cuellos de botella y definir la arquitectura adecuada.