La inferencia eficiente de Large Language Models (LLMs) a escala de producción presenta desafíos computacionales significativos, principalmente debido al gran volumen de operaciones de álgebra lineal y la necesidad de baja latencia y alto throughput. Este proyecto aborda el problema fundamental de cómo ejecutar LLMs pre-entrenados de manera óptima en hardware GPU, transformando la arquitectura del modelo en kernels CUDA de alto rendimiento. La motivación surge de la necesidad de maximizar la utilización del hardware y minimizar los costos operativos en entornos de inferencia, donde los frameworks de alto nivel a menudo introducen overheads.

Históricamente, la optimización de cargas de trabajo computacionales intensivas ha migrado de CPUs a GPUs, especialmente para tareas paralelas como la multiplicación de matrices. La evolución de los LLMs, con sus arquitecturas basadas en Transformers y la predominancia de operaciones matriciales, ha hecho que la optimización a nivel de GPU sea crítica. Este enfoque de 'desde cero' permite una comprensión profunda de las interacciones entre el software y el hardware, revelando los trade-offs inherentes en el diseño de sistemas de inferencia de baja latencia.

Arquitectura del Sistema

El motor de inferencia tiny-vllm está diseñado para ejecutar un modelo LLM específico (Llama 3.2 1B Instruct) en GPUs NVIDIA. La arquitectura se centra en la implementación directa de las operaciones del modelo como kernels CUDA optimizados. El proceso comienza con la carga del modelo desde un archivo Safetensors, que es un formato eficiente para almacenar tensores. El header JSON del archivo Safetensors se parsea para obtener metadatos como dtype, shape y offsets de los tensores, permitiendo la asignación dinámica de memoria en la GPU para los pesos del modelo.

Las operaciones clave del LLM, como la recuperación de embeddings, RMSNorm, RoPE, atención (incluyendo Grouped-Query Attention), SiLU, Softmax y Feed-Forward Networks, se implementan como kernels CUDA. Se utiliza cublasGemmEx de la biblioteca cuBLAS para las multiplicaciones de matrices, aprovechando sus optimizaciones de bajo nivel. La gestión de memoria es un pilar central, con énfasis en la asignación eficiente (cudaMalloc), la copia de datos entre host y device (cudaMemcpy), y la reutilización de buffers para minimizar el consumo de memoria. Se implementan estrategias de batching (estático y continuo) y PagedAttention para mejorar el throughput y reducir la latencia, gestionando el KV cache de manera eficiente. La precisión numérica se maneja utilizando bfloat16 para los pesos y activaciones, y float32 para cálculos intermedios críticos para la estabilidad, como en RMSNorm y Softmax.

Flujo de Inferencia de un Solo Token (Prefill)

  1. 1 Cargar Modelo Parsear Safetensors, asignar pesos en GPU.
  2. 2 Tokenizar Prompt Convertir texto de entrada a secuencia de IDs de token (CPU).
  3. 3 Copiar Tokens a GPU Transferir IDs de token a memoria de GPU.
  4. 4 Embedding Gather Kernel CUDA: Recuperar embeddings para cada token de entrada.
  5. 5 Bucle de Capas (16x) Aplicar RMSNorm, RoPE, Atención (QKV), MLP.
  6. 6 LM Head Proyección final a espacio de vocabulario.
  7. 7 Argmax Seleccionar el token con la puntuación más alta (CPU).
  8. 8 Generar Token Primer token de salida.
CapaTecnologíaJustificación
compute C++ Lenguaje de programación principal para el control del host y la lógica de la aplicación. C++17
compute CUDA Framework y lenguaje para la programación de GPUs NVIDIA, permitiendo la implementación de kernels de alto rendimiento. vs OpenCL, HIP CUDA Toolkit 13.1
data-processing cuBLAS Biblioteca de NVIDIA para operaciones de álgebra lineal de alto rendimiento en GPUs, utilizada para multiplicaciones de matrices (GEMM).
storage Safetensors Formato de serialización de tensores para cargar los pesos del modelo LLM de manera segura y eficiente. vs Pickle, Parquet
data-processing nlohmann/json Biblioteca para parsear el header JSON de los archivos Safetensors. 3.12.0, single-header

Trade-offs

