vLLM 0.30 ya funciona en una CPU arm64 sin GPU con su imagen oficial vllm/vllm-openai-cpu, pero solo con pesos en BF16, FP16 o FP32; llama.cpp 0.5.0 sirve el mismo modelo cuantizado en GGUF. Puse los dos servidores a responder con Qwen3-1.7B por la API compatible con OpenAI, en Docker, el 28 de septiembre de 2026. La máquina es una CPU arm64 de 18 núcleos sin GPU. llama.cpp en Q8_0 generó 72,7 tokens/s en una sola petición, 7,2 veces lo que dio vLLM en BF16, y con 8 peticiones a la vez siguió 3,5 veces por delante. Aquí tienes los comandos, los errores del camino y las medidas. Tienes también la versión en inglés de esta comparación.

Puntos clave

  • La imagen vllm/vllm-openai-cpu:v0.30.0 tiene variante arm64 y ocupa 5,19 GB en disco (1,32 GB comprimida); la de llama.cpp:server-v0.5.0, 1,2 GB (295 MB comprimida).
  • Con una petición, llama.cpp en Q8_0 generó 72,7 tokens/s y vLLM en BF16, 10,1; con 8 a la vez, 236,9 frente a 68,3 tokens/s de total.
  • vLLM escaló casi en línea recta de 1 a 8 peticiones (9,9 a 68,3 tokens/s) y ocupó 11,4 GiB de RAM; llama.cpp, 7,0 GiB.
  • En Apple silicon, vLLM muere al calentar el modelo: GCC 15 compila con -march=native y aborta con "no support for ‘sme’ without ‘sve2’". Se evita con --enforce-eager o con un envoltorio del compilador.
  • En Arm, vLLM no acepta pesos cuantizados, y la imagen CPU no trae el plugin de GGUF. El modelo en BF16 pesa 4,06 GB, frente a los 1,83 GB del GGUF Q8_0 oficial de Qwen.
  • Con temperatura 0, vLLM en BF16 y llama.cpp en Q8_0 y en BF16 devolvieron el mismo texto, con 27 tokens de entrada y 9 de salida en los tres casos.
  • vLLM mete el razonamiento de Qwen3 dentro de content si no le pasas --reasoning-parser qwen3; llama.cpp lo separa por defecto en reasoning_content.

Qué ofrece vLLM en una CPU arm64

vLLM en CPU es un modo de soporte básico, no el camino principal del proyecto. La guía de instalación en CPU de vLLM[1] lo dice así: "vLLM offers basic model inferencing and serving on Arm CPU platform, with support for NEON, data types FP32, FP16 and BF16". Hay ruedas de Python +cpu para aarch64 e imágenes Docker con etiqueta -arm64.

El BF16 nativo exige ARMv8.6-A o posterior (la extensión FEAT_BF16), y la guía cita Graviton3 y Graviton4 de AWS y AmpereOne. La misma página limita la cuantización en CPU a AWQ, GPTQ e INT8 W8A8, y las tres solo en x86 y s390x. En Arm, por tanto, vLLM carga los pesos en 16 bits, el doble de memoria que un Q8_0.

La lista de modelos validados en CPU[2] incluye Qwen/Qwen3-1.7B, pero el hardware validado son Intel Xeon 6 y Xeon 5. Nada en esa página certifica el rendimiento en Arm.

Qué versiones usé y en qué máquina

Probé las últimas versiones estables disponibles el 28 de septiembre de 2026:

  • vLLM 0.30.0: publicada el 22 de septiembre de 2026, con 762 commits de 315 personas según sus notas de versión[3]. Para CPU trae la base Docker en Ubuntu 25.04 con GCC 15 y un LM head más rápido en Arm, "about 6% on Llama-3.1-8B W8A8"
  • llama.cpp 0.5.0: publicada el 23 de septiembre de 2026, con ggml 0.25.0, según su página de versión[4]. El servidor informa de la compilación b11146-7fe450e19
  • Qwen3-1.7B: licencia Apache-2.0, 1.700 millones de parámetros y 28 capas, según la ficha del modelo en Hugging Face[5]. Para llama.cpp usé el GGUF Q8_0 oficial de Qwen[6] y un GGUF BF16 convertido por mí

