Apple Silicon Metal backend (ft serve on macOS arm64) - #65
Conversation
The editable install failed on Apple Silicon for three packaging-level reasons, all fixed here without touching the CUDA build: - build-system: pin torch>=2.11,<2.12 behind platform_system == "Linux"; macOS builds have no compiled extensions and need neither torch nor nvcc - runtime deps: mark torch/flashlib/triton Linux-only (mlx path never imports them); scope the cu130 uv index pin to Linux as well - setup.py: skip the CUDA-linked C++ extensions and the nvcc toolchain check on darwin Also: - server/__init__.py: import launch lazily so ft serve-metal does not pull the torch-dependent scheduler on macOS - utils/logger.py: guard the TP-rank lookup so logging works without torch instead of raising inside format() - new tests/server/test_metal_backend.py (7 tests, all passing on macOS; cover resolver, port allocator, proxy round-trip, 503 semantics) - docs: macOS install method, serve-metal quickstart, Rosetta x86_64 venv troubleshooting (the "no matching wheel tag" trap) Verified end-to-end on M3 (arm64): uv pip install -e . resolves, ft serve-metal serves chat completions/streaming on Metal via mlx.
/model in the interactive shell shows the served model and switches it on servers that support it; the CUDA path answers 404 and the shell says so instead of pretending. - POST /v1/model/load on the Metal proxy: launches a new upstream for the requested model while the old one keeps serving, then swaps the handle atomically (processes + upstream URL + model) and stops the old engine. A failed launch leaves the old model serving; the old engine is only killed after the swap, so in-flight requests finish. - MetalBackendHandle.terminate/_stop_processes now drain child stdout on a daemon thread (an unread 64KiB pipe blocks the child and makes it ignore SIGTERM) and escalate to SIGKILL after a 10s grace. - GET /v1/models is overridden on the proxy to report only the model the upstream actually serves: mlx_lm lists the entire local HF cache, which mislabels ft shell (first id) and would break /model display. - ft shell --model <id> on macOS routes to the Metal backend (the CUDA launcher cannot import there); CUDA boxes keep the native path. - ft serve-metal --shell: serve + chat in one process on Metal, mirroring ft serve --shell-mode. Verified live on M3: /model listing and switch (Llama-3.2-1B <-> Qwen3 0.6B), generation after switch, truthful /v1/models, no orphan engines; 11/11 tests in tests/server/test_metal_backend.py.
mlx_lm upstreams list the whole local HF cache in /v1/models, and the
shell took the first id blindly — which picked an unrelated TTS model
(sorted first) and then failed every request naming it ("Model type
qwen3_tts not supported"). The proxy now reports only the served model
(previous commit); this hardens the client for any other multi-model
server: one id is used directly, several ids are resolved by asking the
server which model a bare request uses, degrading to the first id only
if that probe fails.
…xy control plane scripts/start-metal.sh: env checks (arm64, mlx-lm/llama-server, venv), a port-busy guard that refuses to race an already-serving server (and never silently attaches its chat to someone else's), readiness wait on the API's own /v1/models, then ft shell for interactive testing. The server stops with the chat (cleanup trap); NO_CHAT=1 runs the API alone. The standalone Metal proxy also answers the two control endpoints the shell polls, so attaching is clean instead of a wall of 404s: /v1/stats (empty pools — the shell counts tokens client-side) and /v1/cache/status (geometry with no MoE cache / no reasoning gears). The polling access-log filter the CUDA api_server uses is installed there too. Verified live on M3: script boot, chat turn through the Metal engine, port-busy guard message, no orphan engines after exit; 11/11 tests.
Everything that talks to the HTTP API now works against the Metal backend too: - /health reports maintenance=serving when the upstream is alive — bench_decode_moe.wait_ready (and the daemon) gate on that field and would otherwise poll forever - /v1/requests answered (empty ring) so `ft ctl requests` no longer 404s - mlx_lm's thinking channel (delta.reasoning) is renamed to FreeToken's wire name (delta.reasoning_content) on both the SSE and JSON paths — the shell and the bench read reasoning_content and saw zero tokens from Metal before this - benchmarks/bench_decode_moe.py: --backend metal (+ --metal-engine mlx|llama) spawns ft serve-metal instead of ft serve; the ready wait accepts both health shapes; early-EOS and token-coalescing caveats are printed for Metal instead of looking like failures - daemon serve_manager routes the serve command to serve-metal on macOS (CUDA flags are dropped with a notice, not a parse error) Verified live: bench_decode_moe --backend metal completes a full measured run on M3; ft ctl health/stats/requests/cache against a Metal server; 44 tests pass (metal + daemon).
On Darwin, `ft serve` imported the CUDA launcher (torch) and crashed in a Metal venv. Route it to serve-metal first, and make install.sh take the mlx-lm path on Apple Silicon instead of CUDA wheels.
Piped install.sh has no adjacent checkout. Prefer FlashML-org, then the jasonkneen fork of feat/apple-metal-backend, so Apple Silicon install works while the PR is open.
There was a problem hiding this comment.
🟡 Changes recommended
Critical lifecycle, routing, torch-free startup, shutdown, and access-control issues remain unresolved.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds Apple Silicon Metal serving via MLX or llama.cpp, with macOS routing, installation, proxying, shell support, documentation, and tests.
Changes:
- Adds standalone and integrated Metal backend support.
- Updates CLI, daemon, installer, packaging, and shell behavior for macOS arm64.
- Adds Metal tests, documentation, and benchmark support.
File summaries
| File | Summary |
|---|---|
tests/server/test_serve_macos.py |
Tests Darwin CLI routing. |
tests/server/test_metal_backend.py |
Tests Metal backend and proxy behavior. |
tests/daemon/test_serve_command_platform.py |
Tests platform-specific daemon commands. |
setup.py |
Skips CUDA extensions on macOS. |
scripts/start-metal.sh |
Adds Metal startup helper. |
README.md |
Documents Apple Silicon usage. |
python/freetoken/utils/logger.py |
Provides logging fallback behavior. |
python/freetoken/shell/tui.py |
Adds shell model switching. |
python/freetoken/shell/client.py |
Adds model discovery and loading APIs. |
python/freetoken/shell/__init__.py |
Routes local shell engines on macOS. |
python/freetoken/server/metal.py |
Implements Metal lifecycle and proxying. |
python/freetoken/server/metal_main.py |
Adds the standalone Metal entrypoint. |
python/freetoken/server/launch.py |
Integrates Metal backend selection. |
python/freetoken/server/args.py |
Adds backend and Metal-port arguments. |
python/freetoken/server/api_server.py |
Adds backend-specific route installation. |
python/freetoken/server/__init__.py |
Makes server launch imports lazy. |
python/freetoken/daemon/serve_manager.py |
Selects Metal commands on Darwin. |
python/freetoken/cli.py |
Adds serve-metal and Darwin routing. |
pyproject.toml |
Makes CUDA dependencies Linux-only. |
install.sh |
Adds Apple Silicon installation flow. |
docs/quickstart.md |
Documents Metal quickstarts. |
docs/install.md |
Documents macOS installation. |
docs/cli.md |
Documents serve-metal. |
benchmarks/bench_decode_moe.py |
Adds Metal benchmark support. |
Review details
Suppressed comments (9)
python/freetoken/cli.py:36
- The Darwin branch forwards
ft servearguments unchanged, but the Metal parser only recognizes--shell;--shell-modeis therefore reported as an ignored CUDA flag.ft serve --model ... --shell-modenow starts only the API and never attaches the interactive shell, unlike the existing Linux behavior. Translate this flag to--shellor accept it in the Metal entrypoint.
if sys.platform == "darwin":
from freetoken.server.metal_main import main
return main(argv)
python/freetoken/daemon/serve_manager.py:131
- The new Darwin daemon command launches a Metal parent that owns a separate upstream child. On macOS
osproc.signal_group()cannot inspect/procand falls back to signaling only the parent PID; if the parent requires escalation and receives SIGKILL, itsfinallycleanup cannot terminate the upstream, which can keep serving and hold the port afterft daemon stop. Use portable process-group signaling or an external child cleanup path for Darwin.
subcommand = "serve-metal" if sys.platform == "darwin" else "serve"
argv = [python, "-m", "freetoken.cli", subcommand, "--model", model, "--port", str(port), *args]
python/freetoken/server/metal.py:68
_claim_portcloses its socket before the caller launches the upstream, so the selected port is not reserved. Two Metal servers starting concurrently can both observe the same free port and one will fail to bind; keep a reservation throughPopenor retry on bind failure.
return _claim_port(preferred) or _scan_free_port()
python/freetoken/server/metal.py:378
raise_for_status()runs inside the iterator afterStreamingResponsehas already sent its HTTP 200 response headers. If the upstream rejects a streamed request with 4xx/5xx, clients receive a successful-but-truncated 200 stream instead of the upstream error. The proxy needs to obtain and translate the upstream status before committing the streaming response.
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST", f"{upstream_base_url}{path}", content=body, headers=headers
) as resp:
resp.raise_for_status()
async for chunk in resp.aiter_bytes():
python/freetoken/server/metal.py:413
- Only
POST /v1/messagesis registered here. The native API and Anthropic clients exposePOST /v1/messages/count_tokens; standalone Metal consequently returns FastAPI's generic 404 for clients that preflight token counts, despite the entrypoint documenting the same Anthropic surface. Add a compatible handler or narrow the advertised API contract.
@app.post("/v1/messages")
async def proxy_messages(request: Request):
return await _forward(request, get_backend)
python/freetoken/server/metal_main.py:70
- The standalone command's help says
autoprefers MLX, but it calls the shared resolver whose order is CUDA, MLX, then llama. On a host with a usable CUDA installation,ft serve-metal --backend autoresolves tocudaand thenlaunch_metal_backend()rejects it as unsupported. Use a Metal-only resolver for this entrypoint.
backend = resolve_backend(args.backend)
python/freetoken/server/metal_main.py:41
- The Metal CLI has no
--served-model-name, andparse_known_args()later drops that documentedft serveoption as if it were CUDA-only. The proxy consequently publishes the raw model path rather than the native default basename or an explicitly requested alias, breaking clients configured with the documented model ID. Accept and propagate this option into model listing and responses.
p.add_argument("--model", required=True, help="Model path or HF id for the Metal backend.")
python/freetoken/shell/init.py:85
- This list treats boolean flags as value-taking flags. For example,
ft shell --model M --moe-cache-auto --backend llamaconsumes--backendas the value of--moe-cache-auto, leaving onlyllamato pass through; the Metal entrypoint then defaults to MLX (when installed) instead of honoring the requested backend. Keep boolean Metal-unsupported flags separate from flags that consume a value.
value_flags = {f for f in _ENGINE_FLAGS + _METAL_UNKNOWN_FLAGS}
model = None
passthrough: list[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if arg.startswith("--") and "=" in arg:
name, _, value = arg.partition("=")
if name in value_flags:
if name in _ENGINE_FLAGS:
model = value
else:
passthrough.append(arg)
i += 1
elif arg in value_flags:
value = argv[i + 1] if i + 1 < len(argv) else None
if arg in _ENGINE_FLAGS:
model = value
i += 2 if value is not None else 1
scripts/start-metal.sh:82
- An explicit
--portin"$@"appears after the script's generated--port "$PORT"and therefore overrides the actual server port, while readiness and the pre-check continue using the oldPORT.scripts/start-metal.sh model --port 2000consequently serves on 2000 but polls 1919 and can time out or attach to the wrong process. Parse the effective port before launching or reject duplicate port options.
"$ROOT/.venv/bin/ft" serve-metal --model "$MODEL" --port "$PORT" "$@" &
SERVER_PID=$!
- Files reviewed: 24/24 changed files
- Comments generated: 18
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Why would this benefit when METAL has unified memory? |
- Add load lifecycle (starting→loading→ready→error) with live /health progress (phase + byte counts) matching the CUDA supervisor contract - Sequential model switch: stop old engine before starting new to avoid Metal working-set deadlock on large models - Warm-up generation proves weights are resident before marking engine ready; 503 on generation requests while loading - Gemma-4: inject chat template and <turn|> stop token to fix runaway generation - Fix stream detection from request body (not Accept header) for correct SSE delivery - HF_HUB_OFFLINE=1 when model is fully cached to prevent stalled-CDN hangs - Shell /model switch now shows live load progress via on_progress callback
…eentrantly switch_model() called self._drain_acks() inside its own 'with self._state_lock' block, but _drain_acks acquires that same non-reentrant Lock — every /v1/model/load switch deadlocked a worker thread forever while /health kept reporting a load phase (shell sat at 'loading weights' indefinitely). Move the drain outside the lock block and document why. Also fix the switch test's identity assertion: it compared against old.processes AFTER the attribute was reassigned to a fresh list, so it could never hold; capture the pre-switch list instead. Gate: 19/19 tests pass, py_compile clean.
The injected gemma-4 chat template was missing {{ bos_token }}. When a
chat template is applied, transformers tokenizes with
add_special_tokens=False, so no BOS was ever prepended -- and gemma
without BOS degenerates into endless repetition ('hello hello hello',
stray HTML fragments) and never emits a clean turn-end. Verified
against the cached snapshot tokenizer: first id is now 2, and
<|turn>(105)/<turn|>(106) tokenize as single special tokens.
Also rewrite the template with plain (non-stripping) block tags: the
old {%- -%} whitespace control silently ate the newline after every
<turn|> and after <|turn>model, producing malformed multi-turn
prompts. Every newline is now an explicit escape.
Verified render (single + system/thinking/multi-turn) against the real
tokenizer. Gate: 19/19 tests, py_compile clean.
Root cause of the degenerate output ('1+1+1+1...', API-doc
regurgitation): google/gemma-4-26b-a4b is a BASE model -- never
chat-tuned. No prompt template can make it chat; the chat-capable
checkpoint is google/gemma-4-26b-a4b-it, whose generation_config
declares eos_token_id [1, 106, 50] (<eos>, <turn|>, <tool_call|>) and
whose repo ships the canonical chat_template.jinja.
Keep a fallback for template-less gemma-4 snapshots: bundle the
canonical template verbatim (26b-a4b-it revision, 390 lines) as
gemma4_chat_template.jinja and load it in _gemma4_chat_template()
instead of the hand-rolled guess. The hand-rolled version was close
but missed the empty <|channel>thought\n<channel|> block the model
expects in the generation prompt when thinking is disabled (README:
'Disabled Thinking Behavior').
Verified render against the -it tokenizer:
<bos><|turn>user\nhello<turn|>\n<|turn>model\n<|channel>thought\n<channel|>
Gate: 19/19 tests, py_compile clean.
UMA removes the discrete VRAM/PCIe boundary, so the CUDA-style offload story isn’t the Metal benefit. The Metal backend uses MLX/llama.cpp; FreeToken adds serving, model lifecycle, API compatibility and shell tooling. UMA still has finite capacity and bandwidth, so working-set and sparse-execution choices still matter. |
Refactors shared server utilities into torch-free modules (logging, CORS, and process lifecycle helpers) so Metal CLI/server paths can run cleanly without CUDA/PyTorch imports. Improves Metal backend behavior by fixing route replacement for proxy mode, preserving upstream stream/error status, adding served-model alias support, tightening model-load access, and tracking request/token stats for /v1/stats parity. Also hardens process/port handling across platforms (macOS getpgid fallback, serialized port launch, readiness/output handling) and updates tests for the new lifecycle, API, and shell argument behavior.
Summary
Runs FreeToken on Apple Silicon without CUDA.
ft serveon Darwin routes to the Metal backend (MLX or llama.cpp) before torch is imported.install.shtakes the mlx-lm path on macOS arm64.Why
ft servecurrently imports the CUDA launcher and crashes in a Metal venv (ModuleNotFoundError: torch). There are no macOS wheels on PyPI. This branch is the installable CLI path for M1–M4.Verified on this machine
ft serve --model mlx-community/Qwen3-0.6B-4bit—/health(maintenance: serving),/v1/models, chat completionstests/server/test_serve_macos.py,tests/server/test_metal_backend.py,tests/daemon/test_serve_command_platform.py— 48 passed locally (no CUDA)Notes
.dmg. The download site PR installs this CLI.main, macOS install falls back tojasonkneen/FreeToken@feat/apple-metal-backend.