Ingegneria
Il caricamento che va in segfault invece di fallire
Caricare pesi MLX sullo stream GPU non solleva un errore. Restituisce un codice diverso da zero che probabilmente non hai controllato, e poi leggere i dati fa morire il processo. Ecco perché, e cosa farci.
Aggiornato 2026-09-12Misurato 2026-09-06
Se stai chiamando MLX dal C — attraverso mlx-c, o attraverso un qualunque binding costruito
sopra — e il tuo programma muore con un errore di segmentazione la prima volta che legge un peso,
il motivo è probabilmente questo.
I safetensors si caricano solo sullo stream CPU. mlx_load sullo stream GPU predefinito non
solleva niente che tu possa notare. mlx_array_eval ritorna 1, e se quel codice di ritorno non
l'hai controllato gli array restano non valutati. Leggerli dopo legge memoria che non è mai stata
riempita, e il processo muore da qualche parte che col caricamento non c'entra niente.
La correzione è una riga: passa mlx_default_cpu_stream_new() al caricamento, e nient'altro.
Perché confonde
Perché il modello mentale ovvio è sbagliato in un modo ragionevole. MLX gira sulla GPU, i pesi li vuoi sulla GPU, quindi carichi sullo stream GPU. Il ragionamento va bene e la conclusione no.
Il caricamento è I/O su file e collocazione in memoria, e su Apple Silicon la memoria è comunque unificata: non c'è nessun trasferimento da organizzare. Caricare sullo stream CPU non lascia i tuoi pesi nel posto sbagliato. Il calcolo continua ad avvenire sulla GPU. Cambia solo il caricamento.
Una volta che lo sai è ovvio. Prima di saperlo stai cercando un difetto di memoria nel tuo codice, perché un errore di segmentazione è come si presenta un tuo puntatore sbagliato.
La parte che trasforma un difetto in un mistero
mlx_array_eval restituisce un codice di stato. Ignorarlo è la cosa naturale da fare, perché
quasi sempre è zero e controllare ogni chiamata è rumore.
Non ignorarlo. Un array non valutato non si annuncia. Si legge come zeri, in silenzio, senza un errore da nessuna parte. E una volta che hai degli zeri che scorrono dentro un transformer, il sintomo è «il modello genera zeri» oppure «il modello produce spazzatura» — il che ti manda a guardare il codice del campionamento, o il tokenizer, o l'architettura. Dappertutto tranne che nel caricamento.
È la forma generale di tutta questa classe di difetti, e vale la pena dirla da sola:
Un fallimento che restituisce un valore plausibile costa più di un fallimento che fa crashare, perché il crash ti dice dove guardare.
Quindi: controlla il codice di ritorno di ogni eval. Costa una riga a testa e converte una
classe di misteri in una classe di messaggi d'errore.
Come riprodurlo
Carica un qualunque file safetensors MLX con lo stream predefinito, valuta, e leggi il primo elemento.
// La versione che crasha
mlx_array w;
mlx_load(&w, "model.safetensors", /* stream */ mlx_default_gpu_stream_new());
mlx_array_eval(&w); // ritorna 1 — e non lo guarda nessuno
const float* p;
mlx_array_data_float32(&p, w);
printf("%f\n", p[0]); // errore di segmentazione, qui
// La versione che funziona
mlx_stream s = mlx_default_cpu_stream_new();
mlx_load(&w, "model.safetensors", s);
if (mlx_array_eval(&w) != 0) { /* adesso lo scopri */ }
Il punto in cui crasha è la lettura e non il caricamento, ed è tutto il problema: il debugger ti indica una riga corretta.
Com'è dal Rust
La stessa cosa, un livello più su, e l'ergonomia rende più facile sbagliare: un wrapper che
restituisce Result dal caricamento e si ingoia l'eval sembrerà gestire gli errori come si deve.
Lo schema che regge è trattare la valutazione come un'operazione fallibile dappertutto, non solo dove sembra probabile che fallisca. In pratica significa una piccola funzione che valuta e converte lo stato nel tuo tipo di errore, e non chiamare mai quella grezza direttamente. Non è codice interessante. È il codice che trasforma un segfault in una frase.
Perché il percorso di caricamento è diverso
Un po' di supposizione informata, visto che come tale non è documentato: il caricamento deve mettere in memoria i byte di un file, e il percorso Metal si aspetta di ricevere buffer invece di riempirli da un descrittore di file. Qualunque sia la ragione interna, il comportamento osservabile è stabile e l'aggiramento è banale.
Quello che conta in pratica è che questa non è una decisione di prestazioni che stai prendendo. Non stai scegliendo di caricare lentamente sulla CPU. Stai scegliendo l'unico percorso che funziona, e il calcolo successivo non ne risente.
Quanto è costato, e la forma della ricerca
Due giorni, e quasi tutti spesi nei posti sbagliati. Il percorso vale la pena registrarlo, perché i posti sbagliati non erano posti stupidi.
Il sintomo era uscita spazzatura. Quindi il primo sospetto è stato il campionamento — un errore di uno nella selezione dei primi k, una temperatura applicata due volte, un seme che fa qualcosa di inaspettato. Il campionamento è dove vive la casualità, e la casualità è come si presenta la spazzatura. Due ore per escluderlo fissando il seme e confermando che la stessa spazzatura tornava identica, che è il risultato negativo utile: spazzatura deterministica non è un problema di campionamento.
Poi il tokenizer, perché spazzatura che si decodifica da identificatori di token validi ha un aspetto diverso da spazzatura che non lo fa, e questa sembrava la seconda. Altre ore per confermare che il tokenizer fa il giro completo correttamente da solo.
Poi l'architettura, che è dove una porta ha davvero buone probabilità di sbagliare: una matrice trasposta, una normalizzazione mancante, una rotazione posizionale applicata nel punto sbagliato. È quella costosa da escludere, perché verificarla significa confrontare i tensori intermedi contro un riferimento, livello per livello.
Ed è stato durante quel confronto livello per livello che la risposta vera è comparsa, di traverso: il primo tensore confrontato era già sbagliato, prima che fosse avvenuto qualunque calcolo. Erano i pesi stessi a essere zeri.
La lezione generale è quella che continua a rendere:
Confronta con un riferimento nel punto più a monte che puoi, non nel punto in cui compare il sintomo.
Il sintomo compariva all'uscita, quindi la ricerca è partita da lì. Il difetto era all'ingresso, e mezz'ora di confronto fra i pesi caricati e il riferimento l'avrebbe trovato il primo giorno.
La trappola vicina: gli shard si scoprono, non si nominano
Già che sei nel caricamento, vale la pena evitare anche l'errore accanto.
I pesi di un modello possono stare in un file o in parecchi, e il numero varia per modello e non
per convenzione: Llama-3.2-1B ne pubblica uno, phi-4 ne pubblica due. Del codice che nomina
model.safetensors funziona fino al primo modello a shard, e del codice che dà per scontato
model-00001-of-* fallisce al primo a file singolo.
Leggi la cartella, o meglio, leggi l'indice dei pesi — model.safetensors.index.json e la sua
weight_map — che nomina ogni shard di cui il modello ha bisogno. È anche l'unico modo affidabile
di distinguere un download finito da uno interrotto, perché un conteggio di file non dice niente
su quali file ci siano.
La versione di questa cosa che ha morso due volte
Un'altra cosa della stessa famiglia, visto che è costata un secondo pomeriggio e ha la stessa forma.
Non chiamare eval dentro il ciclo dei livelli. Ce n'era uno, residuo di una prova di concetto:
eval(&h) dopo ogni livello, che è corretto ed enormemente costoso. Su un modello con sedici
livelli sono sedici sincronizzazioni con la GPU per token; su uno con quaranta, quaranta.
Gli array servono valutati solo alla fine del passo. Toglierlo ha portato la generazione da 94 a 244 token al secondo — più della metà della velocità se ne andava a sincronizzarsi con la GPU per niente.
È la stessa lezione dall'altro lato. Il primo difetto era non controllare un risultato che avresti dovuto; questo era forzare un risultato di cui non avevi bisogno. Riguardano entrambi quando avviene la valutazione, e in un grafo pigro quello non è un dettaglio.
La versione corta
- Carica i safetensors sullo stream CPU. Il calcolo continua a girare sulla GPU.
- Controlla il ritorno di ogni
eval. Un array non valutato si legge come zeri, in silenzio. - Scopri gli shard dall'indice invece di nominare i file.
Misurato contro mlx-c il 6 settembre 2026. Se MLX cambia questa cosa, la data è l'avviso che
questa pagina non è stata ricontrollata da allora.