Avete difficoltà con l'intelligenza artificiale o lo sviluppo full-stack? I nostri esperti sono qui per guidarvi: consulenza personalizzata, integrazione tecnica e molto altro. Contattateci a [email protected].

Tecniche di ottimizzazione dell'inferenza LLM

L'ottimizzazione dell'inferenza è una parte fondamentale delle applicazioni di IA generativa utilizzate in produzione. L'uso efficiente degli LLM su scala è una sfida e negli ultimi anni sono state sviluppate molte tecniche per rendere l'inferenza più veloce ed economica. Passiamo in rassegna queste tecniche in questo articolo.

Tecniche di ottimizzazione dell'inferenza LLM

Un focus sull'architettura dei LLM

I modelli linguistici di grandi dimensioni (LLM) sono tutti basati sull'architettura transformer, inventata nel 2017 da Vaswani et al. L'architettura transformer raggiunge un'accuratezza superiore, un apprendimento a pochi colpi e abilità quasi umane in diversi compiti linguistici. Tuttavia, questi modelli di base, spesso composti da decine o centinaia di miliardi di parametri, sono costosi da addestrare e richiedono molte risorse durante l'inferenza. I costi di inferenza aumentano con contesti di input lunghi, che richiedono una notevole potenza di elaborazione a causa dei grandi dati di input. Ciò rende l'inferenza efficiente una sfida cruciale, in particolare nella gestione della memoria e delle risorse di calcolo.

L'architettura del trasformatore
L'architettura del trasformatore

In particolare, la maggior parte dei LLM noti sono LLM di solo decodifica, come GPT-3, GPT-4, LLaMA, Mistral, DeepSeek, ecc. Questi modelli sono preaddestrati a un compito di modellazione causale e funzionano come predittori di parole successive. Elaborano una sequenza di token come input e producono i token successivi in modo autoregressivo fino al raggiungimento di una condizione di arresto.

L'inferenza LLM nei modelli con solo decodificatore comporta due fasi fondamentali: la fase di precompilazione e la fase di decodificazione. Nella fase di prefill, il modello elabora i token in ingresso per calcolare gli stati intermedi (chiavi e valori) per generare il primo nuovo token. Questa fase, che assomiglia a un'operazione matrice-matrice, è altamente parallelizzata e utilizza in modo efficiente le capacità delle GPU. Al contrario, la fase di decodifica genera i token, uno alla volta, basandosi sugli stati dei token precedenti. Questa operazione matrice-vettore è legata alla memoria, in quanto il trasferimento dei dati alla GPU, piuttosto che la velocità di calcolo, determina principalmente la latenza, con conseguente sottoutilizzo della potenza di calcolo della GPU.

L'ottimizzazione della fase di decodifica è un punto focale per affrontare le sfide dell'inferenza. Le soluzioni includono lo sviluppo di meccanismi di attenzione efficienti e una migliore gestione delle chiavi e dei valori per ridurre i colli di bottiglia della memoria. Il post evidenzia gli approcci pratici per migliorare le prestazioni dell'inferenza, partendo dal presupposto che il lettore abbia una conoscenza di base dell'architettura del trasformatore e dei meccanismi di attenzione. Queste ottimizzazioni sono fondamentali per migliorare il throughput e ridurre la latenza nelle implementazioni LLM del mondo reale.

Un'ulteriore complicazione deriva dall'uso di tokenizer diversi tra i vari LLM, che influisce sulla comparabilità dei token. I token, approssimativamente equivalenti a quattro caratteri inglesi, variano nella rappresentazione a seconda del tokenizer, rendendo fuorvianti i confronti diretti del throughput di inferenza (ad esempio, i token al secondo). Questa variabilità sottolinea la necessità di metriche di valutazione standardizzate per valutare e confrontare accuratamente le prestazioni dei LLM durante l'inferenza.

Dosaggio

Il batching è una strategia chiave per migliorare l'utilizzo delle GPU e il throughput dei modelli linguistici di grandi dimensioni (LLM). Elaborando più richieste simultaneamente utilizzando lo stesso modello, il batching distribuisce il costo della memoria dei pesi del modello tra le richieste, consentendo ai batch più grandi di sfruttare una maggiore potenza di calcolo della GPU. Tuttavia, c'è un limite alla dimensione dei batch, poiché batch troppo grandi possono causare un overflow della memoria a causa delle richieste di memoria degli LLM, in particolare in relazione alla cache chiave-valore (KV) (per saperne di più).

Tecniche di dosaggio
Tecniche di dosaggio

