Ottimizzazione del tuo LLM in produzione

Ottimizzazione LLM produzione

Nota: Questo post sul blog è disponibile anche come pagina di documentazione su Transformers.

I Large Language Models (LLM) come GPT3/4, Falcon e LLama stanno avanzando rapidamente nella loro capacità di affrontare compiti centrati sull’essere umano, affermandosi come strumenti essenziali nelle moderne industrie basate sulla conoscenza. Tuttavia, implementare questi modelli in compiti del mondo reale rimane una sfida:

  • Per mostrare una comprensione e una capacità di generazione di testo simili a quelle umane, i LLM attualmente richiedono di essere composti da miliardi di parametri (vedi Kaplan et al, Wei et al). Ciò amplifica di conseguenza le richieste di memoria per l’elaborazione.
  • In molti compiti del mondo reale, i LLM devono essere forniti di informazioni contestuali estese. Ciò richiede la capacità del modello di gestire sequenze di input molto lunghe durante l’elaborazione.

La crux di queste sfide sta nel potenziare le capacità computazionali e di memoria dei LLM, soprattutto quando si gestiscono sequenze di input estese.

In questo post sul blog, esamineremo le tecniche più efficaci al momento della stesura di questo post sul blog per affrontare queste sfide per un efficiente utilizzo dei LLM:

  1. Precisione inferiore: La ricerca ha dimostrato che operare con precisione numerica ridotta, vale a dire a 8 bit e 4 bit, può comportare vantaggi computazionali senza una considerevole diminuzione delle prestazioni del modello.

  2. Flash Attention: Flash Attention è una variante dell’algoritmo di attenzione che non solo fornisce un approccio più efficiente in termini di memoria, ma consente anche un’efficienza aumentata grazie all’utilizzo ottimizzato della memoria GPU.

  3. Innovazioni architetturali: Considerando che i LLM vengono sempre implementati nello stesso modo durante l’elaborazione, vale a dire generazione di testo autoregressiva con un contesto di input lungo, sono state proposte architetture di modelli specializzate che consentono un’elaborazione più efficiente. I progressi più importanti nelle architetture di modelli riguardano Alibi, Rotary embeddings, Multi-Query Attention (MQA) e Grouped-Query-Attention (GQA).

In questo notebook, offriremo un’analisi della generazione auto-regressiva da una prospettiva di tensori. Approfondiremo i vantaggi e gli svantaggi dell’adozione di una precisione inferiore, esploreremo in modo esaustivo gli ultimi algoritmi di attenzione e discuteremo le architetture LLM migliorate. Nel farlo, eseguiremo esempi pratici che mostrano ciascun miglioramento delle funzionalità.

1. Sfruttare il Potere di una Precisione Inferiore

Le esigenze di memoria dei LLM possono essere comprese al meglio considerando il LLM come un insieme di matrici e vettori di pesi e gli input di testo come una sequenza di vettori. Di seguito, la definizione pesi sarà utilizzata per indicare tutte le matrici e i vettori di pesi del modello.

Al momento della stesura di questo post, i LLM sono composti da almeno un paio di miliardi di parametri. Ogni parametro è quindi costituito da un numero decimale, ad esempio 4.5689, che di solito viene memorizzato in formato float32, bfloat16 o float16. Ciò ci permette di calcolare facilmente il requisito di memoria per caricare il LLM in memoria:

Caricare i pesi di un modello con X miliardi di parametri richiede circa 4 * X GB di VRAM in precisione float32

Oggi i modelli vengono tuttavia raramente addestrati con la piena precisione float32, ma di solito con la precisione bfloat16 o meno frequentemente con la precisione float16. Pertanto, la regola approssimativa diventa:

Caricare i pesi di un modello con X miliardi di parametri richiede circa 2 * X GB di VRAM in precisione bfloat16/float16

Per input di testo più brevi (meno di 1024 token), il requisito di memoria per l’elaborazione è molto influenzato dal requisito di memoria per caricare i pesi. Pertanto, per ora, assumiamo che il requisito di memoria per l’elaborazione sia uguale al requisito di memoria per caricare il modello nella VRAM della GPU.

Per dare alcuni esempi di quanto VRAM serve approssimativamente per caricare un modello in bfloat16:

  • GPT3 richiede 2 * 175 GB = 350 GB di VRAM
  • Bloom richiede 2 * 176 GB = 352 GB di VRAM
  • Llama-2-70b richiede 2 * 70 GB = 140 GB di VRAM
  • Falcon-40b richiede 2 * 40 GB = 80 GB di VRAM
  • MPT-30b richiede 2 * 30 GB = 60 GB di VRAM
  • bigcode/starcoder richiede 2 * 15,5 = 31 GB di VRAM

Al momento della stesura di questo documento, il chip GPU più grande sul mercato è l’A100 che offre 80 GB di VRAM. La maggior parte dei modelli elencati precedentemente richiede più di 80 GB solo per essere caricati e quindi richiede necessariamente parallelismo tensoriale e/o parallelismo di pipeline.

🤗 Transformers non supporta il parallelismo tensoriale di default in quanto richiede che l’architettura del modello sia scritta in modo specifico. Se sei interessato a scrivere modelli in un modo adatto al parallelismo tensoriale, dai un’occhiata alla libreria di inferenza di generazione di testo.

Il parallelismo di pipeline ingenuo è supportato di default. Per fare ciò, carica semplicemente il modello con device="auto" che posizionerà automaticamente i diversi livelli sulle GPU disponibili come spiegato qui. Tuttavia, nota che, sebbene molto efficace, questo parallelismo di pipeline ingenuo non affronta il problema dell’attesa della GPU. Per questo è necessario un parallelismo di pipeline più avanzato come spiegato qui.

Se hai accesso a un nodo A100 da 8 x 80 GB, puoi caricare BLOOM come segue

!pip install transformers accelerate bitsandbytes optimum

# from transformers import AutoModelForCausalLM

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

Utilizzando device_map="auto", i livelli di attenzione verranno distribuiti in modo uniforme su tutte le GPU disponibili.