La máquina es una máquina virtual Linux arm64 sobre Apple silicon, con 18 núcleos, 47 GB de RAM y sin GPU. Su CPU anuncia bf16, i8mm, sme y sme2, pero no SVE ni SVE2, y ese detalle rompe vLLM. Durante la puesta en marcha la compartía con otros trabajos, así que limité cada contenedor a 7 núcleos con --cpuset-cpus. Las medidas se tomaron después, en la máquina sin otros trabajos, con los dos servidores sobre los mismos 9 núcleos.

Cómo arrancar vLLM en CPU arm64 con Docker

vLLM arranca con un docker run que monta el modelo en solo lectura y fija la caché KV y los núcleos. Descargué los ficheros de la revisión 70d244cc de Qwen3-1.7B con curl a un directorio compartido ($MODELS), y lancé el contenedor así:

docker run -d --name b6p6-vllm-run -p 127.0.0.1:24601:8000 \
  --cpuset-cpus 10-16 --shm-size 4g \
  --cap-add SYS_NICE --security-opt seccomp=unconfined \
  -e VLLM_CPU_KVCACHE_SPACE=4 -e VLLM_CPU_OMP_THREADS_BIND=10-15 \
  -e CXX=/usr/local/bin/gxx-nosme \
  -v "$PWD/gxx-nosme.sh:/usr/local/bin/gxx-nosme:ro" \
  -v "$MODELS/Qwen--Qwen3-1.7B:/models/Qwen3-1.7B:ro" \
  vllm/vllm-openai-cpu:v0.30.0 /models/Qwen3-1.7B \
  --served-model-name qwen3-1.7b --dtype bfloat16 --max-model-len 4096

Cada opción responde a algo de la guía de CPU:

  • VLLM_CPU_KVCACHE_SPACE: gigabytes reservados para la caché KV. Vale 0 por defecto; con 4 el registro anuncia "CPU KV cache size: 37,376 tokens" y una concurrencia máxima de 9,12 peticiones de 4.096 tokens
  • VLLM_CPU_OMP_THREADS_BIND: núcleos para los hilos OpenMP del modelo. Dejo libre el núcleo 16 para el servidor HTTP, porque la guía recomienda reservar "1-2 CPU cores for the serving framework"
  • --cap-add SYS_NICE y seccomp=unconfined: sin ellos aparece get_mempolicy: Operation not permitted, que según la guía no rompe nada pero desactiva la colocación de memoria por nodo NUMA
  • CXX: el envoltorio del compilador que explico en la sección siguiente. Fuera de Apple silicon no hace falta

Por qué vLLM no arranca en Apple silicon

Sin el envoltorio, vLLM carga los pesos y muere al calentar el modelo. vLLM compila el grafo con torch.compile, e Inductor, su compilador para CPU, genera C++ y lo compila con -march=native. En esta CPU, GCC 15 activa SME, la extensión de matrices de Arm, sin SVE2, y se niega a seguir:

cc1plus: sorry, unimplemented: no support for 'sme' without 'sve2'
torch._inductor.exc.InductorError: CppCompileError: C++ compile error
(EngineCore pid=58) ERROR [core.py:1366] EngineCore failed to start.

La dificultad es que el contenedor sigue en estado Up: el proceso de la API no termina, pero nunca abre el puerto, y curl al endpoint /health falla con código 56. Si no lees el registro, parece que tarda en arrancar.

Hay dos salidas. La primera es --enforce-eager, que según el propio registro equivale a "-cc.mode=none -cc.cudagraph_mode=none": desactiva torch.compile y el servidor arranca.

La segunda salida conserva la compilación y cambia solo la bandera problemática. -march=native+nosme no sirve, porque GCC responde "unknown value ‘native+nosme’ for ‘-march’". Por eso escribí un envoltorio de g++ y lo pasé en la variable CXX, que Inductor respeta:

#!/bin/sh
# gxx-nosme.sh: GCC 15 activa SME sin SVE2 con -march=native
for a in "$@"; do
  shift
  if [ "$a" = "-march=native" ]; then
    a="-march=armv8.6-a+bf16+i8mm+dotprod+fp16"
  fi
  set -- "$@" "$a"
done
exec /usr/bin/g++-15 "$@"

Con el envoltorio, el registro muestra "saved AOT compiled function" y el servidor responde. La lista de extensiones sale de /proc/cpuinfo de esta máquina, así que en otra CPU ajústala. El fallo afecta a CPU con SME y sin SVE2, como estos núcleos de Apple; en un Graviton con SVE no aparece.

Cómo servir el mismo modelo con llama.cpp

llama.cpp sirve el GGUF oficial con un solo contenedor y sin permisos extra. Este es el comando que usé, con 8 ranuras (slots) de 4.096 tokens para igualar el contexto por petición de vLLM:

docker run -d --name b6p6-llama-run -p 127.0.0.1:24602:8080 \
  --cpuset-cpus 10-16 \
  -v "$MODELS/Qwen--Qwen3-1.7B-GGUF:/models:ro" \
  ghcr.io/ggml-org/llama.cpp:server-v0.5.0 \
  -m /models/Qwen3-1.7B-Q8_0.gguf --alias qwen3-1.7b \
  --host 0.0.0.0 --port 8080 -c 32768 -np 8 -t 6

El README del servidor de llama.cpp[7] documenta que el batching continuo está activo por defecto y que -np fija el número de ranuras. Con -c 32768 -np 8 el registro confirma "n_slots = 8, n_ctx_slot = 4096". Si instalas llama.cpp por primera vez, la guía para instalar llama.cpp en Linux, macOS y Docker cubre las demás vías.

Para comparar con el mismo tipo de dato que vLLM convertí también los safetensors a un GGUF BF16 con la imagen full-v0.5.0. El resultado tiene 311 tensores y 4,06 GB:

docker run --rm --cpus 4 \
  -v "$MODELS/Qwen--Qwen3-1.7B:/in:ro" -v "$PWD/gguf:/out" \
  ghcr.io/ggml-org/llama.cpp:full-v0.5.0 --convert --outtype bf16 \
  --outfile /out/Qwen3-1.7B-BF16.gguf /in

Al arrancar, llama-server 0.5.0 avisa de dos cosas: "no API key is set and CORS allows all origins" y que el puerto por defecto pasará a ser el 9931 en una versión futura. Publica siempre el puerto en 127.0.0.1 o pon --api-key si lo expones.

Los dos servidores devuelven el mismo texto

Con temperatura 0 y el modo de razonamiento apagado, vLLM y llama.cpp respondieron exactamente lo mismo a la misma pregunta. Esta es la petición, idéntica para los dos puertos:

curl -s http://127.0.0.1:24601/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model": "qwen3-1.7b", "temperature": 0, "max_tokens": 60,
       "messages": [{"role": "user",
         "content": "¿Cuál es la capital de Portugal? Responde en una frase."}],
       "chat_template_kwargs": {"enable_thinking": false}}'

vLLM en BF16, llama.cpp en Q8_0 y llama.cpp en BF16 contestaron "La capital de Portugal es Lisbona.", con 27 tokens de entrada y 9 de salida. Los dos servidores aplican la misma plantilla de chat y el mismo tokenizador.

"Lisbona" es la grafía italiana: el error es del modelo de 1.700 millones de parámetros, no del servidor. Al pedirle tres ríos españoles y su mar, mandó el Tajo, el Duero y el Ebro al "Mar del Norte". Un modelo tan pequeño sirve para clasificar o resumir texto que le das, no como fuente de datos.

La diferencia visible está en el razonamiento. Qwen3 piensa por defecto, y sin chat_template_kwargs vLLM devuelve el bloque <think> dentro de content. Con --reasoning-parser qwen3 lo mueve al campo reasoning.