Il batching tradizionale o statico ha dei limiti perché le richieste all'interno di un batch spesso generano un numero diverso di token di completamento, portando a tempi di esecuzione diversi. Questo fa sì che tutte le richieste attendano il completamento di quella più lenta, il che può essere problematico quando la lunghezza della generazione varia in modo significativo. Per risolvere questo problema, sono state sviluppate tecniche avanzate come il batching in volo per ottimizzare le prestazioni.

Il batching in volo, noto anche come batching continuo, affronta le sfide poste dalla natura dinamica dei carichi di lavoro LLM, che possono spaziare dalle semplici risposte dei chatbot alla sintesi di documenti complessi o alla generazione di codice. Questi compiti producono output di dimensioni molto diverse, rendendo difficile il batching e l'esecuzione efficiente delle richieste in parallelo. A differenza del batching statico, il batching in-flight consente al server di eliminare immediatamente le sequenze completate dal batch e di iniziare a elaborare nuove richieste mentre altre sono ancora in corso. Questo approccio aumenta significativamente l'utilizzo della GPU adattandosi ai tempi di esecuzione variabili delle richieste negli scenari reali.

Distribuzione multi-GPU con parallelizzazione del modello

La parallelizzazione dei modelli è una strategia fondamentale per gestire la memoria e le richieste di calcolo dei modelli di apprendimento automatico su larga scala, distribuendoli su più GPU. Questo approccio consente di gestire modelli più grandi o batch di input che superano la capacità di memoria di un singolo dispositivo, rendendolo essenziale sia per l'addestramento che per l'inferenza quando i vincoli di memoria sono stretti. Esistono varie tecniche per suddividere i pesi dei modelli, tra cui il parallelismo delle pipeline, il parallelismo dei tensori e il parallelismo delle sequenze, ognuna delle quali affronta aspetti diversi della distribuzione dei modelli. A differenza del parallelismo dei dati, che si concentra sulla replica dei pesi del modello tra i dispositivi per elaborare lotti di input più grandi durante l'addestramento, questi metodi sono più importanti per ridurre le impronte di memoria sia durante l'addestramento che l'inferenza.

Più GPU NVIDIA
Più GPU NVIDIA

Il parallelismo della pipeline divide il modello verticalmente in pezzi sequenziali, e ogni pezzo contiene un sottoinsieme di strati assegnati a un dispositivo separato. Per esempio, in una configurazione di pipeline a quattro vie, ogni dispositivo gestisce un quarto dei livelli del modello, passando le uscite al dispositivo successivo in sequenza. Se da un lato questo riduce in modo significativo i requisiti di memoria per dispositivo, dall'altro introduce delle inefficienze note come "bolle di pipeline", in cui i dispositivi possono rimanere inattivi in attesa degli output dei livelli precedenti. Il microbatching, che suddivide i batch di input in sottobatch più piccoli per l'elaborazione sequenziale, può ridurre queste bolle ma non eliminarle del tutto, poiché i tempi di inattività persistono durante i passaggi in avanti e indietro.

Il parallelismo tensoriale, invece, suddivide orizzontalmente i singoli strati in blocchi computazionali più piccoli che possono essere eseguiti in modo indipendente sui vari dispositivi. Questo è particolarmente efficace per i componenti del trasformatore, come i blocchi di attenzione e i percettori multistrato (MLP), dove, ad esempio, diverse teste di attenzione possono essere assegnate a dispositivi separati per il calcolo parallelo. Tuttavia, il parallelismo tensoriale è meno efficace per operazioni come LayerNorm e Dropout, che non possono essere facilmente suddivise e devono essere replicate tra i dispositivi, con conseguente utilizzo ridondante della memoria per memorizzare le attivazioni. Questa limitazione evidenzia la necessità di approcci complementari per ottimizzare l'efficienza della memoria.

Il parallelismo di sequenza risolve le inefficienze di memoria di operazioni come LayerNorm e Dropout partizionandole lungo la dimensione della sequenza di ingresso, sfruttando la loro indipendenza tra gli elementi della sequenza. Questo metodo riduce l'impronta di memoria delle attivazioni ridondanti, rendendolo un valido complemento al parallelismo tensoriale. Queste tecniche di parallelizzazione non si escludono a vicenda e possono essere combinate per ottimizzare ulteriormente i modelli linguistici di grandi dimensioni (LLM). Inoltre, strategie di ottimizzazione specifiche per il modulo di attenzione possono migliorare la scalabilità e ridurre le richieste di memoria per GPU, consentendo un addestramento e un'inferenza più efficienti per modelli di grandi dimensioni.

Ottimizzazione dell'attenzione

