@ :~/lab/256-esperti-la-costante-nascosta-di-dwarfstar$
← cd ~/lab

./run 256-esperti-la-costante-nascosta-di-dwarfstar

256 esperti, 512 righe: ho infilato un modello da 744B in un motore scritto per un altro

Esplorando DwarfStar, il motore C di inferenza per DeepSeek V4, ho fatto girare GLM-5.2 (744B) su 2× H200 — e ho scoperto quante costanti erano cablate sulla forma di un solo modello.

status
draft
project
ds4
updated
2026-07-07
author
terzi · esplorazione
tags
#deepseek#glm#inferenza#cuda#moe#speculative-decoding
generate cover # alt: Diagramma di un motore di inferenza LLM con costanti cablate 256 e 512 evidenziate su un cluster a due GPU H200

DwarfStar (ds4.c) non è roba mia: è un motore di inferenza nativo in C, MIT, scritto per far girare DeepSeek V4 — Flash e PRO — su macchine personali di fascia alta. Backend Metal, CUDA, ROCm; KV cache in streaming da SSD; inferenza distribuita. Tutto in un singolo albero di file .c senza dipendenze da runtime esterni. È bellissimo e volutamente stretto: un modello alla volta, validato sui logit ufficiali. La domanda che mi sono posto era semplice: e se ci infilassi un modello diverso? Non DeepSeek. GLM-5.2, ~744 miliardi di parametri, la famiglia GlmMoeDsaForCausalLM di Zhipu.

La risposta breve: si può. La risposta lunga è che un motore “stretto” nasconde la sua strettezza in costanti innocue. E la prima che ti morde è il numero 256.

Perché GLM-5.2 in un motore per DeepSeek

Non è masochismo. GLM-5.2 è architetturalmente cugino di DeepSeek-V3.2: MLA + indicizzatore DSA + MoE con esperto condiviso + MTP. È esattamente il percorso che DS4 già implementa. Le differenze sono deltas, non abissi: niente multi-Head-Compression (n_hc=1), niente compressore KV di V4 (attenzione densa), routing sigmoide con top-8 invece di top-6, rope θ=1e6, e una MLA “assorbita” con head_dim = kv_lora + rope = 512 + 64 = 576. Un converter standalone rimappa i safetensors GlmMoeDsa in un GGUF con layout DS4, e ho verificato che l’attenzione assorbita è uguale a quella esplicita di GLM fino a 2e-08. Su un modello piccolo con architettura reale, DS4 riproduce i logit di HF transformers a corr 0.9994 / max|Δ| 0.035 (rumore da quantizzazione). Il tokenizer nativo GLM (154880 token / 321649 merge) sta dentro il GGUF e il BPE byte-level di DS4 lo riproduce esatto su 6 stringhe diverse su 6.

Il vero test è il modello vero: GGUF q2 da ~252 GB, residente su 2× H200 NVL (143 GB l’una, niente NVLink), CUDA 13.2. Prefill ~34 t/s, generazione ~16 t/s. -p "What is the capital of France?"“The capital of France is Paris.” Funziona.

La costante che diceva sempre 256

Ma prima di arrivarci, il router MoE su CUDA mi ha sbattuto la porta in faccia. DeepSeek Flash ha 256 esperti; GLM ne ha un numero diverso, con top-8 e scoring sigmoide invece di top-6 scala-1.5. Il kernel del router non era configurato per 256: era cablato su 256. Stride dei logit, bound del top-k, la scala 1.5 — tutto letterale nel corpo del kernel:

// prima: la forma di DeepSeek incisa nel kernel
const float *log = logits + (uint64_t)t * 256;
for (int i = 0; i < 256; i++) prob[i] = sqrtf(softplus_dev(log[i]));

// dopo: parametrizzato, con la fast-path 256 intatta
const float *log = logits + (uint64_t)t * n_expert;
for (uint32_t i = 0; i < n_expert; i++) prob[i] = sqrtf(softplus_dev(log[i]));