In questo notebook, utilizzeremo bigcode/octocoder poiché può essere eseguito su un singolo chip di dispositivo GPU A100 da 40 GB. Nota che tutte le ottimizzazioni di memoria e velocità che applicheremo in futuro sono applicabili anche ai modelli che richiedono parallelismo di modello o tensoriale.

Dato che il modello viene caricato con una precisione bfloat16, utilizzando la nostra regola empirica sopra, ci aspetteremmo che il requisito di memoria per eseguire l’inferenza con bigcode/octocoder sia di circa 31 GB di VRAM. Proviamoci.

Prima carichiamo il modello e il tokenizer e quindi li passiamo entrambi all’oggetto pipeline di 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 = "Domanda: Scrivi una funzione in Python che trasforma i byte in gigabyte.\n\nRisposta:"

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

Output:

Ecco una funzione Python che trasforma i byte in gigabyte:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nQuesta funzione prende un singolo

Bene, ora possiamo usare direttamente il risultato per convertire i byte in gigabyte.

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

Chiamiamo torch.cuda.max_memory_allocated per misurare l’allocazione di memoria GPU di picco.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Output:

29.0260648727417

Abbastanza vicino al nostro calcolo approssimativo! Possiamo vedere che il numero non è esattamente corretto, poiché passando da byte a kilobyte richiede una moltiplicazione di 1024 invece di 1000. Pertanto, la formula approssimativa può essere intesa anche come un calcolo “al massimo X GB”. Nota che se avessimo provato ad eseguire il modello con una precisione float32 completa, sarebbero stati necessari ben 64 GB di VRAM.

Quasi tutti i modelli vengono addestrati oggi con bfloat16, non c’è motivo di eseguire il modello con una precisione float32 completa se la tua GPU supporta bfloat16. Float32 non darà risultati di inferenza migliori rispetto alla precisione utilizzata per addestrare il modello.

Se non sei sicuro nel quale formato sono archiviati i pesi del modello nell’Hub, puoi sempre guardare la configurazione del checkpoint sotto "torch_dtype", ad esempio qui. Si consiglia di impostare il modello con lo stesso tipo di precisione come indicato nella configurazione durante il caricamento con from_pretrained(..., torch_dtype=...), tranne che se il tipo originale è float32, in tal caso è possibile utilizzare sia float16 o bfloat16 per l’inferenza.

Definiamo una funzione flush(...) per liberare tutta la memoria allocata in modo da poter misurare accuratamente la memoria GPU allocata al massimo.

del pipe
del modello

import gc
import torch

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

Chiamiamola ora per il prossimo esperimento.

flush()

Nella versione recente della libreria accelerate, è possibile utilizzare anche un metodo di utilità chiamato release_memory()

from accelerate.utils import release_memory
# ...

release_memory(modello)

Ora, cosa succede se la tua GPU non ha 32 GB di VRAM? È stato scoperto che i pesi del modello possono essere quantizzati a 8 bit o 4 bit senza una perdita significativa delle prestazioni (vedi Dettmers et al.). Il modello può essere quantizzato anche a 3 o 2 bit con una perdita accettabile delle prestazioni come mostrato nel recente articolo GPTQ 🤯.

Senza entrare troppo nei dettagli, gli schemi di quantizzazione mirano a ridurre la precisione dei pesi cercando allo stesso tempo di mantenere i risultati di inferenza del modello il più accurati possibile (ovvero il più vicino possibile a bfloat16). Si noti che la quantizzazione funziona particolarmente bene per la generazione di testo poiché tutto ciò che ci interessa è scegliere l’insieme di token successivi più probabili e non ci interessa realmente i valori esatti della distribuzione dei logit del token successivo. Tutto ciò che conta è che la distribuzione dei logit del token successivo rimanga approssimativamente la stessa in modo che un’operazione argmax o topk dia gli stessi risultati.

Esistono varie tecniche di quantizzazione, che non discuteremo in dettaglio qui, ma in generale, tutte le tecniche di quantizzazione funzionano come segue:

    1. Quantizzare tutti i pesi alla precisione desiderata
    1. Caricare i pesi quantizzati e passare la sequenza di input di vettori in precisione bfloat16
    1. Dequantizzare dinamicamente i pesi in bfloat16 per eseguire il calcolo con i loro vettori di input in precisione bfloat16
    1. Quantizzare nuovamente i pesi alla precisione desiderata dopo il calcolo con i loro input.

In poche parole, questo significa che le moltiplicazioni matrice pesi-input, con X X X che rappresenta gli input, W W W che rappresenta una matrice dei pesi e Y Y Y che rappresenta l’output:

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

vengono cambiate in

Y=X∗dequantizza(W);quantizza(W) Y = X * \text{dequantizza}(W); \text{quantizza}(W) Y=X∗dequantizza(W);quantizza(W)

per ogni moltiplicazione matrice. La dequantizzazione e la ri-quantizzazione vengono eseguite in sequenza per tutte le matrici dei pesi mentre gli input attraversano il grafo della rete.

Di conseguenza, il tempo di inferenza spesso non diminuisce quando si utilizzano pesi quantizzati, ma aumenta invece. Abbastanza teoria, proviamoci! Per quantizzare i pesi con Transformers, è necessario assicurarsi che la libreria bitsandbytes sia installata.

# !pip install bitsandbytes

Poi possiamo caricare modelli in quantizzazione a 8 bit semplicemente aggiungendo un flag load_in_8bit=True a from_pretrained.

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

Ora, eseguiamo nuovamente il nostro esempio e misuriamo l’utilizzo della memoria.

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

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

Output:

Ecco una funzione Python che trasforma i byte in Giga byte:\n\n```python\ndef byte_a_giga_byte(byte):\n    return byte / 1024 / 1024 / 1024\n```\n\nQuesta funzione prende un singolo

Bel lavoro, otteniamo lo stesso risultato di prima, quindi nessuna perdita di precisione! Vediamo quanto memoria è stata utilizzata questa volta.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Output:

15.219234466552734

Significativamente meno! Siamo scesi a poco più di 15 GB e quindi potremmo eseguire questo modello su GPU per consumatori come la 4090. Stiamo vedendo un guadagno molto bello in efficienza di memoria e più o meno nessun degrado nell’output del modello. Tuttavia, possiamo anche notare un leggero rallentamento durante l’elaborazione.

