Skip to content

Lesson 15 · 24 in the route · 5 min read · Level 2

Local models on the Mac

Five MLX models with sha256, served one at a time by a manager that Windows reaches through a tunnel.

The story behind this lesson What a 16 GB Mac mini really delivers with local models

Tengo una Mac mini M2 Pro con 16 GB al lado de un Windows. Quería saber qué se le puede pedir de verdad a esa Mac con modelos locales, sin pagar por cada llamada. No me interesaba si los modelos “corren”: quería saber cuánto tardan, cuánta memoria piden y cuánto se equivocan. Los comandos están en los pasos 16, 17 y 18 de la guía.

La regla que manda: un modelo pesado a la vez

En Apple Silicon la memoria es unificada: lo que usa la GPU se le quita a todo lo demás. MLX dice que el techo práctico de la GPU en esta Mac es 12,7 GB. Con eso no caben dos modelos grandes juntos. Armé un gestor pequeño con una sola regla: cada trabajo corre en un proceso propio, y cuando el proceso termina el modelo sale de la memoria. No hay que descargarlo a mano. La API solo escucha en 127.0.0.1 y Windows llega por un túnel SSH. No hay ningún puerto abierto.

Lo que rinde

TareaModeloTiempoMemoria pico
Transcribir 60 min con hablantesWhisper large-v3-turbo + Nemotron147 s5,3 GB
Texto / códigoQwen3.5-9B 4 bits~35 tokens/s5,7–6,8 GB
Describir una imagenQwen3-VL-8B 4 bits14 s7,9 GB
Generar una imagen 768×768FLUX.2-klein-4B q433 s12,0 GB

El dato que más me importa: unas 24 horas de audio por hora, separando quién dice qué, con 5,3 GB de memoria. La Mac queda usable mientras tanto. La imagen de la portada la generó esta misma Mac. También es lo único que se acerca al techo de memoria: 1024×1024 no lo probé.

La trampa de medir memoria

El primer trabajo me dio tres cifras distintas para la misma memoria: 1,4 GB según el sistema (RSS), 2,4 GB según MLX y 3,0 GB según el footprint de macOS. El RSS no ve la memoria que usa la GPU. Si mides con él, vas a creer que caben dos modelos donde solo cabe uno. Ahora el gestor guarda las tres cifras, y la que uso para decidir es el footprint.

El error que casi publico

Para medir errores necesitaba saber la respuesta correcta. Por eso no usé grabaciones mías: tienen gente que se puede identificar. Generé diálogos con voces sintéticas y guardé quién decía cada frase. La primera tanda dio entre 37 y 50 % de error, y los dos diarizadores que probaba fallaban exactamente en los mismos pares de voces. Que dos modelos distintos se equivoquen igual es sospechoso. El problema no estaba en los modelos: macOS solo trae dos voces en español. Cualquier otro nombre de voz cae en una de esas dos y produce el mismo audio, byte por byte. Mis audios “de 4 personas” tenían 2. Los modelos tenían razón y mi respuesta correcta estaba mal.

Con voces que de verdad eran distintas, el error quedó entre 4,6 y 8,8 %. Pero no lo presento como “lo que vas a obtener”: con voces sintéticas no hay ruido ni gente hablando a la vez.

El diarizador que elegí, Nemotron, tuvo además un fallo grave: 36,6 % en un audio de 5 minutos. Esta vez sí encontré la causa. Nemotron no agrupa voces al final; asigna casillas de hablante por orden de llegada y lo que decide al principio pesa en todo lo demás. En ese audio, lo que oyó en los primeros ~30 segundos lo llevó a meter la segunda voz en la casilla de la primera. Si corto esos 30 segundos, el error baja a 3,6 %; qué parte exacta del arranque lo provoca no lo aislé. El fallo se repite idéntico en cada corrida y bajar el umbral solo lo atenúa.

Con un audio real, de verdad

Después probé con una grabación real de 56 minutos, que no salió de la Mac. Whisper rindió igual: el pipeline completo con Nemotron tardó 158 s (unas 21 horas de audio por hora) con 5,3 GB.

Los hablantes fueron otra historia:

  • sherpa, sin decirle cuántas personas había, encontró 129.
  • Nemotron encontró 3, pero no coincide con sherpa. Le pasé a sherpa ese 3: ahora los dos dicen 3 personas y aun así no están de acuerdo sobre quién habla en un 38 % del tiempo, más un 11 % donde uno oye voz y el otro no.

