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
| Tarea | Modelo | Tiempo | Memoria pico |
|---|---|---|---|
| Transcribir 60 min con hablantes | Whisper large-v3-turbo + Nemotron | 147 s | 5,3 GB |
| Texto / código | Qwen3.5-9B 4 bits | ~35 tokens/s | 5,7–6,8 GB |
| Describir una imagen | Qwen3-VL-8B 4 bits | 14 s | 7,9 GB |
| Generar una imagen 768×768 | FLUX.2-klein-4B q4 | 33 s | 12,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:
| Task | Model | Disk | License |
|---|---|---|---|
| Transcription | mlx-community/whisper-large-v3-turbo | 1.61 GB | MIT |
| Speakers | mlx-community/Nemotron-3-Diarization (default) · sherpa-onnx: pyannote seg-3.0 + 3D-Speaker (alternative) | 0.20 · 0.05 GB | OpenMDW · MIT + Apache |
| Text/code | mlx-community/Qwen3.5-9B-MLX-4bit | 5.98 GB | Apache-2.0 |
| Vision | mlx-community/Qwen3-VL-8B-Instruct-4bit | 5.78 GB | Apache-2.0 |
| Image | mflux-community/flux2-klein-4b-mflux-q4 | 4.62 GB | Apache-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
- 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). - And it was also measuring the wrong process: by wrapping the child with
/usr/bin/time, the sampled RSS was that oftime(1 MB). Fix: add up the process and its children. mlx_whisper.transcribeis both a module and a function (the package re-exports the function under the same name).from mlx_whisper import transcribegets the function; for the module:importlib.import_module("mlx_whisper.transcribe").tmux-run.shhung: it reads stdin until EOF and over SSH that never comes. Fix:< /dev/null.- 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_downloadpicks up what was already downloaded). - sherpa-onnx compiled but would not load:
libonnxruntime.dylibis missing, which ships in the sibling packagesherpa-onnx-core(it does not install itself). - mlx-audio guesses the model type from the folder name: Kokoro in
~/modelos/ttsfailed (“Model type tts not supported”); in~/modelos/kokoroit 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
- Why does the manager launch a process per job instead of keeping the model loaded?
- Which memory counter would you use to decide whether two models fit at once, and why not RSS?
- What happens if someone changes
HOSTto0.0.0.0? What would have to exist first?