Skip to content

Commit 015d135

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(vllm-cpp): enable and vendor the MLX GEMM provider on darwin/metal
The darwin vllm-cpp image built the Metal backend with vllm.cpp's native MSL GEMM only. vllm.cpp also ships an optional MLX provider for the dense GEMM, kept OFF upstream because it costs a ~19 MB libmlx.dylib plus a ~105 MB mlx.metallib, on the stated position that it must earn that cost by measurement. Measured on an Apple M4 (16 GiB, macOS 26.5.2) it does. One binary, arms toggled with VT_OP_PROVIDER_DISABLE=mlx so there is no build-difference confound, Qwen3-1.7B-bf16 p=512 g=128, 2 reps, arm order alternated per rep: B=1 5.79 vs 3.08 agg tok/s (1.88x) TTFT 3.32 s vs 7.68 s B=8 25.70 vs 13.69 (1.88x) TTFT 13.95 s vs 34.38 s B=16 38.65 vs 17.69 (2.19x) TTFT 18.33 s vs 54.48 s Peak RSS is unchanged (6.65 to 7.50 GB in both arms) and the output is bit-identical: vllm.cpp's three-way parity test measures mlx-vs-msl NMSE of 0 on all six shapes, and mlx-vs-cpu equal to msl-vs-cpu, against a 5e-4 bar. MLX serves the dense GEMM alone; paged attention stays vllm.cpp's own kernel because MLX has no paged-KV primitive. Full disposition, including the INDICATIVE status and the isolation actually achieved, is in vllm.cpp docs/BENCHMARKS.md "MLX GEMM provider A/B on Apple M4". Build: MLX comes from the pinned prebuilt pip wheel (MLX_VERSION, default 0.29.3) into a venv under the backend dir. Building MLX from source needs `xcrun metal`, i.e. a full Xcode the macOS runners do not have, while the wheel ships include/, lib/libmlx.dylib and the compiled metallib ready to link. The install is a stamp FILE rather than a phony target, because a phony prerequisite is always newer than libvllm and would re-link it every invocation. VLLM_CPP_MLX=off restores the previous Metal build. Packaging vendors libmlx.dylib, mlx.metallib and MLX's MIT license into package/lib/. Three things this had to get right, each verified on the M4 before it was written rather than after: 1. libvllm.dylib links @rpath/libmlx.dylib and its build-time LC_RPATH points inside the build venv, a path no user has. Every build rpath is deleted and replaced with @loader_path/lib. 2. MLX loads its metallib from beside its OWN dylib, so both files must land in the same directory or every Metal op fails with "Failed to load the default metallib". 3. install_name_tool invalidates the code signature and macOS refuses to load an arm64 image with a stale one, so the patched library is re-signed ad-hoc. Verified end to end on the M4 by building through this Makefile and running the packaged artifact: `DYLD_PRINT_LIBRARIES` resolves libmlx from package/lib/, `codesign -v` passes, no build-venv path survives in the load commands, and a real generation runs with the provider selected (op=65 selected=mlx) and zero metallib failures. A missing rpath now fails the build instead of the user's first inference. Cost: the darwin vllm-cpp image grows by about 124 MB. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
1 parent c089caf commit 015d135

3 files changed

Lines changed: 134 additions & 4 deletions

File tree

backend/go/vllm-cpp/Makefile

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
1313
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
1414
VLLM_CPP_VERSION?=9e1c9025ae61167a3335454d7cc0de6093c21845
1515

