Migliora le prestazioni dei modelli Falcon con Amazon SageMaker

Migliora le prestazioni dei modelli Falcon con Amazon SageMaker il segreto per ottenere risultati straordinari

Qual è il framework e la configurazione ottimali per l’hosting di modelli di linguaggio di grandi dimensioni (LLM) per applicazioni di intelligenza artificiale generativa che generano testo? Nonostante l’abbondanza di opzioni per la distribuzione di LLM, questa è una domanda difficile da rispondere a causa delle dimensioni dei modelli, delle diverse architetture dei modelli, dei requisiti di prestazioni delle applicazioni e altro ancora. Il container Large Model Inference (LMI) di Amazon SageMaker rende semplice distribuire LLM mettendo insieme una serie di diversi framework e tecniche che ottimizzano la distribuzione di LLM. Il container LMI ha uno stack di servizio potente chiamato DJL serving che è agnostico rispetto al LLM sottostante. Fornisce parametri di configurazione a livello di sistema che possono essere regolati per estrarre le migliori prestazioni dell’infrastruttura di hosting per un determinato LLM. Supporta anche ottimizzazioni recenti come il batching continuo, noto anche come batching iterativo o batching rolling, che fornisce miglioramenti significativi nella velocità di elaborazione.

In un precedente articolo, abbiamo mostrato come è possibile utilizzare il container LMI per distribuire la famiglia di modelli Falcon su SageMaker. In questo articolo, dimostriamo come migliorare la velocità di elaborazione e la latenza nel servire Falcon-40B con tecniche come il batching continuo. Forniamo anche una comprensione intuitiva dei parametri di configurazione forniti dal container LMI di SageMaker che possono aiutarti a trovare la migliore configurazione per la tua applicazione nel mondo reale.

Fondamenti dell’inferenza testuale generativa per LLM

Prima di tutto, vediamo alcuni fondamenti su come effettuare l’inferenza per LLM per la generazione di testo.

Passaggio in avanti, attivazioni e cache KV

Dato un sequenza di token in input, essa viene eseguita in un passaggio in avanti su tutti gli strati del LLM (come Falcon) per generare il token successivo. Un passaggio in avanti si riferisce al processo di passaggio dei dati di input attraverso una rete neurale per produrre un’uscita. Nel caso della generazione di testo, il passaggio in avanti implica l’alimentazione di un seed iniziale o di un contesto nel modello linguistico e la generazione del carattere o del token successivo nella sequenza. Per generare una sequenza di testo, il processo viene spesso eseguito in modo iterativo, ossia viene ripetuto per ogni passo o posizione nella sequenza di output. Ad ogni iterazione, il modello genera il carattere o il token successivo, che diventa parte del testo generato, e questo processo continua fino a quando non viene generata la lunghezza desiderata del testo.

La generazione di testo con modelli linguistici come Falcon o GPT è autoregressiva. Ciò significa che il modello genera un token alla volta, condizionandolo ai token precedentemente generati. In altre parole, ad ogni iterazione, il modello prende il testo generato in precedenza come input e predice il token successivo in base a quel contesto. Come citato in vLLM: Facile, Veloce ed Economico Servizio LLM con PagedAttention, in questo processo di decodifica autoregressiva, tutti i token di input al LLM producono i loro tensori chiave di attenzione e valori, e questi tensori vengono conservati nella memoria GPU per generare i token successivi. Questi tensori chiave e valori memorizzati nella cache sono spesso chiamati cache KV.

Fasi di pre-fill e decode

In un processo di decodifica autoregressiva, come quello utilizzato nella generazione di testo con modelli linguistici come Falcon, ci sono tipicamente due fasi principali: la fase di pre-fill e la fase di decode. Queste fasi sono cruciali per la generazione di testo coerente e rilevante dal punto di vista del contesto.

