Esplorando semplici ottimizzazioni per SDXL

Esplorando semplici trucchi per SDXL

Apri su Colab

Stable Diffusion XL (SDXL) è l’ultima versione del modello di diffusione latente di Stability AI per la generazione di immagini super realistiche di alta qualità. Supera le sfide dei modelli di diffusione stabili precedenti come la resa delle mani e del testo corretta e le composizioni corrette dal punto di vista spaziale. Inoltre, SDXL è anche più consapevole del contesto e richiede meno parole nel prompt per generare immagini di migliore aspetto.

Tuttavia, tutti questi miglioramenti comportano un modello significativamente più grande. Di quanto più grande? Il modello di base di SDXL ha 3,5 miliardi di parametri (in particolare l’UNet), che è approssimativamente 3 volte più grande del modello di diffusione stabile precedente.

Per esplorare come possiamo ottimizzare SDXL per la velocità di inferenza e l’uso della memoria, abbiamo eseguito alcuni test su una GPU A100 (40 GB). Per ogni esecuzione di inferenza, generiamo 4 immagini e ripetiamo il processo 3 volte. Durante il calcolo della latenza di inferenza, consideriamo solo l’iterazione finale delle 3 iterazioni effettuate.

Quindi, se esegui SDXL così com’è con la precisione completa e utilizzi il meccanismo di attenzione predefinito, consumerà 28 GB di memoria e impiegherà 72,2 secondi!

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained("stabilityai/stable-diffusion-xl-base-1.0").to("cuda")pipeline.unet.set_default_attn_processor()

Questo non è molto pratico e può rallentarti perché spesso genererai più di 4 immagini. E se non hai una GPU più potente, ti troverai di fronte a quel frustrante messaggio di errore di memoria esaurita. Quindi come possiamo ottimizzare SDXL per aumentare la velocità di inferenza e ridurre l’utilizzo della memoria?

In 🤗 Diffusers, abbiamo una serie di trucchi e tecniche di ottimizzazione per aiutarti a eseguire modelli ad alta intensità di memoria come SDXL e ti mostreremo come! I due punti su cui ci concentreremo sono la velocità di inferenza e la memoria.

Velocità di inferenza

La diffusione è un processo casuale, quindi non c’è alcuna garanzia che otterrai un’immagine che ti piace. Spesso, dovrai eseguire l’inferenza più volte ed iterare, ed è per questo che ottimizzare la velocità è cruciale. Questa sezione si concentra sull’utilizzo di pesi a precisione ridotta e sull’incorporazione di attenzione efficiente in memoria e di torch.compile da PyTorch 2.0 per aumentare la velocità e ridurre il tempo di inferenza.

Precisione ridotta

I pesi del modello vengono conservati con una determinata precisione espressa come un tipo di dati in virgola mobile. Il tipo di dati in virgola mobile standard è float32 (fp32), che può rappresentare accuratamente una vasta gamma di numeri in virgola mobile. Per l’inferenza, spesso non è necessaria una precisione così elevata, quindi dovresti utilizzare float16 (fp16) che cattura una gamma più limitata di numeri in virgola mobile. Ciò significa che fp16 richiede solo la metà della quantità di memoria per essere memorizzato rispetto a fp32 ed è due volte più veloce perché è più facile da calcolare. Inoltre, le schede GPU moderne dispongono di hardware ottimizzato per eseguire calcoli in fp16, rendendolo ancora più veloce.

Con 🤗 Diffusers, puoi utilizzare fp16 per l’inferenza specificando il parametro torch.dtype per convertire i pesi quando il modello viene caricato:

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0",    torch_dtype=torch.float16,).to("cuda")pipeline.unet.set_default_attn_processor()

Rispetto a una pipeline SDXL completamente non ottimizzata, utilizzando fp16 occupa 21,7 GB di memoria e richiede solo 14,8 secondi. Stai accelerando quasi di un minuto intero l’inferenza!

Attenzione efficiente in memoria

I blocchi di attenzione utilizzati nei moduli dei trasformatori possono essere un collo di bottiglia enorme, perché la memoria aumenta quadraticamente all’aumentare delle sequenze di input. Ciò può rapidamente occupare una grande quantità di memoria e lasciarti con un messaggio di errore di memoria esaurita. 😬