L'articolo del 2017 *Attention Is All You Need* di Vaswani et al. ha introdotto il modello Transformer, la cui pietra angolare è l'autoattenzione. L'autoattenzione consente al modello di valutare la rilevanza di diverse parole in una frase rispetto alle altre, migliorando la comprensione contestuale per compiti come l'elaborazione del linguaggio naturale. Il documento ha formalizzato l'autoattenzione, in particolare attraverso il meccanismo dell'attenzione scalare del prodotto del punto (SDPA), che mappa le coppie query-chiave-valore in un output, rendendolo un componente fondamentale delle moderne reti neurali. Ecco alcune delle tecniche più importanti per ottimizzare i calcoli di attenzione:

La carta dell'attenzione
La carta dell'attenzione

L'attenzione a più teste (MHA) si basa su SDPA eseguendo più operazioni di attenzione in parallelo, ciascuna con proiezioni distinte delle matrici di interrogazione, chiave e valore. Queste operazioni parallele, o "teste", si concentrano su diversi sottospazi di rappresentazione, arricchendo la comprensione dell'input da parte del modello. I risultati di queste teste sono concatenati e proiettati linearmente, mantenendo un'efficienza computazionale paragonabile a quella dell'attenzione a testa singola, riducendo la dimensionalità di ogni testa (ad esempio, dividendo la dimensione del modello per il numero di teste, come 8).

L'attenzione multi-query (MQA) ottimizza l'MHA per l'inferenza condividendo le proiezioni di chiavi e valori su più teste di attenzione, mantenendo al contempo le proiezioni di più query. In questo modo si riducono le richieste di larghezza di banda della memoria e le dimensioni della cache chiave-valore (KV), consentendo di ottenere batch di dimensioni maggiori e un migliore utilizzo dei calcoli. Tuttavia, l'MQA può ridurre leggermente l'accuratezza e i modelli che lo sfruttano richiedono un addestramento o una messa a punto con l'MQA abilitato per mantenere le prestazioni.

La Grouped-query attention (GQA) bilancia MHA e MQA raggruppando le teste delle query e condividendo le proiezioni dei valori-chiave all'interno di ciascun gruppo, ottenendo una qualità vicina a MHA con un'efficienza computazionale più vicina a MQA. Modelli come Llama 2 70B utilizzano GQA e quelli addestrati con MHA possono essere adattati a GQA con un addestramento aggiuntivo minimo. Sia MQA che GQA riducono il fabbisogno di memoria cache di KV, anche se sono necessarie ulteriori ottimizzazioni nella gestione della cache.

FlashAttention migliora i meccanismi di attenzione riordinando i calcoli per sfruttare in modo più efficace le gerarchie di memoria della GPU. A differenza dell'elaborazione tradizionale strato per strato, FlashAttention fonde le operazioni e utilizza il "tiling" per calcolare piccole porzioni della matrice di uscita in una sola volta, riducendo al minimo le operazioni di lettura/scrittura della memoria. Questo algoritmo di attenzione esatta e consapevole dell'I/O si integra perfettamente nei modelli esistenti senza modifiche, offrendo notevoli accelerazioni grazie all'ottimizzazione del movimento dei dati.

Caching chiave-valore

La cache KV è una tecnica di ottimizzazione critica utilizzata durante la fase di decodifica dei modelli linguistici di grandi dimensioni (LLM) per migliorare l'efficienza dei calcoli di autoattenzione. In questa fase, ogni token generato dipende dai tensori chiave (K) e valore (V) di tutti i token precedenti, compresi quelli calcolati durante la fase di precompilazione e le successive fasi di decodifica. Invece di ricalcolare questi tensori per ogni token a ogni passo temporale, la cache KV li memorizza nella memoria della GPU, aggiungendo nuovi tensori alla cache man mano che vengono calcolati. In genere, viene mantenuta una cache KV separata per ogni livello del modello, riducendo in modo significativo le computazioni ridondanti e accelerando il processo di decodifica.

Caching chiave-valore
Caching chiave-valore

I requisiti di memoria per gli LLM sulle GPU sono determinati principalmente da due componenti: i pesi del modello e la cache KV. I pesi del modello, che consistono nei parametri del modello, occupano una notevole quantità di memoria; ad esempio, un modello da 7 miliardi di parametri come Llama 2 7B in precisione a 16 bit richiede circa 14 GB. La cache KV, invece, memorizza i tensori di autoattenzione per evitare di ricomputarli; la sua dimensione è determinata da fattori quali il numero di strati, le teste di attenzione, le dimensioni delle teste e la precisione. Per ogni token, la dimensione della cache è calcolata come 2 * num_layers * (num_heads * dim_head) * precision_in_bytes, dove il fattore 2 tiene conto di entrambe le matrici K e V. Per un batch di input, la dimensione totale della cache KV scala con la dimensione del batch e la lunghezza della sequenza, raggiungendo potenzialmente dimensioni significative, come ~2 GB per un modello Llama 2 7B con una lunghezza della sequenza di 4.096 e una dimensione del batch pari a 1.