La fase di pre-fill include quanto segue:

  • Contesto iniziale – La fase di pre-fill inizia con un contesto iniziale o un testo di partenza fornito dall’utente. Questo contesto iniziale può essere una frase, una frase o anche solo una singola parola. Imposta il punto di partenza per la generazione di testo e fornisce un contesto per ciò che seguirà.
  • Condizionamento del modello – Il contesto fornito viene utilizzato per condizionare il modello linguistico. Il modello utilizza questo contesto come input e genera il token successivo (parola o carattere) nella sequenza in base alla sua comprensione del contesto.
  • Generazione di token – Il modello genera un token alla volta, prevedendo ciò che dovrebbe venire dopo nel testo. Questo token viene aggiunto al contesto, estendendolo efficacemente.
  • Processo iterativo – Il processo di generazione dei token viene ripetuto in modo iterativo. Ad ogni passo, il modello genera un token mentre considera il contesto aggiornato, che include ora i token generati nei passaggi precedenti.

La fase di prefill continua fino a quando non viene raggiunta una condizione di arresto predefinita. Questa condizione può essere una lunghezza massima per il testo generato, un token specifico che segnala la fine del testo o qualsiasi altro criterio stabilito dall’utente o dall’applicazione.

La fase di decodifica include quanto segue:

  • Completamento – Dopo la fase di prefill, si ha un testo parzialmente generato che può essere incompleto o interrotto in qualche punto. La fase di decodifica si occupa di completare il testo per renderlo coerente e grammaticalmente corretto.
  • Continuazione dall’ultimo token – Nella fase di decodifica, il modello parte dall’ultimo token generato durante la fase di prefill. Utilizza questo token come contesto iniziale e genera il token successivo per continuare il testo.
  • Completamento iterativo – Come nella fase di prefill, il processo di generazione dei token è di nuovo iterativo. Il modello genera un token alla volta, basandosi sui token precedenti nella sequenza.
  • Condizione di arresto – La fase di decodifica ha anche una condizione di arresto, che potrebbe essere la stessa della fase di prefill, come raggiungere una lunghezza massima o incontrare un token di fine del testo. Quando questa condizione è soddisfatta, il processo di generazione si interrompe.

La combinazione delle fasi di prefill e decodifica consente ai modelli autoregressivi di generare testi che si basano su un contesto iniziale e producono sequenze di testo coerenti, pertinenti e coerenti dal punto di vista del contesto.

Fai riferimento a Un sistema di servizio distribuito per modelli generativi basati su Transformer per una spiegazione dettagliata del processo.

Ottimizzazione della throughput utilizzando il batching dinamico

Fino ad ora, abbiamo parlato solo di un singolo input. Nella pratica, ci aspettiamo di gestire più richieste che arrivano casualmente dai client dell’applicazione per l’elaborazione contemporanea o in modo sfalsato. Nel modo tradizionale, il batching di base può essere utilizzato per aumentare la throughput e l’utilizzo delle risorse di calcolo della GPU. Il batching consiste nell’unire le rappresentazioni numeriche di più di una richiesta in un batch e nell’eseguire parallelamente i passaggi in avanti autoregressivi. Questo batching intelligente viene fatto dal lato del servizio. Il server DJLServing di SageMaker LMI può essere configurato per unire insieme più richieste per elaborarle in parallelo impostando i seguenti parametri in serving.properties:

  • max_batch_delay = 100 – Il ritardo massimo per l’aggregazione del batch in millisecondi. Il valore predefinito è di 100 millisecondi.
  • batch_size = 32 – La dimensione dinamica del batch. Il valore predefinito è 1.

Questo mostra essenzialmente che DJLServing metterà in coda le richieste per 100 millisecondi alla volta o se il numero di richieste in coda raggiunge la dimensione del batch specificata, il batch verrà pianificato per eseguire il back-end per l’elaborazione. Questo è noto come batching dinamico. È dinamico perché la dimensione del batch può cambiare tra i batch a seconda di quante richieste sono state aggiunte in quel periodo di tempo. Tuttavia, poiché le richieste potrebbero avere caratteristiche diverse (ad esempio, alcune richieste potrebbero avere una forma di 20 token di input e 500 token di output, mentre altre potrebbero essere invertite, con 500 token di input ma solo 20 per l’output), alcune richieste potrebbero completare l’elaborazione più rapidamente di altre nello stesso batch. Ciò potrebbe comportare sottoutilizzazione della GPU mentre si attende che tutte le richieste in volo nel batch completino la fase di decodifica, anche se ci sono altre richieste in attesa di essere elaborate nella coda. Il diagramma seguente illustra questo processo.

