- Cómo construir un motor de inferencia para LLM usando C++ y CUDA sin librerías
- Esto permite entender toda la pila de inferencia de un LLM y comprobar en la práctica cómo distintas optimizaciones afectan la velocidad de inferencia
- Objetivo: implementar un modelo que pueda hacer inferencia rápida en lote único en un servidor con una sola CPU + GPU y lograr una velocidad de procesamiento de tokens superior a llama.cpp
1. Resumen de la arquitectura e inferencia de un LLM
- La mayoría de los LLM principales siguen la misma arquitectura, usando una secuencia continua de bloques Transformer.
- La carga del modelo consiste en definir una clase de bloque Transformer personalizable, organizarla en una secuencia e inicializarla con pesos en formato safetensors.
- La inferencia se realiza principalmente en lote único, y la "fase de decode" ocupa la mayor parte de la ejecución.
1.1 Resumen de la inferencia
- La inferencia se divide en una fase de prefill, donde los tokens del prompt se pasan al modelo para llenar la caché KV, y una fase de decode, donde el modelo se ejecuta repetidamente para generar tokens
- Fase de prefill: procesa los tokens del prompt e inicializa la caché KV
- Fase de decode: genera un token a la vez
- Caché KV: almacena pares clave/valor previos para calcular rápidamente la atención con el contexto pasado
- El forward pass del modelo usa una tabla de embeddings para mapear IDs de tokens a vectores de embedding y transforma el estado a través de una secuencia de bloques Transformer
1.2 Cuellos de botella y benchmarks
- Cuello de botella: en el hardware moderno, el ancho de banda de memoria es el factor limitante
- Para generar cada token durante la inferencia del modelo, hay que leer el modelo completo, por lo que el ancho de banda de memoria pesa más que el cómputo
- La cuantización del modelo es efectiva para mejorar la velocidad de inferencia
- El rendimiento teórico máximo de tokens varía según el hardware, y el rendimiento real puede verificarse con varios motores de inferencia
- Límite teórico de velocidad:
- AMD EPYC 7702P: máximo 13.6 tok/s (base FP16)
- RTX 4090: máximo 67.1 tok/s (base FP16)
- Benchmarks:
- llama.cpp: CPU 8.7 tok/s, GPU 61 tok/s
- calm: GPU 66 tok/s
2. Inferencia basada en CPU
- La implementación inicial en CPU es de un solo hilo y solo soporta pesos FP32
- Se puede empezar a paralelizar el código con multithreading y mejorar el rendimiento usando SIMD
2.1 Multithreading
- Se usa OpenMP para paralelizar la multiplicación matriz-vector (matmul) y la atención multi-head, mejorando el rendimiento
- Resultado de la optimización: mejora de velocidad de 0.6 tok/s → 4.4 tok/s
2.2 Cuantización de pesos y optimización SIMD
- Cuantización: cuantizar pesos FP32 a FP16 reduce a la mitad el uso de memoria y mejora el rendimiento
- SIMD: optimización con AVX2 para procesar 8 valores FP32 al mismo tiempo
- Resultado: 8.4 tok/s
3. Inferencia basada en GPU
- Cuantizando el modelo a FP16 se puede cargar en una RTX 4090 y comenzar a implementar la inferencia en GPU
- Con CUDA, las funciones en C++ (kernels) pueden ejecutarse en paralelo en la GPU
3.1 Port sencillo a CUDA
- Se puede implementar un backend para GPU convirtiendo las operaciones de CPU 1 a 1 en kernels CUDA
- Los kernels CUDA se ejecutan de forma asíncrona, pero dentro del mismo stream se ejecutan secuencialmente
- Problema: por la ineficiencia de los hilos no se aprovechan bien los recursos de la GPU → lento, con 2.9 tok/s
3.2 Mejor multiplicación de matrices (matmul)
- La multiplicación de matrices ocupa una gran parte del tiempo de ejecución en CPU y puede optimizarse con OpenMP
- En GPU, se puede aumentar el aprovechamiento de hilos haciendo que cada bloque procese 1 fila
- Métodos de optimización:
- Un bloque procesa una fila y los hilos dentro del bloque colaboran en el cálculo
- Aplicar warp reduction
- Resultado: mejora de velocidad a 51.7 tok/s
3.3 Fusión de kernels y optimizaciones adicionales
- Se puede mejorar el rendimiento fusionando kernels
- Fusión de kernels: combinar operaciones consecutivas en un solo kernel para minimizar accesos a memoria y tiempo de cómputo
- Con la optimización del patrón de acceso a memoria y la reutilización de espacio, se lograron 56.1 tok/s
3.4 Optimización de attention y manejo de contexto largo
- Problema: con contexto largo, el kernel de attention se vuelve un cuello de botella de rendimiento
- Soluciones:
- Optimización del acceso a memoria: rediseñarlo para leer bloques contiguos de memoria
- Usar memoria compartida en lugar de atomicAdd para resolver el problema de valores decimales perdidos
- Resultado de la optimización:
- Contexto corto: 63.8 tok/s (más rápido que los 61.0 tok/s de llama.cpp)
- Contexto largo: 58.8 tok/s
3.5 Cuantización de la caché KV y problemas de optimización del compilador
- Cuantizar la caché KV a FP16 provoca una caída de rendimiento (por falta de optimización del compilador)
- Solución: desenrollado manual de loops y prefetching de memoria
- Resultado: aprox. 2x más rápido que FP32 y se mantiene el rendimiento en contexto largo de 58.8 tok/s
4. Direcciones de mejora futura
- Optimización del prefill del prompt: procesar varios tokens al mismo tiempo para reducir el tiempo hasta el primer token
- Fusión de kernels de attention: aplicar técnicas de optimización como FlashAttention
- Cuantización más agresiva: aplicar FP8, INT8, INT4 y cuantización de activaciones/caché
- Optimización de kernels: introducir técnicas avanzadas para maximizar el ancho de banda de memoria y la eficiencia de cómputo
- Uso de librerías: aprovechar librerías como cuDNN y cuBLAS para reducir el tiempo de optimización
Resumen de resultados:
- Se alcanzó una velocidad de 63.8 tok/s mediante varias optimizaciones en CPU y GPU
- Se registró un rendimiento similar o superior al de llama.cpp y calm
- Se implementó un motor de inferencia para LLM de alto rendimiento usando solo C++ y CUDA, sin librerías
1 comentarios
Comentarios de Hacker News
wgmmawgmmapueda reducir la portabilidad entre generaciones de Nvidia__shfl_downya no se recomienda hoy en día por problemas de sincronización de warp