← indice

SLM · Deployment

GGUF e Deploy con llama.cpp

Come un checkpoint PyTorch da 530 MB diventa un file standard che qualsiasi runtime compatibile può caricare — e cosa significa deployare un modello addestrato da zero fuori da Python.

Cos'è GGUF

GGUF (GGML Universal File) è il formato binario introdotto da llama.cpp nel 2023 per sostituire il precedente formato proprietario GGML. È diventato lo standard de facto per distribuire LLM quantizzati perché risolve tre problemi che i formati precedenti (PyTorch .pt, SafeTensors, HuggingFace bin) non affrontano:

Un checkpoint PyTorch non è portabile: richiede Python, PyTorch, le stesse classi del modello definite nello stesso modulo. Un file GGUF è leggibile da qualsiasi runtime che supporti lo standard — llama.cpp, llama-server, llamafile, Ollama, LM Studio, Jan.

Versione usata in questo progetto llama.cpp build b11146 (versione 0.5.0), installato via Homebrew su macOS M2. La versione influenza la compatibilità — vedere la sezione "Note di compatibilità" a fine pagina.

Architettura nel GGUF: perché GPT-NeoX

GGUF non è un formato agnostico: ogni file dichiara un'architettura (general.architecture) e llama.cpp esegue il forward pass usando il codice specifico per quell'architettura. Scegliere quella sbagliata significa output privi di senso anche se i pesi sono corretti.

Il nostro modello ha tre proprietà architetturali che vincolano la scelta:

ProprietàNostro modelloArchitettura GGUF
Positional embeddingRoPE (Rotary)gptneox, llama, falcon
ActivationGELUgptneox (LLaMA usa SiLU)
Residual connectionsequenziale (pre-norm → attn → add, poi pre-norm → ffn → add)gptneox con use_parallel_residual=false

L'unica architettura che soddisfa tutte e tre è gptneox con use_parallel_residual = false. Questa flag distingue il comportamento sequenziale del nostro modello da quello parallelo di Pythia, che calcola attenzione e FFN contemporaneamente sulla stessa norma.

Mapping dei pesi

I pesi nel checkpoint PyTorch hanno nomi derivati dall'implementazione Python. GGUF usa una convenzione diversa, derivata dall'ecosistema llama.cpp. Il converter tools/convert_to_gguf.py applica il mapping seguente:

token_embedding.weight
→
token_embd.weight
blocks.i.attention.W_q/k/v.weight
tre tensori separati [512,512]
→
blk.i.attn_qkv.weight
fusi in [1536,512] con np.concatenate
— (assente: il modello non ha attn bias)
→
blk.i.attn_qkv.bias + attn_output.bias
richiesti da llama.cpp → tensor zero [1536], [512]
blocks.i.attention.W_o.weight
→
blk.i.attn_output.weight
blocks.i.norm1.weight / .bias
→
blk.i.attn_norm.weight / .bias
blocks.i.ff.net.0.weight / .bias
→
blk.i.ffn_up.weight / .bias
blocks.i.ff.net.2.weight / .bias
→
blk.i.ffn_down.weight / .bias
blocks.i.norm2.weight / .bias
→
blk.i.ffn_norm.weight / .bias
norm_final.weight / .bias
→
output_norm.weight / .bias
lm_head.weight
→
output.weight
blocks.i.attention.rope_cos / rope_sin
✕
SALTATI — llama.cpp ricalcola RoPE internamente
QKV fusion llama.cpp si aspetta i pesi Q, K, V fusi in un singolo tensore per blocco di attenzione. Questo non altera i valori — è solo una concatenazione lungo la dimensione 0. La decomposizione avviene internamente durante il forward pass.

Il tokenizer e il problema di tokenizer.ggml.pre

Questa è la parte più sottile della conversione — e quella che ha causato l'errore unordered_map::at: key not found durante il test di inferenza.

La pipeline in due stadi

La tokenizzazione in llama.cpp non è un'operazione singola. Prima che qualsiasi ricerca nel vocabolario avvenga, il testo grezzo passa per una fase di pre-tokenizzazione:

Stadio 1 — Pre-tokenizzazione
"Il Parlamento europeo" →
▁Il ▁Parla mento ▁europeo

Il carattere ▁ (U+2581) è il marcatore di spazio di SentencePiece. Non è uno spazio ASCII — è un carattere Unicode distinto che fa parte del token.

↓
Stadio 2 — Lookup nel vocabolario
▁Il ▁Parla mento ▁europeo
→
4821 7203 1944 9118

Ogni chunk viene cercato nella mappa token_to_id. Se un chunk non esiste nella mappa, il codice C++ lancia std::out_of_range.

Il campo tokenizer.ggml.pre nel GGUF dichiara quale strategia usare allo stadio 1. Senza questa dichiarazione, llama.cpp v0.5.0 usa un default che potrebbe non corrispondere al comportamento del tokenizer usato durante il training.

Cosa è successo

Il nostro tokenizer è SentencePiece in modalità BPE, addestrato su Wikipedia italiana. LLaMA 1/2 usa lo stesso tipo di tokenizer e funziona correttamente con llama.cpp — ma dichiara esplicitamente il suo pre-tokenizer e ha un vocabolario più grande (32k) con byte fallback attivo: per ogni byte non rappresentato, esistono token speciali del tipo <0x41> che garantiscono la tokenizzabilità di qualsiasi stringa.

Il nostro vocabolario da 16k potrebbe non avere copertura completa per tutti i caratteri, oppure le opzioni di normalizzazione SPM usate durante il training (NFD, NFKC, lowercase) potrebbero differire da quelle che llama.cpp applica con il pre-tokenizer di default. Il risultato: alcuni chunk prodotti dallo stadio 1 non sono nella token_to_id map → crash.

Tokenizer funzionante — diagnosi corretta Il tokenizer è corretto. llama-tokenize conferma: "Il Parlamento europeo" → [181: ▁Il, 6362: ▁Parlamento, 4332: ▁europeo]. Il problema non era nel tokenizer ma nella chat interface di llama-cli, che cerca il newline token \n via unordered_map::at() senza try-catch. Il nostro vocabolario non ha un token \n esplicito → crash.

Diagnosi dettagliata

All'avvio, llama.cpp tenta di registrare il token del newline per usarlo nella formattazione della chat. Il codice GPT-NeoX in llama-vocab.cpp chiama token_to_id.at("\n"). La riga relativa a LLaMA/SPM è protetta da try-catch e produce solo un warning; il percorso di codice per gptneox potrebbe non avere la stessa protezione — da qui il crash non gestito in llama-cli.

La soluzione: llama-simple

llama-simple fa text completion pura senza chat interface, senza template e senza il lookup del newline token. Funziona correttamente.

# Text completion con llama-simple (installato con llama.cpp)
llama-simple \
  -m slm-run4.gguf \
  -n 120 \
  --temp 0.85 \
  --top-k 40 \
  --top-p 0.9 \
  --repeat-penalty 1.3 \
  "Il Parlamento europeo"

Output su M2 (fp32, Metal): ~200–310 token/s. Caricamento modello ~670 ms.

La correzione permanente per llama-cli Aggiungere un token \n (newline) al vocabolario durante la conversione, oppure popolare il campo tokenizer.ggml.linefeed_id nel GGUF. Questo permetterà a llama-cli e llama-server di funzionare senza il workaround di llama-simple.

Sanity check del vocabolario

# Verifica copertura — da eseguire prima di ogni conversione
def check_coverage(sp, test_text="Il Parlamento europeo è fondato nel"):
    pieces = sp.encode_as_pieces(test_text)
    vocab_set = {sp.id_to_piece(i) for i in range(sp.vocab_size())}
    missing = [p for p in pieces if p not in vocab_set]
    if missing:
        print(f"ATTENZIONE: {len(missing)} pezzi non nel vocabolario: {missing}")
    return len(missing) == 0

Quantizzazione

Il file GGUF prodotto dal converter è fp32 (217 MB). La quantizzazione riduce la precisione numerica dei pesi per guadagnare in velocità e dimensione.

