Due semplici tecniche per garantire che l'addestramento del tuo LLM raggiunga le massime prestazioni.
L'addestramento o il fine-tuning di un modello di base da molti miliardi di parametri richiede una notevole potenza di calcolo, spesse volte pari a centinaia o migliaia di ore di addestramento su GPU. Tuttavia, l'ottimizzazione delle prestazioni di addestramento per un LLM è una sfida e le metriche di alto livello, come l'utilizzo della GPU, potrebbero non mostrare l'effettivo utilizzo. In questo post del blog, mostriamo come superare i livelli di astrazione e velocizzare di 6 volte il fine-tuning del più recente modello Qwen 3.8 27B su AI Runtime. Attraverso un'ottimizzazione sistematica delle prestazioni, anche con un utilizzo della GPU già al 98%, passiamo da 7 ore e 44 minuti a 1 ora e 17 minuti nel fine-tuning di un dataset fisso.
Analizzeremo nel dettaglio le tecniche alla base di questi miglioramenti, da come misurare ciò che conta davvero ai dettagli di implementazione e ai compromessi di progettazione legati a ciascuna ottimizzazione.
Se vuoi iniziare subito, passa direttamente al codice di esempio su AI Runtime e fai una prova!
Iniziamo con un loop di addestramento standard per Qwen 3.8 27B utilizzando la libreria transformers e il dataset di addestramento SFT HuggingFaceH4/ultrachat_200k.
L'addestramento baseline del modello sull'intero dataset impiega 7 ore e 44 minuti e, durante l'addestramento, la GPU mostra un utilizzo medio del 98%. Il risultato è un addestramento a 9.396 tok/s.
Questo utilizzo della GPU sembra elevato, ma come accennato in precedenza, questo dato è fuorviante. La metrica standard di utilizzo della GPU misura solo la percentuale di tempo in cui la GPU sta facendo qualcosa, quindi non ci dice quanto bene il modello stesse utilizzando la GPU mentre era attiva. Per capirlo dobbiamo analizzare la situazione più a fondo.
Un modo per farlo è il meccanismo di trace di torch; possiamo modificare il nostro loop di addestramento come segue (limitando anche il numero di passaggi per ottenere una risposta rapida):
Per un passaggio di esempio, questo ci fornisce i seguenti dati:
| Operazione | Numero di chiamate | Tempo medio (microsec) | Tempo totale (millisec) |
|---|---|---|---|
| aten::bmm | 31,105 | 50.5 | 1,571.80 |
| aten::mul | 46,878 | 16.2 | 758.9 |
| MmBackward0 | 497 | 1,111.4 | 552.4 |
| aten::copy_ | 69,444 | 7.2 | 500.6 |
| aten::add | 45,623 | 8.0 | 366.7 |
| aten::select | 68,645 | 3.4 | 230.5 |
| aten::exp | 9,504 | 21.7 | 206.5 |
| aten::sum | 16,194 | 12.2 | 196.8 |
| aten::empty | 40,709 | 4.7 | 190.5 |
| aten::as_strided | 218,214 | 0.8 | 173.3 |
Tabella 1: CPU - 14,4 s con 1.714.382 chiamate. La tabella contiene le operazioni principali per tempo totale.
| Operazione | Numero di chiamate | Tempo medio (microsec) | Tempo totale (millisec) |
|---|---|---|---|
| nvjet GEMM | 1923 | 1,163.1 | 2,236.8 |
| Addizione floating point element-wise | 42,657 | 51.1 | 2,180.5 |
| Moltiplicazione floating point element-wise | 35,012 | 24 | 841.8 |
| Riempimento floating point element-wise | 33,557 | 21.5 | 723.1 |
| Copia floating point element-wise | 59,282 | 11.8 | 700.7 |
| Memcpy DtoD | 5,585 | 90.5 | 505.2 |
| sm80_xmma GEMM | 31,105 | 20.9 | 651.3 |
Tabella 2: GPU - 10,3 s con 269.448 operazioni. La tabella contiene le operazioni principali per tempo totale
Da queste tabelle notiamo alcuni problemi generali con il modello attuale:
Inoltre, riscontriamo un problema con il padding in eccesso nel batch: il tensore di input deve essere rettangolare e ogni riga viene completata con il padding fino al numero massimo di token per quel batch. Mostreremo un trucco per gli LLM che può essere utilizzato per minimizzarlo.
Nelle sezioni seguenti approfondiremo ciascun argomento tecnico, illustrando di cosa si tratta, le best practice consigliate e i principali compromessi da considerare durante l'applicazione.
Noterai che alcuni kernel vengono eseguiti molto frequentemente! In particolare, per Qwen, il kernel di moltiplicazione di matrici a batch viene eseguito oltre 30.000 volte in un singolo passaggio.
In questo caso esamineremo prima i kernel fusi. Un kernel fuso prende più kernel GPU più piccoli e li combina in un'unica unità. Ciò può migliorare le prestazioni evitando costosi trasferimenti tra la memoria ad alta larghezza di banda della GPU e la sua memoria on-die a larghezza di banda ancora superiore. Inoltre, può consentire una pianificazione più efficiente dei core GPU.
Con un numero così elevato di kernel aggiuntivi, anche se torch.compile potrebbe funzionare, impiegherà molto tempo per l'esecuzione. Quindi, ridurre il numero grezzo di operazioni tramite kernel fusi manualmente è un ottimo primo passo. Inoltre, fusioni note come Flash Attention non sono qualcosa che torch.compile sarebbe in grado di trovare da solo.
La libreria transformers di default utilizza SDPA di torch per i layer di attention standard. Tuttavia, molte di queste piccole operazioni che vediamo provengono dai layer non standard di tipo gated delta network. Fortunatamente, per questi layer esistono due kernel, causal-conv1d e flash-linear-attention, scritti appositamente per fondere molte di queste operazioni per layer. Come fare a scoprirlo? L'implementazione della libreria transformers di Qwen ci dice già quali kernel supporta:
Esistono due metodi principali per ottenere questi kernel:
In genere consigliamo il primo approccio, soprattutto combinato con il nostro supporto per i container Docker: crea il tuo container con tutti i kernel già installati in fase di build per ridurre il tempo necessario all'avvio del job.
Sebbene Kernel Hub sia comodo, dato che i kernel spesso dipendono in modo esplicito dalle versioni di torch e CUDA installate, potrebbe non esserci un kernel disponibile per la tua configurazione specifica; in tal caso, il modello di transformers passerà in modo trasparente a un kernel non ottimizzato con prestazioni inferiori.
Dopo aver installato i pacchetti Python flash-linear-attention e causal-conv1d, abbiamo migliorato il tempo end-to-end di un fattore di 3,7x, riducendolo a 2 ore e 5 minuti. Nelle tabelle seguenti puoi vedere i nuovi kernel (evidenziati) che prendono il posto delle funzioni element-wise delle tabelle precedenti. Anche il numero totale di operazioni eseguite è stato notevolmente ridotto, con un fattore di 15x:
| Operazione | Numero di chiamate | Tempo medio (microsec) | Tempo totale (millisec) |
|---|---|---|---|
| cudaMemsetAsync | 1,854 | 283.9 | 526.3 |
| ChunkGatedDeltaRuleFunction | 96 | 871.7 | 83.7 |
| aten::mm | 1,987 | 39.6 | 78.6 |
| MmBackward0 | 497 | 134.2 | 66.7 |
| ChunkGatedDeltaRuleFunctionBackward | 48 | 1,159.9 | 55.7 |
| DaoAILab::_causal_conv1d_fwd_cpp | 96 | 55.8 | 5.4 |
| CausalConv1dFn | 96 | 51.4 | 4.9 |
Tabella 3: CPU - 3,9 s con 118.316 chiamate. La tabella contiene le operazioni principali per tempo totale.
| Operazione | Numero di chiamate | Tempo medio (microsec) | Tempo totale (millisec) |
|---|---|---|---|
| nvjet GEMM | 1,923 | 1,183.3 | 2,275.5 |
| Elementwise floating point multiply | 2184 | 128.1 | 279.9 |
| Elementwise copy + cast | 1255 | 101.1 | 126.9 |
| chunk_cat flash-linear-attention | 65 | 1,402.0 | 91.2 |
| flash_attention_3 forwards | 16 | 2,693.8 | 43.1 |
| chunk_gated_delta_rule_fwd | 144 | 255.6 | 36.8 |
| flash_attention_3 backwards | 32 | 965.6 | 30.9 |
Tabella 4: GPU - 3,9 s con 18.060 chiamate. La tabella contiene le operazioni principali per tempo totale.
Avvertenza: durante l'addestramento avrai bisogno sia di un kernel per il passaggio forward sia per quello backward, ma alcuni kernel sono solo per il forward, in quanto sono stati implementati esclusivamente per velocizzare l'inferenza.
Punti chiave
Il nostro modello Qwen ora è piuttosto veloce, ma possiamo farlo andare ancora più veloce. Di default, la nostra configurazione di batching utilizza tensor rettangolari 2D da 8.192 token, poiché è la dimensione massima utilizzabile senza esaurire la memoria. Lo svantaggio di questo approccio è che si tende a sprecare una quantità notevole di spazio nel tensor a causa del padding.
Se Qwen utilizzasse solo meccanismi di attention standard, NestedTensor di torch sarebbe ben supportato e aiuterebbe a ridurre questo costo rimuovendo completamente il padding. Tuttavia, flash-linear-attention supporta solo un approccio a tensor impacchettati 1D più tradizionale:

Tuttavia non possiamo farlo in modo arbitrario: passare semplicemente il tensor 1D al modello può andare bene per il pre-addestramento, ma per il fine-tuning dobbiamo assicurarci che, all'interno di un esempio, i token prestino attention solo ai token dello stesso esempio. Per risolvere questo problema dobbiamo passare al modello i position_ids corretti per l'esempio. A sua volta, il modello li passerà ai kernel di attention fusi, applicando la maschera di attention corretta. Se dai un'occhiata al codice di esempio qui, puoi vedere come calcoliamo gli input necessari per far funzionare il tutto.
Con questa modifica all'apparenza semplice, il nostro tempo di addestramento passa da 2 ore e 5 minuti a 1 ora e 17 minuti.
I motivi sono due:
Compromesso: Questo tipo di modifica rende la pipeline di input più complessa e sussiste un elevato rischio di introdurre errori che influiscono sulla qualità del modello. Tuttavia, in questo caso, un risparmio del 36% vale la pena. Esistono altre tecniche per ridurre il padding, come mantenere il meccanismo di batching originale ordinando però tutti gli input per lunghezza in modo da minimizzare il padding; questo ordine potrebbe tuttavia introdurre correlazioni inaspettate tra campioni adiacenti, introducendo bias nel modello.
Punti chiave: Quando si addestrano LLM su un dataset con input più brevi del numero massimo di token addestrabili (ad es. 8192 in questo caso), l'impacchettamento efficiente dei dati nei batch può far risparmiare un tempo significativo. Per questo dataset abbiamo riscontrato un miglioramento di 1,6x nella velocità di addestramento, riducendo il tempo di addestramento da 2 ore e 5 minuti a 1 ora e 17 minuti.
In questo post del blog abbiamo mostrato come moltiplicare per 6 l'efficienza dell'addestramento. I punti chiave sono stati: (1) misurare innanzitutto ciò che conta, non solo l'utilizzo della GPU, ma anche dove il modello trascorre il tempo durante l'addestramento, (2) ottimizzare e fondere i kernel utilizzando librerie note come Flash Linear Attention e (3) personalizzare il modo in cui i batch di dati vengono forniti al modello.
Per farlo su vasta scala, serve una piattaforma che renda predefinito un percorso ad alte prestazioni. In AIR, molti di questi miglioramenti delle prestazioni sono integrati. Includiamo le ultime versioni di CUDA e flash attention nei nostri ambienti di base, tracciamo automaticamente per te le metriche fondamentali come l'utilizzo della GPU, la temperatura e lo stato di salute, e garantiamo che le versioni di torch (e i profiler) siano aggiornate. Anche il nostro agente interno, Genie Code, è ottimizzato per individuare opportunità come queste per ottimizzare ulteriormente il tuo addestramento. Inoltre, la lettura dei dati da Databricks è ottimizzata tramite il nostro UCVolumeDataset.
Prova AIR per il tuo prossimo carico di lavoro di addestramento! Vuoi provarlo? Consulta il cookbook di AI Runtime, con i dettagli su come eseguire questo modello in autonomia.
(Questo post sul blog è stato tradotto utilizzando strumenti basati sull'intelligenza artificiale) Post originale
Iscriviti al nostro blog e ricevi gli ultimi articoli direttamente nella tua casella di posta.