Eliminiamo i modelli e svuotiamo nuovamente la memoria.

del model
del pipe

flush()

Vediamo quanto consumo di memoria GPU di picco dà la quantizzazione a 4 bit. La quantizzazione del modello a 4 bit può essere effettuata con la stessa API di prima, stavolta passando load_in_4bit=True invece di load_in_8bit=True.

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", load_in_4bit=True, low_cpu_mem_usage=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

Output:

Ecco una funzione Python che trasforma i byte in Gigabyte:

```
def bytes_to_gigabytes(byte):
    return byte / 1024 / 1024 / 1024
```

Questa funzione richiede un singolo argomento

Stiamo quasi vedendo lo stesso testo di output di prima, manca solo la parola python giusto prima del frammento di codice. Vediamo quanto memoria è stata richiesta.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Output:

9.543574333190918

Solo 9,5 GB! Non è davvero tanto per un modello di oltre 15 miliardi di parametri.

Anche se qui vediamo una degradazione molto minima dell’accuratezza per il nostro modello, la quantizzazione a 4 bit può spesso portare a risultati diversi rispetto alla quantizzazione a 8 bit o all’inferenza completa bfloat16. Sta all’utente provarlo.

Inoltre, notare che l’inferenza qui è di nuovo un po’ più lenta rispetto alla quantizzazione a 8 bit, che è dovuta al metodo di quantizzazione più aggressivo utilizzato per la quantizzazione a 4 bit che richiede più tempo durante l’inferenza per quantizzare e dequantizzare.

del model
del pipe

flush()

In generale, abbiamo visto che eseguire OctoCoder con una precisione di 8 bit ha ridotto la VRAM della GPU richiesta da 32G a soli 15GB e l’esecuzione del modello con una precisione di 4 bit riduce ulteriormente la VRAM della GPU richiesta a poco più di 9GB.

La quantizzazione a 4 bit consente di eseguire il modello su GPU come RTX3090, V100 e T4, che sono abbastanza accessibili per la maggior parte delle persone.

Per ulteriori informazioni sulla quantizzazione e per vedere come è possibile quantizzare i modelli per richiedere ancora meno memoria VRAM della GPU rispetto a 4 bit, consigliamo di consultare l’implementazione AutoGPTQ.

Come conclusione, è importante ricordare che la quantizzazione del modello scambia una maggiore efficienza di memoria con l’accuratezza e, in alcuni casi, il tempo di inferenza.

Se la memoria della GPU non rappresenta un vincolo per il tuo caso d’uso, spesso non è necessario esaminare la quantizzazione. Tuttavia, molte GPU non possono eseguire LLM senza metodi di quantizzazione e, in questo caso, gli schemi di quantizzazione a 4 bit e 8 bit sono strumenti estremamente utili.

Per ulteriori informazioni sull’uso dettagliato, consigliamo vivamente di consultare la documentazione sulla quantizzazione di Transformers. Successivamente, vediamo come possiamo migliorare l’efficienza computazionale e di memoria utilizzando algoritmi migliori e un’architettura di modello migliorata.

Le LLMs di punta di oggi condividono più o meno la stessa architettura fondamentale che consiste in strati feed-forward, strati di attivazione, strati di normalizzazione dei livelli e, soprattutto, strati di auto-attenzione.

Gli strati di auto-attenzione sono centrali per i Large Language Models (LLMs) in quanto consentono al modello di comprendere le relazioni contestuali tra i token di input. Tuttavia, il consumo di memoria GPU di picco per gli strati di auto-attenzione cresce in modo quadratico sia nella complessità di calcolo che di memoria con il numero di token di input (chiamato anche lunghezza della sequenza) che denotiamo di seguito con N. Se questo non è realmente notabile per sequenze di input più brevi (fino a 1000 token di input), diventa un problema serio per sequenze di input più lunghe (intorno a 16000 token di input).

Analizziamo da vicino. La formula per calcolare l’output O \mathbf{O} O di uno strato di auto-attenzione per un input X \mathbf{X} X di lunghezza N N N è:

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​) è quindi la sequenza di input per lo strato di attenzione. Le proiezioni Q \mathbf{Q} Q e K \mathbf{K} K conterranno ciascuna N N N vettori risultando in QKT \mathbf{QK}^T QKT di dimensioni N2 N^2 N2 .

Gli LLM (Language Model) di solito hanno più teste di attenzione, eseguendo quindi più calcoli di auto-attenzione in parallelo. Assumendo che l’LLM abbia 40 teste di attenzione e funzioni con precisione bfloat16, possiamo calcolare il requisito di memoria per memorizzare le matrici QKT \mathbf{QK^T} QKT come 40*2*N2 40 * 2 * N^2 40∗2∗N2 byte. Per N=1000 N=1000 N=1000 sono necessari solo circa 50 MB di VRAM, tuttavia per N=16000 N=16000 N=16000 avremmo bisogno di 19 GB di VRAM e per N=100,000 N=100,000 N=100,000 avremmo bisogno di quasi 1TB solo per memorizzare le matrici QKT \mathbf{QK}^T QKT.

In breve, l’algoritmo di auto-attenzione predefinito diventa rapidamente proibitivamente costoso in termini di memoria per contesti di input di grandi dimensioni.

Man mano che gli LLM migliorano nella comprensione e generazione di testi, vengono applicati a compiti sempre più complessi. Mentre i modelli una volta gestivano la traduzione o la sintesi di alcune frasi, ora gestiscono intere pagine, richiedendo la capacità di elaborare lunghezze di input estese.

Come possiamo eliminare i requisiti esorbitanti di memoria per lunghezze di input grandi? Abbiamo bisogno di un nuovo modo per calcolare il meccanismo di auto-attenzione che elimini la matrice QKT QK^T QKT. Tri Dao et al. hanno sviluppato proprio un nuovo algoritmo del genere e lo hanno chiamato Flash Attention.

