Optimizando tu LLM en producción

Optimización del LLM en producción

Nota: Esta publicación de blog también está disponible como una página de documentación en Transformers.

Los Modelos de Lenguaje Grande (LLMs, por sus siglas en inglés) como GPT3/4, Falcon y LLama están avanzando rápidamente en su capacidad para abordar tareas centradas en los humanos, estableciéndose como herramientas esenciales en las industrias modernas basadas en el conocimiento. Sin embargo, desplegar estos modelos en tareas del mundo real sigue siendo un desafío:

  • Para mostrar capacidades de comprensión y generación de texto cercanas a las de los humanos, los LLMs actualmente requieren estar compuestos por miles de millones de parámetros (ver Kaplan et al, Wei et. al). Esto amplifica la demanda de memoria para la inferencia.
  • En muchas tareas del mundo real, los LLMs necesitan recibir información contextual extensa. Esto requiere que el modelo pueda manejar secuencias de entrada muy largas durante la inferencia.

La clave de estos desafíos radica en aumentar las capacidades computacionales y de memoria de los LLMs, especialmente cuando se manejan secuencias de entrada extensas.

En esta publicación de blog, repasaremos las técnicas más efectivas en el momento de escribir esta publicación de blog para abordar estos desafíos en la implementación eficiente de LLMs:

  1. Precisión Inferior: La investigación ha demostrado que trabajar con una precisión numérica reducida, específicamente de 8 bits y 4 bits, puede lograr ventajas computacionales sin un declive considerable en el rendimiento del modelo.

  2. Flash Attention: Flash Attention es una variación del algoritmo de atención que no solo proporciona un enfoque más eficiente en el uso de memoria, sino que también logra una mayor eficiencia debido a la utilización optimizada de la memoria de la GPU.

  3. Innovaciones Arquitectónicas: Dado que los LLMs siempre se implementan de la misma manera durante la inferencia, es decir, generación de texto autoregresiva con un contexto de entrada largo, se han propuesto arquitecturas de modelos especializados que permiten una inferencia más eficiente. Los avances más importantes en las arquitecturas de modelos en este sentido son Alibi, embeddings rotatorios, Atención de Múltiples Consultas (MQA) y Atención de Consultas Agrupadas (GQA).

A lo largo de este cuaderno, ofreceremos un análisis de la generación auto-regresiva desde la perspectiva de un tensor. Nos sumergiremos en los pros y los contras de adoptar una precisión inferior, realizaremos una exploración exhaustiva de los últimos algoritmos de atención y discutiremos arquitecturas de LLM mejoradas. Mientras lo hacemos, ejecutaremos ejemplos prácticos que muestren cada una de las mejoras de características.

1. Aprovechando el Poder de la Precisión Inferior

Los requisitos de memoria de los LLMs se pueden entender mejor viendo el LLM como un conjunto de matrices de pesos y vectores y las entradas de texto como una secuencia de vectores. A continuación, se utilizará la definición pesos para significar todas las matrices y vectores de pesos del modelo.

En el momento de escribir esta publicación, los LLMs constan de al menos un par de miles de millones de parámetros. Cada parámetro está compuesto por un número decimal, por ejemplo, 4.5689, que normalmente se almacena en formato float32, bfloat16 o float16. Esto nos permite calcular fácilmente el requisito de memoria para cargar el LLM en memoria:

Cargar los pesos de un modelo con X mil millones de parámetros requiere aproximadamente 4 * X GB de VRAM en precisión float32

Hoy en día, sin embargo, los modelos rara vez se entrenan en precisión float32 completa, sino que generalmente se usan precisión bfloat16 o, con menos frecuencia, precisión float16. Por lo tanto, la regla general se convierte en:

Cargar los pesos de un modelo con X mil millones de parámetros requiere aproximadamente 2 * X GB de VRAM en precisión bfloat16/float16

Para entradas de texto más cortas (menos de 1024 tokens), el requisito de memoria para la inferencia está muy dominado por el requisito de memoria para cargar los pesos. Por lo tanto, por ahora, asumamos que el requisito de memoria para la inferencia es igual al requisito de memoria para cargar el modelo en la VRAM de la GPU.

Para dar algunos ejemplos de cuánta VRAM aproximadamente se necesita para cargar un modelo en bfloat16:

  • GPT3 requiere 2 * 175 GB = 350 GB de VRAM
  • Bloom requiere 2 * 176 GB = 352 GB de VRAM
  • Llama-2-70b requiere 2 * 70 GB = 140 GB de VRAM
  • Falcon-40b requiere 2 * 40 GB = 80 GB de VRAM
  • MPT-30b requiere 2 * 30 GB = 60 GB de VRAM
  • bigcode/starcoder requiere 2 * 15.5 = 31 GB de VRAM

Al momento de escribir este documento, el chip de GPU más grande en el mercado es el A100 que ofrece 80GB de VRAM. La mayoría de los modelos mencionados anteriormente requieren más de 80GB solo para ser cargados y, por lo tanto, necesitan necesariamente paralelismo tensorial y/o paralelismo de canalización.

🤗 Transformers no soporta paralelismo tensorial de forma predeterminada ya que requiere que la arquitectura del modelo esté escrita de una manera específica. Si estás interesado en escribir modelos de una manera amigable al paralelismo tensorial, no dudes en echar un vistazo a la biblioteca de generación de texto-inferencia.

El paralelismo de canalización ingenuo es soportado de forma predeterminada. Para esto, simplemente carga el modelo con device="auto" lo cual colocará automáticamente las diferentes capas en las GPUs disponibles como se explica aquí. Sin embargo, ten en cuenta que aunque es muy efectivo, este paralelismo de canalización ingenuo no aborda los problemas de inactividad de la GPU. Para esto, se requiere un paralelismo de canalización más avanzado como se explica aquí.

Si tienes acceso a un nodo de 8 x 80GB A100, podrías cargar BLOOM de la siguiente manera:

!pip install transformers accelerate bitsandbytes optimum

# from transformers import AutoModelForCausalLM

# model = AutoModelForCausalLM.from_pretrained("bigscience/bloom", device_map="auto", pad_token_id=0)

Al utilizar device_map="auto" las capas de atención serían distribuidas de manera equitativa en todas las GPUs disponibles.

En este cuaderno, utilizaremos bigcode/octocoder ya que se puede ejecutar en un solo chip de GPU A100 de 40 GB. Ten en cuenta que todas las optimizaciones de memoria y velocidad que aplicaremos a partir de ahora son igualmente aplicables a modelos que requieren paralelismo de modelo o tensorial.

Dado que el modelo se carga en precisión bfloat16, utilizando nuestra regla general anterior, esperaríamos que el requisito de memoria para ejecutar la inferencia con bigcode/octocoder sea de aproximadamente 31 GB de VRAM. Vamos a intentarlo.