Visualizzazione semplice del batching dinamico

Batching visivo dinamico – osserva le finestre inattive alla fine della richiesta 2 e 3

Ottimizzazione della velocità di elaborazione utilizzando il batching continuo

Con il batching continuo, noto anche come iterativo o rolling batching, sfruttiamo le differenze tra le fasi di prefill e decode. Per attivare il batching continuo, DJServing fornisce le seguenti configurazioni aggiuntive come specificato in serving.properties:

  • engine=MPI – Ti incoraggiamo ad utilizzare il motore MPI per il batching continuo.
  • option.rolling_batch=auto o lmi-dist – Consigliamo di utilizzare auto perché sceglierà automaticamente l’algoritmo di rolling batch più appropriato insieme ad altre ottimizzazioni in futuro.
  • option.max_rolling_batch_size=32 – Questo limite il numero di richieste concorrenti. Predefinito: 32.

Con il batching continuo, lo stack di servizio (DJLServing) non aspetta che tutte le richieste in batch in corso completino la fase di decode. Al contrario, ai punti di interruzione logici (alla fine di una iterazione nella fase di decode), vengono gestite ulteriori richieste in attesa nella coda mentre il batch corrente è ancora in fase di elaborazione (da qui il nome di rolling batch). Viene effettuato questo controllo per le richieste in sospeso alla fine di ogni iterazione della fase di decode. Ricorda, per ogni richiesta, è necessario eseguire la fase di prefill seguita dalla fase di decode sequenziale. Poiché possiamo elaborare tutti i token dal prompt iniziale di una richiesta in parallelo per la sua fase di prefill, ogni volta che viene elaborata una nuova richiesta, mettiamo temporaneamente in pausa la fase di decode delle richieste in corso nel batch – salviamo temporaneamente la sua cache di KV e le attivazioni in memoria ed eseguiamo la fase di prefill delle nuove richieste.

La dimensione di questa cache può essere configurata con la seguente opzione:

Quando il prefill è completo, uniamo le nuove richieste e le vecchie richieste in pausa in un nuovo rolling batch, che può procedere con la loro fase di decode in parallelo. Nota che le vecchie richieste in pausa possono continuare la fase di decode da dove si erano interrotte e le nuove richieste iniziano dal loro primo nuovo token.

Batching continuo o iterativo visuale

Batching continuo o iterativo visuale – osserva che i tempi di inattività sono sostituiti da richieste successive

Potresti aver già capito che il batching continuo è un approccio quasi simile a quello con cui naturalmente parallelizziamo le attività nella nostra vita quotidiana. Abbiamo messaggi, email, notifiche telefoniche (potenzialmente nuove richieste) che arrivano in momenti casuali (analoghe a molteplici richieste che arrivano in modo casuale in modo scomposto per le GPU). Tutto ciò accade mentre eseguiamo le nostre attività in corso: scriviamo email, codifichiamo, partecipiamo a riunioni (analoghe alle attività attualmente in elaborazione nelle GPU). Ai punti di interruzione logici, mettiamo in pausa le nostre attività in corso e controlliamo le notifiche per decidere se è necessaria qualche azione da parte nostra e, in caso affermativo, la inseriamo nelle nostre attività in corso (rolling batch nella vita reale) o la mettiamo in un elenco delle cose da fare (la coda).

Mettere tutto insieme: come pensare all’utilizzo della memoria delle GPU

Si consiglia di effettuare un test di carico del modello per vedere quale configurazione è la più conveniente per il tuo caso d’uso aziendale. Per capire meglio, visualizziamo la quantità di memoria occupata dalle GPU mentre il modello viene caricato e le richieste successive vengono elaborate in un batch continuo. In questo post, assumiamo di caricare il modello Falcon-40B su uno dei tipi di istanze G5 equipaggiate con GPU NVIDIA A10G, ognuna con 24 GB di memoria. Nota che una comprensione simile si applica anche ai tipi di istanze p3, p4 e p5, che dispongono delle serie di GPU V100, A100 e H100.