Insomma, Flash Attention scompone il calcolo V×Softmax(QKT\mathbf{V} \times \text{Softmax}(\mathbf{QK}^TV×Softmax(QKT) e invece calcola frammenti più piccoli dell’output iterando su più passaggi di calcolo softmax:

Oi←sija∗Oi+sijb∗Vj×Softmax(QKi,jT) per multiple i,j iterations \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{ per multiple } i, j \text{ iterations} Oi​←sija​∗Oi​+sijb​∗Vj​×Softmax(QKi,jT​) per multiple i,j iterations

con sija s^a_{ij} sija​ e sijb s^b_{ij} sijb​ che rappresentano alcune statistiche di normalizzazione softmax che devono essere ricalcolate per ogni i i i e j j j .

Si prega di notare che l’intero Flash Attention è un po’ più complesso e qui è notevolmente semplificato, poiché andare troppo in profondità è fuori dallo scopo di questo documento. Si invita il lettore a consultare il ben scritto articolo su Flash Attention per maggiori dettagli.

La cosa principale da ricordare è:

Tenendo traccia delle statistiche di normalizzazione softmax e utilizzando alcune formule matematiche intelligenti, Flash Attention produce output numericamente identici rispetto allo strato di auto-attenzione predefinito, con un costo di memoria che aumenta solo linearmente con N N N .

Osservando la formula, si potrebbe intuitivamente dire che Flash Attention deve essere molto più lento rispetto alla formula di auto-attenzione predefinita in quanto è necessario eseguire più calcoli. Infatti, Flash Attention richiede più operazioni in virgola mobile rispetto all’attenzione normale in quanto le statistiche di normalizzazione softmax devono essere costantemente ricalcolate (vedere il paper per ulteriori dettagli se interessati)

Tuttavia, Flash Attention è molto più veloce nell’elaborazione rispetto all’attenzione predefinita, il che deriva dalla sua capacità di ridurre significativamente le richieste sulla memoria più lenta e ad alta larghezza di banda della GPU (VRAM), concentrandosi invece sulla memoria più veloce a bordo (SRAM).

In sostanza, Flash Attention si assicura che tutte le operazioni di scrittura e lettura intermedie possano essere eseguite utilizzando la rapida memoria a bordo SRAM anziché accedere alla memoria VRAM più lenta per calcolare il vettore di output O \mathbf{O} O .

Nella pratica, attualmente non c’è assolutamente motivo di non utilizzare Flash Attention se disponibile. L’algoritmo restituisce gli stessi risultati matematici ed è sia più veloce che più efficiente in termini di memoria.

Guardiamo un esempio pratico.

Il nostro modello OctoCoder ora riceve un prompt di input significativamente più lungo che include un cosiddetto prompt di sistema. I prompt di sistema vengono utilizzati per orientare il LLM in un assistente migliore che sia adattato al compito degli utenti. Di seguito, utilizziamo un prompt di sistema che renderà OctoCoder un assistente migliore per la codifica.

system_prompt = """Di seguito sono riportati una serie di dialoghi tra varie persone e un assistente tecnico AI.
L'assistente cerca di essere disponibile, educato, onesto, sofisticato, emotivamente consapevole e umile ma competente.
L'assistente è felice di rispondere a domande di codice e farà del suo meglio per capire esattamente di cosa si ha bisogno.
Tenta anche di evitare di fornire informazioni false o fuorvianti e mette in guardia quando non è del tutto sicuro della risposta corretta.
Detto questo, l'assistente è pratico, fa del suo meglio e non lascia che la prudenza ostacoli troppo l'utilità.

I modelli Starcoder sono una serie di modelli a 15,5 miliardi di parametri addestrati su oltre 80 linguaggi di programmazione da The Stack (v1.2) (escludendo le richieste di esclusione).
Il modello utilizza l'attenzione multi-query, è stato addestrato utilizzando l'obiettivo di riempire gli spazi vuoti e con una finestra di contesto di 8.192 token per un trilione di token di dati fortemente deduplicati.

-----

Domanda: Scrivi una funzione che prende due liste e restituisce una lista che ha elementi alternati da ciascuna lista di input.

Risposta: Certamente. Ecco una funzione che fa questo.

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

Domanda: Puoi scrivere alcuni casi di test per questa funzione?

Risposta: Certamente, ecco alcuni test.

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([], []) == []

Domanda: Modifica la funzione in modo che restituisca tutti gli elementi di input quando le liste hanno una lunghezza non pari. Gli elementi della lista più lunga dovrebbero essere alla fine.

Risposta: Ecco la funzione modificata.

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

-----
"""

Per scopi dimostrativi, duplichiamo il sistema dieci volte in modo che la lunghezza dell’input sia sufficientemente lunga per osservare i risparmi di memoria di Flash Attention. Aggiungiamo il prompt di testo originale "Domanda: Per favore, scrivi una funzione in Python che trasforma i byte in giga byte.\n\nRisposta: Ecco"

long_prompt = 10 * system_prompt + prompt

Istanziamo nuovamente il nostro modello in precisione 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)

Ora eseguiamo il modello come prima senza Flash Attention e misuriamo il requisito di memoria GPU massimo e il tempo di inferenza.

import time

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

print(f"Generato in {time.time() - start_time} secondi.")
result

Output:

Generato in 10.96854019165039 secondi.
Certamente. Ecco una funzione che fa questo.\n\ndef bytes_to_giga(bytes):\n   return bytes / 1024 / 1024 / 1024\n\nRisposta: Certamente. Ecco una funzione che fa questo.\n\ndef

Stiamo ottenendo lo stesso output di prima, tuttavia questa volta il modello ripete la risposta più volte fino a quando viene tagliata a 60 token. Questo non è sorprendente poiché abbiamo ripetuto il prompt del sistema dieci volte per scopi dimostrativi e quindi abbiamo suggerito al modello di ripetersi.

Nota che il prompt del sistema non dovrebbe essere ripetuto dieci volte nelle applicazioni reali – una volta è sufficiente!

Misuriamo il picco di memoria GPU richiesta.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Output:

37.668193340301514

Come possiamo vedere, il picco di memoria GPU richiesta è ora significativamente più alto rispetto all’inizio, principalmente a causa della sequenza di input più lunga. Inoltre, la generazione richiede poco più di un minuto ora.

Chiamiamo flush() per liberare la memoria GPU per il nostro prossimo esperimento.

flush()

A scopo di confronto, eseguiamo la stessa funzione, ma abilitiamo anche Flash Attention. Per farlo, convertiamo il modello in BetterTransformers e, facendo ciò, abilitiamo l’autoattenzione SDPA di PyTorch, che a sua volta si basa su Flash Attention.

model.to_bettertransformer()

Ora eseguiamo lo stesso frammento di codice esattamente come prima e sotto il cofano Transformers utilizzerà 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"Generato in {time.time() - start_time} secondi.")
result