Ganancias
  • Eficiencia de hardware (GPU)
  • Throughput (con batching)
  • Latencia (con continuous batching)
  • Uso de memoria (con buffer reuse y PagedAttention)
Costes
  • Complejidad de desarrollo
  • Portabilidad (dependencia de NVIDIA CUDA)
  • Flexibilidad (modelo-específico inicialmente)
__global__ void embeddingGatherKernel(int *gpu_input_tokens, __nv_bfloat16 *input_embeddings, __nv_bfloat16 *embed_tokens)
{
    int workIndex = threadIdx.x + blockIdx.x * 2048;
    input_embeddings[workIndex] = embed_tokens[gpu_input_tokens[blockIdx.x] * 2048 + threadIdx.x];
    input_embeddings[workIndex + 1024] = embed_tokens[gpu_input_tokens[blockIdx.x] * 2048 + threadIdx.x + 1024];
}
Kernel que recupera los embeddings de los tokens de entrada, paralelizando la operación por token y por dimensión del embedding, manejando la limitación de hilos por bloque.
__global__ void rmsNormKernel(__nv_bfloat16 *input, __nv_bfloat16 *output, __nv_bfloat16 *norm_weights)
{
    __shared__ float rms_vector[1024];
    int workIndex = threadIdx.x + blockIdx.x * 2048;
    rms_vector[threadIdx.x] = (float)input[workIndex] * (float)input[workIndex] + (float)input[workIndex + 1024] * (float)input[workIndex + 1024];
    __syncthreads();
    for (int i = 1; i < 1024; i = i * 2)
    {
        if (threadIdx.x % (i * 2) == 0)
        {
            rms_vector[threadIdx.x] = rms_vector[threadIdx.x] + rms_vector[threadIdx.x + i];
        }
        __syncthreads();
    }
    if (threadIdx.x == 0)
    {
        rms_vector[0] = sqrt(rms_vector[0] / 2048.0 + 1.0e-5);
    }
    __syncthreads();
    output[workIndex] = (__nv_bfloat16)(((float)input[workIndex] / rms_vector[0]) * (float)norm_weights[threadIdx.x]);
    output[workIndex + 1024] = (__nv_bfloat16)(((float)input[workIndex + 1024] / rms_vector[0]) * (float)norm_weights[threadIdx.x + 1024]);
}
Kernel que calcula la normalización RMS para cada embedding, utilizando memoria compartida (`__shared__`) y `__syncthreads()` para una reducción paralela eficiente y numéricamente estable.
// Ejemplo conceptual: buf_A se usa, luego su contenido ya no es necesario.
// buf_B necesita memoria, pero su vida útil comienza después de que buf_A ya no se usa.
// Se asigna un solo bloque de memoria y se usa para ambos, reduciendo el total asignado.
// Ver src/main.cpp para buf_2048_1 y buf_2028_2.
Técnica para minimizar la asignación de memoria en la GPU, reutilizando bloques de memoria para diferentes propósitos en momentos no solapados del ciclo de vida de los datos.

Fundamentos Teóricos

La optimización de la inferencia de LLMs se basa en principios fundamentales de la computación paralela y el álgebra lineal numérica. El uso de kernels CUDA para operaciones matriciales y vectoriales se alinea con el modelo de programación SIMT (Single Instruction, Multiple Thread) de las GPUs, un concepto bien establecido en la computación de alto rendimiento. La técnica de reducción paralela utilizada en RMSNorm y Softmax es un patrón clásico de algoritmos paralelos, descrito en trabajos como 'Prefix Sums and Their Applications' (Blelloch, 1990) o en los fundamentos de la programación paralela en GPUs.

El mecanismo de atención, central en los LLMs, fue introducido en el paper 'Attention Is All You Need' (Vaswani et al., 2017), que sentó las bases para la arquitectura Transformer. La gestión del KV cache y las técnicas de PagedAttention se inspiran en principios de gestión de memoria de sistemas operativos, como la paginación de memoria virtual, adaptados para optimizar el acceso a los tensores clave y valor en la GPU. La elección de bfloat16 refleja un trade-off entre precisión numérica y eficiencia computacional/memoria, un tema recurrente en la investigación de redes neuronales, donde se ha demostrado empíricamente que una menor precisión en la mantisa es aceptable para la inferencia de LLMs, mientras que un rango de exponente amplio (como en float32) es crucial para evitar overflows/underflows.