Gli algoritmi di attenzione efficienti in memoria cercano di ridurre l’onere della memoria nel calcolo dell’attenzione, sfruttando la sparsità o l’incastellamento. Questi algoritmi ottimizzati erano principalmente disponibili come librerie di terze parti che dovevano essere installate separatamente. Ma a partire da PyTorch 2.0, questo non è più il caso. PyTorch 2 ha introdotto attenzione scalata prodotto interno (SDPA), che offre implementazioni fuse di attenzione Flash, attenzione efficiente in memoria (xFormers) e un’implementazione in C++ di PyTorch. SDPA è probabilmente il modo più facile per accelerare l’inferenza: se stai usando PyTorch ≥ 2.0 con 🤗 Diffusers, è abilitato automaticamente per impostazione predefinita!

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16).to("cuda") 

Rispetto a una pipeline SDXL completamente non ottimizzata, l’utilizzo di fp16 e SDPA richiede la stessa quantità di memoria e il tempo di inferenza migliora a 11,4 secondi. Utilizziamo questo come nuovo punto di riferimento con cui confrontare le altre ottimizzazioni.

torch.compile

PyTorch 2.0 ha introdotto anche l’API torch.compile per la compilazione just-in-time (JIT) del tuo codice PyTorch in kernel ottimizzati per l’inferenza. A differenza di altre soluzioni di compilatori, torch.compile richiede modifiche minime al codice esistente ed è semplice come incapsulare il tuo modello nella funzione.

Con il parametro mode, puoi ottimizzare l’overhead di memoria o la velocità di inferenza durante la compilazione, offrendoti molta più flessibilità.

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16).to("cuda")pipeline.unet = torch.compile(pipeline.unet, mode="reduce-overhead", fullgraph=True)

Rispetto al precedente punto di riferimento (fp16 + SDPA), l’incapsulamento dell’UNet con torch.compile migliora il tempo di inferenza a 10,2 secondi.

Modello memoria

I modelli di oggi stanno diventando sempre più grandi, rendendo difficile adattarli alla memoria. Questa sezione si concentra su come è possibile ridurre l’occupazione di memoria di questi enormi modelli in modo da poterli eseguire su GPU consumer. Le tecniche includono il caricamento su CPU, la decodifica dei latenti in immagini in diversi passaggi anziché tutti in una volta, e l’utilizzo di una versione distillata dell’autocoder.

Modello CPU offloading

L’offloading del modello consente di risparmiare memoria caricando l’UNet nella memoria della GPU mentre gli altri componenti del modello di diffusione (encoder di testo, VAE) vengono caricati sulla CPU. In questo modo, l’UNet può essere eseguito per più iterazioni sulla GPU finché non è più necessario.

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16)pipeline.enable_model_cpu_offload()

Rispetto al punto di riferimento, ora richiede 20,2 GB di memoria, salvando 1,5 GB di memoria.

Sequential CPU offloading

Un altro tipo di offloading che può risparmiare ancora più memoria a scapito di un’inferenza più lenta è l’offloading sequenziale su CPU. Invece di offloadare un intero modello, come l’UNet, i pesi del modello memorizzati in diversi sotto-moduli UNet vengono offloadati sulla CPU e caricati sulla GPU solo prima del passaggio in avanti. In sostanza, stai caricando solo parti del modello ogni volta, il che ti consente di risparmiare ancora più memoria. L’unico svantaggio è che è significativamente più lento perché carichi e offloadi sottomoduli molte volte.

from diffusers import StableDiffusionXLPipelinepipeline = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16)pipeline.enable_sequential_cpu_offload()

Rispetto al punto di riferimento, questo richiede 19,9 GB di memoria, ma il tempo di inferenza aumenta a 67 secondi.

Slicing

In SDXL, un codificatore variazionale (VAE) decodifica i latenti raffinati (previsti dall’UNet) in immagini realistiche. Il requisito di memoria di questo passaggio scala con il numero di immagini previste (batch size). A seconda della risoluzione dell’immagine e della VRAM GPU disponibile, può essere molto intensivo in termini di memoria.