La gestione efficiente della cache KV pone delle sfide a causa della sua crescita lineare con la dimensione del batch e la lunghezza della sequenza, che può limitare il throughput e complicare la gestione degli input a contesto lungo. Un'inefficienza comune deriva dall'over-provisioning statico, in cui la memoria viene riservata per la lunghezza massima della sequenza supportata (ad esempio, 2.048 token), indipendentemente dalla dimensione effettiva dell'input. Ciò comporta un notevole spreco di memoria o una frammentazione, poiché gran parte dello spazio riservato rimane spesso inutilizzato per tutta la durata della richiesta, impegnando preziose risorse di memoria della GPU.

Per risolvere queste inefficienze, l'algoritmo PagedAttention introduce un nuovo approccio ispirato alla paginazione del sistema operativo. Divide la cache KV in blocchi di dimensioni fisse, ciascuno dei quali rappresenta un numero prestabilito di token, che possono essere memorizzati in modo non contiguo. Una tabella di blocchi tiene traccia di questi blocchi, recuperandoli quando necessario durante i calcoli di attenzione. Quando vengono generati nuovi token, vengono allocati dinamicamente altri blocchi. Questo metodo riduce al minimo gli sprechi di memoria eliminando la necessità di allocazione contigua e di over-provisioning, consentendo l'utilizzo di batch di dimensioni maggiori e migliorando il throughput, rappresentando così un significativo progresso nella gestione della memoria cache KV per gli LLM.

Ottimizzazione del modello

In questa sezione vengono discusse varie tecniche di ottimizzazione dei modelli linguistici di grandi dimensioni (LLM) per ridurre il loro consumo di memoria e migliorare le prestazioni sulle GPU. I metodi principali includono la quantizzazione, la sparsità e la distillazione, ognuno dei quali mira a diversi aspetti dell'efficienza del modello. Queste tecniche modificano i pesi dei modelli, sfruttano l'accelerazione hardware delle GPU e trasferiscono la conoscenza a modelli più piccoli, consentendo l'esecuzione di modelli più grandi su un hardware limitato e mantenendo le prestazioni. Questi metodi possono degradare l'accuratezza del modello, quindi devono essere usati con cautela.

La quantizzazione riduce la precisione dei pesi e delle attivazioni di un modello, in genere da 32 o 16 bit a 8 o meno bit, consentendo ai modelli di occupare meno memoria e di trasferire i dati in modo più efficiente. Mentre la quantizzazione dei pesi è semplice a causa della loro natura fissa dopo l'addestramento, la quantizzazione delle attivazioni è più complessa a causa degli outlier che espandono la loro gamma dinamica. Tecniche come LLM.int8() risolvono questo problema applicando selettivamente una precisione maggiore a certe attivazioni, oppure riutilizzando l'intervallo dinamico dei pesi quantizzati per le attivazioni, anche se le GPU potrebbero richiedere la conversione dei pesi a una precisione maggiore per le operazioni.

La sparsità comporta la riduzione dei valori del modello prossimi allo zero, creando matrici rade che richiedono meno memoria. Le GPU supportano la sparsità strutturata, come la rappresentazione di due valori su quattro come zeri, che accelera i calcoli. La combinazione di sparsità e quantizzazione può migliorare ulteriormente la velocità di esecuzione. La ricerca continua a esplorare le rappresentazioni sparse ottimali per gli LLM, indicando una strada promettente per migliorare la velocità di inferenza.

La distillazione trasferisce la conoscenza da un modello "insegnante" più grande a un modello "studente" più piccolo, comprimendo le dimensioni e preservando le prestazioni. Ad esempio, DistilBERT ottiene una riduzione delle dimensioni del 40% e un aumento della velocità del 60% rispetto a BERT, mantenendo il 97% delle sue capacità. La distillazione può comportare l'imitazione dei risultati dell'insegnante o l'utilizzo di dati generati dall'insegnante per l'addestramento, con metodi come "Distillare passo dopo passo!" che incorporano razionali per un apprendimento efficiente. Tuttavia, le licenze restrittive di molti LLM avanzati limitano la disponibilità di modelli di insegnanti adatti alla distillazione.