16+
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
17+
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
18+
# metal`, i.e. a full Xcode the macOS runners do not have, while the wheel ships
19+
# include/, lib/libmlx.dylib and the compiled mlx.metallib ready to link.
20+
VLLM_CPP_MLX?=on
21+
MLX_VERSION?=0.29.3
22+
MLX_VENV?=$(abspath ./mlx-venv)
23+
# Resolved lazily (recursive `=`, not `:=`): the glob only matches once the venv
24+
# target has run, and the interpreter version in the path varies per runner.
25+
MLX_ROOT=$(shell echo $(MLX_VENV)/lib/python*/site-packages/mlx)
26+
1627
# The backend consumes only the stable C ABI (libvllm + include/vllm.h), so the
1728
# server, examples and tests of the engine are never built here.
1829
CMAKE_ARGS+=-DVLLM_CPP_SERVER=OFF -DVLLM_CPP_BUILD_TESTS=OFF -DVLLM_CPP_BUILD_EXAMPLES=OFF
@@ -49,6 +60,23 @@ else ifeq ($(BUILD_TYPE),vulkan)
4960
CMAKE_ARGS+=-DVLLM_CPP_VULKAN=ON -DVLLM_CPP_CUDA=OFF
5061
else ifeq ($(BUILD_TYPE),metal)
5162
CMAKE_ARGS+=-DVLLM_CPP_METAL=ON
63+
# The optional MLX GEMM provider. vllm.cpp keeps it OFF by default because it
64+
# is a ~19 MB libmlx.dylib plus a ~105 MB mlx.metallib, and upstream's
65+
# position is that it must earn that cost by measurement. It does, on the
66+
# only hardware this build targets: measured on an Apple M4 against the
67+
# native MSL GEMM in the SAME binary (arms toggled by
68+
# VT_OP_PROVIDER_DISABLE=mlx), Qwen3-1.7B-bf16 p=512 g=128, it is 1.5x to
69+
# 2.2x aggregate throughput and 2x to 3x faster TTFT, at equal peak memory
70+
# and bit-identical output on every parity shape. See vllm.cpp
71+
# docs/BENCHMARKS.md "MLX GEMM provider A/B on Apple M4".
72+
#
73+
# MLX delegates the dense GEMM ONLY: kPagedAttention stays vllm.cpp's own
74+
# kernel, because MLX has no paged-KV primitive at all.
75+
#
76+
# Set VLLM_CPP_MLX=off for a Metal build without it (smaller image, slower).
77+
ifeq ($(VLLM_CPP_MLX),on)
78+
MLX_ENABLED=1
79+
endif
5280
else
5381
CMAKE_ARGS+=-DVLLM_CPP_CUDA=OFF
5482
endif
@@ -68,23 +96,48 @@ sources/vllm.cpp:
6896
git fetch --depth 1 origin $(VLLM_CPP_VERSION) && \
6997
git checkout FETCH_HEAD
7098

71-
$(LIB): sources/vllm.cpp
99+
ifeq ($(MLX_ENABLED),1)
100+
# A stamp FILE, not a phony target: a phony prerequisite is always "newer" than
101+
# $(LIB) and would re-link libvllm on every invocation. Keyed on the version so
102+
# a MLX_VERSION bump reinstalls instead of silently reusing the old wheel.
103+
MLX_STAMP=$(MLX_VENV)/.mlx-$(MLX_VERSION).stamp
104+
MLX_CMAKE_ARGS=-DVLLM_CPP_MLX=ON -DMLX_ROOT=$(MLX_ROOT)
105+
106+
$(MLX_STAMP):
107+
@if [ ! -x "$(MLX_VENV)/bin/pip" ]; then \
108+
python3 -m venv "$(MLX_VENV)" || { echo "vllm-cpp: python3 with venv is required to build the MLX provider; pass VLLM_CPP_MLX=off to build Metal without it" >&2; exit 1; }; \
109+
fi
110+
"$(MLX_VENV)"/bin/pip install --quiet --disable-pip-version-check "mlx==$(MLX_VERSION)"
111+
@# Resolved in the SHELL, not by $(MLX_ROOT): make expands a whole recipe
112+
@# before running its first line, so the glob would still be unmatched here.
113+
@# Every later use (the cmake args, package.sh) expands after this target has
114+
@# completed, where $(MLX_ROOT) does resolve.
115+
@root=$$(echo "$(MLX_VENV)"/lib/python*/site-packages/mlx); \
116+
test -f "$$root/lib/libmlx.dylib" -a -f "$$root/include/mlx/array.h" || \
117+
{ echo "vllm-cpp: mlx==$(MLX_VERSION) did not provide lib/libmlx.dylib + include/mlx/array.h under $$root" >&2; exit 1; }
118+
touch $@
119+
else
120+
MLX_STAMP=
121+
MLX_CMAKE_ARGS=
122+
endif
123+
124+
$(LIB): sources/vllm.cpp $(MLX_STAMP)
72125
mkdir -p build && \
73126
cd build && \
74-
cmake ../sources/vllm.cpp $(CMAKE_ARGS) && \
127+
cmake ../sources/vllm.cpp $(CMAKE_ARGS) $(MLX_CMAKE_ARGS) && \
75128
cmake --build . --config Release -j$(JOBS) --target vllm_shared
76129
cp -fL build/$(LIB) ./$(LIB)
77130

