News & Updates10 min read

One Model Name, Three Engines: vLLM on Linux, vLLM on WSL and llama.cpp on Apple Silicon Behind One Endpoint

The same model ID is now served by vLLM on Linux GPUs, vLLM on Windows/WSL, and llama.cpp on Apple Silicon Macs, and the caller cannot tell which one answered. Here is what had to be made equal for that to be true, the tokens-per-second we measured on an M1 Ultra and an M4 Max next to the GPUs, the 27B experiment that failed first and the rules it left behind, and a self-update bug that only exists when your inference server is a child process.

The Paralon capybara holding one glowing cable that splits toward three machines: a Linux tower with graphics cards, a Windows laptop, and a Mac

This is the worker table for one model on our admin dashboard, as it looked this week:

NodeGPUHostEngineCtxStarted with
rig8x RTX 3060LinuxvLLM16k--tensor-parallel-size 1 --data-parallel-size 3 …
ultraApple M1 UltramacOSllama.cpp16kllama-server -m Qwen3-4B-Q4_K_M.gguf -c 16384 -ngl 99 --jinja --reasoning-format none
maxApple M4 MaxmacOSllama.cpp16kllama-server -m Qwen3-4B-Q4_K_M.gguf -c 16384 -ngl 99 --jinja --reasoning-format none

Three machines, two operating systems, two inference engines, one model ID. A request for that ID lands on whichever of them is least loaded, and the response is the same shape whichever one produced it. The day before, an RTX 5060 Ti under Windows/WSL was in the same table, so make that three operating systems.

We did not set out to build a heterogeneous cluster. We set out to stop wasting the Macs. This post is what it took, with the numbers, including the attempt that failed.

Why put a Mac under a GPU's model name at all

An Apple Silicon Mac is a strange inference machine. It has no discrete GPU, but it has unified memory with 400 to 800 GB/s of bandwidth, and decoding a small language model is a bandwidth problem. A 4B-parameter model in 4-bit weighs about 2.5 GB; an M4 Max reads that per token comfortably above 80 times a second. That is the same neighbourhood as a mid-range NVIDIA card running the same model under vLLM.

Until now our Macs served their own model ID, a small Gemma through Ollama, which few integrations had a reason to pick, because the model people build against is the one the GPUs serve. The obvious fix is to serve that model on the Macs too. The problem is that "the same model" has to mean the same thing to the caller, and two engines have different opinions about what a response looks like.

What had to be equal

The model row in our database describes one recipe per engine. For GPUs it names a Hugging Face repository and the vLLM arguments; for Macs it now names a GGUF file and the llama-server arguments. The hub picks the recipe by the node's platform, the agent starts the right server, and at registration it reports the engine and the exact command line it ran, so the dashboard above can show it. The router does not know or care: it sees a worker with a context length, a KV budget and a measured speed.

Four things had to be made equal for that to be honest:

1. The weights. The GPU side runs Qwen3-4B in AWQ 4-bit; the Mac side runs the official Qwen3-4B-GGUF at Q4_K_M. Same base model, same generation, both 4-bit. Not bit-identical, and we do not claim it; the answers are the same model's answers at the same precision class.

2. The context. Both start at 16,384 tokens. llama-server's -c and vLLM's --max-model-len are the same promise to the router, which uses it to keep long prompts away from workers that cannot hold them.

3. Tool calling. vLLM runs with --enable-auto-tool-choice and the Hermes parser. llama.cpp needs --jinja to use the model's own chat template, which is where Qwen3's tool format lives. Without it, tool calls come back as prose.

4. The reasoning format. This one we found by sending the same prompt to both and diffing. Qwen3 thinks before it answers, and the two engines put the thinking in different places by default:

vLLM (no reasoning parser configured):
  content: "<think>\nOkay, so I need to figure out what 17 multiplied by 23 is…"

llama.cpp (default):
  content: ""
  reasoning_content: "Okay, let's see. The user is asking for 17 multiplied by 23…"

A client that reads content gets an answer from the GPU and an empty string from the Mac. That is not "the same model". One argument fixes it: --reasoning-format none tells llama.cpp to leave the <think> block inline, exactly as our vLLM configuration does. After the change, the two responses above are character-for-character the same shape.

The lesson generalises: when you put two engines behind one name, test the response shape, not just the tokens per second. A benchmark would never have caught this.

The numbers

Same benchmark for everything: the same prompts, 256-token answers, 1, 2 and 4 concurrent requests, measured through our own proxy so nothing else touched the machine.

