← Tutti gli articoli

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.

Di 11 min di letturaRead in English

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:

  1. Quantizzare MedGemma 27B in FP8 in anticipo.
  2. Servirlo con vLLM su Modal.
  3. 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.
  4. 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_code in tool_calls standard, 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-eager salta 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_hub scaricano 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 errori vfs.CompleteRestore. HF_HUB_DISABLE_XET=1 l’ha risolto. Il vecchio HF_HUB_ENABLE_HF_TRANSFER=0 non controlla più questo comportamento.
  • fastsafetensors. Ho provato --load-format fastsafetensors per 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_VALUE su un’immagine base CUDA 12.9. Fissare nvidia-cublas-cu12==12.9.1.4 con --no-deps l’ha risolto.
  • Compilazione compatibile con gli snapshot. Imposta TORCHINDUCTOR_COMPILE_THREADS=1 (obbligatorio per gli snapshot di memoria) e TORCH_CUDA_ARCH_LIST sulla GPU che usi davvero, così non compili per tutte le architetture.
  • Rumore dall’heartbeat NCCL. /sleep spegne un TCPStore che il thread di heartbeat NCCL continua a interrogare, e i log si riempiono di warning “broken pipe”. Impostare TORCH_NCCL_COORD_CHECK_MILSEC a 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 tools della richiesta come firme di funzioni Python con docstring. Dice al modello di usare blocchi tool_code per le chiamate e testo semplice per tutto il resto.
  • Un plugin tool parser (--tool-parser-plugin, --tool-call-parser medgemma). Trova i blocchi tool_code, rimuove il wrapper print(...), interpreta gli argomenti keyword e restituisce oggetti ToolCall standard.

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 blocco tool_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 e tool_code in 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.