fp32
217 MB
Precisione piena, nessuna perdita. Base di partenza.
Q8_0
58 MB
Int8 con scala fp32 per blocco. Qualità praticamente identica a fp32. Prodotto.
Q4_K_M
~35 MB
4 bit per peso, scala K-quant mixed. Ottimo rapporto qualità/dimensione.
Q2_K
~18 MB
2 bit per peso. Perdita misurabile su modelli piccoli — non consigliato sotto 1B.
llama-quantize non supporta gptneox llama.cpp v0.5.0 ha rimosso gptneox dalla whitelist di llama-quantize. Questo blocca solo la produzione del file quantizzato, non il suo utilizzo a runtime: llama.cpp carica Q8_0, Q4_K_M e qualsiasi formato quantizzato normalmente, perché le routine di dequantizzazione sono architecture-agnostic.

Soluzione: quantizzatore Python Q8_0

tools/quantize_q8.py produce un GGUF Q8_0 equivalente a quello che produrrebbe llama-quantize se gptneox fosse nella whitelist. Carica direttamente il checkpoint, applica Q8_0 su tutti i tensori matrice (embedding incluse), e scrive il file GGUF con i metadati corretti.

# Produce slm-run4-q8_0.gguf (58 MB, da checkpoint 556 MB)
python3 tools/quantize_q8.py \
    --checkpoint checkpoints/run4_best.pt \
    --tokenizer  checkpoints/tokenizer.model \
    --output     slm-run4-q8_0.gguf

# Test inferenza sul file quantizzato
llama-simple -m slm-run4-q8_0.gguf -n 60 "Il Parlamento europeo"
# Output: 418 t/s su M2 Metal (vs ~250 t/s fp32)

Il quantizzatore implementa Q8_0 in circa 20 righe di numpy: ogni riga di un tensore matrice viene suddivisa in blocchi da 32 elementi, ciascuno con una scala float16 condivisa e 32 valori int8 (34 byte per blocco). La codifica è identica a quella di llama.cpp.

Risultato slm-run4-q8_0.gguf: 58 MB, 418 t/s su M2 Metal (Apple Silicon GPU via Metal). La velocità aumenta rispetto al fp32 perché la dequantizzazione Q8_0 su Metal è più efficiente del trasferimento e dell'aritmetica fp32 a piena precisione.

Per Q4_K_M (~35 MB, qualità leggermente inferiore) sarà necessario o aggiungere gptneox alla whitelist di una build sorgente di llama.cpp, o estendere il quantizzatore Python ai formati K-quant (più complessi di Q8_0).

llama-server: API OpenAI-compatibile

llama-server è incluso nella stessa installazione di llama.cpp e espone un endpoint HTTP compatibile con l'API OpenAI. Questo significa che qualsiasi client che usa OpenAI SDK può puntare al server locale senza modifiche.

# Avvio del server (fp32, porta default 8080)
llama-server \
  --model slm-run4.gguf \
  --ctx-size 512 \
  --host 0.0.0.0 \
  --port 8080

# Con quantizzato (quando disponibile)
llama-server \
  --model slm-run4-q4_k_m.gguf \
  --ctx-size 512 \
  --n-gpu-layers 33     # offload su Metal (M2)

Endpoint esposti

POST
/v1/completions
Completamento testo — compatibile OpenAI legacy. Input: prompt, max_tokens, temperature, top_p.
POST
/v1/chat/completions
Chat in formato messages[]. Richiede chat template nel GGUF — non adatto senza SFT.
GET
/v1/models
Elenca il modello caricato con metadati (context length, architettura).
POST
/tokenize
Tokenizza una stringa e restituisce gli ID — utile per debug del tokenizer.

Uso da Python con OpenAI SDK

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="non-rilevante"  # llama-server non autentica in locale
)

response = client.completions.create(
    model="slm-run4",
    prompt="Il Parlamento europeo",
    max_tokens=80,
    temperature=0.8,
    top_p=0.9
)
print(response.choices[0].text)
Perché usare /completions e non /chat/completions Il nostro modello è un base model — un completatore di testo, non un assistente istruzionale. Il formato chat (system/user/assistant) richiede SFT su dati di conversazione. Fino a Run #5 (SFT), l'endpoint corretto è /v1/completions con un prompt grezzo.

