Ingeniería
El cargador que revienta en vez de fallar
Cargar pesos de MLX en el flujo de GPU no lanza ningún error. Devuelve un código distinto de cero que probablemente no comprobaste, y después leer los datos mata el proceso. Aquí está el porqué, y qué hacer al respecto.
Actualizado 2026-09-12Medido 2026-09-06
Si estás llamando a MLX desde C —a través de mlx-c, o de cualquier enlace construido encima— y
tu programa muere con un fallo de segmentación la primera vez que lee un peso, probablemente sea
por esto.
Los safetensors solo se cargan en el flujo de CPU. mlx_load sobre el flujo de GPU por
omisión no lanza nada que vayas a notar. mlx_array_eval devuelve 1, y si no comprobaste ese
código de retorno, los arreglos se quedan sin evaluar. Leerlos después es leer memoria que nunca
se rellenó, y el proceso muere en algún sitio que no tiene nada que ver con la carga.
El arreglo es una línea: pásale mlx_default_cpu_stream_new() a la carga, y nada más.
Por qué esto confunde
Porque el modelo mental evidente está equivocado de una manera razonable. MLX corre en la GPU, quieres los pesos en la GPU, así que cargas en el flujo de GPU. Ese razonamiento está bien y la conclusión no.
Cargar es entrada/salida de fichero y colocación en memoria, y en Apple Silicon la memoria es unificada de todos modos: no hay ninguna transferencia que organizar. Cargar en el flujo de CPU no deja tus pesos en el sitio equivocado. El cálculo sigue ocurriendo en la GPU. Solo cambia la carga.
Una vez que lo sabes, es obvio. Antes de saberlo, estás buscando un fallo de memoria en tu propio código, porque un fallo de segmentación es a lo que se parece tu propio puntero equivocado.
La parte que convierte un fallo en un misterio
mlx_array_eval devuelve un código de estado. Ignorarlo es lo natural, porque la mayor parte del
tiempo vale cero y comprobar cada llamada parece ruido.
No lo ignores. Un arreglo sin evaluar no se anuncia. Se lee como ceros, en silencio, sin ningún error en ninguna parte. Y en cuanto hay ceros circulando por un transformador, el síntoma es «el modelo genera ceros» o «el modelo saca basura», lo que te manda a mirar el código de muestreo, o el tokenizador, o la arquitectura. A todas partes menos al cargador.
Esa es la forma general de toda esta clase de defecto, y merece decirse por separado:
Un fallo que devuelve un valor plausible sale más caro que un fallo que revienta, porque el reventón te dice dónde mirar.
Así que comprueba el valor de retorno de cada eval. Cuesta una línea cada vez y convierte una
clase de misterios en una clase de mensajes de error.
Cómo reproducirlo
Carga cualquier fichero de safetensors de MLX con el flujo por omisión, evalúa y lee el primer elemento.
// La versión que revienta
mlx_array w;
mlx_load(&w, "model.safetensors", /* stream */ mlx_default_gpu_stream_new());
mlx_array_eval(&w); // devuelve 1 — y nadie mira
const float* p;
mlx_array_data_float32(&p, w);
printf("%f\n", p[0]); // fallo de segmentación, aquí
// La versión que funciona
mlx_stream s = mlx_default_cpu_stream_new();
mlx_load(&w, "model.safetensors", s);
if (mlx_array_eval(&w) != 0) { /* ahora sí te enteras */ }
El lugar del reventón es la lectura, no la carga, y ese es el problema entero: tu depurador apunta a una línea que es correcta.
Cómo se ve desde Rust
Lo mismo, una capa más arriba, y la ergonomía facilita equivocarse: una envoltura que devuelve un
Result desde la carga y se traga la evaluación parecerá que está tratando los errores como es
debido.
El patrón que aguanta consiste en tratar la evaluación como una operación falible en todas partes, y no solo allí donde parece probable que falle. En la práctica eso significa una pequeña función auxiliar que evalúa y convierte el estado en tu tipo de error, y no llamar nunca a la función en crudo directamente. No es código interesante. Es el código que convierte un fallo de segmentación en una frase.
Por qué el camino de carga es distinto siquiera
Algo de especulación informada, ya que esto no está documentado como tal: la carga tiene que colocar los bytes de un fichero en memoria, y el camino de Metal espera que le entreguen búferes en lugar de rellenarlos desde un descriptor de fichero. Sea cual sea el motivo interno, el comportamiento observable es estable y el rodeo es trivial.
Lo que importa en la práctica es que esto no es una decisión de rendimiento que estés tomando. No estás eligiendo cargar despacio en la CPU. Estás eligiendo el único camino que funciona, y el cálculo posterior no se ve afectado.
Cuánto llevó esto, y qué forma tuvo la búsqueda
Dos días, y casi todos en los sitios equivocados. El recorrido merece anotarse porque los sitios equivocados no eran sitios tontos.
El síntoma era salida sin sentido. Así que el primer sospechoso fue el muestreo: un desfase de uno en la selección top-k, una temperatura aplicada dos veces, una semilla haciendo algo inesperado. En el muestreo vive el azar, y el azar es a lo que se parece la basura. Dos horas descartándolo, fijando la semilla y confirmando que volvía la misma basura idéntica, que es el resultado negativo útil: la basura determinista no es un problema de muestreo.
Luego el tokenizador, porque la basura que se decodifica desde identificadores de token válidos se ve distinta de la que no, y esta se parecía a la segunda. Unas horas más confirmando que el tokenizador hace correctamente el viaje de ida y vuelta por su cuenta.
Luego la arquitectura, que es donde una portación tiene verdaderas probabilidades de estar mal: una matriz transpuesta, una normalización que falta, una incrustación rotatoria aplicada en el punto equivocado. Ese es el descarte caro, porque verificarlo significa comparar tensores intermedios contra una referencia, capa por capa.
Y fue durante esa comparación capa por capa cuando apareció la respuesta de verdad, de lado: el primer tensor comparado ya estaba mal, antes de que hubiera ocurrido cálculo alguno. Los pesos mismos eran ceros.
La lección general es la que no deja de pagar:
Compara contra una referencia en el punto más temprano que puedas, no en el punto donde aparece el síntoma.
El síntoma aparecía en la salida, así que ahí empezó la búsqueda. El defecto estaba en la entrada, y media hora comparando los pesos cargados contra la referencia lo habría encontrado el primer día.
La trampa vecina: los fragmentos se descubren, no se nombran
Ya que estás en el cargador, la equivocación de al lado también merece evitarse.
Los pesos de un modelo pueden estar en un fichero o en varios, y la cantidad varía según el modelo
y no según una convención: Llama-3.2-1B publica un fichero, phi-4 publica dos. El código que
nombra model.safetensors funciona hasta el primer modelo fragmentado, y el que da por supuesto
model-00001-of-* falla con el primero de un solo fichero.
Lee el directorio o, mejor, lee el índice de pesos —model.safetensors.index.json y su
weight_map—, que nombra cada fragmento que el modelo necesita. Es además la única manera fiable
de distinguir una descarga terminada de una interrumpida, porque un recuento de ficheros no dice
nada sobre si están los ficheros correctos.
La versión de esto que mordió dos veces
Una cosa más de la misma familia, ya que costó una segunda tarde y tiene la misma forma.
No llames a eval dentro del bucle de capas. Allí había uno, resto de una prueba de concepto:
eval(&h) después de cada capa, que es correcto y enormemente caro. En un modelo de dieciséis
capas eso son dieciséis sincronizaciones con la GPU por token; en uno de cuarenta, cuarenta.
Los arreglos solo hace falta evaluarlos al final del paso. Quitarlo llevó la generación de 94 a 244 tokens por segundo: más de la mitad de la velocidad se estaba yendo en sincronizar con la GPU sin motivo.
Es la misma lección desde el otro lado. El primer defecto fue no comprobar un resultado que deberías haber comprobado; este fue forzar un resultado que no necesitabas. Los dos van de cuándo ocurre la evaluación, y en un grafo perezoso eso no es un detalle.
La versión corta
- Carga los safetensors en el flujo de CPU. El cálculo sigue corriendo en la GPU.
- Comprueba el retorno de cada
eval. Un arreglo sin evaluar se lee como ceros, en silencio. - Descubre los fragmentos desde el índice en vez de nombrar ficheros.
Medido contra mlx-c el 6 de septiembre de 2026. Si MLX cambia esto, la fecha es tu aviso de que
esta página no se ha vuelto a comprobar desde entonces.