No sé cuál acierta. Lo voy a saber leyendo los subtítulos, y esa es la conclusión honesta: con audio real, la separación de hablantes en una máquina local es un borrador que revisa una persona, no un resultado.

La Mac contra el Windows de al lado

Para las herramientas, la diferencia fue mayor de lo que esperaba. Los tests en bash del kit corrieron 14 a 17 veces más rápido en la Mac, y git entre 2 y 10 veces. En Python puro la diferencia fue de apenas 1,6×. Eso apunta a lo caro que es lanzar procesos en Windows con Git Bash, no a la potencia de la CPU.

Con claude -p separé dos tiempos. La parte del modelo en la nube es igual en las dos máquinas, 11–15 s. La parte local no: 30 s en Windows contra 4 s en la Mac. Al quitar los servidores MCP la brecha casi no se cierra, así que el grueso es la plataforma y no mi configuración.

Lo que no rinde: código con reglas exactas

Le di tres funciones pequeñas, con tests ocultos, al modelo local y a claude -p. Claude acertó 3 de 3 por US$ 0,91. El modelo local de 9B acertó 1 de 3, gratis, incluso con un reintento en el que veía los tests que fallaban. Le activé el modo de razonamiento: tardó 5 veces más y siguió en 1 de 3. Hoy no lo usaría para código que tiene que ser correcto. Sí lo usaría para resumir, clasificar o redactar borradores, donde un error se ve y cuesta poco.

En resumen

Una Mac mini de 16 GB sirve como máquina de transcripción (el texto sí; quién habló, a revisar) y como ejecutor rápido de herramientas. Para generar imágenes alcanza, pero justo. Para reemplazar a un modelo grande en código no alcanza. Lo que más aprendí fue a desconfiar de mis propios datos de prueba antes de culpar al modelo.

Goal

Five local models (transcription, speakers, text/code, vision, image) on the 16 GB Mac, downloaded with verified sha256, served one at a time by a manager that Windows uses through an SSH tunnel.

Why

On the Mac, memory is unified: whatever the GPU reserves, everything else loses. With 16 GB two large models do not fit at once, so the design revolves around one rule: one heavy model at a time, unloaded when done. The safest way to unload a model is for the process that loaded it to exit.

Repo: IchiSieben/modelos-mac (private). Weights outside git, in ~/modelos on the Mac.

Steps

1. Measure the disk before downloading anything

mac$ diskutil info / | grep "Container Free"
   Container Free Space:      105.2 GB

Rule: after downloading everything, 60 GB must remain free → there are 45.2 GB for everything new (models + Python + environment + ffmpeg). Full inventory and cleanup recommendations (nothing was deleted): modelos-mac/docs/DISCO.md.

2. Tools (no sudo)

mac$ brew install uv ffmpeg python@<versión>
mac$ uv --version; ffmpeg -version | head -1; python3.12 --version
uv 0.12.23 (Homebrew 2026-10-03 aarch64-apple-darwin)
ffmpeg version 9.0.2
Python 3.12.15

It cost 0.32 GB. The system Python (3.9.6) is no use: mlx-lm requires ≥ 3.11.

3. Choose models from today’s documentation, not from memory

The Hugging Face API (/api/models/<repo>?blobs=true) was read for size, license and whether the model is gated. One per task, the most permissively licensed one that fits:

TaskModelDiskLicense
Transcriptionmlx-community/whisper-large-v3-turbo1.61 GBMIT
Speakersmlx-community/Nemotron-3-Diarization (default) · sherpa-onnx: pyannote seg-3.0 + 3D-Speaker (alternative)0.20 · 0.05 GBOpenMDW · MIT + Apache
Text/codemlx-community/Qwen3.5-9B-MLX-4bit5.98 GBApache-2.0
Visionmlx-community/Qwen3-VL-8B-Instruct-4bit5.78 GBApache-2.0
Imagemflux-community/flux2-klein-4b-mflux-q44.62 GBApache-2.0

Full proposal (2 candidates per task, why one and not the other): modelos-mac/docs/MODELOS.md.

4. A single environment, outside the repo

mac$ export UV_CACHE_DIR=~/modelos/uv-cache UV_PROJECT_ENVIRONMENT=~/modelos/envs/main
mac$ uv sync --python 3.12 --extra mlx --extra dev

