MiniLM on the Neural Engine, served — measured 2026-09-04
M4 (4P+6E), macOS 26.6.1, coremltools 9.0, m0serve 0.17.1 (portable
apple-m1 build), uvicorn 0.52.4 with uvloop and --interface wsgi. Every
row is one sequential run on an idle machine, driven one configuration at a
time. The app and harness are bench/embed_coreml;
the engine is @qkstat/embed's Core ML backend in mojo-addon-examples.
The question
The desktop-Mac note asked whether there is a
reason to run this server on a Mac rather than in a container. The
Apple Silicon design note (ideas/m0serve-apple-silicon.md) narrowed it to
fixed-shape encoders on the Neural Engine, and its experiment E4 found
that MLModel.predict holds the GIL for its duration — so the question
for m0serve is how it serves a view that never releases the GIL and
answers in half a millisecond.
What was served
POST /embed with one 10-token sentence, answered with a 384-float
embedding: HF tokenizers from a file, one Core ML predict on the engine
(0.48 ms in process), JSON out. The same WSGI callable under both servers,
the same interpreter (Python 3.13) for every row. --doctor reported
apple_target: m1, performance_cpus: 4, protocol: wsgi.
wrk -t4 -c8 -d15s:
| Server | processes | backend | req/s | p50 ms | p99 ms |
|---|---|---|---|---|---|
m0serve --workers 1 --blocking-threads 2 |
1 | engine | 1684 | 4.73 | 5.29 |
m0serve --workers 1 --blocking-threads 1 |
1 | engine | 1680 | 4.74 | 5.11 |
m0serve --workers 1, zero-config pool (8 threads) |
1 | engine | 1159 | 7.07 | 15.75 |
uvicorn --workers 1 |
1 | engine | 1444 | 5.54 | 9.47 |
uvicorn --workers 2 |
2 | engine | 2586 | 3.08 | 4.73 |
m0serve --workers 1 --blocking-threads 2 |
1 | MAX, CPU | 727 | 10.91 | 13.14 |
uvicorn --workers 1 |
1 | MAX, CPU | 438 | 18.14 | 23.15 |
uvicorn --workers 2 |
2 | MAX, CPU | 446 | 17.86 | 24.54 |
a second m0serve --workers 1 on the same port (refused: D6) |
1 | engine | 1694 | 4.71 | 4.98 |
At 32 connections: m0serve 1690 req/s at p99 19.5 ms, uvicorn's two workers 2647 at 19.6. With 8 sentences per request: 5518 sentences/s against 9569.
What the table says
- Process for process, m0serve serves this view 17% faster at 44% lower p99. Predict holds the GIL, so one process is one predict at a time and the server's own overhead is the whole difference between the two single-process rows.
- Handler threads do nothing for a view that never releases the GIL, and
the zero-config eight cost 31% of the throughput and tripled p99 — the
hand-off barrier (
_yield_turn) doing its job on eight threads that can never overlap. Pass--blocking-threads 1or2explicitly for such a view. This is not a reason to change the zero-config default, which was sized for views that block. - A second process is worth 1.5x, and only uvicorn gets it. See below.
Three things m0serve's prefork cannot do here
- Core ML cannot run in a forked child.
--workers 2died 3 of 3: the Objective-C runtime refuses (+[NSPlaceholderString initialize] may have been in progress in another thread when fork() was called), and withOBJC_DISABLE_INITIALIZE_FORK_SAFETY=YESthe model load segfaults in CoreServices' libdispatch, the crash report saying "crashed on child side of fork pre-exec". This is CLAUDE.md's rule — afterfork()withoutexec, platform runtimes are off limits — met by a runtime the application cannot avoid. uvicorn's workers are spawned, so it is unaffected. - The tokenizer's download path dies the same way, earlier and more
confusingly:
tokenizers.Tokenizer.from_pretrainedresolves proxies through_scproxy, and the worker is SIGKILLed with the supervisor respawning it. The app loads the tokenizer from a file. - A second independent process on the port is refused, by design:
SO_REUSEPORTis off (SPEC D6), so the secondm0serve --workers 1exited with "address already in use", all 200 warm connections went to the one pid, and the row equals one process. The only multi-process path m0serve offers is its supervisor, which forks.
The feature this asks for is a spawn-based worker mode: the supervisor
binds the listener and posix_spawns workers that inherit it, each
initialising Python fresh. It buys the measured 1.5x on this app and
retires the after-fork class of crashes for every macOS application, not
just this one.
Also found on the way, in the engine's own runtime
Recorded here because a server is where they show and a notebook is where they hide:
- Core ML releases each numpy input off-thread, without the GIL, from
MLE5ExecutionStream.resetQueuea few seconds after the stream idles. A server that warms up and then waits died with SIGSEGV every time, on coremltools 9.0 and 9.1.dev1 alike; a script that exits promptly never sees it. The backend keeps persistent input buffers per shape and fills them in place, which leaves one non-atomic off-thread decrement it cannot remove. - HF BERT's additive attention mask is -inf in fp16, and the engine
(which saturates rather than producing NaN) returned embeddings with
cosine 0.26–0.55 against PyTorch, with an all-ones mask too. A
timing-only experiment cannot see this.
-1e4fixes it.
Shipped the same evening: --spawn-workers (SPEC E15)
The mode exists: the supervisor forks as before and the child at once
execvs the binary with M0_WORKER_INDEX set, adopting the listener, the
bus socketpairs and a now file-backed shared page by descriptor. Gated by
smoke-spawn-workers on both CI platforms, with the macOS negative arm
(urllib.request.getproxies() kills a forked worker 3 of 3, answers under
spawn) and three exec-path tests in test_respawn.mojo.
A/B against fork, two workers, five starts each and two alternated wrk rounds on the bare app:
| Measure | fork | spawn |
|---|---|---|
| start to first answered request, median of 5 | 82 ms | 92 ms |
hello wrk -t4 -c64 -d10s, req/s, round 1 / round 2 |
195k / 199k | 199k / 193k |
The exec costs about ten milliseconds per worker at start and nothing after. The default stays fork.
The 1.5x did not arrive. The Core ML app on two spawned workers:
| Shape | req/s | p50 ms | p99 ms | warm connections per pid |
|---|---|---|---|---|
| c8, 1 sentence | 1675 | 4.74 | 6.83 | 197 / 3 |
| c32, 1 sentence | 1695 | 18.9 | 30.2 | 199 / 1 |
| c8, 8 sentences | 711 (5684 sentences/s) | 11.5 | 13.5 | 193 / 7 |
One worker's throughput, because one worker holds nearly every
connection. This is not the exec: forked workers show the same skew on
the bare app (32 of 32 in a burst, 26 of 32 on a 50 ms ramp), and so do
spawned ones (31 of 32, 27 of 32). Two mechanisms tried behind a knob and
reverted: a level-triggered listen with one accept per wakeup (a burst
still 31 of 32; the ramp balanced once, 17 to 15, and not again), and the
same plus sched_yield after the accept (28 to 4). The loop re-enters
kevent in microseconds and wins the next race before its sibling is
scheduled. uvicorn's workers spread (123 to 77 warm, and 2586 req/s)
because a Python loop iteration is slow enough to lose races. The item
became SPEC E16 and was built the next day: the worker that wins the accept
passes the connection to the least-loaded sibling over a pre-fork
SCM_RIGHTS channel, on both platforms (Linux measured first: 23–31 of
32 to one worker there too). The mechanism, the measurements and the
payoff on this app are in Accept sharing.