llamafile: eseguibile autocontenuto

llamafile (Mozilla) è un singolo file eseguibile che contiene sia il modello GGUF che un runtime llama.cpp compilato per macOS/Linux/Windows. Non richiede installazione: si scarica, si rende eseguibile, si avvia.

# Creazione llamafile da GGUF (richiede zipalign dalla toolchain llamafile)
cp /usr/local/bin/llamafile slm-run4.llamafile
zipalign -j0 slm-run4.llamafile slm-run4-q4_k_m.gguf
chmod +x slm-run4.llamafile

# Avvio — apre automaticamente un browser con interfaccia web
./slm-run4.llamafile

# In modalità server headless
./slm-run4.llamafile --server --nobrowser --port 8080

Il file risultante (slm-run4.llamafile) è distribuibile — chiunque può eseguirlo su qualsiasi macchina senza Python, PyTorch o llama.cpp installati. Per un modello da 35 MB (Q4_K_M), il llamafile pesa circa 38 MB.

Pipeline completa

1
Training PyTorch
train.py → checkpoints/run4_best.pt (530 MB, fp32)
2
Conversione GGUF
tools/convert_to_gguf.py → slm-run4.gguf (217 MB, fp32)
Mapping pesi, fusione QKV, scrittura tokenizer
3
Quantizzazione
python3 tools/quantize_q8.py → slm-run4-q8_0.gguf 58 MB, 418 t/s M2
Q4_K_M richiede build sorgente llama.cpp o estensione del quantizzatore Python
4a
Inferenza diretta
llama-simple -m slm-run4.gguf -n 100 --temp 0.85 --repeat-penalty 1.3 "prompt"
~200 t/s su M2 fp32. llama-cli richiede fix aggiuntivo (newline token).
4b
Server OpenAI-compatibile
llama-server --model slm-run4-q4_k_m.gguf --port 8080
Endpoint /v1/completions raggiungibile da qualsiasi client OpenAI SDK
4c
Distribuzione llamafile
Bundle GGUF + runtime in un eseguibile autocontenuto distribuibile senza dipendenze

Note di compatibilità (llama.cpp v0.5.0)

GPT-NeoX rimosso da llama-quantize Build b11146 non supporta la quantizzazione dell'architettura gptneox. La versione fp32 (217 MB) è scrivibile e caricabile, ma llama-quantize restituisce "unknown model architecture". Opzioni: downgrade a una build pre-b3000 via Homebrew, oppure implementare un quantizzatore Q8_0 in Python (circa 20 righe con numpy).
llama-cli crash — newline token non nel vocabolario La chat interface di llama-cli cerca \n via unordered_map::at("\n") senza try-catch nel percorso gptneox. Il nostro vocabolario BPE non ha un token newline esplicito → crash. Workaround: usare llama-simple per text completion pura. Fix permanente: aggiungere il token \n al vocabolario o settare tokenizer.ggml.linefeed_id nel GGUF.
Struttura GGUF verificata Il file è valido: architettura gptneox riconosciuta, 17 KV pair corretti, 147 tensori letti senza errori, use_parallel_residual = false presente, RoPE dimension count = 64 corretto.

Verso Run #5: GGUF e il task specifico

Il deploy via GGUF è la porta di uscita da Python verso il mondo reale. Ma l'utilità di un modello deployato dipende interamente da cosa sa fare.

Un base model come Run #4 esposto via /v1/completions è un completatore di testo — utile per scrittura assistita, generazione di bozze, completamento di pattern. Non è un assistente.

Il passaggio che trasforma il modello in qualcosa di utilizzabile in un contesto applicativo è il Supervised Fine-Tuning (SFT) su un task specifico. Con SFT, il modello impara a seguire istruzioni nella forma [contesto] → [azione richiesta] → [output strutturato], che è esattamente il pattern necessario per RAG, tool use e pipeline agentiche.

La scelta del task per Run #5 determina il valore del progetto — vedi Fine-tuning e Valutazione.