Output:

Generato in 3.0211617946624756 secondi.
 Certamente. Ecco una funzione che fa questo.\n\ndef bytes_to_giga(bytes):\n   return bytes / 1024 / 1024 / 1024\n\nRisposta: Certamente. Ecco una funzione che fa questo.\n\ndef

Otteniamo lo stesso risultato esatto di prima, ma possiamo osservare un miglioramento significativo della velocità grazie a Flash Attention.

Misuriamo ancora una volta il consumo di memoria.

bytes_to_giga_bytes(torch.cuda.max_memory_allocated())

Output:

32.617331981658936

E siamo quasi tornati ai nostri originali 29GB di picco di memoria GPU dall’inizio.

Possiamo osservare che utilizziamo solo circa 100MB in più di memoria GPU quando passiamo una sequenza di input molto lunga con Flash Attention rispetto a quando passiamo sequenze di input brevi come fatto all’inizio.

flush()

3. La scienza dietro le architetture LLM: selezione strategica per input di testo lunghi e chat

Fino ad ora abbiamo cercato di migliorare l’efficienza computazionale e di memoria attraverso:

  • Casting dei pesi in un formato di precisione inferiore
  • Sostituzione dell’algoritmo di autoattenzione con una versione più efficiente in termini di memoria e calcolo

Adesso vediamo come possiamo cambiare l’architettura di un LLM in modo che sia più efficace ed efficiente per compiti che richiedono input di testo lunghi, ad esempio:

  • Question Answering con recupero,
  • Riassunto,
  • Chat

Si noti che la chat non solo richiede che il LLM gestisca input di testo lunghi, ma richiede anche che il LLM sia in grado di gestire efficientemente il dialogo tra utente e assistente (come ChatGPT).

Una volta addestrata, l’architettura fondamentale del LLM è difficile da modificare, quindi è importante prendere in considerazione i compiti del LLM in anticipo e ottimizzare di conseguenza l’architettura del modello. Ci sono due componenti importanti dell’architettura del modello che diventano rapidamente collo di bottiglia in termini di memoria e/o prestazioni per sequenze di input grandi.

  • Le incrustazioni posizionali
  • La cache chiave-valore

Andiamo più in dettaglio su ciascun componente

3.1 Miglioramento dei positional embeddings dei LLMs

L’autoattenzione mette ogni token in relazione con gli altri token. Come esempio, la matrice Softmax(QKT) \text{Softmax}(\mathbf{QK}^T) Softmax(QKT) della sequenza di input del testo “Ciao”, “Io”, “amo”, “te” potrebbe apparire come segue:

Ogni token di parola viene assegnato una massa di probabilità a cui partecipa con tutti gli altri token di parola e, quindi, viene messo in relazione con tutti gli altri token di parola. Ad esempio, la parola “amo” partecipa alla parola “Ciao” con il 0,05%, a “Io” con il 0,3%, e a se stessa con il 0,65%.

Un LLM basato sull’autoattenzione, ma senza positional embeddings, avrebbe grandi difficoltà nel comprendere le posizioni degli input di testo tra loro. Questo perché il punteggio di probabilità calcolato da QKT \mathbf{QK}^T QKT mette in relazione ogni token di parola con ogni altro token di parola in calcoli O(1) O(1) O(1), indipendentemente dalla loro distanza posizionale relativa tra loro. Pertanto, per il LLM senza positional embeddings, ogni token sembra avere la stessa distanza da tutti gli altri token, ad esempio differenziare tra “Ciao amo te” e “Te amo Io ciao” sarebbe molto sfidante.

Per far sì che il LLM comprenda l’ordine delle frasi, è necessario un indizio aggiuntivo che di solito viene applicato sotto forma di positional encodings (o anche chiamati positional embeddings). I positional encodings codificano la posizione di ciascun token in una presentazione numerica che il LLM può utilizzare per comprendere meglio l’ordine delle frasi.

Gli autori del paper “Attention Is All You Need” hanno introdotto i positional embeddings sinusoidali P=p1,…,pN \mathbf{P} = \mathbf{p}_1, \ldots, \mathbf{p}_N P=p1​,…,pN​ . dove ogni vettore pi \mathbf{p}_i pi​ viene calcolato come una funzione sinusoidale della sua posizione i i i . Gli positional encodings vengono quindi semplicemente aggiunti ai vettori della sequenza di input 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 \mathbf{x}_1 + \mathbf{p}_1, \ldots, \mathbf{x}_N + \mathbf{p}_N x1​+p1​,…,xN​+pN​ , fornendo al modello un indizio migliore per apprendere l’ordine delle frasi.

Invece di utilizzare positional embeddings fissi, altri (come Devlin et al.) hanno utilizzato positional encodings appresi per cui gli positional embeddings P \mathbf{P} P vengono appresi durante l’addestramento.

Gli positional embeddings sinusoidali e appresi erano solitamente i metodi predominanti per codificare l’ordine delle frasi nei LLMs, ma sono stati riscontrati alcuni problemi legati a questi positional encodings:

  • 1.) Gli positional embeddings sinusoidali e appresi sono entrambi positional embeddings assoluti, ovvero codificano un embedding univoco per ogni id di posizione: 0,…,N 0, \ldots, N 0,…,N . Come dimostrato da Huang et al. e Su et al.], gli positional embeddings assoluti portano a una scarsa performance del LLM per input di testo lunghi. Per input di testo lunghi, è vantaggioso se il modello apprende la distanza posizionale relativa tra i token di input invece della loro posizione assoluta.
  • 2.) Quando si utilizzano positional embeddings appresi, il LLM deve essere addestrato su una lunghezza di input fissa N N N , il che rende difficile l’estrapolazione a una lunghezza di input più lunga rispetto a quella su cui è stato addestrato.