78131
vllm-cpp: main.go govllmcpp.go backend.go options.go $(LIB)
79132
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o vllm-cpp ./
80133

81134
package: vllm-cpp
82-
bash package.sh
135+
MLX_ROOT="$(MLX_ROOT)" bash package.sh
83136

84137
build: package
85138

86139
clean: purge
87-
rm -rf libvllm.so libvllm.dylib package sources/vllm.cpp vllm-cpp
140+
rm -rf libvllm.so libvllm.dylib package sources/vllm.cpp vllm-cpp "$(MLX_VENV)"
88141

89142
purge:
90143
rm -rf build

backend/go/vllm-cpp/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,38 @@ options:
4141
- max_num_seqs:16
4242
```
4343
44+
## Apple Silicon: the MLX GEMM provider
45+
46+
`BUILD_TYPE=metal` builds the Metal backend with vllm.cpp's optional MLX
47+
provider for the dense GEMM (`VLLM_CPP_MLX=on`, the default here). Upstream keeps
48+
it off because it costs a ~19 MB `libmlx.dylib` plus a ~105 MB `mlx.metallib`;
49+
this backend accepts that because the provider was measured to pay for it on an
50+
Apple M4, against the native MSL GEMM in the SAME binary (arms toggled with
51+
`VT_OP_PROVIDER_DISABLE=mlx`), Qwen3-1.7B-bf16 at p=512 g=128:
52+
53+
| Concurrency | MLX agg tok/s | native agg tok/s | speedup |
54+
|--:|--:|--:|--:|
55+
| 1 | 5.79 | 3.08 | 1.88x |
56+
| 8 | 25.70 | 13.69 | 1.88x |
57+
| 16 | 38.65 | 17.69 | 2.19x |
58+
59+
TTFT improves 2x to 3x, peak memory is unchanged, and the GEMM output is
60+
bit-identical to the native kernel on every parity shape. MLX serves the dense
61+
GEMM only: paged attention stays vllm.cpp's own kernel, because MLX has no
62+
paged-KV primitive. Full disposition in vllm.cpp `docs/BENCHMARKS.md`,
63+
"MLX GEMM provider A/B on Apple M4".
64+
65+
Build knobs:
66+
67+
- `VLLM_CPP_MLX=off` builds Metal without the provider: ~124 MB smaller, slower.
68+
- `MLX_VERSION` pins the wheel (default `0.29.3`). MLX is consumed as the
69+
prebuilt pip wheel because building it from source needs `xcrun metal`, i.e. a
70+
full Xcode the macOS runners do not have.
71+
72+
Packaging vendors `libmlx.dylib`, `mlx.metallib` and MLX's MIT license into
73+
`package/lib/`, and rewrites `libvllm.dylib`'s rpath to `@loader_path/lib`
74+
(re-signing it, since `install_name_tool` invalidates the signature). The
75+
metallib must stay beside `libmlx.dylib`: MLX looks for it there.
76+
4477
Testing: `make test` runs the unit specs; export `VLLM_CPP_MODEL=<model>` (and
4578
optionally `VLLM_CPP_LIBRARY=<libvllm path>`) to enable the e2e specs.

backend/go/vllm-cpp/package.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,50 @@ elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
4343
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
4444
elif [ $(uname -s) = "Darwin" ]; then
4545
echo "Detected Darwin"
46+
# Vendor the optional MLX GEMM provider, when libvllm was built against it.
47+
# Three facts drive every line below, each verified on an Apple M4 before it
48+
# was written:
49+
# 1. libvllm.dylib carries an LC_LOAD_DYLIB on @rpath/libmlx.dylib, and its
50+
# build-time LC_RPATH points inside the build venv. That path does not
51+
# exist on a user's machine, so it must become @loader_path/lib.
52+
# 2. MLX finds its ~100 MB mlx.metallib beside its OWN dylib, so the two
53+
# files have to land in the same directory or every Metal op dies with
54+
# "Failed to load the default metallib".
55+
# 3. install_name_tool invalidates the code signature, and macOS refuses to
56+
# load an arm64 image whose signature does not match, so the patched
57+
# library must be re-signed ad-hoc afterwards.
58+
if otool -L "$CURDIR/package/libvllm.dylib" 2>/dev/null | grep -q "libmlx.dylib"; then
59+
MLX_LIB_DIR="${MLX_ROOT}/lib"
60+
if [ ! -f "$MLX_LIB_DIR/libmlx.dylib" ] || [ ! -f "$MLX_LIB_DIR/mlx.metallib" ]; then
61+
echo "Error: libvllm.dylib links libmlx.dylib but $MLX_LIB_DIR is missing libmlx.dylib/mlx.metallib" >&2
62+
exit 1
63+
fi
64+
echo "Vendoring the MLX GEMM provider from $MLX_LIB_DIR"
65+
cp -fLv "$MLX_LIB_DIR/libmlx.dylib" "$CURDIR/package/lib/"
66+
cp -fLv "$MLX_LIB_DIR/mlx.metallib" "$CURDIR/package/lib/"
67+
# MLX is MIT and we redistribute its binaries, so its license ships with
68+
# them. mlx-metal is the wheel carrying the dylib and the metallib.
69+
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx_metal-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
70+
if [ -z "$MLX_LICENSE" ]; then
71+
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
72+
fi
73+
if [ -z "$MLX_LICENSE" ]; then
74+
echo "Error: could not find the MLX LICENSE to redistribute alongside libmlx.dylib" >&2
75+
exit 1
76+
fi
77+
cp -fLv "$MLX_LICENSE" "$CURDIR/package/lib/LICENSE.mlx"
78+
# Drop every build-tree rpath, then point at the packaged copy.
79+
otool -l "$CURDIR/package/libvllm.dylib" | awk '/LC_RPATH/{f=1;next} f&&/ path /{print $2;f=0}' | while read -r rp; do
80+
install_name_tool -delete_rpath "$rp" "$CURDIR/package/libvllm.dylib" 2>/dev/null || true
81+
done
82+
install_name_tool -add_rpath "@loader_path/lib" "$CURDIR/package/libvllm.dylib"
83+
codesign -f -s - "$CURDIR/package/libvllm.dylib"
84+
# A broken rpath must fail the BUILD, not the user's first inference.
85+
if ! otool -l "$CURDIR/package/libvllm.dylib" | grep -q "@loader_path/lib"; then
86+
echo "Error: libvllm.dylib did not get the @loader_path/lib rpath" >&2
87+
exit 1
88+
fi
89+
fi
4690
else
4791
echo "Error: Could not detect architecture"
4892
exit 1

0 commit comments

Comments
 (0)