Primero cargamos el modelo y el tokenizador y luego los pasamos a un objeto de canalización de Transformers.

from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
import torch

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", torch_dtype=torch.bfloat16, device_map="auto", pad_token_id=0)
tokenizer = AutoTokenizer.from_pretrained("bigcode/octocoder")

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

prompt = "Pregunta: Por favor escribe una función en Python que transforme bytes a Gigabytes.\n\nRespuesta:"

result = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
result

Resultado:

Aquí tienes una función en Python que transforma bytes a Gigabytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nEsta función toma un solo

Genial, ahora podemos usar directamente el resultado para convertir bytes en Gigabytes.

def bytes_to_giga_bytes(bytes):
  return bytes / 1024 / 1024 / 1024

Llamemos a torch.cuda.max_memory_allocated para medir la asignación máxima de memoria de la GPU.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Resultado:

29.0260648727417

¡Casi coincide con nuestro cálculo aproximado! Podemos ver que el número no es exactamente correcto ya que para pasar de bytes a kilobytes se requiere una multiplicación de 1024 en lugar de 1000. Por lo tanto, la fórmula aproximada también se puede entender como un cálculo de “como máximo X GB”. Ten en cuenta que si hubiéramos intentado ejecutar el modelo en precisión float32 completa, se habrían requerido 64 GB de VRAM.

Casi todos los modelos se entrenan en bfloat16 hoy en día, no hay razón para ejecutar el modelo en precisión float32 completa si tu GPU admite bfloat16. Float32 no proporcionará mejores resultados de inferencia que la precisión que se utilizó para entrenar el modelo.

Si no estás seguro de en qué formato se almacenan los pesos del modelo en el Hub, siempre puedes consultar la configuración del punto de control bajo "torch_dtype", por ejemplo, aquí. Se recomienda establecer el modelo en el mismo tipo de precisión que se indica en la configuración al cargar con from_pretrained(..., torch_dtype=...) excepto si el tipo original es float32, en cuyo caso se puede utilizar tanto float16 como bfloat16 para inferencia.

Definamos una función flush(...) para liberar toda la memoria asignada para poder medir con precisión la memoria GPU máxima asignada.

del pipe
del model

import gc
import torch

def flush():
  gc.collect()
  torch.cuda.empty_cache()
  torch.cuda.reset_peak_memory_stats()

Llamémosla ahora para el próximo experimento.

flush()

En la versión reciente de la biblioteca accelerate, también puedes utilizar un método de utilidad llamado release_memory()

from accelerate.utils import release_memory
# ...

release_memory(model)

Ahora, ¿qué pasa si tu GPU no tiene 32 GB de VRAM? Se ha descubierto que los pesos del modelo se pueden cuantizar a 8 bits o 4 bits sin una pérdida significativa de rendimiento (ver Dettmers et al.). El modelo se puede cuantizar incluso a 3 o 2 bits con una pérdida aceptable de rendimiento, como se muestra en el reciente artículo de GPTQ 🤯.

Sin entrar en demasiados detalles, los esquemas de cuantización tienen como objetivo reducir la precisión de los pesos mientras intentan mantener los resultados de inferencia del modelo lo más precisos posible (también conocidos como cercanos a bfloat16). Ten en cuenta que la cuantización funciona especialmente bien para la generación de texto, ya que lo único que nos importa es elegir el conjunto de tokens siguientes más probables y no nos importan realmente los valores exactos de la distribución logit del token siguiente. Lo único que importa es que la distribución logit del token siguiente se mantenga aproximadamente igual para que una operación de argmax o topk dé los mismos resultados.

Existen varias técnicas de cuantización, que no discutiremos en detalle aquí, pero en general, todas las técnicas de cuantización funcionan de la siguiente manera:

    1. Cuantizar todos los pesos a la precisión objetivo
    1. Cargar los pesos cuantizados y pasar la secuencia de entrada de vectores en precisión bfloat16
    1. Des-cuantizar dinámicamente los pesos a bfloat16 para realizar el cálculo con sus vectores de entrada en precisión bfloat16
    1. Cuantizar nuevamente los pesos a la precisión objetivo después del cálculo con sus entradas.

En resumen, esto significa que las multiplicaciones de matrices de entrada-pesos, siendo X la entrada, W una matriz de pesos y Y la salida:

Y=X∗W Y = X * W Y=X∗W

cambian a

Y=X∗des-cuantizar(W);cuantizar(W) Y = X * \text{des-cuantizar}(W); \text{cuantizar}(W) Y=X∗des-cuantizar(W);cuantizar(W)

para cada multiplicación de matriz. La des-cuantización y re-cuantización se realizan de forma secuencial para todas las matrices de pesos a medida que las entradas se ejecutan a través del grafo de la red.

Por lo tanto, el tiempo de inferencia a menudo no se reduce al utilizar pesos cuantizados, sino que aumenta. Suficiente teoría, ¡vamos a probarlo! Para cuantizar los pesos con Transformers, debes asegurarte de que la biblioteca bitsandbytes esté instalada.

# !pip install bitsandbytes

Luego podemos cargar modelos con cuantización de 8 bits simplemente agregando la bandera load_in_8bit=True a from_pretrained.

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", load_in_8bit=True, pad_token_id=0)

Ahora, ejecutemos nuestro ejemplo nuevamente y midamos el uso de memoria.

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

result = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
result

Salida:

Aquí hay una función de Python que transforma bytes en gigabytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nEsta función toma un solo

¡Genial, obtenemos el mismo resultado que antes, así que no hay pérdida de precisión! Veamos cuánta memoria se utilizó esta vez.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Salida:

15.219234466552734

¡Significativamente menos! Ahora solo tenemos un poco más de 15 GB y, por lo tanto, podríamos ejecutar este modelo en GPU de consumo como el 4090. Estamos viendo una mejora muy buena en la eficiencia de memoria y más o menos ninguna degradación en la salida del modelo. Sin embargo, también podemos notar una ligera desaceleración durante la inferencia.

Eliminamos los modelos y liberamos la memoria nuevamente.

del modelo
del pipe

flush()

Veamos cuál es el consumo máximo de memoria de la GPU con la cuantización de 4 bits. La cuantización del modelo a 4 bits se puede hacer con la misma API que antes, esta vez pasando load_in_4bit=True en lugar de load_in_8bit=True.

modelo = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", load_in_4bit=True, low_cpu_mem_usage=True, pad_token_id=0)

pipe = pipeline("text-generation", model=modelo, tokenizer=tokenizer)

resultado = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
resultado

Salida:

Aquí hay una función de Python que transforma bytes a Gigabytes:\n\n```\ndef bytes_to_gigabytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nEsta función toma un único argumento

Casi vemos el mismo texto de salida que antes, solo falta el python justo antes del fragmento de código. Veamos cuánta memoria se requirió.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Salida:

9.543574333190918

¡Solo 9.5GB! Eso realmente no es mucho para un modelo de más de 15 mil millones de parámetros.

Aunque vemos muy poca degradación en la precisión de nuestro modelo aquí, la cuantización de 4 bits puede, en la práctica, dar resultados diferentes en comparación con la cuantización de 8 bits o la inferencia completa con bfloat16. Depende del usuario probarlo.

También tenga en cuenta que la inferencia aquí nuevamente fue un poco más lenta en comparación con la cuantización de 8 bits, lo cual se debe al método de cuantización más agresivo utilizado para la cuantización de 4 bits, lo que lleva más tiempo durante la inferencia los procesos de cuantización y des-cuantización.

del modelo
del pipe

flush()

En general, vimos que ejecutar OctoCoder en precisión de 8 bits redujo la VRAM de GPU requerida de 32G a solo 15GB, y ejecutar el modelo en precisión de 4 bits reduce aún más la VRAM de GPU requerida a poco más de 9GB.

La cuantización de 4 bits permite que el modelo se ejecute en GPU como RTX3090, V100 y T4, que son bastante accesibles para la mayoría de las personas.

Para obtener más información sobre la cuantización y ver cómo se pueden cuantizar los modelos para requerir incluso menos memoria VRAM de GPU que los 4 bits, recomendamos consultar la implementación de AutoGPTQ.

En conclusión, es importante recordar que la cuantización del modelo intercambia una mayor eficiencia de memoria por precisión y, en algunos casos, tiempo de inferencia.

Si la memoria de la GPU no es un problema para su caso de uso, a menudo no es necesario considerar la cuantización. Sin embargo, muchas GPU simplemente no pueden ejecutar LLM sin métodos de cuantización, y en este caso, los esquemas de cuantización de 4 y 8 bits son herramientas extremadamente útiles.

Para obtener más información detallada sobre el uso, recomendamos encarecidamente consultar la documentación de cuantización de Transformers. A continuación, veamos cómo podemos mejorar la eficiencia computacional y de memoria utilizando mejores algoritmos y una arquitectura de modelo mejorada.

Los LLMs de mejor rendimiento de hoy comparten más o menos la misma arquitectura fundamental que consta de capas de alimentación directa, capas de activación, capas de normalización de capas y, lo más importante, capas de autoatención.

Las capas de autoatención son fundamentales para los modelos de lenguaje grandes (LLMs) porque permiten que el modelo comprenda las relaciones contextuales entre los tokens de entrada. Sin embargo, el consumo máximo de memoria de la GPU para las capas de autoatención crece de manera cuadrática tanto en la complejidad de cálculo como en la de memoria con el número de tokens de entrada (también llamada longitud de secuencia), que denotamos en lo siguiente como N N N. Si bien esto no es realmente perceptible para secuencias de entrada más cortas (de hasta 1000 tokens de entrada), se convierte en un problema grave para secuencias de entrada más largas (alrededor de 16000 tokens de entrada).

Veamos más de cerca. La fórmula para calcular la salida O \mathbf{O} O de una capa de autoatención para una entrada X \mathbf{X} X de longitud N N N es:

O=Attn(X)=V×Softmax(QKT) con Q=WqX,V=WvX,K=WkX \textbf{O} = \text{Attn}(\mathbf{X}) = \mathbf{V} \times \text{Softmax}(\mathbf{QK}^T) \text{ con } \mathbf{Q} = \mathbf{W}_q \mathbf{X}, \mathbf{V} = \mathbf{W}_v \mathbf{X}, \mathbf{K} = \mathbf{W}_k \mathbf{X} O=Attn(X)=V×Softmax(QKT) con Q=Wq​X,V=Wv​X,K=Wk​X mathbfX=(x1,…xN) mathbf{X} = (\mathbf{x}_1, … \mathbf{x}_{N}) mathbfX=(x1​,…xN​) es la secuencia de entrada a la capa de atención. Las proyecciones Q \mathbf{Q} Q y K \mathbf{K} K consistirán cada una en N N N vectores, lo que resultará en que QKT \mathbf{QK}^T QKT tenga un tamaño de N2 N^2 N2 .

Los LLMs generalmente tienen múltiples “attention heads”, lo que implica realizar múltiples cálculos de autoatención en paralelo. Suponiendo que el LLM tiene 40 “attention heads” y se ejecuta en precisión bfloat16, podemos calcular el requisito de memoria para almacenar las matrices QKT \mathbf{QK^T} QKT en 40∗2∗N2 40 * 2 * N^2 40∗2∗N2 bytes. Para N=1000 N=1000 N=1000 solo se necesitan alrededor de 50 MB de VRAM, sin embargo, para N=16000 N=16000 N=16000 necesitaríamos 19 GB de VRAM, y para N=100,000 N=100,000 N=100,000 necesitaríamos casi 1TB solo para almacenar las matrices QKT \mathbf{QK}^T QKT.

En resumen, el algoritmo de autoatención por defecto rápidamente se vuelve prohibitivamente costoso en memoria para contextos de entrada grandes.

A medida que los LLMs mejoran en comprensión y generación de texto, se aplican a tareas cada vez más complejas. Mientras que antes los modelos se ocupaban de la traducción o resumen de unas pocas oraciones, ahora manejan páginas enteras, lo que exige la capacidad de procesar longitudes de entrada extensas.

¿Cómo podemos deshacernos de los exorbitantes requisitos de memoria para longitudes de entrada grandes? Necesitamos una nueva forma de calcular el mecanismo de autoatención que elimine la matriz QKT QK^T QKT. Tri Dao et al. desarrollaron exactamente un nuevo algoritmo así y lo llamaron Flash Attention.

En pocas palabras, Flash Attention divide la computación V×Softmax(QKT\mathbf{V} \times \text{Softmax}(\mathbf{QK}^TV×Softmax(QKT) en partes más pequeñas y en su lugar calcula fragmentos más pequeños de la salida iterando en múltiples pasos de cálculo de softmax:

Oi←sija∗Oi+sijb∗Vj×Softmax(QKi,jT) para múltiples i,j iteraciones \textbf{O}_i \leftarrow s^a_{ij} * \textbf{O}_i + s^b_{ij} * \mathbf{V}_{j} \times \text{Softmax}(\mathbf{QK}^T_{i,j}) \text{ para múltiples } i, j \text{ iteraciones} Oi​←sija​∗Oi​+sijb​∗Vj​×Softmax(QKi,jT​) para múltiples i,j iteraciones

siendo sija s^a_{ij} sija​ y sijb s^b_{ij} sijb​ algunas estadísticas de normalización de softmax que deben ser recalculadas para cada i i i y j j j.

Tenga en cuenta que el conjunto completo de Flash Attention es un poco más complejo y se simplifica mucho aquí, ya que profundizar demasiado está fuera del alcance de este cuaderno. Invitamos al lector a echar un vistazo al artículo sobre Flash Attention, bien escrito, para obtener más detalles.

La idea principal aquí es:

Manteniendo un registro de las estadísticas de normalización de softmax y utilizando algunas matemáticas inteligentes, Flash Attention produce salidas numéricamente idénticas en comparación con la capa de autoatención por defecto, con un costo de memoria que solo aumenta linealmente con N N N.

Al observar la fórmula, intuitivamente se podría decir que Flash Attention debe ser mucho más lento en comparación con la fórmula de auto-atención por defecto, ya que se necesita realizar más cálculos. De hecho, Flash Attention requiere más FLOPs en comparación con la atención normal, ya que las estadísticas de normalización softmax tienen que ser constantemente recomputadas (ver el artículo para más detalles si está interesado).

Sin embargo, Flash Attention es mucho más rápido en la inferencia en comparación con la atención por defecto, lo cual se debe a su capacidad de reducir significativamente las demandas en la memoria más lenta y de alta velocidad de banda de la GPU (VRAM), enfocándose en cambio en la memoria más rápida en el chip (SRAM).

Esencialmente, Flash Attention se asegura de que todas las operaciones intermedias de escritura y lectura se puedan realizar utilizando la memoria rápida en el chip SRAM en lugar de tener que acceder a la memoria más lenta VRAM para calcular el vector de salida O \mathbf{O} O.

En la práctica, actualmente no hay absolutamente ninguna razón para no utilizar Flash Attention si está disponible. El algoritmo proporciona matemáticamente las mismas salidas, y es más rápido y más eficiente en el uso de la memoria.

Veamos un ejemplo práctico.

Nuestro modelo OctoCoder ahora recibe una entrada de texto de longitud significativamente mayor que incluye una llamada “system prompt” (texto de sistema). Los “system prompts” se utilizan para dirigir el LLM hacia un asistente mejor adaptado a la tarea de los usuarios. A continuación, utilizamos un “system prompt” que hará de OctoCoder un mejor asistente de codificación.

system_prompt = """A continuación se muestran una serie de diálogos entre varias personas y un asistente técnico de IA.
El asistente intenta ser útil, educado, honesto, sofisticado, con conciencia emocional y humilde pero conocedor.
El asistente está dispuesto a ayudar con preguntas de código y hará todo lo posible por comprender exactamente lo que se necesita.
También intenta evitar proporcionar información falsa o engañosa, y hace advertencias cuando no está completamente seguro de la respuesta correcta.
Dicho esto, el asistente es práctico, realmente hace todo lo posible y no deja que la precaución obstaculice demasiado la utilidad.

Los modelos Starcoder son una serie de modelos con 15.5B parámetros entrenados en más de 80 lenguajes de programación de "The Stack" (v1.2) (excluyendo solicitudes de exclusión voluntaria).
El modelo utiliza Atención Multiquery, fue entrenado utilizando el objetivo "Fill-in-the-Middle" y con una ventana de contexto de 8,192 tokens para un billón de tokens de datos fuertemente deduplicados.

-----

Pregunta: Escribe una función que tome dos listas y devuelva una lista que tenga elementos alternados de cada lista de entrada.

Respuesta: Claro. Aquí hay una función que hace eso.

def alternating(list1, list2):
   results = []
   for i in range(len(list1)):
       results.append(list1[i])
       results.append(list2[i])
   return results

Pregunta: ¿Puedes escribir algunos casos de prueba para esta función?

Respuesta: Claro, aquí hay algunos tests.

assert alternating([10, 20, 30], [1, 2, 3]) == [10, 1, 20, 2, 30, 3]
assert alternating([True, False], [4, 5]) == [True, 4, False, 5]
assert alternating([], []) == []

Pregunta: Modifica la función para que devuelva todos los elementos de entrada cuando las listas tienen una longitud desigual. Los elementos de la lista más larga deben estar al final.

Respuesta: Aquí está la función modificada.

def alternating(list1, list2):
   results = []
   for i in range(min(len(list1), len(list2))):
       results.append(list1[i])
       results.append(list2[i])
   if len(list1) > len(list2):
       results.extend(list1[i+1:])
   else:
       results.extend(list2[i+1:])
   return results

-----
"""

Con fines de demostración, duplicamos el sistema por diez para que la longitud de la entrada sea lo suficientemente larga como para observar el ahorro de memoria de Flash Attention. Adjuntamos el texto original de la llamada "Pregunta: Por favor, escribe una función en Python que transforme bytes a gigabytes.\n\nRespuesta: Aquí"

long_prompt = 10 * system_prompt + prompt

Instanciamos nuestro modelo nuevamente en precisión bfloat16.

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", torch_dtype=torch.bfloat16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained("bigcode/octocoder")

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

Ahora ejecutemos el modelo como antes, sin Flash Attention, y midamos el requisito máximo de memoria de la GPU y el tiempo de inferencia.

import time

start_time = time.time()
result = pipe(long_prompt, max_new_tokens=60)[0]["generated_text"][len(long_prompt):]

print(f"Generado en {time.time() - start_time} segundos.")
result

Salida:

Generado en 10.96854019165039 segundos.
Claro. Aquí hay una función que hace eso.\n\ndef bytes_to_giga(bytes):\n   return bytes / 1024 / 1024 / 1024\n\nRespuesta: Claro. Aquí hay una función que hace eso.\n\ndef

Estamos obteniendo la misma salida que antes, sin embargo, esta vez, el modelo repite la respuesta varias veces hasta que se alcanzan los 60 tokens de límite. Esto no es sorprendente ya que hemos repetido la indicación del sistema diez veces con fines de demostración y, por lo tanto, hemos dado al modelo la pista de que se repita a sí mismo.

Nota que la indicación del sistema no debe repetirse diez veces en aplicaciones del mundo real, ¡una vez es suficiente!

Vamos a medir el requisito máximo de memoria GPU.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Salida:

37.668193340301514

Como podemos ver, el requisito máximo de memoria GPU es ahora significativamente mayor que al principio, lo cual se debe en gran medida a la secuencia de entrada más larga. Además, la generación ahora tarda un poco más de un minuto.

Llamamos a flush() para liberar la memoria GPU para nuestro próximo experimento.

flush()

Para comparar, ejecutemos la misma función, pero habilitando Flash Attention. Para hacerlo, convertimos el modelo a BetterTransformers y, al hacerlo, habilitamos la atención propia SDPA de PyTorch que, a su vez, se basa en Flash Attention.

model.to_bettertransformer()

Ahora ejecutamos el mismo fragmento de código exacto que antes y bajo la capa, Transformers utilizará Flash Attention.

start_time = time.time()
with torch.backends.cuda.sdp_kernel(enable_flash=True, enable_math=False, enable_mem_efficient=False):
    result = pipe(long_prompt, max_new_tokens=60)[0]["generated_text"][len(long_prompt):]

print(f"Generado en {time.time() - start_time} segundos.")
result

Salida:

Generado en 3.0211617946624756 segundos.
Claro. Aquí hay una función que hace eso.\n\ndef bytes_to_giga(bytes):\n   return bytes / 1024 / 1024 / 1024\n\nRespuesta: Claro. Aquí hay una función que hace eso.\n\ndef

Obtenemos el mismo resultado exacto que antes, pero podemos observar una mejora significativa de velocidad gracias a Flash Attention.

Midamos el consumo de memoria una última vez.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Salida:

32.617331981658936

Y casi hemos vuelto a nuestra memoria GPU máxima original de 29GB desde el principio.

Podemos observar que solo usamos aproximadamente 100MB más de memoria GPU al pasar una secuencia de entrada muy larga con Flash Attention en comparación con pasar secuencias de entrada cortas como se hizo al principio.

flush()

3. La ciencia detrás de las arquitecturas LLM: Selección estratégica para entradas de texto largas y chat

Hasta ahora hemos analizado cómo mejorar la eficiencia computacional y de memoria mediante:

  • Convertir los pesos a un formato de precisión inferior
  • Reemplazar el algoritmo de autoatención con una versión más eficiente en memoria y cálculo

Ahora veamos cómo podemos cambiar la arquitectura de un LLM para que sea más efectivo y eficiente para tareas que requieren entradas de texto largas, por ejemplo:

  • Preguntas y respuestas con recuperación mejorada,
  • Resumen,
  • Chat

Ten en cuenta que el chat no solo requiere que el LLM maneje entradas de texto largas, sino que también exige que el LLM pueda manejar eficientemente el diálogo de ida y vuelta entre el usuario y el asistente (como ChatGPT).

Una vez entrenada, la arquitectura fundamental del LLM es difícil de cambiar, por lo que es importante tomar consideraciones sobre las tareas del LLM de antemano y optimizar en consecuencia la arquitectura del modelo. Hay dos componentes importantes de la arquitectura del modelo que rápidamente se convierten en cuellos de botella de memoria y/o rendimiento para secuencias de entrada largas.

  • Las incrustaciones posicionales
  • La caché clave-valor

Vamos a repasar cada componente con más detalle

3.1 Mejorando las incrustaciones posicionales de LLMs

La autoatención coloca cada token en relación con los demás tokens. Como ejemplo, la matriz Softmax(QKT) \text{Softmax}(\mathbf{QK}^T) Softmax(QKT) de la secuencia de entrada de texto “Hola”, “Yo”, “amo”, “tú” podría verse así:

A cada token de palabra se le asigna una masa de probabilidad en la que se relaciona con todos los demás tokens de palabra y, por lo tanto, se pone en relación con todos los demás tokens de palabra. Por ejemplo, la palabra “amo” se relaciona con la palabra “Hola” con un 0.05%, con “Yo” con un 0.3% y consigo misma con un 0.65%.

Un LLM basado en autoatención, pero sin incrustaciones de posición, tendría grandes dificultades para entender las posiciones de las entradas de texto entre sí. Esto se debe a que la puntuación de probabilidad computada por QKT \mathbf{QK}^T QKT relaciona cada token de palabra con cada otro token de palabra en cálculos O(1) O(1) O(1) sin importar su distancia posicional relativa entre sí. Por lo tanto, para el LLM sin incrustaciones de posición, cada token parece tener la misma distancia a todos los demás tokens, por ejemplo, diferenciar entre “Hola Yo amo tú” y “Tú amas Yo hola” sería muy desafiante.

Para que el LLM comprenda el orden de las oraciones, se necesita una pista adicional que generalmente se aplica en forma de codificaciones posicionales (también llamadas incrustaciones posicionales). Las codificaciones posicionales codifican la posición de cada token en una presentación numérica que el LLM puede aprovechar para entender mejor el orden de las oraciones.

Los autores del documento Attention Is All You Need introdujeron incrustaciones posicionales sinusoidales P=p1,…,pN \mathbf{P} = \mathbf{p}_1, \ldots, \mathbf{p}_N P=p1​,…,pN​ donde cada vector pi \mathbf{p}_i pi​ se calcula como una función sinusoidal de su posición i i i. Las incrustaciones posicionales se agregan simplemente a los vectores de secuencia de entrada X^=x^1,…,x^N \mathbf{\hat{X}} = \mathbf{\hat{x}}_1, \ldots, \mathbf{\hat{x}}_N X^=x^1​,…,x^N​ = x1​+p1,…,xN​+pN​ lo que indica al modelo que aprenda mejor el orden de las oraciones.

En lugar de usar incrustaciones de posición fijas, otros (como Devlin et al.) utilizaron codificaciones posicionales aprendidas para las cuales las incrustaciones posicionales P \mathbf{P} P se aprenden durante el entrenamiento.

Las incrustaciones posicionales sinusoidales y aprendidas solían ser los métodos predominantes para codificar el orden de las oraciones en LLMs, pero se encontraron algunos problemas relacionados con estas codificaciones posicionales:

  • 1.) Las incrustaciones posicionales sinusoidales y aprendidas son ambas incrustaciones posicionales absolutas, es decir, codifican una incrustación única para cada posición id: 0,…,N 0, \ldots, N 0,…,N. Como mostraron Huang et al. y Su et al.], las incrustaciones posicionales absolutas conducen a un rendimiento deficiente de LLM para entradas de texto largas. Para entradas de texto largas, es ventajoso si el modelo aprende la distancia posicional relativa que los tokens de entrada tienen entre sí en lugar de su posición absoluta.
  • 2.) Al usar incrustaciones posicionales aprendidas, el LLM debe entrenarse en una longitud de entrada fija N N N, lo que dificulta la extrapolación a una longitud de entrada más larga que la que se entrenó.

Recientemente, las incrustaciones posicionales relativas que pueden abordar los problemas mencionados anteriormente se han vuelto más populares, especialmente:

  • Rotary Position Embedding (RoPE)
  • ALiBi

Tanto RoPE como ALiBi argumentan que es mejor indicarle al LLM el orden de las oraciones directamente en el algoritmo de autoatención, ya que es allí donde los tokens de palabras se ponen en relación entre sí. Más específicamente, el orden de las oraciones debería ser indicado modificando el cálculo de QKT \mathbf{QK}^T QKT.

Sin entrar en demasiados detalles, RoPE señala que la información posicional se puede codificar en pares de consulta-clave, por ejemplo, qi \mathbf{q}_i qi​ y xj \mathbf{x}_j xj​, girando cada vector por un ángulo θ∗i \theta * i θ∗i y θ∗j \theta * j θ∗j, respectivamente, con i, j i, j i,j describiendo la posición de cada vector en la oración:

q^iTx^j=qiTRθ,i−jxj. \mathbf{\hat{q}}_i^T \mathbf{\hat{x}}_j = \mathbf{{q}}_i^T \mathbf{R}_{\theta, i -j} \mathbf{{x}}_j. q^​iT​x^j​=qiT​Rθ,i−j​xj​. Por lo tanto, Rθ,i−j \mathbf{R}_{\theta, i – j} Rθ,i−j​ representa una matriz de rotación. θ \theta θ no se aprende durante el entrenamiento, sino que se establece en un valor predefinido que depende de la longitud máxima de la secuencia de entrada durante el entrenamiento.

Al hacerlo, la puntuación de probabilidad entre qi \mathbf{q}_i qi​ y qj \mathbf{q}_j qj​ solo se ve afectada si i≠j i \ne j i=j y depende únicamente de la distancia relativa i−j i – j i−j independientemente de las posiciones específicas de los vectores i i i y j j j.

RoPE se utiliza en varios de los LLM más importantes en la actualidad, como:

  • Falcon
  • Llama
  • PaLM

Como alternativa, ALiBi propone un esquema de codificación de posición relativa mucho más simple. La distancia relativa entre los tokens de entrada se agrega como un número entero negativo escalado por un valor predefinido m a cada entrada de consulta-clave de la matriz QKT \mathbf{QK}^T QKT justo antes del cálculo softmax.

Como se muestra en el artículo de ALiBi, esta simple codificación posicional relativa permite que el modelo mantenga un alto rendimiento incluso en secuencias de entrada de texto muy largas.

ALiBi se utiliza en varios de los LLM más importantes en la actualidad, como:

  • MPT
  • BLOOM

Tanto RoPE como ALiBi pueden extrapolar las codificaciones de posición a longitudes de entrada no vistas durante el entrenamiento, pero se ha demostrado que la extrapolaración funciona mucho mejor de manera predeterminada para ALiBi en comparación con RoPE. Para ALiBi, simplemente se aumentan los valores de la matriz de posición triangular inferior para que coincidan con la longitud de la secuencia de entrada. Para RoPE, mantener el mismo θ \theta θ que se usó durante el entrenamiento conduce a resultados pobres al pasar entradas de texto mucho más largas que las vistas durante el entrenamiento, c.f Press et al.. Sin embargo, la comunidad ha encontrado un par de trucos efectivos que adaptan θ \theta θ, permitiendo que las incrustaciones de posición de RoPE funcionen bien para secuencias de entrada de texto extrapoladas (ver aquí).

Tanto RoPE como ALiBi son incrustaciones de posición relativas que no se aprenden durante el entrenamiento, sino que se basan en las siguientes intuiciones:

  • Las señales de posición sobre las entradas de texto deben darse directamente a la matriz QKT QK^T QKT de la capa de autocodificación
  • Se debe incentivar al LLM a aprender una codificación de posición relativa constante que tienen entre sí las incrustaciones posicionales
  • Cuanto más alejados estén los tokens de entrada de texto entre sí, menor será la probabilidad de su probabilidad de consulta-valor. Tanto RoPE como ALiBi disminuyen la probabilidad de consulta-clave de los tokens que están lejos entre sí. RoPE disminuyendo el producto vectorial al aumentar el ángulo entre los vectores de consulta-clave. ALiBi agregando números negativos grandes al producto vectorial

En conclusión, los LLM que se pretenden implementar en tareas que requieren manejar grandes entradas de texto se entrenan mejor con incrustaciones de posición relativas, como RoPE y ALiBi. También hay que tener en cuenta que incluso si un LLM con RoPE y ALiBi solo se ha entrenado con una longitud fija, por ejemplo N1=2048 N_1 = 2048 N1​=2048, aún se puede utilizar en la práctica con entradas de texto mucho más grandes que N1 N_1 N1​, como N2=8192>N1 N_2 = 8192 > N_1 N2​=8192>N1​, extrapolando las incrustaciones posicionales.

3.2 La caché de clave-valor

La generación de texto auto-regresiva con LLMs funciona iterativamente colocando una secuencia de entrada, muestreando el siguiente token, agregando el siguiente token a la secuencia de entrada y continuando así hasta que el LLM produzca un token que indique que la generación ha terminado.

Por favor, eche un vistazo al Tutorial de Generación de Texto de Transformer para obtener una explicación más visual de cómo funciona la generación auto-regresiva.

Vamos a ejecutar un pequeño fragmento de código para mostrar cómo funciona en la práctica la generación auto-regresiva. Simplemente tomaremos el siguiente token más probable usando torch.argmax.

input_ids = tokenizer(prompt, return_tensors="pt")["input_ids"].to("cuda")

for _ in range(5):
  next_logits = model(input_ids)["logits"][:, -1:]
  next_token_id = torch.argmax(next_logits,dim=-1)

  input_ids = torch.cat([input_ids, next_token_id], dim=-1)
  print("forma de input_ids", input_ids.shape)

generated_text = tokenizer.batch_decode(input_ids[:, -5:])
generated_text

Salida:

forma de input_ids torch.Size([1, 21])
forma de input_ids torch.Size([1, 22])
forma de input_ids torch.Size([1, 23])
forma de input_ids torch.Size([1, 24])
forma de input_ids torch.Size([1, 25])
['Aquí hay una función en Python']

Como podemos ver, cada vez que aumentamos los tokens de entrada de texto con el token recién muestreado.

Con muy pocas excepciones, los LLMs se entrenan utilizando el objetivo de modelado de lenguaje causal y, por lo tanto, enmascaran la matriz de puntuación de atención de la parte superior del triángulo; por eso en los dos diagramas anteriores las puntuaciones de atención se dejan en blanco (es decir, tienen una probabilidad de 0). Para un resumen rápido del modelado de lenguaje causal, puedes consultar la entrada de blog Atención Self Ilustrada.

Como consecuencia, los tokens nunca dependen de tokens anteriores, más específicamente, el vector qi \mathbf{q}_i qi​ nunca se relaciona con ninguna clave o vector de valores kj,vj \mathbf{k}_j, \mathbf{v}_j kj​,vj​ si j>i j > i j>i . En su lugar, qi \mathbf{q}_i qi​ solo se relaciona con los vectores de clave-valor previos km<i,vm<i , para m∈{0,…i−1} \mathbf{k}_{m < i}, \mathbf{v}_{m < i} \text{ , for } m \in \{0, \ldots i – 1\} km<i​,vm<i​ , para m∈{0,…i−1}. Con el fin de reducir el cálculo innecesario, uno puede almacenar en caché los vectores de clave-valor de cada capa para todos los pasos de tiempo anteriores.

A continuación, le diremos al LLM que utilice la caché de clave-valor recuperándola y enviándola para cada pase hacia adelante. En Transformers, podemos recuperar la caché de clave-valor pasando la bandera use_cache a la llamada de forward y luego podemos pasarla con el token actual.

past_key_values = None # past_key_values es la caché de clave-valor
generated_tokens = []
next_token_id = tokenizer(prompt, return_tensors="pt")["input_ids"].to("cuda")

for _ in range(5):
  next_logits, past_key_values = model(next_token_id, past_key_values=past_key_values, use_cache=True).to_tuple()
  next_logits = next_logits[:, -1:]
  next_token_id = torch.argmax(next_logits, dim=-1)

  print("forma de input_ids", input_ids.shape)
  print("longitud de la caché de clave-valor", len(past_key_values[0][0]))  # past_key_values tiene forma [num_layers, 0 para k, 1 para v, tamaño_lote, longitud, dim_oculta]
  generated_tokens.append(next_token_id.item())

generated_text = tokenizer.batch_decode(generated_tokens)
generated_text

Salida:

forma de input_ids torch.Size([1, 20])
longitud de la caché de clave-valor 20
forma de input_ids torch.Size([1, 20])
longitud de la caché de clave-valor 21
forma de input_ids torch.Size([1, 20])
longitud de la caché de clave-valor 22
forma de input_ids torch.Size([1, 20])
longitud de la caché de clave-valor 23
forma de input_ids torch.Size([1, 20])
longitud de la caché de clave-valor 24
['Aquí', 'hay', 'una', 'función', 'en Python']

Como se puede ver, al usar la caché de clave-valor, los tokens de entrada de texto no aumentan de longitud, sino que permanecen como un solo vector de entrada. Por otro lado, la longitud de la caché de clave-valor se incrementa en uno en cada paso de decodificación.

Al hacer uso de la caché de clave-valor, el producto QKT \mathbf{QK}^T QKT se reduce esencialmente a qcKT \mathbf{q}_c\mathbf{K}^T qc​KT, siendo qc \mathbf{q}_c qc​ la proyección de consulta del token de entrada actual que siempre es solo un vector.

Usar la caché de clave-valor tiene dos ventajas:

  • Aumento significativo en la eficiencia computacional, ya que se realizan menos cálculos en comparación con el cálculo de la matriz completa QKT \mathbf{QK}^T QKT. Esto lleva a un aumento en la velocidad de inferencia.
  • La memoria máxima requerida no aumenta de forma cuadrática con el número de tokens generados, sino que solo aumenta de manera lineal.

Siempre se debe hacer uso de la caché de clave-valor, ya que produce resultados idénticos y acelera significativamente las secuencias de entrada más largas. Transformers tiene la caché de clave-valor habilitada de forma predeterminada al usar el pipeline de texto o el método generate.

Se debe tener en cuenta que la caché de clave-valor es especialmente útil para aplicaciones como el chat, donde se requieren múltiples pasadas de decodificación autorrregresiva. Veamos un ejemplo.

Usuario: ¿Cuántas personas viven en Francia?
Asistente: Aproximadamente 75 millones de personas viven en Francia.
Usuario: ¿Y cuántas hay en Alemania?
Asistente: Alemania tiene aproximadamente 81 millones de habitantes.

En este chat, el LLM realiza la decodificación autorrregresiva dos veces:

    1. La primera vez, la caché de clave-valor está vacía y la indicación de entrada es "Usuario: ¿Cuántas personas viven en Francia?" y el modelo genera de forma autorrregresiva el texto "Aproximadamente 75 millones de personas viven en Francia" mientras incrementa la caché de clave-valor en cada paso de decodificación.
    1. La segunda vez la indicación de entrada es "Usuario: ¿Cuántas personas viven en Francia? \n Asistente: Aproximadamente 75 millones de personas viven en Francia \n Usuario: ¿Y cuántas en Alemania?". Gracias a la caché, todos los vectores de clave-valor de las dos primeras frases ya están calculados. Por lo tanto, la indicación de entrada solo consiste en "Usuario: ¿Y cuántas en Alemania?". Mientras procesa la indicación de entrada acortada, sus vectores de clave-valor calculados se concatenan con la caché de clave-valor de la primera decodificación. La segunda respuesta del Asistente "Alemania tiene aproximadamente 81 millones de habitantes" se genera de forma autorrregresiva con la caché de clave-valor que consiste en los vectores de clave-valor codificados de "Usuario: ¿Cuántas personas viven en Francia? \n Asistente: Aproximadamente 75 millones de personas viven en Francia \n Usuario: ¿Y cuántas hay en Alemania?".

Aquí se deben notar dos cosas:

    1. Mantener todo el contexto es crucial para los LLMs utilizados en chats, para que el LLM comprenda todo el contexto previo de la conversación. Por ejemplo, para el ejemplo anterior, el LLM necesita entender que el usuario se refiere a la población al preguntar "¿Y cuántas hay en Alemania?".
    1. La caché de clave-valor es extremadamente útil para el chat, ya que nos permite hacer crecer continuamente el historial de chat codificado en lugar de tener que volver a codificar el historial de chat desde cero (como sería el caso al usar una arquitectura codificador-decodificador).

Sin embargo, hay una limitación. Si bien la memoria máxima requerida para la matriz QKT \mathbf{QK}^T QKT se reduce significativamente, mantener la caché de clave-valor en memoria puede volverse muy costoso en términos de memoria para secuencias de entrada largas o chats de múltiples turnos. Recuerde que la caché de clave-valor debe almacenar los vectores de clave-valor para todos los vectores de entrada anteriores xi, para i∈{1,…,c−1} \mathbf{x}_i \text{, para } i \in \{1, \ldots, c – 1\} xi​, para i∈{1,…,c−1} para todas las capas de autoatención y todas las cabezas de atención.

Calculemos la cantidad de valores flotantes que deben almacenarse en la caché de clave-valor para el LLM bigcode/octocoder que usamos anteriormente. La cantidad de valores flotantes asciende a dos veces la longitud de la secuencia multiplicada por el número de cabezas de atención multiplicado por la dimensión de la cabeza de atención y por el número de capas. Calculando esto para nuestro LLM con una longitud hipotética de secuencia de entrada de 16000, tenemos:

config = modelo.config
2 * 16_000 * config.n_capa * config.n_cabecera * config.n_embd // config.n_cabecera

Salida:

7864320000

¡Aproximadamente 8 mil millones de valores de punto flotante! Almacenar 8 mil millones de valores de punto flotante en precisión float16 requiere alrededor de 15 GB de RAM, ¡que es aproximadamente la mitad de los propios pesos del modelo! Los investigadores han propuesto dos métodos que permiten reducir significativamente el costo de memoria de almacenar la caché de clave-valor:

    1. Atención Multi-Consulta (MQA)

Atención Multi-Consulta fue propuesto en el artículo Decodificación Rápida del Transformador de Noam Shazeer: ¡Un solo cabezal de escritura es todo lo que necesitas! Como dice el título, Noam descubrió que en lugar de usar pesos de proyección de clave-valor n_cabecera, se puede usar un solo par de pesos de proyección de cabeza-valor que se comparte en todas las cabezas de atención sin que el rendimiento del modelo se degrade significativamente.

Al usar un solo par de pesos de proyección de cabeza-valor, los vectores clave-valor ki,vi \mathbf{k}_i, \mathbf{v}_i ki​,vi​ deben ser idénticos en todas las cabezas de atención, lo que a su vez significa que solo necesitamos almacenar 1 par de proyección de clave-valor en la caché en lugar de n_cabecera.

Dado que la mayoría de los modelos de lenguaje utilizan entre 20 y 100 cabezas de atención, MQA reduce significativamente el consumo de memoria de la caché de clave-valor. Para el modelo de lenguaje utilizado en este cuaderno, podríamos reducir el consumo de memoria requerido de 15 GB a menos de 400 MB con una longitud de secuencia de entrada de 16000.

Además de ahorrar memoria, MQA también mejora la eficiencia computacional como se explica a continuación. En la decodificación auto-regresiva, se deben volver a cargar vectores clave-valor grandes, concatenarlos con el par de vectores clave-valor actual y luego alimentarlos a la computación qcKT \mathbf{q}_c\mathbf{K}^T qc​KT en cada paso. Para la decodificación auto-regresiva, el ancho de banda de memoria requerido para la recarga constante puede convertirse en un cuello de botella de tiempo grave. Al reducir el tamaño de los vectores clave-valor, se accede a menos memoria, lo que reduce el cuello de botella del ancho de banda de memoria. Para más detalles, por favor consulte el artículo de Noam.

La parte importante de entender aquí es que reducir el número de cabezas de atención de clave-valor a 1 solo tiene sentido si se utiliza una caché de clave-valor. El consumo máximo de memoria del modelo para un pase hacia adelante único sin caché de clave-valor permanece sin cambios, ya que cada cabeza de atención todavía tiene un vector de consulta único para que cada cabeza de atención todavía tenga una matriz QKT \mathbf{QK}^T QKT diferente.

MQA ha sido ampliamente adoptado por la comunidad y ahora es utilizado por muchos de los modelos de lenguaje más populares:

  • Falcon
  • PaLM
  • MPT
  • BLOOM

También, el punto de control utilizado en este cuaderno – bigcode/octocoder – utiliza MQA.

    1. Atención Agrupada de Consulta (GQA)

Atención Agrupada de Consulta, como propuso Ainslie et al. de Google, descubrió que el uso de MQA a menudo puede llevar a la degradación de calidad en comparación con el uso de proyecciones de cabeza de clave-valor múltiple sin modificaciones. El artículo argumenta que se puede mantener un mayor rendimiento del modelo al reducir menos drásticamente el número de pesos de proyección de cabeza de consulta. En lugar de usar un solo peso de proyección de clave-valor, se deben usar n < n_cabecera pesos de proyección de clave-valor. Al elegir n con un valor significativamente menor que n_cabecera, como 2, 4 o 8, se pueden mantener casi todas las ganancias de memoria y velocidad de MQA mientras se sacrifica menos capacidad del modelo y, por lo tanto, posiblemente menos rendimiento.

Además, los autores de GQA descubrieron que se pueden mejorar los puntos de control existentes para tener una arquitectura GQA con tan solo el 5% de la computación original de pre-entrenamiento. Si bien el 5% de la computación original de pre-entrenamiento aún puede ser una cantidad masiva, el entrenamiento adicional de GQA permite que los puntos de control existentes sean útiles para secuencias de entrada más largas.

GQA se propuso recientemente, por lo que hay menos adopción en el momento de escribir este cuaderno. La aplicación más notable de GQA es Llama-v2.

Como conclusión, se recomienda encarecidamente utilizar GQA o MQA si el LLM se implementa con decodificación auto-regresiva y se requiere manejar secuencias de entrada grandes, como es el caso, por ejemplo, en un chat.

Conclusión

La comunidad de investigación está constantemente buscando nuevas formas ingeniosas de acelerar el tiempo de inferencia para LLMs cada vez más grandes. Como ejemplo, una dirección de investigación prometedora es la decodificación especulativa, donde los “tokens fáciles” son generados por modelos de lenguaje más pequeños y rápidos, y solo los “tokens difíciles” son generados por el propio LLM. Entrar en más detalles está fuera del alcance de este cuaderno, pero se puede leer más al respecto en esta interesante publicación de blog.

La razón por la cual LLMs masivos como GPT3/4, Llama-2-70b, Claude, PaLM pueden ejecutarse tan rápidamente en interfaces de chat como Hugging Face Chat o ChatGPT se debe en gran parte a las mejoras mencionadas anteriormente en precisión, algoritmos y arquitectura. A medida que avancemos, los aceleradores como GPUs, TPUs, etc. se volverán cada vez más rápidos y permitirán más memoria, pero siempre se debe asegurar utilizar los mejores algoritmos y arquitecturas disponibles para obtener el máximo rendimiento 🤗

We will continue to update Zepes; if you have any questions or suggestions, please contact us!

Share:

Was this article helpful?

93 out of 132 found this helpful

Discover more

Inteligencia Artificial

Entendiendo Flash-Atención y Flash-Atención-2 El camino para ampliar la longitud del contexto de los modelos de lenguaje

Escalar el contexto de los grandes modelos de lenguaje (LLMs) sigue siendo uno de los mayores desafíos para ampliar e...

Inteligencia Artificial

Conoce a Prismer Un modelo de visión-lenguaje de código abierto con un conjunto de expertos.

Varios modelos recientes de visión y lenguaje han demostrado notables habilidades de generación multimodal. Pero típi...

Inteligencia Artificial

Investigadores de UCLA y CMU presentan Stormer Una red neuronal Transformadora escalable para una pronóstico meteorológico de mediano alcance hábil y confiable.

Uno de los principales problemas que enfrenta la ciencia y la sociedad hoy en día es la predicción del tiempo. La pre...

Inteligencia Artificial

Tencent AI Lab presenta GPT4Video un modelo de lenguaje grande multimodal unificado para la comprensión de instrucciones y la generación consciente de seguridad.

El problema de comprensión y generación de videos ha sido abordado por investigadores del Laboratorio de IA de Tencen...

Inteligencia Artificial

Descubre RAGs una aplicación de Streamlit que te permite crear una tubería RAG a partir de una fuente de datos utilizando lenguaje natural.

Los GPT se destacan en inteligencia artificial en cuanto a tareas de NLP. No obstante, las tuberías construidas e imp...