llama.cpp lo separa sin opciones extra, pero en un campo con otro nombre, reasoning_content. Si tu cliente lee uno de los dos, cambiar de servidor rompe esa lectura.

Qué formatos de pesos acepta cada uno en arm64

El formato de los pesos marca la memoria que necesitas antes que cualquier otra cosa. Esta tabla resume lo que comprobé y lo que dicen las fuentes:

Aspecto vLLM 0.30.0 CPU llama.cpp 0.5.0
Imagen arm64 en disco 5,19 GB (1,32 GB comprimida) 1,2 GB (295 MB comprimida)
Tipos de dato en Arm BF16, FP16, FP32 GGUF: BF16, Q8_0, Q4_K_M y otros
Cuantización en Arm Ninguna según la guía de CPU Todas las de GGUF
Tamaño de Qwen3-1.7B 4,06 GB en BF16 1,83 GB en Q8_0
GGUF Plugin externo, no incluido en la imagen Formato nativo
Permisos del contenedor SYS_NICE y seccomp sin confinar recomendados Ninguno extra
Memoria en uso (RSS) 11,4 GiB en reposo, caché KV de 4 GiB incluida 7,0 GiB en reposo y 9,1 GiB tras las pruebas (Q8_0, 8 ranuras de 4.096 tokens)
Compilación al arrancar torch.compile; falla en Apple silicon sin apaño No compila
Razonamiento de Qwen3 En content, o en reasoning con parser En reasoning_content

Intenté cargar el GGUF Q8_0 en vLLM y falló con "It looks like the config file at ‘/models/gguf/Qwen3-1.7B-Q8_0.gguf’ is not a valid JSON file". La documentación de GGUF en vLLM[8] explica el motivo: el soporte "has migrated to OOT vllm-gguf-plugin", y la imagen CPU no trae ese plugin. La misma página lo califica de "highly experimental and under-optimized".

Cómo comparé el rendimiento de los dos

La comparación mide lo que nota un cliente de la API, no lo que dice cada motor en sus registros. Escribí un script en Python, sin dependencias, que abre peticiones en streaming contra /v1/chat/completions y registra por cada una:

  • TTFT: tiempo hasta el primer token, desde que sale la petición hasta el primer fragmento de texto
  • Tokens/s por petición: tokens generados después del primero, divididos por el tiempo desde ese primer token
  • Tokens/s agregados: todos los tokens de una tanda divididos por lo que tarda la tanda entera
  • Memoria: la memoria del cgroup del contenedor y el RSS de sus procesos, antes y después de cada tanda

Cada petición fijó max_tokens a 256 con ignore_eos, y todas devolvieron exactamente 256 tokens, así que los dos servidores generaron lo mismo. Las tandas lanzaron 1, 4 y 8 peticiones a la vez, con prompts de 46 a 64 tokens; cada configuración tuvo una tanda de calentamiento y 5 medidas, y doy medianas. Cada prompt empieza por un número de petición único para que ninguna caché de prefijos se salte el procesado del prompt.

Los dos servidores corrieron sobre los núcleos 0 a 8. vLLM usó 8 hilos OpenMP y dejó el noveno a su servidor HTTP; llama.cpp, 6 hilos. Con llama-bench en esos núcleos, 6 hilos dieron 80,98 tokens/s y 8 hilos, 47,26 con una desviación de ±26,95, así que el reparto favorece a vLLM, no a llama.cpp. Cada configuración empezó con una carga media por debajo de 3; durante las tandas, los propios servidores la subieron hasta 11,3.

Cuánto rinde cada uno con 1, 4 y 8 peticiones

llama.cpp en Q8_0 ganó en todas las medidas, y la ventaja se estrechó al subir la concurrencia. Con una petición generó 7,2 veces más tokens por segundo que vLLM; con 8, 3,5 veces más. Estas son las medianas:

Tokens por segundo en total, según las peticiones simultáneas tokens/s · más es mejor
vLLM BF16, compilado
1 petición: 9,9
4 peticiones: 35,1
8 peticiones: 68,3
vLLM BF16, --enforce-eager
1 petición: 10,4
4 peticiones: -
8 peticiones: 67,9
llama.cpp Q8_0
1 petición: 70,9
4 peticiones: 187,9
8 peticiones: 236,9
llama.cpp BF16
1 petición: 4,1
4 peticiones: -
8 peticiones: -
Ver tabla de valores
Configuración1 petición4 peticiones8 peticiones
vLLM BF16, compilado9,935,168,3
vLLM BF16, --enforce-eager10,4-67,9
llama.cpp Q8_070,9187,9236,9
llama.cpp BF164,1--

Qwen3-1.7B en Docker sobre una CPU arm64 (núcleos 0 a 8), mediana de cada configuración.

Tokens por segundo de cada petición tokens/s · más es mejor
vLLM BF16, compilado
1 petición: 10,1
4 peticiones: 9,3
8 peticiones: 9,6
vLLM BF16, --enforce-eager
1 petición: 10,6
4 peticiones: -
8 peticiones: 9,5
llama.cpp Q8_0
1 petición: 72,7
4 peticiones: 50,9
8 peticiones: 32,4
llama.cpp BF16
1 petición: 4,6
4 peticiones: -
8 peticiones: -
Ver tabla de valores
Configuración1 petición4 peticiones8 peticiones
vLLM BF16, compilado10,19,39,6
vLLM BF16, --enforce-eager10,6-9,5
llama.cpp Q8_072,750,932,4
llama.cpp BF164,6--

Mismas pruebas: la velocidad que ve cada cliente cuando comparte el servidor.

Tiempo hasta el primer token segundos · menos es mejor
vLLM BF16, compilado
1 petición: 0,53 s
4 peticiones: 1,93 s
8 peticiones: 3,74 s
vLLM BF16, --enforce-eager
1 petición: 0,48 s
4 peticiones: -
8 peticiones: 3,40 s
llama.cpp Q8_0
1 petición: 0,11 s
4 peticiones: 0,39 s
8 peticiones: 0,78 s
llama.cpp BF16
1 petición: 6,39 s
4 peticiones: -
8 peticiones: -
Ver tabla de valores
Configuración1 petición4 peticiones8 peticiones
vLLM BF16, compilado0,53 s1,93 s3,74 s
vLLM BF16, --enforce-eager0,48 s-3,40 s
llama.cpp Q8_00,11 s0,39 s0,78 s
llama.cpp BF166,39 s--

Prompt corto; con uno de 2.034 tokens, 14,2 s en vLLM y 6,6 s en llama.cpp.

vLLM se comportó como promete su planificador: cada petición mantuvo unos 9,5 tokens/s aunque hubiera 8 a la vez, y el total creció 6,9 veces. llama.cpp repartió su velocidad entre las ranuras: cada petición bajó de 72,7 a 32,4 tokens/s y el total solo creció 3,3 veces. Partía de tan arriba que con 8 peticiones seguía muy por delante.

Con un prompt largo de 2.034 tokens y 64 de salida, el primer token tardó 14,2 s en vLLM y 6,6 s en llama.cpp. La compilación con torch.compile no aportó nada medible. Con --enforce-eager vLLM dio 10,6 tokens/s frente a 10,1, y su motor arrancó en 0,19 s en vez de 36,8 s.

La fila de llama.cpp en BF16 explica buena parte de la diferencia. Con los mismos pesos que vLLM, llama.cpp se quedó en 4,6 tokens/s, menos de la mitad que vLLM. En esta CPU, la imagen precompilada carga la variante libggml-cpu-armv8.2_2.so y avisa de que los tensores BF16 "cannot be used with preferred buffer type CPU_REPACK". El formato de los pesos pesó más que el servidor: en Arm, cuantizar a Q8_0 es lo que vLLM no puede hacer.

Cuándo elegir vLLM y cuándo llama.cpp en una CPU

