Servire MedGemma 27B su Modal: FP8, sleep mode di vLLM e cold start da 21 secondi
Self-hosting di MedGemma 27B con vLLM su Modal: API compatibile OpenAI con scale-to-zero, quantizzazione FP8, GPU snapshot, cold start da 21 s e tool calling.
Puoi servire un modello medico da 27 miliardi di parametri su GPU serverless, pagare solo mentre risponde e ottenere comunque un cold start di circa 21 secondi. La ricetta che ha funzionato per me:
- Quantizzare MedGemma 27B in FP8 in anticipo.
- Servirlo con vLLM su Modal.
- Mettere vLLM in sleep al livello 2 prima che Modal faccia lo snapshot della memoria GPU, così lo snapshot contiene circa 1 GiB di stato del runtime invece di 32 GiB di pesi.
- Al risveglio, ricaricare i pesi da un volume in cache.
Per arrivarci sono serviti due fix a bug di vLLM, un tool-call parser su misura e qualche variabile d’ambiente che non avrei mai indovinato.
L’ho costruito in Turn.io, dove gestiamo agenti AI per servizi sanitari su WhatsApp. Volevamo capire se un modello medico open potesse sostituire i modelli di frontiera per una parte di quel traffico. Per scoprirlo, invece dei benchmark pubblici, l’abbiamo messo alla prova con le stesse eval con simulazioni che usiamo per i bot in produzione. Il modello quantizzato è pubblico: turnio/medgemma-27b-text-it-FP8-Dynamic. Questo articolo si occupa della parte di serving.
In breve
- FP8 su disco, non a runtime. Quantizzare i pesi BF16 in FP8 mentre vLLM li carica esaurisce la memoria durante la creazione dello snapshot. Per questo quantizzo una volta sola con llm-compressor (
FP8_DYNAMIC, senza dati di calibrazione), che dimezza i pesi da ~54 GiB a ~27 GiB, e pubblico il risultato. - Snapshot del processo, non dei pesi. Il livello 2 della sleep mode di vLLM scarta i pesi prima che Modal faccia lo snapshot della memoria GPU. Lo snapshot è sceso da ~32 GiB a ~1 GiB e i cold start da ~100 s a ~21–24 s.
- Il livello 2 ha richiesto vLLM 0.16.0 più una patch di una riga. Un bug corrompeva i pesi FP8 al ricaricamento, l’altro impediva del tutto a vLLM di liberare i pesi.
- Gemma 3 non parla il linguaggio delle tool call di OpenAI. Un piccolo plugin parser per vLLM e un chat template traducono i suoi blocchi
tool_codeintool_callsstandard, anche in streaming. - Dimensiona la GPU per il contesto, non per i pesi. Il modello sta su una L40S, ma il contesto completo da 131K entra solo su una H100. Questo raddoppia circa il costo orario a container attivo, da circa 1,95 $ a 3,95 $.
Perché fare self-hosting di un modello medico
Per la maggior parte degli agenti, le API dei modelli di frontiera sono la scelta di default giusta. Sono valide, costano poco a chiamata e a tenerle in piedi ci pensa qualcun altro. I nostri motivi per provare un modello open erano specifici:
- MedGemma è addestrato su testo medico.
- Volevamo un modello sotto il nostro controllo da cima a fondo.
- Volevamo confrontarlo con i modelli OpenAI e Claude che i nostri clienti usano già, sulle conversazioni che hanno davvero.
L’ultimo punto è quello che conta di più. Non abbiamo deciso in base ai punteggi su MedQA: abbiamo eseguito le stesse eval con simulazioni multi-turno su bot reali in esecuzione, con ciascun modello dietro. I risultati ora alimentano una policy di routing tra modelli.
Il problema di un modello da 27B è il costo. Una GPU sempre accesa per un traffico che arriva a ondate brucia soldi mentre nessuno sta parlando. Modal permette ai container di scalare a zero e fattura al secondo, quindi l’intera questione di design è diventata: quanto velocemente riesce a rispondere alla prima richiesta un container scalato a zero?
La struttura del deploy
L’app è una singola classe Modal che avvia vllm serve come sottoprocesso ed espone la sua porta come web endpoint. Così i client hanno un’API /v1/chat/completions compatibile con OpenAI, e qualsiasi SDK OpenAI funziona cambiando base_url.
@app.cls(
image=vllm_image,
gpu="H100",
scaledown_window=10 * MINUTES, # scale-to-zero dopo 10 min di inattività
min_containers=0,
max_containers=3, # tetto di sicurezza sui costi
volumes={
"/root/.cache/huggingface": hf_cache_vol,
"/root/.cache/vllm": vllm_cache_vol,
},
secrets=[modal.Secret.from_name("huggingface-secret"),
modal.Secret.from_name("medgemma-api-key")],
enable_memory_snapshot=True,
experimental_options={"enable_gpu_snapshot": True},
)
@modal.concurrent(max_inputs=16) # piccola coda oltre il max_num_seqs=8 di vLLM
class VllmServer:
...
Alcune scelte che vale la pena sottolineare:
- I pesi stanno su un volume Modal (la cache di Hugging Face), non nell’immagine. Le immagini restano piccole e i pesi vengono scaricati una volta sola.
- La API key la fa rispettare vLLM stesso (
--api-key), che la legge da un secret di Modal. L’endpoint è pubblico, quindi non è facoltativo. max_containers=3è un tetto ai costi, non un’impostazione di performance. Un client fuori controllo deve finire in coda, non sulla tua carta di credito.
Passo 1: quantizzare in FP8 in autonomia
Il checkpoint ufficiale è in BF16, circa 54 GiB. Il mio primo tentativo usava la quantizzazione FP8 a runtime di vLLM (--quantization fp8). Durante la creazione dello snapshot ha esaurito la memoria, perché le copie BF16 e FP8 coesistono per un breve momento.
La soluzione è salvare su disco pesi già in FP8. Per un breve periodo ho usato un checkpoint FP8 della community, poi l’ho sostituito con il nostro. Trattandosi di un modello medico, volevo sapere esattamente come erano stati prodotti i pesi e poter ripetere il processo per le nuove release di MedGemma. La quantizzazione è una funzione Modal one-shot con llm-compressor:
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
model = AutoModelForCausalLM.from_pretrained("google/medgemma-27b-text-it", dtype="auto")
recipe = QuantizationModifier(
targets="Linear",
scheme="FP8_DYNAMIC", # scale dei pesi per canale, attivazioni dinamiche per token
ignore=["lm_head"],
)
oneshot(model=model, recipe=recipe)
model.save_pretrained(save_dir, save_compressed=True)
FP8_DYNAMIC non richiede un dataset di calibrazione. Le scale dei pesi vengono calcolate per canale di output e le attivazioni vengono quantizzate per token al momento dell’inferenza. Il risultato pesa circa 27 GiB in formato compressed-tensors. Sta su una L40S da 48 GB lasciando spazio per la KV cache, e vLLM lo carica con --dtype auto.
Le scale per canale sono leggermente più precise delle scale per tensore del vecchio formato FP8 “nativo”. Per un modello di queste dimensioni, in pratica, la differenza è trascurabile. E comunque il nostro vero controllo di qualità erano le eval con simulazioni, non la perplexity.
Passo 2: cold start veloci con gli snapshot della GPU
Senza snapshot, un container nuovo non scarica nulla (il volume è già caldo) ma passa comunque minuti a caricare i pesi, compilare e fare il warm-up. Dall’inizio alla fine ci volevano circa 5 minuti. Gli snapshot della memoria GPU di Modal ti permettono di fare quel lavoro una volta, congelare il processo e ripristinarlo a ogni cold start.
L’approccio ovvio è fare lo snapshot dell’intero modello caricato. Funziona, ma lo snapshot è enorme. La sleep mode di vLLM è pensata proprio per questo: POST /sleep prepara l’engine a essere congelato e POST /wake_up lo rimette in funzione. Ha due livelli:
| Livello 1 | Livello 2 | |
|---|---|---|
| In sleep | Pesi copiati GPU → CPU (e quindi nello snapshot) | Pesi scartati |
| Al risveglio | Copia CPU → GPU | Ricaricamento dei pesi da disco |
| Dimensione dello snapshot | ~32 GiB | ~1 GiB |
| Ripristino dello snapshot | ~95 s | ~8 s |
| Risveglio | ~2,5 s | ~13–16 s |
| Cold start totale | ~100 s | ~21–24 s |
Il livello 2 si risveglia più lentamente perché deve leggere 27 GiB dal volume. Ma ripristinare uno snapshot da 32 GiB è talmente più lento che il risparmio netto è di quasi 80 secondi. Il collo di bottiglia si sposta dall’I/O dello snapshot all’I/O del volume, e il volume è più veloce.
Il ciclo di vita si adatta perfettamente ai due tipi di hook @modal.enter di Modal:
@modal.enter(snap=True) # gira una volta; Modal fa lo snapshot subito dopo
def start(self):
self.vllm_proc = subprocess.Popen(cmd)
wait_ready(self.vllm_proc)
warmup() # una richiesta minima per innescare l'init lazy
vllm_sleep(level=2) # scarta i pesi prima dello snapshot
@modal.enter(snap=False) # gira a ogni ripristino
def wake(self):
wake_up() # rimappa la memoria GPU, ancora senza pesi
reload_weights() # POST /collective_rpc {"method": "reload_weights"}
reset_prefix_cache() # evita di servire voci vecchie della prefix cache
wait_ready(self.vllm_proc)
Due flag hanno aiutato a mantenere piccolo lo snapshot:
--enforce-eagersalta la cattura dei CUDA graph. Toglie dallo snapshot 10–20 GiB di memoria GPU e 10–20 s di inizializzazione, al prezzo di una latenza per token leggermente più alta, che per un chatbot va benissimo.--gpu-memory-utilization 0.85, che è anche un limite massimo. Il ricaricamento layerwise dei pesi dopo lo sleep di livello 2 ha bisogno di spazio di buffer temporaneo sulla GPU, quindi salire oltre rompe il risveglio.
I due bug di vLLM dietro il livello 2
Sulla carta, il livello 2 è un solo argomento. In pratica il mio primo tentativo produceva output senza senso dopo il risveglio, e sono tornato al livello 1 il pomeriggio stesso. C’erano di mezzo due bug distinti.
Bug 1: pesi quantizzati ricaricati nel layout sbagliato
Quando vLLM carica per la prima volta un modello quantizzato, esegue process_weights_after_loading() su ogni layer, riorganizzando i tensori del checkpoint nel layout che i kernel si aspettano. Il vecchio percorso di reload_weights() saltava questo passaggio. Caricava tensori in formato checkpoint dentro parametri in formato kernel, e con l’FP8 in compressed-tensors questo non sollevava alcun errore. Produceva semplicemente output senza senso. È la issue vllm#28606.
La soluzione è il ricaricamento layerwise, che riesegue il post-processing layer per layer (vllm#32133). È arrivato con vLLM 0.16.0, quindi il primo passo è stato aggiornare.
Bug 2: un and al posto di una virgola
Con la 0.16.0, lo sleep di livello 2 riportava di aver liberato circa 5 GiB e che 27,88 GiB erano ancora in uso. I pesi non venivano rilasciati affatto. La causa si è rivelata un solo carattere in gpu_worker.py:
# vLLM 0.16.0
with self.mem_allocator.use_memory_pool(tag="weights"
) and set_current_vllm_config(self.vllm_config):
# corretto upstream in vllm#32947
with self.mem_allocator.use_memory_pool(tag="weights"
), set_current_vllm_config(self.vllm_config):
a and b restituisce b quando a è truthy, quindi si entra solo nel secondo context manager. Il memory pool che etichetta le allocazioni dei pesi non diventa mai attivo. Lo sleep non può liberare ciò che l’allocator non ha mai tracciato.
La correzione (vllm#32947) è stata mergiata upstream dopo il rilascio della 0.16.0, quindi la applico come patch in fase di build dell’immagine:
.run_commands(
"sed -i 's/) and set_current_vllm_config(/), set_current_vllm_config(/' "
"/usr/local/lib/python3.12/site-packages/vllm/v1/worker/gpu_worker.py",
)
Con la patch, lo sleep ha liberato 34,48 GiB lasciandone in uso 0,9 GiB. La creazione dello snapshot è passata da circa 2 minuti a 15 secondi. Sulla stessa GPU anche la KV cache è cresciuta, da 13.680 a 17.872 token: un bonus gratuito del tracciamento corretto della memoria.
Problemi minori che rompevano gli snapshot
Una volta individuati, nessuno di questi ha richiesto molto tempo per essere risolto, ma ognuno è costato un ciclo di deploy per trovarlo:
- Hugging Face Xet. Le versioni più recenti di
huggingface_hubscaricano tramite il backend Xet, che scrive nel volume di cache dei file che non esistono più quando Modal ripristina lo snapshot. Il ripristino falliva con errorivfs.CompleteRestore.HF_HUB_DISABLE_XET=1l’ha risolto. Il vecchioHF_HUB_ENABLE_HF_TRANSFER=0non controlla più questo comportamento. - fastsafetensors. Ho provato
--load-format fastsafetensorsper velocizzare il ricaricamento dei pesi con GPU Direct Storage. I volumi Modal non supportano GDS, quindi l’ho tolto. - Incompatibilità di cuBLAS. Il torch di vLLM 0.16.0 include cuBLAS 12.8, che causava
CUBLAS_STATUS_INVALID_VALUEsu un’immagine base CUDA 12.9. Fissarenvidia-cublas-cu12==12.9.1.4con--no-depsl’ha risolto. - Compilazione compatibile con gli snapshot. Imposta
TORCHINDUCTOR_COMPILE_THREADS=1(obbligatorio per gli snapshot di memoria) eTORCH_CUDA_ARCH_LISTsulla GPU che usi davvero, così non compili per tutte le architetture. - Rumore dall’heartbeat NCCL.
/sleepspegne un TCPStore che il thread di heartbeat NCCL continua a interrogare, e i log si riempiono di warning “broken pipe”. ImpostareTORCH_NCCL_COORD_CHECK_MILSECa dieci minuti fa dormire il thread per tutto il ciclo di risveglio.
Tool calling: insegnare a vLLM a leggere Gemma 3
I nostri agenti usano dei tool, quindi un modello senza tool_calls in stile OpenAI non si può mettere dietro di loro. Gemma 3, e di conseguenza MedGemma, è stato addestrato a chiamare i tool con sintassi Python dentro un blocco delimitato:
```tool_code
print(calculate_bmi(height_cm=180, weight_kg=75))
```
vLLM non sa interpretarlo, quindi il deploy include due file aggiuntivi, entrambi caricati tramite i punti di estensione previsti da vLLM:
- Un chat template che rende i
toolsdella richiesta come firme di funzioni Python con docstring. Dice al modello di usare blocchitool_codeper le chiamate e testo semplice per tutto il resto. - Un plugin tool parser (
--tool-parser-plugin,--tool-call-parser medgemma). Trova i blocchitool_code, rimuove il wrapperprint(...), interpreta gli argomenti keyword e restituisce oggettiToolCallstandard.
Il parsing è fatto per lo più di regex, più un piccolo scanner carattere per carattere per gli argomenti, perché i valori possono contenere virgole tra virgolette o parentesi annidate. Le parti interessanti sono le stranezze:
- Testo semplice travestito da tool call. A volte il modello “risponde” con
print("Sure, here's…")dentro un bloccotool_code. Il parser riconosce una semplice stringa letterale e la restituisce come contenuto normale invece che come tool call malformata. - Token di thinking che trapelano. Gemma 3 ogni tanto emette i suoi marker di ragionamento interno (
<unused94>…<unused95>), a volte solo metà della coppia. Vengono rimossi sia in modalità streaming sia non streaming. - Marker spezzati tra chunk dello stream. In streaming
```può arrivare in un delta etool_codein quello successivo. Il parser trattiene qualsiasi suffisso che potrebbe essere l’inizio del marker, così i client non vedono mai backtick spuri prima che venga rilevata una tool call.
Il parser ha unit test e un test di integrazione in streaming, e la CI li esegue a ogni push. I parser in streaming sono esattamente il tipo di codice che funziona nella demo e si rompe al terzo confine tra token.
Dimensionamento: la GPU serve per il contesto, non per i pesi
Il modello FP8 sta comodamente su una L40S (48 GB). Per mesi è girato lì, con il contesto limitato a 8K token, a circa metà del prezzo di una H100.
Il limite c’era per via della KV cache, non dei pesi. Gemma 3 usa un’attention ibrida: 10 layer globali fanno attention sull’intera sequenza, mentre 52 layer sliding window tengono solo più o meno una finestra di token. Una sequenza completa da 131K token richiede circa 13,7 GiB di KV cache, di cui circa 10 GiB per i 10 layer globali. Sulla L40S, dopo pesi e buffer, restavano circa 9 GiB per la KV cache. La H100 ne ha circa 35 GiB, abbastanza per circa tre richieste a lunghezza piena in contemporanea.
Il passaggio alla H100 ha portato il costo orario a container attivo da circa 1,95 $ a 3,95 $. Con lo scale-to-zero, è il costo per ogni ora in cui il container è effettivamente sveglio, non per ogni ora del mese. Le impostazioni di serving per la H100:
--max-model-len 131072 # contesto completo
--max-num-seqs 8 # sequenze concorrenti
--max-num-batched-tokens 8192 # per step dell'engine; il chunked prefill spezza i prompt più lunghi
--gpu-memory-utilization 0.85 # tetto imposto dal buffer del reload layerwise
Un middleware poco glamour
Dopo il lancio, alcuni client ricevevano dei 404 senza mai arrivare al modello. Un client aveva impostato come base URL l’URL completo delle completions, quindi l’SDK aggiungeva di nuovo il path: /v1/chat/completions/chat/completions. Un altro interrogava /v1/health, mentre vLLM espone l’health check su /health.
Invece di inseguire ogni singola integrazione, ho aggiunto un middleware ASGI di 20 righe, caricato con il flag --middleware di vLLM, che riscrive quei due path. Gira prima del middleware di autenticazione di vLLM, che protegge solo i path /v1, quindi la riscrittura non apre alcun accesso.
Non è elegante, ma riflette onestamente come si comportano i client nel mondo reale.
Consigli per chi ci prova dopo di me
- Misura separatamente ogni fase del cold start. Ho aggiunto log di timing
[cold-start]a ogni step del risveglio (restore, wake, reload, reset della prefix cache, ready). Senza, avrei ottimizzato la cosa sbagliata, perché a dominare era il ripristino dello snapshot, non il caricamento del modello. - Leggi la riga di log dello sleep. “Sleep mode freed X GiB, Y GiB still in use” è il numero più utile di tutto il setup. Se Y è grande, il tuo snapshot si porta dietro pesi che credi di aver scartato.
- Tieni in casa il tuo checkpoint quantizzato. È un job una tantum, è riproducibile ed elimina una terza parte dalla supply chain di un modello medico.
- Decidi in base alle tue eval, non ai benchmark. Un modello economico da ospitare e bravo su MedQA può comunque essere quello sbagliato per le tue conversazioni. Le eval con simulazioni su bot reali hanno dato la risposta che i benchmark non potevano dare.
Se vuoi un modello open dietro ai tuoi agenti, con cold start, tool calling ed eval gestiti come si deve, è il tipo di lavoro che faccio.