Inferenza speculativa

L'inferenza speculativa, nota anche come campionamento speculativo o generazione assistita, è un metodo per parallelizzare l'esecuzione di modelli linguistici autoregressivi di grandi dimensioni (LLM), come i modelli di tipo GPT, che tipicamente generano il testo token per token. Nell'esecuzione standard, ogni token dipende da tutti i token precedenti per il contesto, rendendo impossibile la generazione parallela, poiché l'ennesimo token deve essere generato prima dell'(n+1)esimo. L'inferenza speculativa risolve questo problema utilizzando una bozza di modello "più economica" per prevedere simultaneamente più token futuri, che vengono poi verificati o rifiutati in parallelo dal modello principale, consentendo una generazione più rapida del testo.

Il processo prevede la generazione di una bozza di continuazione di diversi token utilizzando un metodo che richiede meno risorse, seguita da una verifica parallela da parte del modello principale che utilizza la bozza come contesto speculativo. Se il modello di verifica corrisponde ai token della bozza, questi vengono accettati; in caso contrario, i token non corrispondenti e quelli successivi vengono scartati e il processo si ripete con una nuova bozza. Le bozze di token possono essere generate con diversi approcci, come l'addestramento di più modelli, la messa a punto di più teste su un modello pre-addestrato per prevedere i token futuri, o l'impiego di un modello di bozza più piccolo accanto a un modello di verifica più grande e più capace, ciascuno con i propri compromessi.

Inferenza disaggregata

L'inferenza disaggregata è una tecnica in cui i compiti di calcolo sono suddivisi su hardware diversi per ottimizzare le prestazioni, i costi e l'uso delle risorse. In particolare, separa le fasi di precompilazione e decodifica. Disaggregando queste fasi, ognuna può essere assegnata all'hardware più adatto alle sue esigenze di calcolo, migliorando l'efficienza e la scalabilità.

Inferenza disaggregata
Inferenza disaggregata

La precompilazione è un'operazione ad alta intensità di calcolo, che richiede significative moltiplicazioni matriciali per elaborare l'intero prompt di input e produrre cache KV. Questa fase trae vantaggio da hardware ad alte prestazioni come le GPU o le TPU, che eccellono nelle computazioni in parallelo. Dato che il prefilling è un'attività una tantum per ogni richiesta di inferenza, può essere scaricato su un nodo di calcolo centralizzato e potente, ottimizzato per tali carichi di lavoro. Questa configurazione consente un'elaborazione più rapida di richieste di grandi dimensioni e riduce il carico sui dispositivi meno capaci, rendendola ideale per gli ambienti basati su cloud o data center in cui è disponibile hardware ad alta velocità.

La decodifica, al contrario, è legata alla memoria e comporta la generazione iterativa di token, facendo molto affidamento sull'accesso alle cache KV. Richiede meno potenza di calcolo ma necessita di un accesso rapido alla memoria, il che la rende adatta a hardware meno potente e ottimizzato per la memoria, come CPU o dispositivi edge. Spostando la decodifica su un hardware separato, potenzialmente più vicino all'utente finale, come i server on-premises o i dispositivi edge, l'inferenza disaggregata riduce la latenza e la richiesta di banda di rete. Questa separazione consente un'implementazione flessibile, in cui la pre-compilazione viene eseguita su server cloud di fascia alta e la decodifica avviene su dispositivi locali o edge, ottimizzando l'allocazione delle risorse e consentendo una scalabilità efficiente per applicazioni come chatbot in tempo reale o sistemi di intelligenza artificiale interattivi.

Conclusione

Recentemente sono state inventate molte tecniche di ottimizzazione dell'inferenza per migliorare le prestazioni degli LLM.

L'implementazione di queste tecniche richiede una profonda conoscenza dell'architettura LLM e dell'hardware in uso, per cui è generalmente più facile utilizzare un motore di inferenza esistente che abbia già implementato queste tecniche, come vLLM, TensorRT-LLM, LMDeploy, ecc. Abbiamo implementato queste tecniche nel nostro motore di inferenza presso NLP Cloud e abbiamo scritto un post sul blog dedicato ai motori di inferenza se volete distribuire i vostri modelli: potete leggerlo qui.

Se non potete o non volete implementare da soli i vostri LLM, potete utilizzare NLP Cloud e sfruttare modelli di IA generativi veloci e su scala in produzione. Provate subito l'inferenza veloce su NLP Cloud!

Se avete domande sui motori di inferenza in generale, non esitate a chiedercele, è sempre un piacere consigliarvi!

Julien
CTO di NLP Cloud