Recentemente, gli positional embeddings relativi che possono affrontare i problemi sopra citati sono diventati più popolari, in particolare:

  • Rotary Position Embedding (RoPE)
  • ALiBi

Sia RoPE che ALiBi sostengono che sia meglio fornire al LLM informazioni sull’ordine delle frasi direttamente nell’algoritmo di autoattenzione, poiché è lì che i token di parola vengono messi in relazione tra loro. Più specificamente, l’ordine delle frasi dovrebbe essere indicato modificando il calcolo di QKT \mathbf{QK}^T QKT.

Senza entrare troppo nei dettagli, RoPE nota che le informazioni di posizione possono essere codificate in coppie di query-key, ad esempio qi \mathbf{q}_i qi​ e xj \mathbf{x}_j xj​ ruotando ciascun vettore di un angolo θ∗i \theta * i θ∗i e θ∗j \theta * j θ∗j rispettivamente, con i,j i, j i,j che descrivono la posizione di ciascun vettore nella frase:

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​. Rθ,i−j \mathbf{R}_{\theta, i – j} Rθ,i−j​ rappresenta quindi una matrice di rotazione. θ \theta θ non viene appreso durante l’addestramento, ma viene invece impostato su un valore predefinito che dipende dalla lunghezza massima della sequenza di input durante l’addestramento.

In questo modo, il punteggio di probabilità tra qi \mathbf{q}_i qi​ e qj \mathbf{q}_j qj​ viene influenzato solo se i≠j i≠j i≠j e dipende unicamente dalla distanza relativa i−j i – j i−j, indipendentemente dalle posizioni specifiche di i i i e j j j di ciascun vettore.

RoPE è utilizzato in diversi dei più importanti LLM di oggi, come:

  • Falcon
  • Llama
  • PaLM

Come alternativa, ALiBi propone uno schema di codifica delle posizioni relative molto più semplice. La distanza relativa tra i token di input viene aggiunta come un intero negativo scalato da un valore predefinito m a ciascuna voce di query-key della matrice QKT \mathbf{QK}^T QKT proprio prima del calcolo softmax.

Come mostrato nell’articolo di ALiBi, questa semplice codifica posizionale relativa consente al modello di mantenere un’alta performance anche con sequenze di input di testo molto lunghe.

ALiBi è utilizzato in diversi dei più importanti LLM di oggi, come:

  • MPT
  • BLOOM

Sia RoPE che ALiBi possono estrapolare le codifiche di posizione a lunghezze di input non osservate durante l’addestramento, ma è stato dimostrato che l’estrapolazione funziona molto meglio “out-of-the-box” per ALiBi rispetto a RoPE. Per ALiBi, è sufficiente aumentare i valori della matrice di posizione triangolare inferiore fino a raggiungere la lunghezza della sequenza di input. Per RoPE, mantenere lo stesso θ \theta θ utilizzato durante l’addestramento porta a risultati scadenti quando si passano input di testo molto più lunghi rispetto a quelli osservati durante l’addestramento, c.f Press et al.. Tuttavia, la comunità ha trovato un paio di trucchi efficaci per adattare θ \theta θ. permettendo così alle codifiche posizionali di RoPE di funzionare bene per sequenze di input di testo estrapolate (vedi qui).

Sia RoPE che ALiBi sono codifiche posizionali relative che non vengono apprese durante l’addestramento, ma si basano sulle seguenti intuizioni:

  • Le indicazioni posizionali sui testi in input dovrebbero essere fornite direttamente alla matrice QKT \mathbf{QK}^T QKT dello strato di auto-attenzione
  • Il LLM dovrebbe essere incentivato ad apprendere una costante distanza relativa che le codifiche posizionali hanno l’una con l’altra
  • Più i token di input del testo sono lontani l’uno dall’altro, più bassa è la probabilità della loro probabilità di query-valore. Sia RoPE che ALiBi riducono la probabilità query-key dei token lontani l’uno dall’altro. RoPE diminuendo il loro prodotto vettoriale aumentando l’angolo tra i vettori query-key. ALiBi aggiungendo numeri negativi grandi al prodotto vettoriale

In conclusione, i LLM che sono destinati ad essere utilizzati in compiti che richiedono la gestione di lunghi input di testo vengono addestrati meglio con codifiche posizionali relative, come RoPE e ALiBi. Si noti anche che anche se un LLM con RoPE e ALiBi è stato addestrato solo su una lunghezza fissa, ad esempio N1=2048, può comunque essere utilizzato nella pratica con input di testo molto più grandi di N1, come N2=8192>N1, estrapolando le codifiche posizionali.

3.2 La cache chiave-valore

La generazione di testo auto-regressiva con LLM funziona iterativamente inserendo una sequenza di input, campionando il token successivo, aggiungendo il token successivo alla sequenza di input e continuando a farlo fino a quando LLM produce un token che indica che la generazione è terminata.

Si prega di dare un’occhiata al Tutorial di Generazione di Testo di Transformer per ottenere una spiegazione più visuale di come funziona la generazione auto-regressiva.

Eseguiamo un breve frammento di codice per mostrare come funziona l’auto-regressione nella pratica. Prenderemo semplicemente il token successivo più probabile tramite 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 di input_ids", input_ids.shape)

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

Output:

forma di input_ids torch.Size([1, 21])
forma di input_ids torch.Size([1, 22])
forma di input_ids torch.Size([1, 23])
forma di input_ids torch.Size([1, 24])
forma di input_ids torch.Size([1, 25])
[' Ecco una funzione Python']

Come possiamo vedere, ogni volta che aumentiamo i token di input del testo con il token appena campionato.

Ad eccezione di pochi casi, gli LLM vengono addestrati utilizzando l’obiettivo di modellazione linguistica causale e quindi mascherano la matrice triangolare superiore del punteggio di attenzione – ecco perché nei due diagrammi sopra i punteggi di attenzione sono lasciati in bianco (cioè hanno probabilità 0). Per un breve riepilogo sulla modellazione linguistica causale, puoi fare riferimento al blog Illustrated Self Attention.