MachineEngine1 request, tok/s2 concurrent, total4 concurrent, totalper request at 4
Apple M1 Ultra, 64 GBllama.cpp, Q4_K_M8010213935
Apple M4 Max, 36 GBllama.cpp, Q4_K_M8713013634
RTX 5060 Ti, WSLvLLM, AWQ96 (24 h median, production)

At one request, the Macs are within 10 to 20% of a current-generation mid-range NVIDIA card. At four, per-request speed on the Macs drops to about a third: llama-server's parallel slots gain far less from batching than vLLM's continuous batching does, which is why the router gives a GPU worker more slots than a Mac. For single-request latency, which is what a chat user feels, the Macs are simply more workers at the same speed.

Prompt processing, from llama-server's own timings on 45-token prompts: about 250 tokens per second on the M1 Ultra, 500 to 680 on the M4 Max. Fine for chat, and a reason not to put a 100k-token prompt on one.

The experiment that failed first

Before the 4B, we tried the model people actually want: Qwen3.8 27B, which our GPUs serve tensor-parallel across pairs of RTX 4090s at 113 tokens per second per request. llama.cpp has a Q4_K_M build of it and, since a recent release, supports its multi-token-prediction draft head (--spec-type draft-mtp), so on paper an M1 Ultra with 64 GB should be a respectable 27B worker.

Measured, same benchmark:

concurrenttotal tok/sper requestp50 for 256 tokens
110.78 to 1624 s
212.87.733 s
415.03 to 662 s

Prompt processing ran at 25 to 75 tokens per second on 46-token prompts, which is CPU-class, not GPU-class. The MTP draft was accepted 22 to 57% of the time, so with 8 drafted tokens per step it cost more than it saved.

The reason was not llama.cpp and not the model. It was memory. The machine reported 59 GB of 64 in use while our process held 22 GB, so about 37 GB belonged to whatever else its owner runs on it, and 6 GB was free. macOS does not keep 20 GB of weights resident on the GPU when it is that tight; it pages them, and every token pays for it. Two minutes after we stopped the model, the machine still showed 37 GB used.

That worker had already been registered under the shared model name. Our router picks two workers at random and takes the less loaded one, and an idle worker always wins, whatever its measured speed. So every time the GPU pair was busy, the next caller would have got the 27B at 8 tokens per second. We pulled it within the hour.

Three rules came out of that morning:

  • Judge a Mac by free memory, not total. The scheduler used to skip its live free-memory check on Macs ("Ollama manages memory itself"). It now applies the same check it applies to GPUs, on the free-plus-inactive figure the Mac agent reports, three quarters of it to be safe. A 64 GB Mac with 37 GB in use is a 27 GB machine.
  • Measure per-request speed on that machine before it joins a shared name. Total memory, chip name and published benchmarks say nothing about what the owner is running beside you. The acceptance test is the same prompt on the new worker and on a GPU worker, side by side.
  • A 27B on a Mac needs 48 GB or a 36 GB machine with nothing else on it. The Q4_K_M weights are 17.7 GB; with the MTP draft, the vision projector and a 32k KV cache the process is 22 GB resident. We will run it when a Mac with that room shows up, and measure first.

The bug that only exists when the server is a child process

One more, because it cost us an hour and it will cost you one too if your agent supervises an inference server.

Our Mac agent updates itself by downloading the new binary and calling exec() on it: same PID, new code. exec() keeps the process's children. The llama-server the old agent had started kept running, on the same port, with the old arguments. The new agent started a new llama-server with the new arguments; it failed to bind the port and exited. Then the agent's readiness probe asked the port whether a server was up, the old one answered, and the agent reported the new command line as live.

The dashboard said --reasoning-format none; the server answering did not have it. Nothing was down, so no alert fired. We only noticed because we re-ran the response-shape check after the update.

The fix is three small things: stop the model before exec() and tell the hub, so it re-allocates the moment the new agent reconnects; on start, if the port already answers without a process of ours, kill whatever is running from our binary path before starting; and readiness means our process answers, not anyone on the port. Verified on the next update, in the log:

llama-server already answering on port 8090 without a process of ours; stopping the stale one
Starting llama-server: … --jinja --reasoning-format none
llama-server is ready

What this means

If you call the API: nothing changes. The model ID is the same, the response shape is the same, and a request may be served by a Mac when that is the least loaded worker. Speed at one request is within the range the GPUs already show for this model.

If you provide a Mac: an Apple Silicon machine with at least 8 GB free serves the 4B model at the speeds above, through llama.cpp, with the exact command line visible to us and to you. The 27B needs 48 GB, or a 36 GB Mac that does nothing else.

If you run your own mixed fleet: the checklist is the section on what had to be equal. Weights, context, tool format, reasoning format. Test the shape. Then judge the Mac by what is free, not by what is installed.

Keep reading

Related Articles