Ecco una panoramica di come ottenere un valore approssimativo della memoria totale richiesta per servire Falcon-40B:

  • Dimensione del modello = Numero dei parametri del modello (40 miliardi per Falcon-40B) x 4 byte per parametro (per FP32) = 160 GB
  • Memoria totale approssimativa richiesta per caricare Falcon-40B per l’elaborazione delle inferenze = Dimensione del modello (=160 GB) + Cache KV (Cache di attenzione) (=*20 GB) + Sovraccarico di memoria aggiuntivo dovuto ai framework di ML (circa 2 GB)
Memory Visual

Memory Visual – Comprensione della dimensione della memoria di un modello Falcon-40B caricato

Per Falcon-40B, se comprimiamo il modello quantizzando il modello al tipo di dati bfloat16 (2 byte), la dimensione del modello diventa approssimativamente 80 GB. Come puoi vedere, questa dimensione è ancora superiore alla memoria supportata da un singolo dispositivo acceleratore, quindi è necessario adottare una tecnica di partizionamento del modello (sharding) con un approccio speciale di parallelismo dei tensori e suddividere il modello su più dispositivi acceleratori. Supponiamo di aver scelto g5.24xlarge, che ha 4 dispositivi GPU A10G. Se configuriamo DJLServing (serving.properties) con quanto segue, possiamo aspettarci che i 80 GB di pesi del modello vengano divisi equamente tra tutte le 4 GPU:

  • option.tensor_parallel_degree%0Aoption.-,tensor_parallel_degree,-%3D2%0A%23%20specify%20per) = 4 o 8, o utilizzare max (massimo delle GPU rilevate sull’istanza)

Con tensor_parallel_degree impostato su 4, circa il 20 GB della memoria GPU di 24 GB (circa l’84%) è già utilizzato prima ancora che venga elaborata una singola richiesta. Il restante 16% della GPU verrà utilizzato per la cache KV delle richieste in arrivo. È possibile che per lo scenario aziendale e i suoi requisiti di latenza e throughput, siano più che sufficienti 2-3 GB della memoria rimanente. In caso contrario, è possibile aumentare la dimensione dell’istanza a g5.48xlarge, che dispone di 8 GPU e utilizza tensor_parallel_degree impostato su 8. In tale caso, solo circa 10 GB della memoria disponibile di 24 GB di ogni GPU vengono utilizzati per i pesi del modello e disponiamo di circa il 60% della GPU rimanente per le attivazioni e la cache KV. Intuitivamente, possiamo vedere che questa configurazione potrebbe consentirci di ottenere un throughput più elevato. Inoltre, poiché disponiamo di un buffer più grande ora, possiamo aumentare i parametri max_rolling_batch_prefill_tokens e max_rolling_batch_size per ottimizzare ulteriormente il throughput. Insieme, questi due parametri controlleranno le preallocazioni delle attivazioni prefill e della cache KV per il modello. Un numero maggiore per questi due parametri correla a un throughput maggiore, a condizione che tu abbia abbastanza buffer per la cache KV nella memoria GPU.

Batching continuo con PagedAttention

PagedAttention è un nuovo algoritmo di ottimizzazione sviluppato da UC Berkeley che migliora il processo di batching continuo consentendo alla cache di attenzione (cache KV) di essere non contigua, allocando memoria in pagine o blocchi di dimensione fissa. Questa idea è ispirata ai concetti di memoria virtuale e paging utilizzati dai sistemi operativi.

Come indicato nel paper vLLM, la cache di attenzione di ogni sequenza di token è suddivisa in blocchi e mappata in blocchi fisici tramite una tabella dei blocchi. Durante il calcolo dell’attenzione, un kernel PagedAttention può utilizzare la tabella dei blocchi per recuperare efficientemente i blocchi dalla memoria fisica. Ciò comporta una significativa riduzione dello spreco di memoria e permette di avere batch più grandi, un maggiore utilizzo della GPU e una maggiore capacità di calcolo.

Confronto delle prestazioni

