# Optimiser les LLM pour la vitesse et la mémoire

Les grands modèles de langage (LLM), tels que GPT3/4, [Falcon](https://huggingface.co/tiiuae/falcon-40b) et [Llama](https://huggingface.co/meta-llama/Llama-2-70b-hf), progressent rapidement dans leur capacité à traiter des tâches centrées sur l'humain, s'imposant comme des outils essentiels dans les industries modernes fondées sur la connaissance. Le déploiement de ces modèles dans des tâches réelles reste toutefois difficile :

- Pour afficher des capacités de compréhension et de génération de texte proches de celles de l’humain, les LLM doivent actuellement être composés de milliards de paramètres (voir [Kaplan et al](https://huggingface.co/papers/2001.08361), [Wei et. al](https://huggingface.co/papers/2206.07682)). Cela amplifie par conséquent les exigences en mémoire pour l’inférence.
- Dans de nombreuses tâches réelles, les LLM doivent recevoir des informations contextuelles étendues. Cela nécessite que le modèle soit capable de gérer des séquences d'entrée très longues lors de l'inférence.

Le cœur de ces défis réside dans l'augmentation des capacités de calcul et de mémoire des LLM, en particulier lors du traitement de séquences d'entrée étendues.

Dans ce guide, nous passons en revue les techniques efficaces pour le déploiement efficace des LLM :

1. **Précision réduite :** Les recherches ont montré que le fonctionnement à une précision numérique réduite, à savoir [8 bits et 4 bits](./main_classes/quantization), peut offrir des avantages de calcul sans dégradation significative des performances du modèle.

2. **Flash Attention :** Flash Attention est une variante de l'algorithme d'attention qui offre non seulement une approche plus efficace en termes de mémoire, mais également une efficacité accrue grâce à une utilisation optimisée de la mémoire GPU.

3. **Innovations architecturales :** Étant donné que les LLM sont toujours déployés de la même manière lors de l'inférence, à savoir la génération de texte autorégressive avec un contexte d'entrée long, des architectures de modèles spécialisées ont été proposées permettant une inférence plus efficace. Les avancées les plus importantes en matière d'architecture de modèles sont [Alibi](https://huggingface.co/papers/2108.12409), [les embeddings rotationnels](https://huggingface.co/papers/2104.09864), [l'attention multi-requête (MQA)](https://huggingface.co/papers/1911.02150) et [l'attention groupée par requête (GQA)](https://huggingface.co/papers/2305.13245).

Tout au long de ce guide, nous proposons une analyse de la génération auto-régressive du point de vue des tenseurs. Nous examinons les avantages et les inconvénients de l'adoption d'une précision inférieure, nous explorons de manière approfondie les derniers algorithmes d'attention et nous discutons des architectures de LLM améliorées. En procédant ainsi, nous exécutons des exemples pratiques illustrant chacune des améliorations de fonctionnalités.

## 1. Précision réduite

Les besoins en mémoire des LLM se comprennent le mieux en considérant le LLM comme un ensemble de matrices et de vecteurs de poids, et les entrées textuelles comme une séquence de vecteurs. Dans la suite, le terme *poids* désignera l'ensemble des matrices et vecteurs de poids du modèle.

Au moment de la rédaction de ce guide, les LLM comptent au moins quelques milliards de paramètres. Chaque paramètre est ainsi constitué d'un nombre décimal, par exemple `4.5689`, généralement stocké au format [float32](https://en.wikipedia.org/wiki/Single-precision_floating-point_format), [bfloat16](https://en.wikipedia.org/wiki/Bfloat16_floating-point_format) ou [float16](https://en.wikipedia.org/wiki/Half-precision_floating-point_format). Cela nous permet de calculer facilement la quantité de mémoire nécessaire pour charger le LLM en mémoire :

> *Charger les poids d’un modèle de X milliards de paramètres nécessite environ 4 \* X Go de VRAM en précision float32*

Aujourd'hui, les modèles sont toutefois rarement entraînés en précision float32 complète, mais généralement en précision bfloat16 ou, moins fréquemment, en précision float16. La règle empirique devient donc :

> *Charger les poids d’un modèle de X milliards de paramètres nécessite environ 2 \* X Go de VRAM en précision bfloat16/float16*

Pour des entrées textuelles courtes (moins de 1024 jetons), l'exigence mémoire pour l'inférence est très largement dominée par l'exigence mémoire nécessaire au chargement des poids. Par conséquent, pour l'instant, supposons que l'exigence mémoire pour l'inférence est égale à l'exigence mémoire pour charger le modèle dans la VRAM du GPU.

Voici quelques exemples de la quantité approximative de VRAM nécessaire pour charger un modèle en bfloat16 :

- **GPT3** nécessite 2 \* 175 Go = **350 Go** de VRAM
- [**Bloom**](https://huggingface.co/bigscience/bloom) nécessite 2 \* 176 Go = **352 Go** de VRAM
- [**Llama-2-70b**](https://huggingface.co/meta-llama/Llama-2-70b-hf) nécessite 2 \* 70 Go = **140 Go** de VRAM
- [**Falcon-40b**](https://huggingface.co/tiiuae/falcon-40b) nécessite 2 \* 40 Go = **80 Go** de VRAM
- [**MPT-30b**](https://huggingface.co/mosaicml/mpt-30b) nécessite 2 \* 30 Go = **60 Go** de VRAM
- [**bigcode/starcoder**](https://huggingface.co/bigcode/starcoder) nécessite 2 \* 15,5 = **31 Go** de VRAM

Au moment de la rédaction de ce document, la puce GPU la plus puissante du marché est l’A100 & H100, offrant 80 Go de VRAM. La plupart des modèles listés précédemment nécessitent plus de 80 Go rien que pour être chargés et exigent donc obligatoirement le [tensor parallelism](./perf_train_gpu_many#tensor-parallelism) et/ou le [pipeline parallelism](./perf_train_gpu_many#pipeline-parallelism).

🤗 Transformers prend désormais en charge le parallélisme de tenseurs pour les modèles pris en charge disposant de `base_tp_plan` dans leurs classes de configuration respectives. En savoir plus sur le parallélisme de tenseurs [ici](./perf_train_gpu_many#tensor-parallelism). De plus, si vous souhaitez écrire des modèles de manière compatible avec le parallélisme de tenseurs, n'hésitez pas à consulter [la bibliothèque text-generation-inference](https://github.com/huggingface/text-generation-inference/tree/main/server/text_generation_server/models/custom_modeling).

Le parallélisme de pipeline naïf est pris en charge nativement. Pour cela, il suffit de charger le modèle avec `device="auto"`, ce qui place automatiquement les différentes couches sur les GPU disponibles, comme expliqué [ici](https://huggingface.co/docs/accelerate/v0.22.0/en/concept_guides/big_model_inference). Toutefois, bien qu’efficace, ce parallélisme de pipeline naïf ne résout pas le problème d’inactivité des GPU. Pour cela, un parallélisme de pipeline plus avancé est nécessaire, comme expliqué [ici](./perf_train_gpu_many#pipeline-parallelism).

Si vous avez accès à un nœud 8 x 80 Go A100, vous pouvez charger BLOOM comme suit

```bash
!pip install transformers accelerate bitsandbytes optimum
```

```python
from transformers import AutoModelForCausalLM

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

En utilisant `device_map="auto"`, les couches d'attention seraient réparties de manière égale sur tous les GPU disponibles.

Dans ce guide, nous utiliserons [bigcode/octocoder](https://huggingface.co/bigcode/octocoder) car il peut être exécuté sur une puce de GPU A100 de 40 Go. Notez que toutes les optimisations de mémoire et de vitesse que nous appliquerons par la suite sont tout aussi applicables aux modèles nécessitant un parallélisme de modèle ou de tenseurs.

Le modèle étant chargé en précision bfloat16, selon notre règle empirique ci-dessus, nous devrions nous attendre à un besoin de mémoire d’environ 31 Go de VRAM pour exécuter l’inférence avec `bigcode/octocoder`. Essayons.

Nous chargeons d'abord le modèle et le tokenizer, puis nous passons les deux à l'objet [pipeline](./main_classes/pipelines) de Transformers.

```python
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
import torch

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

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

```python
prompt = "Question: Please write a function in Python that transforms bytes to Giga bytes.\n\nAnswer:"

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

**Sortie** :

```text
Here is a Python function that transforms bytes to Giga bytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single
```

Parfait, nous pouvons maintenant utiliser directement le résultat pour convertir des octets en gigaoctets.

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

Appelons [`torch.accelerator.memory.max_memory_allocated`](https://docs.pytorch.org/docs/main/generated/torch.accelerator.memory.max_memory_allocated.html) pour mesurer l'allocation mémoire maximale de l'accélérateur.

```python
bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())
```

**Sortie** :

```text
29.0260648727417
```

Assez proche de notre estimation approximative ! On peut voir que le nombre n’est pas tout à fait exact, car le passage des octets aux kilo-octets nécessite une multiplication par 1024 plutôt que par 1000. La formule approximative peut donc aussi être comprise comme un calcul de « au plus X Go ». Notez que si nous avions tenté d’exécuter le modèle en précision float32 complète, il aurait fallu pas moins de 64 Go de VRAM.

> Presque tous les modèles sont désormais entraînés en bfloat16, il n'y a donc aucune raison d'exécuter le modèle en précision float32 complète si [ votre GPU prend en charge bfloat16 ](https://discuss.pytorch.org/t/bfloat16-native-support/117155/5). Float32 ne donnera pas de meilleurs résultats d'inférence que la précision utilisée pour entraîner le modèle.

Si vous ne savez pas dans quel format les poids du modèle sont stockés sur le Hub, vous pouvez toujours consulter la configuration du point de contrôle sous `"dtype"`, *par exemple* [ici](https://huggingface.co/meta-llama/Llama-2-7b-hf/blob/6fdf2e60f86ff2481f2241aaee459f85b5b0bbb9/config.json#L21). Il est recommandé de définir le modèle sur le même type de précision que celui indiqué dans la configuration lors du chargement avec `from_pretrained(..., dtype=...)`, sauf si le type d'origine est float32, auquel cas on peut utiliser `float16` ou `bfloat16` pour l'inférence.

Définissons une fonction `flush(...)` pour libérer toute la mémoire allouée afin de pouvoir mesurer précisément la mémoire GPU allouée au pic.

```python
del pipe
del model

import gc
import torch

def flush():
  gc.collect()
  torch.accelerator.empty_cache()
  torch.accelerator.reset_peak_memory_stats()
```

Appelons-le maintenant pour l'expérience suivante.

```python
flush()
```

À partir de la bibliothèque Accelerate, vous pouvez également utiliser une méthode utilitaire indépendante du dispositif appelée [release_memory](https://github.com/huggingface/accelerate/blob/29be4788629b772a3b722076e433b5b3b5c85da3/src/accelerate/utils/memory.py#L63), qui prend en compte divers backends matériels tels que XPU, MLU, NPU, MPS, et d'autres.

```python
from accelerate.utils import release_memory
# ...

release_memory(model)
```

Et si votre GPU ne dispose pas de 32 Go de VRAM ? Il a été démontré que les poids du modèle peuvent être quantifiés en 8 bits ou 4 bits sans perte de performance significative (voir [Dettmers et al.](https://huggingface.co/papers/2208.07339)). Le modèle peut même être quantifié en 3 ou 2 bits avec une perte de performance acceptable, comme le montre la récente [publication GPTQ](https://huggingface.co/papers/2210.17323) 🤯.

Sans entrer dans trop de détails, les schémas de quantisation visent à réduire la précision des poids tout en essayant de conserver les résultats d'inférence du modèle aussi précis que possible (*autrement dit* aussi proches que possible de bfloat16). Il est à noter que la quantisation fonctionne particulièrement bien pour la génération de texte, car il ne s'agit que de choisir le *ensemble des jetons suivants les plus probables* et que l'on s'en soucie peu des valeurs exactes de la distribution des *logits* du jeton suivant. L'essentiel est que la distribution des *logits* du jeton suivant reste globalement la même, afin qu'une opération `argmax` ou `topk` donne les mêmes résultats.

Il existe diverses techniques de quantification, que nous ne détaillerons pas ici, mais en général, toutes les techniques de quantification fonctionnent de la manière suivante :

- 1. Quantiser tous les poids à la précision cible
- 2. Charger les poids quantifiés et transmettre la séquence d’entrée de vecteurs en précision bfloat16
- 3. Déquantifier dynamiquement les poids en bfloat16 afin d'effectuer le calcul avec leurs vecteurs d'entrée en précision bfloat16

En résumé, cela signifie que les multiplications *matrice de poids-entrées*, où $X$ désigne les *entrées*, $W$ une matrice de poids et $Y$ la sortie :

$$ Y = X * W $$

sont modifiées en

$$ Y = X * \text{dequantize}(W) $$

pour chaque multiplication de matrices. La déquantification et la re-quantification sont effectuées séquentiellement pour toutes les matrices de poids lorsque les entrées traversent le graphe du réseau.

Par conséquent, le temps d’inférence n’est souvent **pas** réduit lors de l’utilisation de poids quantifiés, mais augmente au contraire. Assez de théorie, passons à la pratique ! Pour quantiser les poids avec Transformers, vous devez vous assurer que la bibliothèque [`bitsandbytes`](https://github.com/bitsandbytes-foundation/bitsandbytes) est installée.

```bash
!pip install bitsandbytes
```

Nous pouvons ensuite charger des modèles en quantification 8 bits en ajoutant simplement le drapeau `load_in_8bit=True` à `from_pretrained`.

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

Maintenant, exécutons à nouveau notre exemple et mesurons l'utilisation de la mémoire.

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

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

**Sortie** :

```text
Here is a Python function that transforms bytes to Giga bytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single
```

Parfait, nous obtenons le même résultat qu'auparavant, donc aucune perte de précision ! Voyons maintenant combien de mémoire a été utilisée cette fois.

```python
bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())
```

**Sortie** :

```text
15.219234466552734
```

Beaucoup moins ! Nous sommes passés à un peu plus de 15 Go, ce qui permet donc d’exécuter ce modèle sur des GPU grand public comme le 4090. Nous observons un très bel gain en efficacité mémoire et une dégradation quasi nulle de la sortie du modèle. Cependant, nous pouvons également noter un léger ralentissement lors de l’inférence.

Nous supprimons les modèles et vidons à nouveau la mémoire.

```python
del model
del pipe
```

```python
flush()
```

Voyons quelle consommation mémoire GPU de pointe la quantification 4 bits permet d’obtenir. La quantification du modèle en 4 bits se fait avec la même API que précédemment, en passant cette fois `load_in_4bit=True` au lieu de `load_in_8bit=True`.

```python
model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", quantization_config=BitsAndBytesConfig(load_in_4bit=True), pad_token_id=0)

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

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

**Sortie** :

```text
Here is a Python function that transforms bytes to Giga bytes:\n\n```\ndef bytes_to_gigabytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single argument
```

Nous obtenons presque le même texte de sortie qu’auparavant – seul le `python` manque juste avant l’exemple de code. Voyons combien de mémoire a été nécessaire.

```python
bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())
```

**Sortie** :

```text
9.543574333190918
```

Seulement 9,5 Go ! Ce n'est vraiment pas beaucoup pour un modèle de plus de 15 milliards de paramètres.

Bien que nous observions une dégradation très faible de la précision pour notre modèle ici, la quantification 4 bits peut en pratique souvent conduire à des résultats différents par rapport à la quantification 8 bits ou à une inférence complète en `bfloat16`. C’est à l’utilisateur de tester.

Notez également que l’inférence était ici à nouveau un peu plus lente par rapport à la quantification 8 bits, ce qui est dû à la méthode de quantification plus agressive utilisée pour la quantification 4 bits, entraînant un temps de traitement plus long pour $\text{quantize}$ et $\text{dequantize}$ lors de l’inférence.

```python
del model
del pipe
```

```python
flush()
```

Dans l’ensemble, nous avons constaté que l’exécution d’OctoCoder en précision 8 bits réduisait la VRAM GPU requise de 32 Go à seulement 15 Go, et que l’exécution du modèle en précision 4 bits réduisait encore la VRAM GPU requise à un peu plus de 9 Go.

La quantification 4 bits permet d’exécuter le modèle sur des GPU tels que RTX3090, V100 et T4, qui sont assez accessibles pour la plupart des personnes.

Pour plus d’informations sur la quantification et pour voir comment quantiser des modèles afin de nécessiter encore moins de mémoire VRAM GPU que la quantification 4 bits, nous recommandons d’examiner l’implémentation [`GPT-QModel`](./quantization/gptq).

> En conclusion, il est important de rappeler que la quantification des modèles échange une efficacité mémoire accrue contre la précision et, dans certains cas, le temps d'inférence.

Si la mémoire GPU n’est pas une contrainte pour votre cas d’usage, il n’y a souvent pas besoin d’envisager la quantification. Cependant, de nombreuses GPU ne peuvent tout simplement pas exécuter des LLM sans méthodes de quantification, et dans ce cas, les schémas de quantification sur 4 bits et 8 bits sont des outils extrêmement utiles.

Pour des informations d’utilisation plus détaillées, nous vous recommandons vivement de consulter la [documentation sur la quantification de Transformers](./quantization/overview). Passons maintenant en revue les moyens d’améliorer l’efficacité computationnelle et mémoire grâce à de meilleurs algorithmes et à une architecture de modèle optimisée.

## 2. Flash Attention

Les LLM les plus performants actuels partagent plus ou moins la même architecture fondamentale, composée de couches feed-forward, de couches d'activation, de couches de normalisation par couche et, surtout, de couches d'auto-attention.

Les couches d'auto-attention sont au cœur des grands modèles de langage (LLM), car elles permettent au modèle de comprendre les relations contextuelles entre les jetons d'entrée. Toutefois, la consommation mémoire GPU maximale des couches d'auto-attention croît de manière *quadratique*, tant en complexité de calcul qu'en complexité mémoire, avec le nombre de jetons d'entrée (également appelé *longueur de séquence*), que nous notons $N$ dans la suite. Bien que cela ne soit guère perceptible pour les séquences d'entrée courtes (jusqu'à 1000 jetons d'entrée), cela devient un problème sérieux pour les séquences d'entrée plus longues (autour de 16000 jetons d'entrée).

Examinons cela de plus près. La formule permettant de calculer la sortie $\mathbf{O}$ d'une couche d'auto-attention pour une entrée $\mathbf{X}$ de longueur $N$ est la suivante :

$$ \textbf{O} = \text{Attn}(\mathbf{X}) = \mathbf{V} \times \text{Softmax}(\mathbf{QK}^T) \text{ avec } \mathbf{Q} = \mathbf{W}_q \mathbf{X}, \mathbf{V} = \mathbf{W}_v \mathbf{X}, \mathbf{K} = \mathbf{W}_k \mathbf{X} $$

$\mathbf{X} = (\mathbf{x}_1, ... \mathbf{x}_{N})$ est donc la séquence d'entrée de la couche d'attention. Les projections $\mathbf{Q}$ et $\mathbf{K}$ seront chacune composées de $N$ vecteurs, ce qui donne à $\mathbf{QK}^T$ une taille de $N^2$ .

Les LLM possèdent généralement plusieurs têtes d'attention, ce qui implique l'exécution de plusieurs calculs d'auto-attention en parallèle. En supposant que le LLM dispose de 40 têtes d'attention et fonctionne en précision bfloat16, nous pouvons calculer la quantité de mémoire nécessaire pour stocker les matrices $\mathbf{QK^T}$, soit $40 * 2 * N^2$ octets. Pour $N=1000$, il ne faut que 50 Mo de VRAM environ, mais pour $N=16000$, il en faudrait 19 Go, et pour $N=100,000$, il en faudrait près de 1 To rien que pour stocker les matrices $\mathbf{QK}^T$.

En résumé, l'algorithme d'auto-attention par défaut devient rapidement prohibitif en termes de mémoire pour les contextes d'entrée de grande taille.

À mesure que les LLM progressent en compréhension et en génération de texte, ils sont appliqués à des tâches de plus en plus complexes. Alors que les modèles traitaient autrefois la traduction ou la résumés de quelques phrases, ils gèrent désormais des pages entières, exigeant la capacité de traiter des longueurs d'entrée étendues.

Comment éliminer les besoins en mémoire exorbitants pour les longueurs d’entrée importantes ? Il nous faut une nouvelle façon de calculer le mécanisme d’auto-attention qui se passe de la matrice $\mathbf{QK}^T$. [Tri Dao et al.](https://huggingface.co/papers/2205.14135) ont développé exactement ce nouvel algorithme et l’ont appelé **Flash Attention**.

En résumé, Flash Attention découpe le calcul $\mathbf{V} \times \text{Softmax}(\mathbf{QK}^T)$ et calcule à la place de plus petits morceaux de la sortie en itérant sur plusieurs étapes de calcul du softmax :

$$ \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{ pour plusieurs itérations } i, j $$

où $s^a_{ij}$ et $s^b_{ij}$ sont des statistiques de normalisation softmax qui doivent être recalculées pour chaque $i$ et $j$ .

Veuillez noter que l’ensemble de Flash Attention est un peu plus complexe et est considérablement simplifié ici, car entrer dans trop de détails dépasse le cadre de ce guide. Le lecteur est invité à consulter le bien rédigé [article Flash Attention](https://huggingface.co/papers/2205.14135) pour plus de détails.

L'essentiel à retenir ici est le suivant :

> En suivant les statistiques de normalisation softmax et en utilisant des mathématiques astucieuses, Flash Attention produit des sorties **numériquement identiques** par rapport à la couche d'auto-attention par défaut, avec un coût mémoire qui n'augmente que linéairement avec $N$ .

En examinant la formule, on pourrait intuitivement penser que Flash Attention est beaucoup plus lent que la formule d'auto-attention par défaut, car davantage de calculs sont nécessaires. En effet, Flash Attention requiert plus de FLOPs que l'attention normale, car les statistiques de normalisation du softmax doivent être constamment recalculées (voir l'[article](https://huggingface.co/papers/2205.14135) pour plus de détails si vous êtes intéressé)

> Cependant, Flash Attention est beaucoup plus rapide lors de l’inférence par rapport à l’attention par défaut, grâce à sa capacité à réduire considérablement les exigences sur la mémoire à large bande passante mais plus lente du GPU (VRAM), en se concentrant plutôt sur la mémoire sur puce plus rapide (SRAM).

Concrètement, Flash Attention s'assure que toutes les opérations intermédiaires d'écriture et de lecture peuvent être réalisées à l'aide de la mémoire SRAM *on-chip* rapide, plutôt que d'avoir à accéder à la mémoire VRAM plus lente pour calculer le vecteur de sortie $\mathbf{O}$ .

En pratique, il n'y a actuellement aucune raison de **ne pas** utiliser Flash Attention s'il est disponible. L'algorithme produit des résultats mathématiquement identiques, tout en étant à la fois plus rapide et plus efficace en termes de mémoire.

## 3. Innovations architecturales

Jusqu'à présent, nous avons examiné comment améliorer l'efficacité computationnelle et mémoire en :

- Casting des poids vers un format de précision inférieure
- Remplacer l'algorithme d'auto-attention par une version plus efficace en termes de mémoire et de calcul

Examinons maintenant comment modifier l'architecture d'un LLM pour qu'elle soit la plus efficace et efficiente possible pour les tâches nécessitant des entrées textuelles longues, *par exemple* :

- Questions-réponses augmentées par récupération,
- Résumé,
- Chat

Notez que *chat* exige non seulement que le LLM gère des entrées textuelles longues, mais aussi qu’il soit capable de traiter efficacement le dialogue aller-retour entre l’utilisateur et l’assistant (comme ChatGPT).

Une fois l'entraînement terminé, il est difficile de modifier l'architecture fondamentale du LLM. Il est donc important de réfléchir en amont aux tâches du LLM et d'optimiser en conséquence l'architecture du modèle. Deux composants de l'architecture du modèle deviennent rapidement des goulets d'étranglement en termes de mémoire et/ou de performances pour les séquences d'entrée de grande taille.

- Les embeddings positionnels
- Le cache clé-valeur

Passons en revue chaque composant plus en détail

### 3.1 Amélioration des embeddings positionnels des LLM

L'auto-attention met chaque jeton en relation avec les jetons des autres. À titre d'exemple, la matrice $\text{Softmax}(\mathbf{QK}^T)$ de la séquence d'entrée de texte *"Hello", "I", "love", "you"* pourrait ressembler à ce qui suit :

![](/blog/assets/163_optimize_llm/self_attn_tokens.png)

Chaque jeton de mot reçoit une masse de probabilité indiquant dans quelle mesure il porte son attention sur tous les autres jetons de mots, et est donc mis en relation avec l’ensemble des autres jetons. Par exemple, le mot *« love »* porte son attention sur le mot *« Hello »* à 5 %, sur *« I »* à 30 % et sur lui-même à 65 %.

Un LLM basé sur l'auto-attention, mais sans embeddings de position, aurait de grandes difficultés à comprendre les positions relatives des entrées textuelles. En effet, le score de probabilité calculé par $\mathbf{QK}^T$ associe chaque jeton de mot à chaque autre jeton de mot en $O(1)$ calculs, indépendamment de leur distance positionnelle relative. Par conséquent, pour un LLM sans embeddings de position, chaque jeton semble être à la même distance de tous les autres jetons, *par exemple*, distinguer *"Hello I love you"* de *"You love I hello"* serait très difficile.

Pour que le LLM comprenne l’ordre des phrases, un *indice* supplémentaire est nécessaire et est généralement appliqué sous la forme de *encodages positionnels* (ou également appelés *embeddings positionnels*). Les encodages positionnels encodent la position de chaque jeton dans une représentation numérique que le LLM peut exploiter pour mieux comprendre l’ordre des phrases.

Les auteurs de l'article [*Attention Is All You Need*](https://huggingface.co/papers/1706.03762) ont introduit des embeddings positionnels sinusoïdaux $\mathbf{P} = \mathbf{p}_1, \ldots, \mathbf{p}_N$ . où chaque vecteur $\mathbf{p}_i$ est calculé comme une fonction sinusoïdale de sa position $i$ . Les encodages positionnels sont ensuite simplement ajoutés aux vecteurs de la séquence d'entrée $\mathbf{\hat{X}} = \mathbf{\hat{x}}_1, \ldots, \mathbf{\hat{x}}_N$ = $\mathbf{x}_1 + \mathbf{p}_1, \ldots, \mathbf{x}_N + \mathbf{p}_N$ , ce qui permet au modèle d'apprendre mieux l'ordre des phrases.

Plutôt que d’utiliser des embeddings de position fixes, d’autres (tels que [Devlin et al.](https://huggingface.co/papers/1810.04805)) ont utilisé des encodages positionnels appris, pour lesquels les embeddings de position $\mathbf{P}$ sont appris pendant l’entraînement.

Les embeddings de position sinusoïdaux et appris étaient autrefois les méthodes prédominantes pour encoder l'ordre des phrases dans les LLM, mais plusieurs problèmes liés à ces encodages positionnels ont été identifiés :

  1. Les embeddings de position sinusoïdaux et appris sont tous deux des embeddings de position absolus, *c.-à-d.* qu’ils encodent un embedding unique pour chaque identifiant de position : $0, \ldots, N$ . Comme l’ont montré [Huang et al.](https://huggingface.co/papers/2009.13658) et [Su et al.](https://huggingface.co/papers/2104.09864), les embeddings de position absolus entraînent de mauvaises performances des LLM sur des entrées textuelles longues. Pour les entrées textuelles longues, il est avantageux que le modèle apprenne la distance de position relative entre les jetons d’entrée plutôt que leur position absolue.
  2. Lors de l'utilisation d'embeddings de position appris, le LLM doit être entraîné sur une longueur d'entrée fixe $N$, ce qui rend difficile l'extrapolation à une longueur d'entrée supérieure à celle utilisée pour l'entraînement.

Récemment, les plongements positionnels relatifs, capables de résoudre les problèmes mentionnés ci-dessus, sont devenus plus populaires, notamment :

- [Embedding de position rotatif (RoPE)](https://huggingface.co/papers/2104.09864)
- [ALiBi](https://huggingface.co/papers/2108.12409)

À la fois *RoPE* et *ALiBi* soutiennent qu'il est préférable d'indiquer l'ordre des phrases au LLM directement dans l'algorithme d'auto-attention, car c'est là que les jetons de mots sont mis en relation les uns avec les autres. Plus précisément, l'ordre des phrases doit être indiqué en modifiant le calcul de $\mathbf{QK}^T$.

Sans entrer dans trop de détails, *RoPE* note que les informations de position peuvent être encodées dans les paires requête-clé, *par exemple* $\mathbf{q}_i$ et $\mathbf{x}_j$ en faisant pivoter chaque vecteur d'un angle $\theta * i$ et $\theta * j$ respectivement, où $i, j$ décrivent la position de chaque vecteur dans la phrase :

$$ \mathbf{\hat{q}}_i^T \mathbf{\hat{x}}_j = \mathbf{{q}}_i^T \mathbf{R}_{\theta, i -j} \mathbf{{x}}_j. $$

$\mathbf{R}_{\theta, i - j}$ représente donc une matrice de rotation. $\theta$ n'est *pas* appris pendant l'entraînement, mais est fixé à une valeur prédéfinie qui dépend de la longueur maximale de la séquence d'entrée lors de l'entraînement.

> En procédant ainsi, le score de probabilité entre $\mathbf{q}_i$ et $\mathbf{q}_j$ n'est affecté que si $i \ne j$ et ne dépend que de la distance relative $i - j$, indépendamment des positions spécifiques $i$ et $j$ de chaque vecteur.

*RoPE* est utilisé dans plusieurs des LLM les plus importants d'aujourd'hui, tels que :

- [**Falcon**](https://huggingface.co/tiiuae/falcon-40b)
- [**Llama**](https://huggingface.co/papers/2302.13971)
- [**PaLM**](https://huggingface.co/papers/2204.02311)

En alternative, *ALiBi* propose un schéma d'encodage de position relative beaucoup plus simple. La distance relative entre les jetons d'entrée est ajoutée, sous forme d'entier négatif mis à l'échelle par une valeur prédéfinie `m`, à chaque entrée requête-clé de la matrice $\mathbf{QK}^T$, juste avant le calcul du softmax.

![](/blog/assets/163_optimize_llm/alibi.png)

Comme le montre l’article [ALiBi](https://huggingface.co/papers/2108.12409), ce codage positionnel relatif simple permet au modèle de conserver de bonnes performances même sur des séquences d’entrée de texte très longues.

*ALiBi* est utilisé dans plusieurs des LLM les plus importants d’aujourd’hui, tels que :

- [**MPT**](https://huggingface.co/mosaicml/mpt-30b)
- [**BLOOM**](https://huggingface.co/bigscience/bloom)

Les encodages de position *RoPE* et *ALiBi* peuvent tous deux extrapoler vers des longueurs d'entrée non vues lors de l'entraînement, bien qu'il ait été démontré que l'extrapolation fonctionne beaucoup mieux nativement pour *ALiBi* que pour *RoPE*. Pour ALiBi, il suffit d'augmenter les valeurs de la matrice de position triangulaire inférieure pour qu'elles correspondent à la longueur de la séquence d'entrée. Pour *RoPE*, conserver la même valeur de $\theta$ que celle utilisée lors de l'entraînement conduit à de mauvais résultats lorsque les entrées textuelles sont beaucoup plus longues que celles vues pendant l'entraînement, *cf* [Press et al.](https://huggingface.co/papers/2108.12409). Cependant, la communauté a trouvé quelques astuces efficaces qui adaptent $\theta$, permettant ainsi aux embeddings de position *RoPE* de fonctionner correctement pour les séquences d'entrée textuelles extrapolées (voir [ici](https://github.com/huggingface/transformers/pull/24653)).

> RoPE et ALiBi sont des embeddings de position relative qui ne sont *pas* appris lors de l'entraînement, mais qui reposent plutôt sur les intuitions suivantes :

- Les indices positionnels concernant les entrées textuelles doivent être fournis directement à la matrice $\mathbf{QK}^T$ de la couche d'auto-attention.
- Le LLM doit être incité à apprendre un encodage positionnel à distance *relative* constante.
- Plus les jetons d'entrée de texte sont éloignés les uns des autres, plus la probabilité de leur requête-valeur est faible. À la fois RoPE et ALiBi réduisent la probabilité requête-clé des jetons éloignés les uns des autres. RoPE la réduit en diminuant leur produit vectoriel en augmentant l'angle entre les vecteurs requête-clé. ALiBi la réduit en ajoutant de grands nombres négatifs au produit vectoriel.

En conclusion, les LLM destinés à être déployés sur des tâches nécessitant le traitement de grandes entrées textuelles sont mieux entraînés avec des embeddings positionnels relatifs, tels que RoPE et ALiBi. Il est également à noter qu’un LLM entraîné avec RoPE et ALiBi uniquement sur une longueur fixe, disons $N_1 = 2048$, peut tout de même être utilisé en pratique avec des entrées textuelles bien plus grandes que $N_1$, comme $N_2 = 8192 > N_1$, en extrapolant les embeddings positionnels.

### 3.2 Le cache clé-valeur

La génération de texte auto-régressive avec des LLM fonctionne en insérant itérativement une séquence d’entrée, en échantillonnant le jeton suivant, en l’ajoutant à la séquence d’entrée, et en répétant ce processus jusqu’à ce que le LLM produise un jeton indiquant que la génération est terminée.

Consultez le [tutoriel sur la génération de texte de Transformers](./llm_tutorial#default-generate) pour une explication plus visuelle du fonctionnement de la génération auto-régressive.

Exécutons un court extrait de code pour montrer comment fonctionne l'auto-régression en pratique. Nous prendrons simplement le jeton suivant le plus probable via `torch.argmax`.

```python
input_ids = tokenizer(prompt, return_tensors="pt")["input_ids"].to(model.device)

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("shape of input_ids", input_ids.shape)

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

**Sortie** :

```text
shape of input_ids torch.Size([1, 21])
shape of input_ids torch.Size([1, 22])
shape of input_ids torch.Size([1, 23])
shape of input_ids torch.Size([1, 24])
shape of input_ids torch.Size([1, 25])
[' Here is a Python function']
```

Comme nous pouvons le voir, à chaque fois, nous augmentons les jetons d'entrée du texte par le jeton tout juste échantillonné.

À quelques exceptions près, les LLM sont entraînés à l'aide de l'[objectif de modélisation de langage causal](./tasks/language_modeling#causal-language-modeling) et masquent donc le triangle supérieur de la matrice des scores d'attention - c'est pourquoi, dans les deux schémas ci-dessus, les scores d'attention sont laissés vides (*alias* ont une probabilité de 0). Pour un rappel rapide sur la modélisation de langage causal, vous pouvez consulter le [*blog Illustrated Self Attention*](https://jalammar.github.io/illustrated-gpt2/#part-2-illustrated-self-attention).

En conséquence, les jetons *ne dépendent jamais* des jetons ultérieurs, plus précisément le vecteur $\mathbf{q}_i$ n’est jamais mis en relation avec les vecteurs clés et valeurs $\mathbf{k}_j, \mathbf{v}_j$ si $j > i$ . En revanche, $\mathbf{q}_i ne fait attention qu’aux vecteurs clé-valeur précédents $\mathbf{k}_{m < i}, \mathbf{v}_{m < i} \text{ , pour } m \in \{0, \ldots i - 1\}$. Afin de réduire les calculs inutiles, il est donc possible de mettre en cache les vecteurs clé-valeur de chaque couche pour toutes les étapes précédentes.

Dans la suite, nous demanderons au LLM d’utiliser le cache clé-valeur en le récupérant et en le transmettant à chaque passe avant. Dans Transformers, nous pouvons récupérer le cache clé-valeur en passant le drapeau `use_cache` à l’appel `forward`, puis le transmettre avec le jeton courant.

```python
past_key_values = None # past_key_values is the key-value cache
generated_tokens = []
next_token_id = tokenizer(prompt, return_tensors="pt")["input_ids"].to(model.device)

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("shape of input_ids", next_token_id.shape)
  print("length of key-value cache", past_key_values.get_seq_length())  # past_key_values are of shape [num_layers, 0 for k, 1 for v, batch_size, length, hidden_dim]
  generated_tokens.append(next_token_id.item())

generated_text = tokenizer.batch_decode(generated_tokens)
generated_text
```

**Sortie** :

```text
shape of input_ids torch.Size([1, 1])
length of key-value cache 20
shape of input_ids torch.Size([1, 1])
length of key-value cache 21
shape of input_ids torch.Size([1, 1])
length of key-value cache 22
shape of input_ids torch.Size([1, 1])
length of key-value cache 23
shape of input_ids torch.Size([1, 1])
length of key-value cache 24
[' Here', ' is', ' a', ' Python', ' function']
```

Comme on peut le voir, lors de l'utilisation du cache clé-valeur, les jetons d'entrée texte ne sont *pas* allongés, mais restent un vecteur d'entrée unique. En revanche, la longueur du cache clé-valeur est augmentée d'un à chaque étape de décodage.

> L’exploitation du cache clé-valeur signifie que $\mathbf{QK}^T$ est essentiellement réduit à $\mathbf{q}_c\mathbf{K}^T$, où $\mathbf{q}_c$ est la projection de requête du jeton d’entrée actuellement transmis, qui est *toujours* un simple vecteur.

L'utilisation du cache clé-valeur présente deux avantages :

- Augmentation significative de l'efficacité computationnelle, car moins de calculs sont effectués par rapport au calcul de la matrice complète $\mathbf{QK}^T$. Cela se traduit par une accélération de la déduction
- La mémoire maximale requise n'augmente pas de manière quadratique avec le nombre de jetons générés, mais seulement de manière linéaire.

> On doit *toujours* utiliser le cache clé-valeur, car il produit des résultats identiques et offre un gain de vitesse significatif pour les séquences d’entrée plus longues. Transformers active le cache clé-valeur par défaut lors de l’utilisation du pipeline de texte ou de la méthode [`generate`](./main_classes/text_generation). Nous avons un guide entier dédié aux caches [ici](./kv_cache).

Notez que, malgré notre recommandation d’utiliser des caches clé-valeur, la sortie de votre LLM peut être légèrement différente lorsque vous les utilisez. Il s’agit d’une propriété des noyaux de multiplication de matrices eux-mêmes — vous pouvez en savoir plus à ce sujet [ici](https://github.com/huggingface/transformers/issues/25420#issuecomment-1775317535).

#### 3.2.1 Conversation multi-tours

Le cache clé-valeur est particulièrement utile pour des applications telles que le chat, où plusieurs passes de décodage auto-régressif sont nécessaires. Examinons un exemple.

```text
User: How many people live in France?
Assistant: Roughly 75 million people live in France
User: And how many are in Germany?
Assistant: Germany has ca. 81 million inhabitants
```

Dans ce chat, le LLM exécute le décodage auto-régressif deux fois :

  1. La première fois, le cache clé-valeur est vide et le prompt d'entrée est `"User: How many people live in France?"` et le modèle génère de manière auto-régressive le texte `"Roughly 75 million people live in France"` tout en augmentant le cache clé-valeur à chaque étape de décodage.
  2. La deuxième fois, l’invite d’entrée est `"User: How many people live in France? \n Assistant: Roughly 75 million people live in France \n User: And how many in Germany?"`. Grâce au cache, tous les vecteurs clé-valeur des deux premières phrases sont déjà calculés. L’invite d’entrée ne se compose donc que de `"User: And how many in Germany?"`. Lors du traitement de l’invite d’entrée raccourcie, ses vecteurs clé-valeur calculés sont concaténés au cache clé-valeur de la première décodage. La réponse du deuxième Assistant, `"Germany has ca. 81 million inhabitants"`, est ensuite générée de manière auto-régressive, le cache clé-valeur étant constitué des vecteurs clé-valeur encodés de `"User: How many people live in France? \n Assistant: Roughly 75 million people live in France \n User: And how many are in Germany?"`.

Deux points doivent être notés ici :

  1. Conserver tout le contexte est crucial pour les LLM déployés en chat afin que le LLM comprenne tout le contexte précédent de la conversation. Par exemple, pour l’exemple ci-dessus, le LLM doit comprendre que l’utilisateur fait référence à la population lorsqu’il pose la question `"And how many are in Germany"`.
  2. Le cache clé-valeur est extrêmement utile pour le chat, car il nous permet de faire croître continuellement l’historique de conversation encodé, au lieu de devoir réencoder l’historique de conversation à partir de zéro (comme ce serait le cas, par exemple, avec une architecture encodeur-décodeur).

Dans `transformers`, un appel `generate` renvoie `past_key_values` lorsque `return_dict_in_generate=True` est passé, en plus de la valeur par défaut `use_cache=True`. Notez que ce n'est pas encore disponible via l'interface `pipeline`.

```python
# Generation as usual
prompt = system_prompt + "Question: Please write a function in Python that transforms bytes to Giga bytes.\n\nAnswer: Here"
model_inputs = tokenizer(prompt, return_tensors='pt')
generation_output = model.generate(**model_inputs, max_new_tokens=60, return_dict_in_generate=True)
decoded_output = tokenizer.batch_decode(generation_output.sequences)[0]

# Piping the returned `past_key_values` to speed up the next conversation round
prompt = decoded_output + "\nQuestion: How can I modify the function above to return Mega bytes instead?\n\nAnswer: Here"
model_inputs = tokenizer(prompt, return_tensors='pt')
generation_output = model.generate(
  **model_inputs,
  past_key_values=generation_output.past_key_values,
  max_new_tokens=60,
  return_dict_in_generate=True
)
tokenizer.batch_decode(generation_output.sequences)[0][len(prompt):]
```

**Sortie** :

```text
 is a modified version of the function that returns Mega bytes instead.

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

Answer: The function takes a number of bytes as input and returns the number of
```

Parfait, aucun temps supplémentaire n’est consacré au recalcul des mêmes clés et valeurs pour la couche d’attention ! Il y a toutefois un point à prendre en compte. Bien que la mémoire de pointe requise pour la matrice $\mathbf{QK}^T$ soit considérablement réduite, le maintien du cache clé-valeur en mémoire peut devenir très coûteux en mémoire pour les séquences d’entrée longues ou les conversations multi-tours. N’oubliez pas que le cache clé-valeur doit stocker les vecteurs clé-valeur pour tous les vecteurs d’entrée précédents $\mathbf{x}_i \text{, pour } i \in \{1, \ldots, c - 1\}$ pour toutes les couches d’auto-attention et pour toutes les têtes d’attention.

Calculons le nombre de valeurs flottantes qui doivent être stockées dans le cache clé-valeur pour le LLM `bigcode/octocoder` que nous avons utilisé précédemment. Le nombre de valeurs flottantes correspond à deux fois la longueur de la séquence, multipliée par le nombre de têtes d'attention, multipliée par la dimension des têtes d'attention, et multipliée par le nombre de couches. En effectuant ce calcul pour notre LLM avec une longueur de séquence d'entrée hypothétique de 16000, on obtient :

```python
config = model.config
2 * 16_000 * config.n_layer * config.n_head * config.n_embd // config.n_head
```

**Sortie** :

```text
7864320000
```

Environ 8 milliards de valeurs flottantes ! Stocker 8 milliards de valeurs flottantes en précision `float16` nécessite environ 15 Go de RAM, soit à peu près la moitié de la taille des poids du modèle ! Les chercheurs ont proposé deux méthodes permettant de réduire considérablement le coût mémoire du stockage du cache clé-valeur, qui sont explorées dans les sous-sections suivantes.

#### 3.2.2 Multi-Query-Attention (MQA)

[Multi-Query-Attention](https://huggingface.co/papers/1911.02150) a été proposé dans l'article *Fast Transformer Decoding: One Write-Head is All You Need* de Noam Shazeer. Comme l'indique le titre, Noam a découvert qu'au lieu d'utiliser `n_head` poids de projections clé-valeur, on peut utiliser une seule paire de poids de projection tête-valeur partagée entre toutes les têtes d'attention, sans que les performances du modèle ne se dégradent de manière significative.

> En utilisant une seule paire de poids de projection tête-valeur, les vecteurs clé-valeur $\mathbf{k}_i, \mathbf{v}_i$ doivent être identiques pour toutes les têtes d'attention, ce qui signifie que nous n'avons besoin de stocker qu'une seule paire de projections clé-valeur dans le cache au lieu de `n_head`.

La plupart des LLM utilisent entre 20 et 100 têtes d'attention, ce qui permet à MQA de réduire considérablement la consommation mémoire du cache clé-valeur. Pour le LLM utilisé dans ce notebook, nous avons donc pu réduire la consommation mémoire requise de 15 Go à moins de 400 Mo pour une longueur de séquence d'entrée de 16000.

En plus des économies de mémoire, le MQA améliore également l'efficacité computationnelle, comme expliqué ci-dessous. Lors du décodage auto-régressif, de grands vecteurs clé-valeur doivent être rechargés, concaténés avec la paire de vecteurs clé-valeur courante, puis utilisés dans le calcul $\mathbf{q}_c\mathbf{K}^T$ à chaque étape. Pour le décodage auto-régressif, la bande passante mémoire requise pour ce rechargement constant peut devenir un goulot d'étranglement temporel important. En réduisant la taille des vecteurs clé-valeur, moins de mémoire doit être accédée, ce qui réduit ainsi le goulot d'étranglement de la bande passante mémoire. Pour plus de détails, veuillez consulter [l'article de Noam](https://huggingface.co/papers/1911.02150).

L'aspect essentiel à comprendre ici est que réduire le nombre de têtes d'attention clé-valeur à 1 n'a de sens que si un cache clé-valeur est utilisé. La consommation mémoire maximale du modèle pour un seul passage avant sans cache clé-valeur reste inchangée, car chaque tête d'attention possède toujours un vecteur de requête unique, de sorte que chaque tête d'attention possède toujours une matrice $\mathbf{QK}^T$ différente.

MQA a connu une large adoption par la communauté et est désormais utilisé par de nombreux LLM parmi les plus populaires :

- [**Falcon**](https://huggingface.co/tiiuae/falcon-40b)
- [**PaLM**](https://huggingface.co/papers/2204.02311)
- [**MPT**](https://huggingface.co/mosaicml/mpt-30b)
- [**BLOOM**](https://huggingface.co/bigscience/bloom)

De plus, le point de contrôle utilisé dans ce notebook - `bigcode/octocoder` - fait appel à MQA.

#### 3.2.3 Grouped-Query-Attention (GQA)

[Grouped-Query-Attention](https://huggingface.co/papers/2305.13245), proposé par Ainslie et al. de Google, a montré que l'utilisation de MQA peut souvent entraîner une dégradation de la qualité par rapport à l'utilisation de projections multi-têtes de clé-valeur classiques. L'article soutient qu'il est possible de préserver davantage les performances du modèle en réduisant de manière moins drastique le nombre de poids de projection des têtes de requête. Au lieu d'utiliser un seul poids de projection clé-valeur, il convient d'utiliser `n < n_head` poids de projection clé-valeur. En choisissant `n` comme une valeur nettement inférieure à `n_head`, par exemple 2, 4 ou 8, la quasi-totalité des gains en mémoire et en vitesse apportés par MQA peut être conservée, tout en sacrifiant moins de capacité du modèle et donc, a priori, moins de performances.

De plus, les auteurs de GQA ont constaté que les points de contrôle de modèles existants peuvent être *uptrained* pour adopter une architecture GQA avec seulement 5 % du calcul de pré-entraînement initial. Bien que 5 % du calcul de pré-entraînement initial puissent toujours représenter une quantité considérable, l'*uptraining* GQA permet aux points de contrôle existants de rester utiles pour des séquences d'entrée plus longues.

GQA n’a été proposé que récemment, ce qui explique qu’il soit moins adopté au moment de la rédaction de ce notebook. L’application la plus notable de GQA est [Llama-v2](https://huggingface.co/meta-llama/Llama-2-70b-hf).

> En conclusion, il est fortement recommandé d’utiliser GQA ou MQA si le LLM est déployé avec un décodage auto-régressif et doit traiter de longues séquences d’entrée, comme c’est le cas par exemple pour le chat.

## Conclusion

La communauté de la recherche propose en permanence de nouvelles approches ingénieuses pour accélérer le temps d’inférence des LLM de plus en plus volumineux. À titre d’exemple, l’une de ces pistes de recherche prometteuses est le [décodage spéculatif](https://huggingface.co/papers/2211.17192), où les « jetons faciles » sont générés par des modèles de langage plus petits et plus rapides, et seuls les « jetons difficiles » sont générés par le LLM lui-même. Aborder le sujet en détail dépasse le cadre de ce notebook, mais vous pouvez en savoir plus dans ce [article de blog](https://huggingface.co/blog/assisted-generation).

La raison pour laquelle les LLM massifs tels que GPT3/4, Llama-2-70b, Claude et PaLM s’exécutent si rapidement dans des interfaces de chat comme [Hugging Face Chat](https://huggingface.co/chat/) ou ChatGPT est en grande partie due aux améliorations susmentionnées en matière de précision, d’algorithmes et d’architecture. À l’avenir, les accélérateurs tels que les GPU, les TPU, etc. ne feront que s’accélérer et offrir davantage de mémoire, mais il convient néanmoins de toujours s’assurer d’utiliser les meilleurs algorithmes et architectures disponibles afin d’obtenir le meilleur rapport qualité-prix 🤗

