Ingénierie
Le chargeur qui plante au lieu d'échouer
Charger des poids MLX sur le flux GPU ne lève aucune erreur. Cela renvoie un code non nul que vous n'avez probablement pas vérifié, puis la lecture des données tue le processus. Voici pourquoi, et quoi faire.
Mis à jour 2026-09-12Mesuré 2026-09-06
Si vous appelez MLX depuis C — via mlx-c, ou via n'importe quelle liaison bâtie dessus — et que
votre programme meurt d'une erreur de segmentation la première fois qu'il lit un poids, c'est
probablement pour cette raison.
Les safetensors ne se chargent que sur le flux CPU. mlx_load sur le flux GPU par défaut ne
lève rien que vous puissiez remarquer. mlx_array_eval renvoie 1, et si vous n'avez pas vérifié
ce code de retour, les tableaux restent non évalués. Les lire ensuite revient à lire une mémoire
qui n'a jamais été remplie, et le processus meurt quelque part qui n'a rien à voir avec le
chargement.
Le correctif tient en une ligne : passez mlx_default_cpu_stream_new() au chargement, et rien
d'autre.
Pourquoi c'est déroutant
Parce que le modèle mental évident est faux d'une manière raisonnable. MLX tourne sur le GPU, vous voulez les poids sur le GPU, donc vous chargez sur le flux GPU. Ce raisonnement est correct et la conclusion ne l'est pas.
Charger, c'est de l'entrée-sortie de fichier et du placement mémoire, et sur Apple Silicon la mémoire est de toute façon unifiée : il n'y a aucun transfert à organiser. Charger sur le flux CPU ne laisse pas vos poids au mauvais endroit. Le calcul, lui, se fait toujours sur le GPU. Seul le chargement change.
Une fois qu'on le sait, c'est évident. Avant de le savoir, on cherche un bogue de mémoire dans son propre code, parce qu'une erreur de segmentation, c'est à cela que ressemble son propre mauvais pointeur.
La partie qui transforme un bogue en mystère
mlx_array_eval renvoie un code d'état. L'ignorer est la chose naturelle à faire, parce que la
plupart du temps il vaut zéro et que vérifier chaque appel ressemble à du bruit.
Ne l'ignorez pas. Un tableau non évalué ne s'annonce pas. Il se lit comme des zéros, silencieusement, sans erreur nulle part. Et dès que des zéros circulent dans un transformeur, le symptôme devient « le modèle produit des zéros » ou « le modèle sort n'importe quoi » — ce qui vous envoie regarder le code d'échantillonnage, ou le tokeniseur, ou l'architecture. Partout sauf le chargeur.
C'est la forme générale de toute cette classe de défauts, et elle mérite d'être énoncée seule :
Une défaillance qui renvoie une valeur plausible coûte plus cher qu'une défaillance qui plante, parce que le plantage vous dit où regarder.
Donc : vérifiez le code de retour de chaque eval. Cela coûte une ligne à chaque fois et convertit
une classe de mystères en une classe de messages d'erreur.
Comment le reproduire
Chargez n'importe quel fichier safetensors MLX avec le flux par défaut, évaluez, et lisez le premier élément.
// La version qui plante
mlx_array w;
mlx_load(&w, "model.safetensors", /* stream */ mlx_default_gpu_stream_new());
mlx_array_eval(&w); // renvoie 1 — et personne ne regarde
const float* p;
mlx_array_data_float32(&p, w);
printf("%f\n", p[0]); // erreur de segmentation, ici
// La version qui marche
mlx_stream s = mlx_default_cpu_stream_new();
mlx_load(&w, "model.safetensors", s);
if (mlx_array_eval(&w) != 0) { /* maintenant vous l'apprenez */ }
L'endroit du plantage est la lecture, pas le chargement, et c'est là tout le problème : votre débogueur pointe une ligne qui est correcte.
À quoi cela ressemble depuis Rust
La même chose, un étage plus haut, et l'ergonomie facilite l'erreur : une enveloppe qui renvoie un
Result depuis le chargement et avale l'évaluation aura l'air de traiter les erreurs
correctement.
Le motif qui tient consiste à traiter l'évaluation comme une opération faillible partout, et pas seulement là où l'échec paraît probable. En pratique, cela veut dire une petite fonction d'aide qui évalue et convertit l'état en votre type d'erreur, et ne jamais appeler la fonction brute directement. Ce n'est pas du code intéressant. C'est le code qui transforme une erreur de segmentation en une phrase.
Pourquoi le chemin de chargement est différent, au juste
Un peu de spéculation informée, puisque ce n'est pas documenté comme tel : le chargement doit placer les octets d'un fichier en mémoire, et le chemin Metal s'attend à recevoir des tampons plutôt qu'à les remplir depuis un descripteur de fichier. Quelle que soit la raison interne, le comportement observable est stable et le contournement trivial.
Ce qui compte en pratique, c'est que ce n'est pas une décision de performance que vous prenez. Vous ne choisissez pas de charger lentement sur le processeur. Vous choisissez le seul chemin qui fonctionne, et le calcul qui suit n'en est pas affecté.
Combien de temps cela a pris, et la forme de la recherche
Deux jours, et presque tout passé aux mauvais endroits. Le trajet mérite d'être consigné parce que les mauvais endroits n'étaient pas des endroits stupides.
Le symptôme était une sortie incohérente. Le premier suspect fut donc l'échantillonnage : un décalage d'un rang dans la sélection top-k, une température appliquée deux fois, une graine qui fait quelque chose d'inattendu. C'est dans l'échantillonnage que vit le hasard, et le hasard, c'est à cela que ressemble l'incohérence. Deux heures pour l'écarter, en figeant la graine et en constatant que la même incohérence revenait à l'identique — ce qui est le résultat négatif utile : une incohérence déterministe n'est pas un problème d'échantillonnage.
Ensuite le tokeniseur, parce qu'une incohérence qui se décode depuis des identifiants de jeton valides ne ressemble pas à une incohérence qui ne le fait pas, et celle-ci ressemblait à la seconde. Quelques heures de plus pour confirmer que le tokeniseur fait correctement l'aller-retour tout seul.
Ensuite l'architecture, là où un portage a vraiment des chances d'être faux : une matrice transposée, une normalisation manquante, un plongement rotatif appliqué au mauvais endroit. C'est l'écartement coûteux, parce que le vérifier signifie comparer les tenseurs intermédiaires à une référence, couche par couche.
Et c'est pendant cette comparaison couche par couche que la vraie réponse est apparue, de côté : le premier tenseur comparé était déjà faux, avant qu'aucun calcul n'ait eu lieu. Les poids eux-mêmes étaient des zéros.
La leçon générale est celle qui continue de rapporter :
Comparez à une référence au point le plus précoce possible, pas au point où le symptôme apparaît.
Le symptôme apparaissait à la sortie, donc la recherche a commencé là. Le défaut était à l'entrée, et une demi-heure passée à comparer les poids chargés à la référence l'aurait trouvé dès le premier jour.
Le piège voisin : les fragments se découvrent, ils ne se nomment pas
Tant que vous êtes dans le chargeur, l'erreur d'à côté vaut aussi d'être évitée.
Les poids d'un modèle peuvent tenir dans un fichier ou dans plusieurs, et le nombre varie selon le
modèle plutôt que selon une convention : Llama-3.2-1B publie un fichier, phi-4 en publie deux.
Du code qui nomme model.safetensors fonctionne jusqu'au premier modèle fragmenté, et du code qui
suppose model-00001-of-* échoue au premier modèle en un seul fichier.
Lisez le répertoire, ou mieux, lisez l'index des poids — model.safetensors.index.json et sa
weight_map — qui nomme chaque fragment dont le modèle a besoin. C'est aussi le seul moyen fiable
de distinguer un téléchargement terminé d'un téléchargement interrompu, parce qu'un nombre de
fichiers ne dit rien sur la présence des bons fichiers.
La version de ceci qui a mordu deux fois
Encore une chose de la même famille, puisqu'elle a coûté un second après-midi et qu'elle a la même forme.
N'appelez pas eval à l'intérieur de la boucle des couches. Il y en avait un, resté d'une preuve
de concept : eval(&h) après chaque couche, ce qui est correct et énormément coûteux. Sur un
modèle à seize couches, cela fait seize synchronisations GPU par jeton ; sur un modèle à quarante,
quarante.
Les tableaux n'ont besoin d'être évalués qu'à la fin de l'étape. Le retirer a fait passer la génération de 94 à 244 jetons par seconde : plus de la moitié de la vitesse partait à se synchroniser avec le GPU sans raison.
C'est la même leçon vue de l'autre côté. Le premier défaut consistait à ne pas vérifier un résultat qu'on aurait dû vérifier ; celui-ci consistait à forcer un résultat dont on n'avait pas besoin. Les deux portent sur le moment où l'évaluation a lieu, et dans un graphe paresseux ce n'est pas un détail.
La version courte
- Chargez les safetensors sur le flux CPU. Le calcul tourne toujours sur le GPU.
- Vérifiez le retour de chaque
eval. Un tableau non évalué se lit comme des zéros, en silence. - Découvrez les fragments depuis l'index plutôt que de nommer des fichiers.
Mesuré contre mlx-c le 6 septembre 2026. Si MLX change cela, la date est votre avertissement que
cette page n'a pas été revérifiée depuis.