En una CPU arm64, llama.cpp fue más rápido con 1, 4 y 8 peticiones, usó 4,4 GiB menos de RAM y arranca sin permisos extra ni apaños. vLLM pidió el doble de memoria para los pesos y, en Apple silicon, un apaño para arrancar. A cambio ofrece la misma API y el mismo servidor que usarías con GPU, métricas Prometheus en /metrics y un planificador que agrupa las peticiones concurrentes en cada paso. Si en producción ya sirves con vLLM y su alto rendimiento en GPU, la variante CPU te deja probar sin cambiar de herramienta.

Elige llama.cpp para servir un modelo pequeño en CPU arm64: con 8 peticiones a la vez dio 3,5 veces el total de vLLM. Considera vLLM solo si ya lo usas en GPU y quieres la misma herramienta en pruebas, o si necesitas que cada petición mantenga su velocidad con más usuarios. Mide antes con tu modelo, porque en esta prueba ni siquiera con 8 peticiones alcanzó a llama.cpp. Para elegir el modelo en sí, la comparativa de LLM sin GPU para CPU arm64 mide tres candidatos con llama.cpp.

Preguntas frecuentes

¿vLLM funciona en un Mac con Apple silicon?

La guía de CPU describe el soporte nativo en macOS como experimental: hay que compilar desde el código y solo admite FP32 y FP16. Las notas de la 0.30.0 ya listan ruedas de CPU para macOS. Dentro de una máquina virtual Linux arm64, como en esta prueba, la imagen vllm-openai-cpu funciona si evitas el error de SME sin SVE2, con --enforce-eager o con el envoltorio de g++.

¿Puedo usar un modelo GGUF con vLLM en CPU?

No con la imagen oficial de CPU. vLLM movió el soporte de GGUF a un plugin externo que esa imagen no incluye, y en Arm tampoco acepta AWQ, GPTQ ni W8A8. Para un modelo cuantizado en una CPU arm64, llama.cpp es la opción directa.

¿Hace falta desactivar el razonamiento de Qwen3 para servirlo?

No, pero conviene decidirlo en el cliente. Con chat_template_kwargs y enable_thinking a false, los dos servidores responden sin bloque de razonamiento. Si lo dejas activo, arranca vLLM con --reasoning-parser qwen3, o el texto de <think> acabará mezclado con la respuesta.

Conclusión

vLLM 0.30 sirve un modelo pequeño en una CPU arm64 con la misma API que llama.cpp 0.5.0, y con temperatura 0 los dos devolvieron el mismo texto. En rendimiento no hubo empate: llama.cpp en Q8_0 generó 72,7 tokens/s con una petición y 236,9 con 8, frente a 10,1 y 68,3 de vLLM en BF16. Además usó 4,4 GiB menos de RAM.

vLLM escala bien con la concurrencia, pero en Arm no puede cuantizar, y eso pesa más. Si quieres repetir la prueba en tu CPU, usa los comandos de arriba y compara Q8_0 contra BF16 antes que servidor contra servidor.

Fuentes: [1] Guía de instalación de vLLM en CPU[1], [2] Modelos validados en CPU de vLLM[2], [3] Notas de la versión 0.30.0 de vLLM[3], [4] Etiquetas de vllm/vllm-openai-cpu en Docker Hub[9], [5] Documentación de GGUF en vLLM[8], [6] Versión 0.5.0 de llama.cpp[4], [7] README del servidor de llama.cpp[7], [8] Ficha de Qwen3-1.7B en Hugging Face[5], [9] GGUF oficial de Qwen3-1.7B[6].

Fuentes

  1. guía de instalación en CPU de vLLM
  2. modelos validados en CPU
  3. notas de versión
  4. página de versión
  5. ficha del modelo en Hugging Face
  6. GGUF Q8_0 oficial de Qwen
  7. README del servidor de llama.cpp
  8. documentación de GGUF en vLLM
  9. Etiquetas de vllm/vllm-openai-cpu en Docker Hub