La regola che mi sono dato — e che vale per tutto questo lavoro — è: zero regressioni sul percorso DeepSeek. Il caso n_expert == 256 resta sui kernel warp/paralleli esistenti, byte-identici; solo i conteggi diversi vengono dirottati sul kernel seriale parametrizzato. Ho verificato che Flash gira a 40.8 t/s con --logprob-vectors sullo stesso build. Questa generalizzazione del router è finita in un PR upstream (#466).

Poco più in là, la stessa storia con l’indicizzatore DSA: top_k cablato a 512 (il valore di Flash). PRO ne usa 1024, e su --cuda emetteva spazzatura di caratteri di controllo a ogni prompt, perché if (top_k > 512u) return 0; faceva no-oppare l’intera attenzione indicizzata, e gli array in shared memory (comp_rows[512], scores[768]) troncavano le 1024 righe selezionate. Allargati a [1024]/[1280] e alzato il muro di dispatch: da spazzatura a “The capital of France is Paris.”, con la modalità think che ragiona e poi risponde. Bonus: il top-k parallelo invece del fallback single-thread dava +27% (gen 1.69 vs 1.33 t/s). Flash regressione byte-stabile.

La spina: handshake completato, poi morto con 0.5 GiB liberi

Il momento che mi ha fatto perdere mezza giornata non era un kernel sbagliato. Era una euristica di memoria che aveva senso su una macchina e non sull’altra.

GLM-q2 caricava. I due H200 completavano l’handshake distribuito. E poi — all’atto della creazione della sessione, dopo che i pesi erano già dentro e la stretta di mano era andata a buon fine — OOM. Il grafo di prefill non trovava memoria. Con 0.5 GiB liberi su una scheda da 143 GB.

Il colpevole era cuda_q8_f16_cache_reserve_bytes(). La cache di dequantizzazione q8→fp16 è eager: riempie la HBM fino alla riserva. Sotto i 112 GiB la riserva è “5% o 4 GiB, il maggiore”. Ma su schede ≥ 112 GiB c’era un caso speciale che restituiva un forfait di 512 MiB:

// il caso speciale che affamava il grafo sulle schede grandi
if (total_bytes >= 112ull * 1024ull * 1024ull * 1024ull) {
    return 512ull * 1048576ull;   // 512 MiB, punto.
}

Su un H200 la cache opzionale si mangiava 14 GiB e lasciava mezzo giga per il grafo di sessione, i workspace cuBLAS, i buffer transitori. E DS4_CUDA_WEIGHT_CACHE_LIMIT_GB non tocca questa cache. Peggio: caricare un modello MTP la disabilita, quindi il bug si nasconde proprio nei test che sembrano innocui. La toppa è togliere il caso speciale — 5%/4 GiB per tutti. Prima: OOM (cache 14 GiB, liberi 0.5). Dopo: 7 GiB liberi, gira a 16 t/s. È il gemello CUDA di un bug già noto sul runtime ROCm (PR #446); l’ho mandato su come PR #472.

La lezione da diario: una riserva eager che scala male è invisibile finché non cambi ordine di grandezza della scheda. La macchina su cui è nato il codice non aveva schede da 143 GB. La mia sì. Il bug era lì da sempre, in attesa di un modello abbastanza grande.

Dove finisce il mio contributo (onestà da diario)

  • Backend GLM-5.2: funziona, genera testo coerente, residente su 2× H200. L’ho postato come report, non come PR merge-abile: il diff sul motore è grande e sperimentale, gated tutto su n_hc==1 così Flash/PRO restano byte-identici. Il converter Python è standalone, non nell’albero.
  • Fix CUDA (router 256→generico, indicizzatore 512→1024, riserva cache): questi sì, model-independent, upstreamati come PR distinti (#466, #472). Il router per ora accetta 256 o 384, non è ancora arbitrario.
  • Serving: ds4-server che parla GLM-5.2 su /v1 (chat template + stop multi-EOS), rotta /health che diventa ready solo quando la rotta distribuita è completa, più il wrapper di gateway. Roba di plumbing, ma è quella che rende il modello usabile e non solo eseguibile.
  • DSpark (speculative decoding a blocchi di DeepSeek, DFlash + testa markoviana): qui sono onesto — c’è il design completo e il loader + flag --dspark che carica e valida il drafter GLM (5 layer Qwen3-style, 7.1 GB), verificato sul box. Ma il runtime del draft — il grafo forward, la cattura degli hidden, la verifica a blocco-K — NON è ancora costruito. È scaffolding. Non fingo che generi.

Perché scommetterci comunque? Perché i numeri, non la teoria, lo giustificano. Ho misurato DSpark su llama.cpp (Qwen3-8B, stesso box H200): 0.89× su target veloce, 1.74× su target lento, con una curva monotona pulita — break-even intorno ai 130 t/s. GLM-q2 gira a 16 t/s (62 ms/token): profondamente nel regime favorevole. Lo speedup atteso è ≥1.7×. Lo speculative decoding è regime-dipendente, ed è la stessa lezione che ho imparato col MTP di PRO: su token cheap l’overhead di draft+verify mangia il guadagno; su token costosi paga.

Cosa ho imparato

Che un motore “stretto e veloce” paga la sua velocità in costanti. 256, 512, 1.5, 112 GiB — non sono numeri magici a caso, sono la forma di un modello specifico incisa nel codice caldo. Portarci sopra un modello diverso non è scrivere un adattatore: è andare a caccia di ogni punto in cui qualcuno ha scritto la dimensione che aveva sotto mano invece di quella che il modello dichiara. E la parte più insidiosa non sono i kernel sbagliati — quelli urlano, sputano caratteri di controllo. È l’euristica ragionevole che affama il grafo con mezzo giga libero, dopo un handshake perfetto, solo perché la tua scheda è più grande di quella dell’autore. La generalizzazione, quando la fai bene, non è “rendere tutto configurabile”: è isolare la fast-path esistente, lasciarla byte-identica, e dirottare solo il caso nuovo. Nessuna regressione per chi c’era prima di te.

DwarfStar / ds4.c è un progetto MIT di terzi (© The ds4.c authors). Qui ho raccontato la mia esplorazione: un backend GLM-5.2 sperimentale e alcuni fix CUDA model-independent proposti upstream. Il codice di GLM-5.2 e il converter restano fuori dall’albero, per ora.