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.
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:
| Suite | Tipo | Casos | Ejemplo |
|---|---|---|---|
| departamento | elegir entre 4 | 6 | ”Me habéis cobrado dos veces la cuota” → facturación |
| intención sobre una subasta | elegir entre 4 | 5 | ”¿Hasta cuándo tengo para hacer la transferencia?” → pago |
| ¿es spam? | sí/no | 6 | ”Crypto investment opportunity, 300% returns” → sí |
| ¿está enfadado? | sí/no | 4 | ”Si mañana no está solucionado os denuncio” → sí |
| ¿contiene datos personales? | sí/no | 4 | ”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áquina | Coste | Runtime | typed-decisions p50 / p95 | multilingual p50 / p95 | Aciertos td / ml |
|---|---|---|---|---|---|
| Portátil, i5-7300HQ (4 núcleos, 2017) | ya lo tenía | PyTorch | 489 / 752 ms | 173 / 291 ms | 21/25 · 18/25 |
| VPS ARM, 2 OCPU, 11 GB | 0 €/mes (capa gratuita) | ONNX fp32 | 848 / 1.593 ms | 297 / 375 ms | 21/25 · 18/25 |
| VPS x86, 16 vCPU EPYC, 62 GB | VPS de uso general | ONNX fp32, 4 hilos | 326 / 680 ms | 94 / 243 ms | 21/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 proceso | p50 | p95 |
|---|---|---|
| 1 | 266 ms | 403 ms |
| 2 | 190 ms | 514 ms |
| 4 | 170 ms | 779 ms |
| 8 | 1.051 ms | 3.228 ms |
| 16 (por defecto) | 5.097 ms | 10.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:
| Suite | typed-decisions | multilingual |
|---|---|---|
| departamento (6) | 5 | 4 |
| intención subasta (5) | 4 | 4 |
| spam (6) | 5 | 6 |
| enfadado (4) | 4 | 2 |
| datos personales (4) | 3 | 2 |
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.onnxviven 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.
Artículos Relacionados
Laya: el motor de decisiones System-1 open source que supera a Jev en latencia y calibración
40 ms en GPU, de 94 a 848 ms en CPU según la máquina, multilingüe y sin coste por token. Cómo Laya traslada la inferencia probabilística a producción para enrutamiento, moderación y control de flujos junto a LLMs.
De la demo al entorno real: cómo estructurar flujos con LangGraph para evitar alucinaciones en producción
Los bucles de agentes no estructurados fallan en entornos corporativos. Analizamos cómo diseñar StateGraphs deterministas de 5 nodos con memoria compartida y tipos estructurados en Pydantic.
¿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.