Everything resolved in a single environment: mlx 0.32.3, mlx-lm 0.32.0, mlx-vlm 0.7.4, mlx-whisper 0.4.3, mlx-audio 0.5.7, mflux 0.21.0. MLX reports max_recommended_working_set_size = 12.7 GB: the practical GPU ceiling on this Mac.

5. Download with a manifest and sha256

mac$ python scripts/download.py --dry-run
total 18.19 GB; free now 103.3 GB; after 85.2 GB
mac$ tmux new -d -s md-dl "python scripts/download.py > ~/modelos/logs/download.log 2>&1"
ok stt 1.61 GB in 35.4 s
ok llm 5.98 GB in 134.2 s
ok vlm 5.78 GB in 105.3 s
ok img 4.62 GB in 134.3 s

HF_HOME=~/modelos/hf: without it Hugging Face keeps one copy in ~/.cache/huggingface and another in the destination folder. Each large file is compared against the LFS sha256 reported by the API; the manifest ends up in ~/modelos/MANIFEST.json (a copy without personal paths in the repo). After everything (models + tools + environment): 85.0 GB free, that is, 20.2 GB used.

6. The manager

mac$ tmux new -d -s md-server "python -m server.app"   # así se probó; hoy lo arranca un LaunchAgent (17 §8)
mac$ lsof -nP -iTCP:8765 -sTCP:LISTEN
Python  3730 <usuario>  7u  IPv4  TCP 127.0.0.1:8765 (LISTEN)

Only 127.0.0.1: the API has no authentication, its protection is not listening on the network. From Windows it is reached through a tunnel, and the client opens it by itself:

PS> python client.py run image --prompt "A red bicycle ... Cusco" --params '{"steps":4,"width":768,"height":768}' --out bici.png
PS> python client.py run vision --file bici.png --prompt "Describe la imagen..."
"La imagen muestra una bicicleta roja apoyada contra una pared de piedra ... La bicicleta es roja y la puerta es azul."

(capture of the real setup: not published; see docs/CAPTURAS.md for the demo-mode ones)

Architecture (why a child process per job, why SQLite, why a tunnel): modelos-mac/docs/ARQUITECTURA.md. Contract: modelos-mac/docs/SPEC.md.

7. From enjambre

PS> python enjambre.py run --node mac --repo enjambre-sandbox --task TASK-local-smoke.md --engine local
{"id": "local-smoke", "engine": "local", "exit": 0, "duration_s": 15.99, "cost_usd": 0.0,
 "load_s": 2.47, "peak_footprint_mb": 5953.3, "output": "state/local/local-smoke.md"}

The local engine returns text, not commits: the answer lands in state/local/<id>.md and in the ledger with engine: "local".

What actually went wrong

  1. RSS lies on unified memory. The first job gave: RSS 1.43 GB, MLX peak 2.36 GB, macOS peak memory footprint 3.0 GB. GPU pages do not count toward RSS. The manager stores all three; the headline is the footprint (/usr/bin/time -l).
  2. And it was also measuring the wrong process: by wrapping the child with /usr/bin/time, the sampled RSS was that of time (1 MB). Fix: add up the process and its children.
  3. mlx_whisper.transcribe is both a module and a function (the package re-exports the function under the same name). from mlx_whisper import transcribe gets the function; for the module: importlib.import_module("mlx_whisper.transcribe").
  4. tmux-run.sh hung: it reads stdin until EOF and over SSH that never comes. Fix: < /dev/null.
  5. The Mac rebooted halfway through the download (FileVault screen over SSH: “This system is locked”). No password is typed: you wait for the owner. The download resumed by itself (snapshot_download picks up what was already downloaded).
  6. sherpa-onnx compiled but would not load: libonnxruntime.dylib is missing, which ships in the sibling package sherpa-onnx-core (it does not install itself).
  7. mlx-audio guesses the model type from the folder name: Kokoro in ~/modelos/tts failed (“Model type tts not supported”); in ~/modelos/kokoro it works.

How to verify

PS> python client.py status          # lista de trabajos con tiempos y memoria
mac$ curl -s 127.0.0.1:8765/health   # {"running": null, "queued": 0, "free_gb": ...}
mac$ python -m pytest -q             # 15 passed

Self-check

  1. Why does the manager launch a process per job instead of keeping the model loaded?
  2. Which memory counter would you use to decide whether two models fit at once, and why not RSS?
  3. What happens if someone changes HOST to 0.0.0.0? What would have to exist first?
Saved only in this browser.