Ecco dove è utile il “slicing”. Il tensore di input da decodificare viene suddiviso in fette e il calcolo per decodificarlo viene completato in diversi passaggi. Ciò consente di risparmiare memoria e consentire batch size più grandi.

pipe = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", torch_dtype=torch.float16)pipe = pipe.to("cuda")pipe.enable_vae_slicing()

Con calcoli frazionati, riduciamo la memoria a 15,4 GB. Se aggiungiamo lo smaltimento sequenziale della CPU, si riduce ulteriormente a 11,45 GB, consentendo di generare 4 immagini (1024×1024) per istruzione. Tuttavia, con lo smaltimento sequenziale, aumenta anche la latenza dell’elaborazione.

Calcoli di caching

Tutti i modelli di generazione di immagini condizionati al testo utilizzano tipicamente un codificatore di testo per calcolare le codifiche dal prompt di input. SDXL utilizza due codificatori di testo! Questo contribuisce parecchio alla latenza dell’elaborazione. Tuttavia, poiché queste codifiche rimangono invariate durante il processo di diffusione inversa, possiamo precalcolarle e riutilizzarle man mano che procediamo. In questo modo, dopo aver calcolato le codifiche del testo, possiamo rimuovere i codificatori di testo dalla memoria.

Innanzitutto, carica i codificatori di testo e i loro tokenizzatori corrispondenti e calcola le codifiche dal prompt di input:

tokenizzatori = [tokenizzatore, tokenizzatore_2]codificatori_di_testo = [codificatore_di_testo, codificatore_di_testo_2](    prompt_embeds,    negative_prompt_embeds,    pooled_prompt_embeds,    negative_pooled_prompt_embeds) = codifica_prompt(tokenizzatori, codificatori_di_testo, prompt)

Successivamente, svuota la memoria della GPU per rimuovere i codificatori di testo:

del codificatore_di_testo, codificatore_di_testo_2, tokenizzatore, tokenizzatore_2svuota()

Ora le codifiche sono pronte per passare direttamente alla pipeline di SDXL:

da diffusori import StableDiffusionXLPipelinepipe = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0",    codificatore_di_testo=None,    codificatore_di_testo_2=None,    tokenizzatore=None,    tokenizzatore_2=None,    dtype_torch=torch.float16,)pipe = pipe.to("cuda")args_chiamata = dict(        prompt_embeds=prompt_embeds,        negative_prompt_embeds=negative_prompt_embeds,        pooled_prompt_embeds=pooled_prompt_embeds,        negative_pooled_prompt_embeds=negative_pooled_prompt_embeds,        num_images_per_prompt=num_images_per_prompt,        num_inference_steps=num_inference_steps,)immagine = pipe(**args_chiamata).images[0]

In combinazione con SDPA e fp16, possiamo ridurre la memoria a 21,9 GB. Le altre tecniche discusse in precedenza per ottimizzare la memoria possono essere utilizzate anche con calcoli memorizzati nella cache.

Miniautoencoder

Come accennato in precedenza, un VAE decodifica i latenti in immagini. Naturalmente, questo passaggio è direttamente vincolato dalle dimensioni del VAE. Quindi, usiamo un miniautoencoder più piccolo! Il Miniautoencoder di madebyollin, disponibile su the Hub è solo di 10 MB ed è distillato dal VAE originale utilizzato da SDXL.

da diffusori import AutoencoderTinypipe = StableDiffusionXLPipeline.from_pretrained(    "stabilityai/stable-diffusion-xl-base-1.0", dtype_torch=torch.float16)pipe.vae = AutoencoderTiny.from_pretrained("madebyollin/taesdxl", dtype_torch=torch.float16)pipe = pipe.to("cuda")

Con questa configurazione, riduciamo il requisito di memoria a 15,6 GB riducendo contemporaneamente la latenza dell’elaborazione.

Conclusione

Per concludere e riassumere i risparmi derivanti dalle nostre ottimizzazioni:

Speriamo che queste ottimizzazioni rendano un gioco da ragazzi eseguire le tue pipeline preferite. Prova queste tecniche e condividi con noi le tue immagini! 🤗