Di conseguenza, i token non dipendono mai dai token precedenti, più specificamente il vettore qi \mathbf{q}_i qi​ non viene mai messo in relazione con alcuna chiave, vettori di valori kj,vj \mathbf{k}_j, \mathbf{v}_j kj​,vj​ se j>i j > i j>i . Invece, qi \mathbf{q}_i qi​ fa riferimento solo ai vettori di chiave-valore precedenti km<i,vm<i , for m∈{0,…i−1} \mathbf{k}_{m < i}, \mathbf{v}_{m < i} \text{ , for } m \in \{0, \ldots i – 1\} km<i​,vm<i​ , for m∈{0,…i−1}. Per ridurre i calcoli non necessari, è possibile memorizzare nella cache i vettori di chiave-valore di ogni layer per tutti i passaggi precedenti.

Nel seguito, diremo all’LLM di utilizzare la cache chiave-valore recuperandola e inoltrandola per ogni iterazione. In Transformers, possiamo recuperare la cache chiave-valore passando il flag use_cache alla chiamata di forward e quindi passarlo con il token corrente.

past_key_values = None # past_key_values è la cache chiave-valore
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 di input_ids", input_ids.shape)
  print("lunghezza della cache chiave-valore", len(past_key_values[0][0]))  # past_key_values sono di forma [num_layers, 0 per k, 1 per v, batch_size, length, hidden_dim]
  generated_tokens.append(next_token_id.item())

generated_text = tokenizer.batch_decode(generated_tokens)
generated_text

Output:

forma di input_ids torch.Size([1, 20])
lunghezza della cache chiave-valore 20
forma di input_ids torch.Size([1, 20])
lunghezza della cache chiave-valore 21
forma di input_ids torch.Size([1, 20])
lunghezza della cache chiave-valore 22
forma di input_ids torch.Size([1, 20])
lunghezza della cache chiave-valore 23
forma di input_ids torch.Size([1, 20])
lunghezza della cache chiave-valore 24
[' Ecco', ' una', ' funzione', ' Python', '']

Come si può vedere, quando si utilizza la cache chiave-valore, i token di input del testo non aumentano in lunghezza, ma rimangono un singolo vettore di input. La lunghezza della cache chiave-valore d’altra parte aumenta di uno ad ogni passo di decodifica.

Utilizzando la cache chiave-valore, il QKT \mathbf{QK}^T QKT viene essenzialmente ridotto a qcKT \mathbf{q}_c\mathbf{K}^T qc​KT, con qc \mathbf{q}_c qc​ che rappresenta la proiezione di query del token di input attualmente passato, che è sempre solo un singolo vettore.

L’utilizzo della cache chiave-valore ha due vantaggi:

  • Aumento significativo dell’efficienza computazionale poiché vengono eseguiti meno calcoli rispetto al calcolo della matrice completa QKT \mathbf{QK}^T QKT. Ciò porta a un aumento della velocità di inferenza
  • La memoria massima richiesta non aumenta in modo quadratico con il numero di token generati, ma aumenta solo linearmente.

Si dovrebbe sempre utilizzare la cache chiave-valore in quanto porta a risultati identici e a una significativa accelerazione per sequenze di input più lunghe. Transformers ha la cache chiave-valore abilitata per impostazione predefinita quando si utilizza il pipeline di testo o il metodo generate.

Si noti che la cache chiave-valore è particolarmente utile per applicazioni come le chat in cui sono richiesti più passaggi di decodifica auto-regressiva. Vediamo un esempio.

Utente: Quante persone vivono in Francia?
Assistente: Circa 75 milioni di persone vivono in Francia.
Utente: E quante ce ne sono in Germania?
Assistente: La Germania ha circa 81 milioni di abitanti.

In questa chat, LLM esegue la decodifica auto-regressiva due volte:

    1. La prima volta, la cache chiave-valore è vuota e l’input prompt è "Utente: Quante persone vivono in Francia?" e il modello genera in modo auto-regressivo il testo "Circa 75 milioni di persone vivono in Francia" aumentando la cache chiave-valore ad ogni passo di decodifica.
    1. La seconda volta, l’input prompt è "Utente: Quante persone vivono in Francia? \n Assistente: Circa 75 milioni di persone vivono in Francia \n Utente: E quante ce ne sono in Germania?". Grazie alla cache, tutti i vettori chiave-valore per le prime due frasi sono già calcolati. Pertanto, l’input prompt è costituito solo da "Utente: E quante ce ne sono in Germania?". Durante l’elaborazione dell’input prompt abbreviato, i vettori chiave-valore calcolati vengono concatenati alla cache chiave-valore della prima decodifica. La risposta dell’Assistente "La Germania ha circa 81 milioni di abitanti" viene quindi generata in modo auto-regressivo con la cache chiave-valore composta dai vettori chiave-valore codificati di "Utente: Quante persone vivono in Francia? \n Assistente: Circa 75 milioni di persone vivono in Francia \n Utente: E quante ce ne sono in Germania?".

Qui vanno notate due cose:

    1. Mantenere tutto il contesto è cruciale per gli LLM utilizzati in chat, affinché l’LLM comprenda tutto il contesto precedente della conversazione. Ad esempio, per l’esempio sopra, l’LLM deve capire che l’utente si riferisce alla popolazione quando chiede "E quante ce ne sono in Germania".
    1. La cache chiave-valore è estremamente utile per le chat in quanto ci consente di far crescere continuamente la cronologia della chat codificata anziché dover ricodificare nuovamente l’intera cronologia della chat (come ad esempio sarebbe il caso quando si utilizza un’architettura encoder-decoder).

Tuttavia, c’è un problema. Mentre la memoria di picco richiesta per la matrice QKT \mathbf{QK}^T QKT viene significativamente ridotta, mantenere la cache chiave-valore in memoria può diventare molto costoso in termini di memoria per sequenze di input lunghe o chat a più turni. Ricordare che la cache chiave-valore deve memorizzare i vettori chiave-valore per tutti i vettori di input precedenti xi, per i ∈ {1,…,c−1} \mathbf{x}_i, \text{ per } i \in \{1, \ldots, c – 1\} xi​, per i ∈ {1,…,c−1} per tutti gli strati di auto-attenzione e per tutte le attenzioni.