Per garantire un efficace test di carico della configurazione di distribuzione, si consiglia di iniziare considerando lo scenario aziendale e definendo chiaramente le caratteristiche dell’input e dell’output per l’applicazione basata su LLM. Ad esempio, se si sta lavorando su un caso di utilizzo di riassunto di un call center, l’input potrebbe consistere in un testo più grande, come una trascrizione di chat di 500 token tra un agente del servizio clienti e un cliente, ma l’output potrebbe essere relativamente più piccolo, intorno ai 100 token, rappresentando un riassunto della trascrizione. D’altra parte, se si sta lavorando su uno scenario di generazione di codice, l’input potrebbe essere breve come 15 token, come “scrivi un’implementazione efficiente in Python per descrivere tutte le risorse EC2, inclusa la paginazione”, ma l’output potrebbe essere molto più grande, arrivando a 500 token. È anche importante considerare se ottenere una latenza inferiore o massimizzare la capacità di calcolo è la priorità principale per il tuo scenario specifico.

Dopo aver acquisito una comprensione completa dello scenario aziendale, puoi analizzare e determinare la configurazione ottimale per il tuo ambiente di hosting. In questo contesto, l’ambiente di hosting comprende vari elementi chiave, tra cui il tipo di istanza e altri parametri di configurazione come tensor_parallel_degree, max_rolling_batch_size, max_rolling_batch_prefill_tokens e altro ancora. Il nostro obiettivo è individuare la configurazione più efficace per supportare i requisiti di tempo di risposta, capacità di calcolo e qualità dell’output del modello.

Nella nostra analisi, abbiamo effettuato un benchmark delle prestazioni per illustrare i vantaggi del batching continuo rispetto al batching dinamico tradizionale. Abbiamo utilizzato le configurazioni dettagliate nella seguente tabella in serving.properties per il batching dinamico e il batching iterativo, utilizzando un contenitore LMI su SageMaker.

Batching Dinamico Batching Continuo Batching Continuo con PagedAttention

engine=Python

option.model_id=tiiuae/falcon-40b

option.tensor_parallel_degree=8

option.dtype=fp16

batch_size=4

max_batch_delay=100

option.trust_remote_code=true

engine=MPI

option.model_id={{s3_url}}

option.trust_remote_code=true

option.tensor_parallel_degree=8

option.max_rolling_batch_size=32

option.rolling_batch=auto

option.dtype=fp16

option.max_rolling_batch_prefill_tokens=1024

option.paged_attention=False

engine=MPI

option.model_id={{s3_url}}

option.trust_remote_code=true

option.tensor_parallel_degree=8

option.max_rolling_batch_size=32

option.rolling_batch=auto

option.dtype=fp16

option.max_rolling_batch_prefill_tokens=1024

option.paged_attention=True

I due template sono stati valutati per Falcon-40B con il tipo di dati FP16 implementato su ml.g5.48xlarge in un paio di scenari diversi che rappresentano applicazioni reali:

  • Un piccolo numero di token di input con un grande numero di token generati – In questo scenario, il numero di token di input è stato fissato a 32 e sono stati generati 128 nuovi token
Strategia di batching Throughput (token/sec) Latency p90 (sec)
Batching dinamico 5,53 58,34
Batching continuo 56,04 4,74
Batching continuo con PagedAttention 59,18 4,76
  • Un input grande con un piccolo numero di token generati – Qui, fissiamo il numero di token di input a 256 e richiediamo al LLM di riassumere l’input in 32 token
Strategia di batching Throughput (token/sec) Latency p90 (sec)
Batching dinamico 19,96 59,31
Batching continuo 46,69 3,88
Batching continuo con PagedAttention 44,75 2,67

Possiamo vedere che il batching continuo con PagedAttention fornisce un miglioramento del throughput di 10 volte maggiore nello scenario 1 e 2,3 volte maggiore nello scenario 2 rispetto all’utilizzo del batching dinamico su SageMaker utilizzando il container LMI.

Conclusioni

In questo articolo, abbiamo esaminato come i LLM utilizzano la memoria e spiegato come il batching continuo aumenta il throughput utilizzando un container LMI su SageMaker. Abbiamo dimostrato i vantaggi del batching continuo per Falcon-40B utilizzando un container LMI su SageMaker mostrando i risultati dei benchmark. Puoi trovare il codice nel repo GitHub.