Exprimiendo vLLM en AMD Strix Halo: 2,3 veces llama.cpp con carga

vLLM en la iGPU de Strix Halo, medido frente a llama.cpp con el mismo modelo de 35B: 2,1 veces más rápido por token con grafos, 2,3 veces el rendimiento con 16 peticiones a la vez, el arranque de 9 minutos a 97 segundos, y el único caso en el que llama.cpp sigue ganando.
Strix Halo, de AMD, junta una GPU integrada grande (gfx1151) con una memoria unificada que la GPU puede usar casi entera: ajustando el kernel de Linux, PyTorch ve 124 GB de memoria de GPU. Basta para servir un modelo de 35.000 millones de parámetros sin tarjeta dedicada.
El modelo es Qwen3.6-35B-A3B: una mezcla de expertos con 3.000 millones de parámetros activos por token y una arquitectura híbrida que combina atención normal con Gated DeltaNet, una atención lineal con estado recurrente. llama.cpp lo mueve bien. La pregunta era qué aporta vLLM en el mismo hardware, y qué hace falta para llegar ahí.
En resumen, con el mismo modelo y los mismos prompts:
- 2,3 veces el rendimiento de llama.cpp con 16 peticiones a la vez (2,07 frente a 0,89 peticiones por segundo).
- 2,1 veces más rápido por token generado con grafos en lugar del modo eager (39 frente a 81 ms).
- El arranque, de 9 minutos a 97 segundos.
- Respuestas idénticas para peticiones idénticas.
- Y una excepción, para ser justos: con una sola petición larga, llama.cpp sigue siendo algo más rápido.
Lo conseguí con cuatro palancas, y ninguna toca el modelo ni sus kernels.
Palanca 1: un bucle de autotuning que duraba horas
En el primer arranque, vLLM se quedaba atascado en el calentamiento, con la GPU al 100 % y sin escribir nada en el log. py-spy dump (a través de podman unshare, porque dentro de un contenedor rootless no se permite ptrace) enseñó dónde estaba: _warmup_prefill_kernels → el autotuner de Triton → triton.testing.do_bench.
do_bench decide cuántas veces ejecutar un kernel midiendo cinco lanzamientos y dividiendo su presupuesto entre esa estimación, y antes de cada ejecución borra un búfer de 256 MiB. El calentamiento hace autotuning de kernels diminutos cuyo tiempo estimado en esta iGPU es prácticamente cero, así que la división da cientos de miles de repeticiones por cada configuración que se compara. Habría acabado, pero en horas.
Lo arregla sustituir do_bench desde un sitecustomize.py dentro de la imagen, que Python carga antes que cualquier otra cosa:
# do_bench de Triton calcula las repeticiones como rep / estimación. Con kernels
# diminutos la estimación sale ~0 ms en gfx1151 y se ejecuta cientos de miles de
# veces. Con 3 ejecuciones de calentamiento y 10 medidas basta para ordenarlos.
import triton.testing as tt
def do_bench(fn, warmup=25, rep=100, grad_to_none=None, quantiles=None, return_mode="mean", **_):
import torch
for _ in range(3):
fn()
torch.cuda.synchronize()
times = []
for _ in range(10):
start, end = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
start.record(); fn(); end.record(); torch.cuda.synchronize()
times.append(start.elapsed_time(end))
return tt._summarize_statistics(times, quantiles, return_mode)
tt.do_bench = do_bench
Diez medidas sobran para ordenar las configuraciones del autotuning. El mismo autotuning tarda ahora segundos (uno de los kernels, merge_16x16_to_64x64_inverse_kernel, en 4,07 s).
Palanca 2: dejar de rehacer trabajo en cada arranque
Con el autotuning arreglado, el primer arranque tardaba 9 minutos y los siguientes entre 5 y 9. Había dos costes que se pagaban en cada arranque y se tiraban a la basura.
- aiter compilaba su módulo principal cada vez. La librería de kernels de AMD compila
module_aiter_coreconhipccla primera vez que se importa: unos 46 segundos, dentro del contenedor, que se perdían al pararlo. Ahora se compila una vez, al construir la imagen. La pega: aiter detecta la arquitectura de la GPU preguntando al dispositivo e ignoraGPU_ARCHS, así que la imagen hay que construirla con la GPU a la vista (podman build --device /dev/kfd --device /dev/dri). 22 segundos, una sola vez. - Triton guardaba en caché los kernels, pero no los veredictos. Los kernels compilados estaban en caché, pero cada medida del autotuning se repetía en cada arranque.
TRITON_CACHE_AUTOTUNING=1guarda qué configuración ganó. Las cachés están fuera del contenedor: 239 MB de Triton y 642 MB detorch.compile.
Listo a los 97 segundos: pesos en 33 s, compilación, autotuning y perfilado en unos 15 s, captura de grafos en unos 15 s. La imagen final pesa 14,7 GB, frente a los 61,8 GB de la oficial de AMD.
Palanca 3: grafos en lugar de eager, 2,1 veces más rápido por token
El consejo habitual para vLLM en iGPU es --enforce-eager. En modo eager cada token generado costaba 81 ms, cinco veces lo que necesita llama.cpp en el mismo hardware: Python lanza uno a uno los kernels de cada capa para cada token, y con solo 3.000 millones de parámetros activos esa sobrecarga es casi todo el trabajo.
Sin --enforce-eager, vLLM compila el modelo (44 s la primera vez, después en caché) y captura siete grafos por tramos, así que cada paso reproduce un grafo grabado en lugar de lanzar cada kernel por separado.
39 ms por token. Las respuestas en mi conjunto de evaluación no cambiaron, y una petición repetida seguía saliendo idéntica, bit a bit.
Palanca 4: lotes más grandes, 2,3 veces llama.cpp con carga
El rendimiento con concurrencia es donde vLLM se gana su sitio. llama.cpp atiende un número fijo de slots y, en Vulkan, no procesa juntos los prefill de varias secuencias. vLLM mete todas las peticiones en curso en cada pasada del modelo.
La última palanca fue --max-num-batched-tokens: hasta 8.192 tokens por paso en lugar de 2.048. Con 8 peticiones a la vez el rendimiento pasó de 0,98 a 1,18 peticiones por segundo; con 16, de 1,77 a 2,07. La latencia de una petición suelta no empeoró.
2,07 peticiones por segundo con 16 a la vez: 2,3 veces llama.cpp con el mismo modelo (llama.cpp con un GGUF Q4 y 4 slots). Cada palanca suma: vLLM en eager ya gana a llama.cpp con carga, los grafos añaden un 13 % y los lotes más grandes, otro 17 %.
Una petición cada vez: donde llama.cpp sigue ganando
Con una sola petición, vLLM gana en los prompts cortos, empatan en los medios y llama.cpp sigue siendo algo más rápido en los largos. En solitario, la iGPU está limitada por el ancho de banda de memoria, y los kernels Q4 de llama.cpp están afinados justo para eso. Con concurrencia, agrupar peticiones gana de largo. Si tu tráfico es un usuario cada vez con prompts largos, llama.cpp sigue siendo una gran opción; si son muchos usuarios, vLLM.
Misma petición, misma respuesta
La cifra que no cabe en un gráfico de rendimiento: en vLLM, la misma petición repetida da la misma salida, bit a bit. En llama.cpp, con esta familia de modelos híbridos, el mismo prompt a temperatura 0 se movía hasta 0,44 en log-probabilidad entre ejecuciones. Para cualquier cosa que lea log-probabilidades, o que tenga que dar la misma respuesta dos veces, eso basta para decidir. Por qué varía llama.cpp da para un post propio.
Lo que no compensó
- MTP (decodificación especulativa con la cabeza de borrador del propio modelo). Se aceptaba el 76 % de los tokens del borrador, con log-probabilidades completas, pero los aciertos de la caché de prefijo caían a cero y esta build no trae el kernel fusionado que verifica el borrador, así que ese paso se hace con Triton. Con 16 peticiones a la vez, el rendimiento bajaba a 0,80 por segundo. De momento, no.
- Más reutilización de la caché de prefijo a base de ajustes. En un modelo híbrido, vLLM iguala el tamaño de página de la caché de atención al del estado recurrente, lo que fija los bloques de atención en 1.056 tokens. Los prompts de unos 1.200 tokens rara vez reutilizan más de un bloque: solo el 28 % de los tokens sale de la caché.
--mamba-block-sizeno lo cambia.
Conclusiones
- Antes de culpar al hardware, mira dónde está atascado. Un proceso al 100 % y sin logs no está colgado, está ocupado en algo, y
py-spy dumpte dice en qué con un solo comando. - Pon un suelo a los bucles de medición. Cualquier cosa que divida un presupuesto entre un tiempo medido explota cuando la medida es casi cero.
- Guarda en caché los veredictos, no solo los artefactos. Los resultados del autotuning y la build de aiter eran decenas de segundos en cada arranque.
- Prueba los grafos aunque lo que se recomiende sea eager. Aquí partieron por la mitad el tiempo por token.
- Mide la concurrencia y la petición suelta por separado. Cada una tiene su ganador.
- El tamaño de lote es una palanca de rendimiento. 8.192 tokens por paso dieron un 17 % más con carga, sin coste.
Con cuatro palancas y sin tocar el modelo, vLLM en la Strix Halo pasó de no terminar nunca el calentamiento a dar 2,3 veces el rendimiento de llama.cpp, ir el doble de rápido por token y estar listo en 97 segundos.