Updated August 2026. The original version of this post ran Qwen3-Coder-30B on
mlx_lm.serverunder mlx-lm 0.30.6. Eighteen months of daily use later, the architecture still holds, but almost everything I learned since then is about the things that only show up after you run this setup continuously: a still-unfixed Apple GPU driver bug, why your Mac gets slower as context fills, why benchmarks lie to you, and why the "easy" GUI option is not actually faster. Original content is preserved below; new material is marked [2026].
Tags: #llm #ai #programming #apple
What We're Building
The objective is to operate "OpenCode", an open-source agentic coding CLI, powered by a large language model running entirely on your Mac, without cloud dependencies, API costs, or external data transmission.
The Architecture (Three Layers)
Three interconnected components work together:
- OpenCode (The App) - User interface that sends messages and executes tools like file reading and code editing
- mlx_lm.server (The Hub) - Middleware translating between OpenCode and the model via HTTP on localhost:8080
- The LLM (The Brain) - Neural network processing numerical representations of text
Key Concepts
Tokens are numerical representations of text. Models process these numbers rather than raw text, with tokenizers handling conversion between text and token sequences.
Tool Calls represent structured requests where the model cannot directly access files but can request actions. The model outputs a tool call, the application executes it, and results feed back to the model for continued reasoning.
Tool Parsers interpret model-specific output formats. Different architectures use different structures (XML tags, JSON, special tokens), requiring appropriate parsers for proper function.
What Is MLX?
MLX is Apple's machine learning framework optimized for Apple Silicon. The key advantage: unified memory architecture allows the CPU and GPU to share RAM, enabling efficient model loading without copying overhead.
mlx-lm provides model loading, inference, and an OpenAI-compatible HTTP server through mlx_lm.server.
[2026] The Bug Nobody Warns You About: Kernel Panics from set_wired_limit
This is the most important thing in this post. If you take one thing away, take this.
If you run mlx_lm.server as a long-lived service, your Mac can kernel panic and reboot. Not crash the Python process: reboot the entire machine. The signature is:
panic(cpu 8 caller 0x...): "completeMemory() prepare count underflow" @IOGPUMemory.cpp:550
Kernel Extensions in backtrace:
com.apple.iokit.IOGPUFamily(...)
This is tracked as ml-explore/mlx#3186, open since March 2026. As of late August 2026 it is still unfixed, with no response from Apple or the MLX maintainers across the entire thread. It has been reproduced on M1 Ultra, M3 Ultra, M4 base, M4 Pro, M4 Max and M5, from 32GB to 256GB, so it is not a "you bought the wrong Mac" problem.
What actually triggers it
The valuable work in that thread is a controlled single-variable isolation by a commenter running ten fresh-server soak runs per arm (full report and shim). The findings overturned the obvious assumption:
- It is not prefill size. A faithful replay of a crashing session, sequential requests with prefills up to 23k tokens, ran 111 minutes clean.
-
It is concurrency plus prompt-cache eviction churn. Add two concurrent request streams and unique prompts overflowing
--prompt-cache-bytes, and it panics in 102 to 108 seconds, 3 out of 3 cold boots. - It is not OOM. Genuine memory exhaustion produces a clean userspace Metal error. The panic happens with headroom to spare.
The one mitigation that works
mlx_lm.server calls mx.set_wired_limit(...) at startup, raising the wired memory ceiling to roughly 75% of RAM. Wired pages cannot be reclaimed by the OS. That single call is the discriminating factor:
| Change (one variable per arm) | Result |
|---|---|
| none (stock) | panic x3 @ 102 to 108s |
set_wired_limit never called |
no panic, 10/10 runs, 4.2h |
+ also no periodic clear_cache
|
no panic, 10/10 runs, 5.0h |
only skip periodic clear_cache
|
panic @ 99.9s |
sysctl iogpu.disable_wired_collector=1 + metal4 async mapping off |
panic @ 109.6s |
RotatingKVCache / lower wired cap / MLX_MAX_OPS_PER_BUFFER
|
die earlier as clean userspace OOM |
Note what fails: the sysctl workarounds you will find on forums, and bounding the KV cache. Also worth knowing, --max-kv-size is silently ignored for architectures that define make_cache (the Qwen3.5 hybrids, for example).
The cost of the fix is about 3% sequential throughput, because weights become pageable.
Implementing it
There is no upstream opt-out. As of mlx-lm 0.31.3, mx.set_wired_limit is still called unconditionally in seven places, including mlx_lm/server.py. So you wrap the server in a launcher that no-ops it before importing:
#!/usr/bin/env python3
"""Launcher that disables set_wired_limit before mlx_lm.server starts.
Mitigation for ml-explore/mlx#3186."""
import os, sys
import mlx.core as mx
def main():
if os.environ.get("MLX_LM_DISABLE_WIRED_LIMIT") == "1":
mx.set_wired_limit = lambda *a, **k: 0
print("[shim] wired limit disabled", file=sys.stderr)
from mlx_lm.server import main as server_main
sys.argv = ["mlx_lm.server"] + sys.argv[1:]
server_main()
if __name__ == "__main__":
main()
Patching at the mlx.core module level matters: mlx_lm shares that module object, so every call site is covered, including the one inside server.py that a narrower patch would miss.
Run it with MLX_LM_DISABLE_WIRED_LIMIT=1 and confirm the [shim] line appears in your logs. Do not assume it applied.
[2026] Why Your Mac Gets Laggy as Context Fills
I lived with this for months without diagnosing it: the further a coding session got through a large context window, the more the whole machine dragged. Not just the model, everything.
It is the same wired memory, viewed from a different angle. As the KV cache grows, wired allocations grow with it, and wired pages cannot be reclaimed by macOS. So instead of the OS paging something out, your entire system starves.
With the shim in place, the same memory is pageable. Under a heavy multi-compaction session I watched free RAM dip to 44% and then recover to 86% once the task finished. That reclaim is exactly what wiring prevents. Same workload, no lag.
This is worth internalizing: that lag is not "the model is big", it is the early stage of the same unreclaimable-memory pathology that ends in the kernel panic above. If your Mac gets sluggish at high context, you are on the road to a reboot.
Setting Up
Prerequisites
- Apple Silicon Mac (M1 or newer)
- Minimum 32GB RAM (64GB+ recommended)
- macOS with Homebrew
Installation Steps
Step 1: Install Python 3.12
brew install python@3.12
Step 2: Create Virtual Environment
/opt/homebrew/bin/python3.12 -m venv ~/mlx-env
source ~/mlx-env/bin/activate
pip install --upgrade pip
pip install mlx-lm
Step 3: Install OpenCode
npm install -g opencode-ai
# or
brew install anomalyco/tap/opencode
Step 4: Start Model Server
source ~/mlx-env/bin/activate
mlx_lm.server --model mlx-community/Qwen3-Coder-30B-A3B-Instruct-8bit --port 8080
Step 5: Configure OpenCode
Create ~/.config/opencode/opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"mlx": {
"npm": "@ai-sdk/openai-compatible",
"name": "MLX (local)",
"options": {
"baseURL": "http://localhost:8080/v1"
},
"models": {
"mlx-community/Qwen3-Coder-30B-A3B-Instruct-8bit": {
"name": "Qwen3-Coder 30B 8bit",
"tools": { "task": true }
}
}
}
}
}
[2026] Running It as a Real Service
Step 4 above is fine for trying things out, but a terminal-bound server dies with your terminal. Here is the LaunchAgent I actually use, at ~/Library/LaunchAgents/com.mlx-lm.coder.plist, wired through the shim:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.mlx-lm.coder</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/mlx-env/bin/python3</string>
<string>/Users/you/mlx-env/mlx_server_shim.py</string>
<string>--model</string>
<string>/Users/you/mlx-models/Qwen3.8-27B-MLX-4bit</string>
<string>--port</string>
<string>8081</string>
<string>--chat-template-args</string>
<string>{"enable_thinking": true}</string>
<string>--prompt-cache-size</string>
<string>4</string>
<string>--prompt-cache-bytes</string>
<string>8000000000</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>MLX_LM_DISABLE_WIRED_LIMIT</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/mlx-lm-coder.log</string>
<key>StandardErrorPath</key>
<string>/tmp/mlx-lm-coder.log</string>
</dict>
</plist>
[2026] The launchd Trap That Cost Me an Hour
launchctl kickstart -k restarts the process but reuses launchd's cached job definition. Your edited plist is silently ignored.
The service comes back, the port answers, the logs look healthy, and your change did nothing. I edited that plist three times (added a token cap, changed concurrency, repointed --model to a new path) and none of it applied. I only noticed when the server failed to load after I moved the weights and the log showed it still opening the old path.
After any plist edit:
launchctl bootout gui/$(id -u)/com.mlx-lm.coder
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.mlx-lm.coder.plist
Then verify against the running process, never the file:
ps -o args= -p $(pgrep -f mlx_server_shim.py) | tr ' ' '\n'
A corollary, since KeepAlive respawns a crashed server transparently: when you health-check a long-running service, compare the PID, not whether the port responds. A crash and respawn looks identical to healthy uptime from the outside.
[2026] Do Not Trust Your Own Benchmarks
I nearly published a completely fabricated result, and the mistake is easy to make.
I upgraded MLX, benchmarked, and measured a 2.7x speedup: 30 tok/s before, 84 tok/s after. Wonderful. Except a second model was resident on the GPU during the "before" run and not during the "after" run. With the GPU actually quiet, both versions do about 83 tok/s. The upgrade changed nothing.
On unified memory, a co-resident model does not just compete for RAM, it competes for memory bandwidth, which is the binding constraint for decode. That is enough to halve your numbers.
The tell is variance. A contaminated sweep produced 20.5, 36.6 and 72.4 tok/s for the same config. Wild run-to-run spread means contention, not noise. Clean runs are boring and tight.
So: before trusting any local LLM timing, confirm nothing else holds a model.
pgrep -fl mlx_server_shim.py # or your equivalent
And build the check into the harness so it aborts rather than producing a plausible wrong number.
[2026] Reading MLX Releases: Does This Commit Help You?
MLX ships fast, and release notes look exciting. Most of it will not touch your workload. A concrete example from 0.32.2:
- "Read each K/V byte once in gqa-8 decode attention" looked like a direct win for my chat model. It does nothing for it. That path targets a GQA ratio of 8, and Gemma 4 26B-A4B is 16 attention heads over 8 KV heads, a ratio of 2. It never enters the optimized path.
-
"Fused full-attention path for head_dim 256" was even more tempting, since that model does have
head_dim: 256. But it is gated to NAX devices, and an M3 Ultra reportsapplegpu_g15d.
Measured before and after on a quiet GPU: identical within noise.
The practical habit is to check the commit against your model's actual config.json before getting excited:
python3 -c "
import json; c=json.load(open('config.json')); t=c.get('text_config',c)
h,kv=t['num_attention_heads'],t['num_key_value_heads']
print('heads',h,'kv',kv,'GQA ratio',h//kv,'head_dim',t.get('head_dim'))"
None of this means "skip the upgrade". I kept 0.32.2 because it is a large correctness release with no regression. It just is not a speedup, and I would rather say so than repeat a changelog.
[2026] LM Studio vs Direct mlx_lm.server
I ran the coder through LM Studio for a while, because JIT loading with an idle TTL is genuinely convenient. Then I moved it back to a direct mlx_lm.server. The measured difference on an identical agentic task:
| Backend | Same task, end to end |
|---|---|
Direct mlx_lm.server
|
63.7s |
| LM Studio (MLX engine) | 65.1s |
A wash, and it should be: LM Studio's Apple Silicon engine is mlx-lm underneath. Same model, same quant, same speed. Anyone telling you one is dramatically faster than the other is measuring something else, probably contention (see above).
The real differences are operational:
Direct mlx_lm.server wins on:
- Exact version control over mlx and mlx-lm
- The wired-limit shim, which is the whole ballgame. LM Studio raises the wired limit and gives you no way to stop it
- No cold start, since the model never idles out
- Prompt cache that persists across sessions
LM Studio wins on:
- JIT load plus idle TTL, so the model leaves RAM when unused.
mlx_lm.serverhas no TTL and holds the model forever - A GUI, per-model load profiles, and
reasoningEffortin the API
That last point matters if you migrate: reasoningEffort is an LM Studio API extension. mlx_lm.server ignores it. Set reasoning server-side instead, via --chat-template-args '{"enable_thinking": true}'. Check your model's chat_template.jinja for what it accepts. Qwen3.8 takes reasoning_effort of xhigh, medium or low, and raises on anything else.
My conclusion: use LM Studio to explore models, run production through mlx_lm.server with the shim.
[2026] OpenCode Harness Gotchas
Two behaviors that cost me real debugging time, because both look exactly like the model hanging:
-
opencode runblocks forever at init waiting on a permission prompt when there is no TTY. It never sends a single request to your server. Pass--auto. -
It ignores the process working directory, resolving the project to
$HOMEand then failing its file picker with "Can not run certain FFF features in a file system root or home directories". Pass--dir <path>.
Both hangs reproduce identically against any backend, so a stall here is never evidence about your model or your server. Check whether a request actually arrived before you blame inference:
grep -c "POST /v1/chat/completions" /tmp/mlx-lm-coder.log
The Model Hunt
(Original 2025 content, preserved. Current recommendation follows.)
Attempt 1: GLM-4.7-Flash-4bit
Failed because general-purpose models lack the precision needed for agentic coding workflows, producing repetitive loops on complex tasks.
Attempt 2: Qwen2.5-72B-Instruct-4bit
Downloaded successfully (~40GB) but processing took 18 minutes for modest context, causing connection crashes. Dense models activate every parameter per token, making them prohibitively slow despite fitting in RAM.
Attempt 3: Devstral-Small-2-24B-8bit
Server warning stated: "Received tools but model does not support tool calling." No available parser existed for this architecture in mlx-lm v0.30.6.
Attempt 4: Qwen3-Coder-30B-A3B-Instruct-8bit (Winner)
Successfully operated with correct tool parsing, strong instruction-following, and adequate speed. Uses approximately 33GB RAM. Mixture of Experts architecture activates only 3B of 30B parameters per token, achieving 3B-model inference speed with 30B-model knowledge.
Attempt 5: Qwen3-Coder-Next-4bit (Winner After Bug Fix)
The crash revealed a critical debugging lesson. The model shipped with "tool_parser_type": "json_tools" in its config, but outputs XML-formatted tool calls. The parser attempted JSON parsing on XML content, causing immediate failure.
The Fix: Manually edit the cached tokenizer_config.json:
find ~/.cache/huggingface/hub/models--mlx-community--Qwen3-Coder-Next-4bit \
-name "tokenizer_config.json"
Change "json_tools" to "qwen3_coder". After restart, tool calling functions properly.
[2026] Current Daily Driver: Qwen3.8-27B MLX 4-bit
Dense 27B, 256k native context, and it loads text-only under mlx_lm despite shipping a vision config (model_type: qwen3_5, with a text_config block). Roughly 15GB on disk, about 34 tok/s decode on an M3 Ultra.
Yes, dense, after I spent 2025 preaching MoE. The nuance I would add now: MoE still wins decisively on throughput per parameter, but a 27B dense model at 4-bit is fast enough for interactive agentic work while being noticeably more reliable at tool calling and long-horizon edits. For comparison, on the same machine a 26B-A4B MoE chat model runs about 83 tok/s, roughly 2.4x the tokens per second, and it is the right pick for conversation. For coding I will trade tok/s for fewer wrong turns.
Measured in real use: multiple compactions in a single session, prefills up to 146,838 tokens, 324 requests served, zero crashes. That last number is only possible because of the wired-limit shim. The original issue report in mlx#3186 was a panic on a ~173k-token prefill, which is the same territory.
Quantization Note
The community now publishes many quants of the same model at nearly the same size, and they are not equivalent. Measured against the bf16 original by top-1 token agreement, at ~16GB the various 4-bit builds land around 89%, while a 5.00bpw DWQ build at 17.7GB reaches ~91.5%, and 6-bit at 22.8GB reaches ~96.6%. If you have the RAM headroom, going one step up in bits buys more fidelity than switching between same-size 4-bit builds.
Treat vendor-published agreement charts with mild suspicion, though: DWQ methods are trained to reproduce the parent model's outputs, so "agreement with bf16" is a metric they optimize for directly. It is a fair number, not a neutral one.
Model Comparison Table
| Model | Type | RAM | Speed | Tool Calls | Verdict |
|---|---|---|---|---|---|
| GLM-4.7-Flash-4bit | MoE | ~16GB | Fast | Yes | Loops on complex tasks |
| Qwen2.5-72B-4bit | Dense | ~42GB | Very slow | Untested | Too slow for interactive use |
| Devstral-24B-8bit | Dense | ~25GB | Medium | No (no parser) | Dead on arrival |
| Qwen3-Coder-30B-8bit | MoE | ~33GB | Fast | Yes | Good 2025 pick |
| Qwen3-Coder-Next-4bit | MoE | ~50GB | Fast | Yes (after fix) | Best quality (needs 64GB+ RAM) |
| Qwen3.8-27B-MLX-4bit | Dense | ~15GB | ~34 tok/s | Yes | [2026] Current daily driver |
Tool Call Flow
Complete sequence from user input to response:
- User enters query in OpenCode
- HTTP POST sends message and available tools to localhost:8080
- Server tokenizes message to token IDs
- Model runs inference on GPU
- Parser (e.g., qwen3_coder) converts XML output to structured JSON
- OpenCode receives parsed tool call and executes it
- Model receives execution results and generates natural language response
RAM and Model Selection Guidance
RAM Requirements:
- 16GB: Small models only (7B-4bit), tight margins
- 32GB: 7B-8bit or 13B-4bit comfortably
- 64GB: Qwen3-Coder-30B-8bit (optimal)
- 96GB+: Qwen3-Coder-Next-4bit or multiple models
Keep models under 75% of total RAM for system overhead.
[2026] A correction to my own advice above: "keep models under 75% of RAM" is where the danger actually lives, because ~75% is precisely what set_wired_limit claims by default. The rule is not wrong, but it is not the safety margin I implied. With wiring left enabled you can sit comfortably inside that budget and still panic the kernel, because the problem is unreclaimable memory, not insufficient memory. Disable the wired limit and the same budget behaves the way you expect.
MoE vs Dense Models:
Mixture of Experts architectures activate only a subset of parameters per token, providing speed comparable to smaller dense models while maintaining quality approaching their full parameter count. For local inference, MoE models deliver superior speed-to-quality ratios.
Pre-Download Checklist
- Verify mlx-lm has a tool parser for the architecture
- Confirm it's a coding-focused model
- Prefer MoE over dense architectures for interactive use
- Check quantization level against available RAM
- Validate
tool_parser_typein the model'stokenizer_config.jsonmatches actual output format -
[2026] Check
num_attention_heads/num_key_value_headsif you care about specific kernel optimizations -
[2026] If it ships a vision config, confirm
mlx_lmloads it text-only before committing to the download
Debugging Tips
- Looping responses: Model may lack agentic coding capability; try alternatives
- "Model does not support tool calling": No parser available for this architecture in current mlx-lm version
-
JSON decode errors on tool calls: Parser mismatch; verify
tool_parser_typein config - Very slow responses: Dense model too large; switch to MoE or more quantized variant
- Connection failures: Ensure server runs on correct port matching OpenCode configuration
-
[2026] Machine reboots under load: Not your fault. mlx#3186. Disable
set_wired_limit - [2026] Whole Mac lags as context fills: Same root cause. Wired memory cannot be reclaimed
-
[2026] Config change had no effect: If it is a LaunchAgent,
kickstartignored your plist edit. Use bootout plus bootstrap - [2026] Benchmark numbers swing wildly: Another model is resident. Check before believing anything
-
[2026]
opencode runhangs with no server request: Missing--autoor--dir, not a model problem
Conclusion
Running local AI coding assistants on Apple Silicon is practical and achievable, and eighteen months on I still use this daily instead of a cloud assistant.
What changed is my sense of where the difficulty lives. In 2025 I thought the hard part was model selection and tool parsers. Those are solved problems now. The hard part is everything that only appears once the thing runs continuously: an Apple driver bug nobody has fixed, memory that the OS cannot reclaim, benchmarks that lie when a second model is resident, and service tooling that silently ignores your edits.
None of that is in a quickstart guide, which is why it is in this one.
Top comments (3)
Thanks for this concise and helpful write-up... got it all working on my 36GB M3, although that machine is definitely too short of memory to run Qwen3.
However, as of
mlx-lm v0.31.3there is a Mistral parser, and it seems to work pretty well with Devstral-Small-2505-8bit, which is also small enough to run on the 36GB machine without blowing everything else out of the water (e.g. I can still run Chrome, the next biggest memory hog 🤦🏼♂️).Did you try olama?
Yes, Ollama is easier to configure, but it loses out on speed compared to MLX or LM Studio, as both are optimized for the Unified Memory Architecture on Apple Silicon