Calcoliamo il numero di valori float che devono essere memorizzati nella cache chiave-valore per l’LLM bigcode/octocoder che abbiamo usato in precedenza. Il numero di valori float ammonta a due volte la lunghezza della sequenza moltiplicata per il numero di attenzioni per testa di attenzione e per il numero di strati. Calcolando questo per il nostro LLM con una lunghezza di sequenza di input ipotetica di 16000 si ottiene:

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

Output:

7864320000

Circa 8 miliardi di valori float! Memorizzare 8 miliardi di valori float in precisione float16 richiede circa 15 GB di RAM, che è circa la metà dei pesi del modello stessi! I ricercatori hanno proposto due metodi che consentono di ridurre significativamente il costo di memoria per memorizzare la cache chiave-valore:

    1. Multi-Query-Attention (MQA)

Multi-Query-Attention è stato proposto nell’articolo di Noam Shazeer “Fast Transformer Decoding: One Write-Head is All You Need”. Come suggerisce il titolo, Noam ha scoperto che invece di utilizzare n_head pesi di proiezione chiave-valore, è possibile utilizzare una singola coppia di pesi di proiezione testa-valore condivisa tra tutte le attenzioni senza che le prestazioni del modello si degradino significativamente.

Utilizzando una singola coppia di pesi di proiezione testa-valore, i vettori chiave-valore ki,vi \mathbf{k}_i, \mathbf{v}_i ki​,vi​ devono essere identici in tutte le attenzioni, il che significa che abbiamo bisogno di memorizzare solo una coppia di proiezione chiave-valore nella cache invece di n_head coppie.

Dato che la maggior parte dei LLM utilizza tra 20 e 100 attenzioni, MQA riduce significativamente il consumo di memoria della cache chiave-valore. Per il LLM utilizzato in questo notebook, è possibile ridurre il consumo di memoria richiesto da 15 GB a meno di 400 MB con una lunghezza di sequenza di input di 16000.

Oltre ai risparmi di memoria, MQA porta anche a un miglioramento dell’efficienza computazionale come spiegato di seguito. Nella decodifica auto-regressiva, è necessario ricaricare grandi vettori chiave-valore, concatenarli con la coppia corrente di vettori chiave-valore e quindi alimentarli nella computazione qcKT \mathbf{q}_c\mathbf{K}^T qc​KT ad ogni passaggio. Per la decodifica auto-regressiva, la larghezza di banda di memoria necessaria per il ricaricamento costante può diventare un serio collo di bottiglia in termini di tempo. Riducendo le dimensioni dei vettori chiave-valore si riduce la quantità di memoria da accedere, riducendo così il collo di bottiglia della larghezza di banda di memoria. Per ulteriori dettagli, si prega di consultare l’articolo di Noam.

La parte importante da capire qui è che ridurre il numero di attenzioni chiave-valore a 1 ha senso solo se viene utilizzata una cache chiave-valore. Il consumo di memoria massimo del modello per un singolo passaggio in avanti senza cache chiave-valore rimane invariato, in quanto ogni attenzione ha comunque un vettore di query unico in modo che ogni attenzione abbia ancora una matrice QKT \mathbf{QK}^T QKT diversa.

MQA è stato ampiamente adottato dalla comunità ed è ora utilizzato da molti dei LLM più popolari:

  • Falcon
  • PaLM
  • MPT
  • BLOOM

Inoltre, il checkpoint utilizzato in questo notebook – bigcode/octocoder – utilizza MQA.

    1. Grouped-Query-Attention (GQA)

Grouped-Query-Attention, come proposto da Ainslie et al. di Google, ha scoperto che l’utilizzo di MQA può spesso comportare una degradazione della qualità rispetto all’utilizzo delle proiezioni chiave-valore multi-testa vanilla. L’articolo sostiene che è possibile mantenere più prestazioni del modello riducendo meno drasticamente il numero di pesi di proiezione testa-query. Invece di utilizzare solo un singolo peso di proiezione chiave-valore, dovrebbero essere utilizzati n < n_head pesi di proiezione chiave-valore. Scegliendo n un valore significativamente più piccolo di n_head, come ad esempio 2, 4 o 8, si possono mantenere quasi tutti i guadagni di memoria e velocità da MQA sacrificando meno capacità del modello e quindi presumibilmente meno prestazioni.

Inoltre, gli autori di GQA hanno scoperto che i checkpoint dei modelli esistenti possono essere aggiornati per avere un’architettura GQA con una quantità di calcolo di pre-training originale ridotta fino al 5%. Anche se il 5% del calcolo di pre-training originale può comunque essere una quantità enorme, l’aggiornamento GQA consente di utilizzare i checkpoint esistenti per sequenze di input più lunghe.

GQA è stato proposto solo di recente, motivo per cui c’è una minore adozione al momento della stesura di questo notebook. La più nota applicazione di GQA è Llama-v2.

In conclusione, è vivamente consigliato utilizzare GQA o MQA se LLM viene utilizzato con decodifica auto-regressiva e deve gestire grandi sequenze di input, come ad esempio nel caso di chat.

Conclusioni

La comunità di ricerca sta costantemente proponendo nuovi modi intelligenti per velocizzare il tempo di inferenza per LLM sempre più grandi. Ad esempio, una promettente direzione di ricerca è la decodifica speculativa, in cui i “token facili” vengono generati da modelli di linguaggio più piccoli e veloci e solo i “token difficili” vengono generati dal LLM stesso. Approfondire ulteriormente è fuori dallo scopo di questo notebook, ma è possibile leggere maggiori dettagli in questo interessante articolo del blog.

La ragione per cui LLM enormi come GPT3/4, Llama-2-70b, Claude, PaLM possono funzionare così velocemente nelle interfacce di chat come Hugging Face Chat o ChatGPT è in gran parte grazie ai miglioramenti sopra menzionati nella precisione, negli algoritmi e nell’architettura. In futuro, acceleratori come le GPU, i TPU, ecc… diventeranno sempre più veloci e permetteranno di avere più memoria, ma è comunque importante sempre utilizzare gli algoritmi e le architetture migliori disponibili per ottenere il massimo risultato possibile 🤗