mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-01 11:00:24 -04:00
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]
156 lines
6.2 KiB
Makefile
156 lines
6.2 KiB
Makefile
CMAKE_ARGS?=
|
|
BUILD_TYPE?=
|
|
NATIVE?=false
|
|
|
|
GOCMD?=go
|
|
GO_TAGS?=
|
|
# nproc doesn't exist on the macOS runners: an empty JOBS turns `-j$(JOBS)`
|
|
# into bare `-j` (unlimited clang jobs), which swap-thrashes the 3-core Mac
|
|
# until the 6h GHA timeout. Fall back to sysctl there, then to a constant.
|
|
JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)
|
|
|
|
# vllm.cpp version
|
|
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
|
|
VLLM_CPP_VERSION?=9e1c9025ae61167a3335454d7cc0de6093c21845
|
|
|
|
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
|
|
# Consumed as the prebuilt pip wheel: 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 mlx.metallib ready to link.
|
|
VLLM_CPP_MLX?=on
|
|
MLX_VERSION?=0.29.3
|
|
MLX_VENV?=$(abspath ./mlx-venv)
|
|
# Resolved lazily (recursive `=`, not `:=`): the glob only matches once the venv
|
|
# target has run, and the interpreter version in the path varies per runner.
|
|
MLX_ROOT=$(shell echo $(MLX_VENV)/lib/python*/site-packages/mlx)
|
|
|
|
# The backend consumes only the stable C ABI (libvllm + include/vllm.h), so the
|
|
# server, examples and tests of the engine are never built here.
|
|
CMAKE_ARGS+=-DVLLM_CPP_SERVER=OFF -DVLLM_CPP_BUILD_TESTS=OFF -DVLLM_CPP_BUILD_EXAMPLES=OFF
|
|
CMAKE_ARGS+=-DCMAKE_BUILD_TYPE=Release
|
|
|
|
# vllm.cpp sets no global -march: SIMD tiers are per-file with runtime dispatch,
|
|
# so ONE portable library serves every CPU of the target arch (unlike the
|
|
# ggml-based backends and their avx/avx2/avx512 variant builds).
|
|
UNAME_M := $(shell uname -m)
|
|
|
|
ifeq ($(BUILD_TYPE),cublas)
|
|
# Blackwell-family targets only: other CUDA arches are build-supported
|
|
# upstream but have no runtime-proven fast path. amd64 gets the consumer
|
|
# (120a) + GB10 (121a) fat binary; arm64 CUDA (l4t-style images, DGX
|
|
# Spark) is GB10 only. Triton-AOT GDN cubins are vendored per-arch, no
|
|
# Python needed to consume them.
|
|
ifeq ($(UNAME_M),x86_64)
|
|
# NO -DVLLM_CPP_TRITON on fat builds: the vendored Triton-AOT cubin
|
|
# trees are per-arch and the engine refuses a multi-arch build unless
|
|
# pinned to one tree (unsound for the other arch). The non-AOT GDN
|
|
# path serves the fat binary; single-arch builds keep the cubins.
|
|
#
|
|
# CUDA builds REQUIRE the CUDA 13 toolchain: 12.x nvcc lacks
|
|
# compute_121a (GB10) and its ptxas rejects the sm_120a NVFP4 MMA
|
|
# kernels ("Vector type too large"), so no cuda-12 variant is shipped.
|
|
ifeq ($(CUDA_MAJOR_VERSION),12)
|
|
$(error vllm.cpp needs the CUDA 13 toolchain: CUDA 12.x cannot compile the Blackwell fp4 kernels)
|
|
endif
|
|
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON "-DVLLM_CPP_CUDA_ARCHITECTURES=120a;121a"
|
|
else
|
|
CMAKE_ARGS+=-DVLLM_CPP_CUDA=ON -DVLLM_CPP_CUDA_ARCHITECTURES=121a -DVLLM_CPP_TRITON=ON
|
|
endif
|
|
else ifeq ($(BUILD_TYPE),vulkan)
|
|
CMAKE_ARGS+=-DVLLM_CPP_VULKAN=ON -DVLLM_CPP_CUDA=OFF
|
|
else ifeq ($(BUILD_TYPE),metal)
|
|
CMAKE_ARGS+=-DVLLM_CPP_METAL=ON
|
|
# The optional MLX GEMM provider. vllm.cpp keeps it OFF by default because it
|
|
# is a ~19 MB libmlx.dylib plus a ~105 MB mlx.metallib, and upstream's
|
|
# position is that it must earn that cost by measurement. It does, on the
|
|
# only hardware this build targets: measured on an Apple M4 against the
|
|
# native MSL GEMM in the SAME binary (arms toggled by
|
|
# VT_OP_PROVIDER_DISABLE=mlx), Qwen3-1.7B-bf16 p=512 g=128, it is 1.5x to
|
|
# 2.2x aggregate throughput and 2x to 3x faster TTFT, at equal peak memory
|
|
# and bit-identical output on every parity shape. See vllm.cpp
|
|
# docs/BENCHMARKS.md "MLX GEMM provider A/B on Apple M4".
|
|
#
|
|
# MLX delegates the dense GEMM ONLY: kPagedAttention stays vllm.cpp's own
|
|
# kernel, because MLX has no paged-KV primitive at all.
|
|
#
|
|
# Set VLLM_CPP_MLX=off for a Metal build without it (smaller image, slower).
|
|
ifeq ($(VLLM_CPP_MLX),on)
|
|
MLX_ENABLED=1
|
|
endif
|
|
else
|
|
CMAKE_ARGS+=-DVLLM_CPP_CUDA=OFF
|
|
endif
|
|
|
|
UNAME_S := $(shell uname -s)
|
|
ifeq ($(UNAME_S),Darwin)
|
|
LIB=libvllm.dylib
|
|
else
|
|
LIB=libvllm.so
|
|
endif
|
|
|
|
sources/vllm.cpp:
|
|
mkdir -p sources/vllm.cpp
|
|
cd sources/vllm.cpp && \
|
|
git init && \
|
|
git remote add origin $(VLLM_CPP_REPO) && \
|
|
git fetch --depth 1 origin $(VLLM_CPP_VERSION) && \
|
|
git checkout FETCH_HEAD
|
|
|
|
ifeq ($(MLX_ENABLED),1)
|
|
# A stamp FILE, not a phony target: a phony prerequisite is always "newer" than
|
|
# $(LIB) and would re-link libvllm on every invocation. Keyed on the version so
|
|
# a MLX_VERSION bump reinstalls instead of silently reusing the old wheel.
|
|
MLX_STAMP=$(MLX_VENV)/.mlx-$(MLX_VERSION).stamp
|
|
MLX_CMAKE_ARGS=-DVLLM_CPP_MLX=ON -DMLX_ROOT=$(MLX_ROOT)
|
|
|
|
$(MLX_STAMP):
|
|
@if [ ! -x "$(MLX_VENV)/bin/pip" ]; then \
|
|
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; }; \
|
|
fi
|
|
"$(MLX_VENV)"/bin/pip install --quiet --disable-pip-version-check "mlx==$(MLX_VERSION)"
|
|
@# Resolved in the SHELL, not by $(MLX_ROOT): make expands a whole recipe
|
|
@# before running its first line, so the glob would still be unmatched here.
|
|
@# Every later use (the cmake args, package.sh) expands after this target has
|
|
@# completed, where $(MLX_ROOT) does resolve.
|
|
@root=$$(echo "$(MLX_VENV)"/lib/python*/site-packages/mlx); \
|
|
test -f "$$root/lib/libmlx.dylib" -a -f "$$root/include/mlx/array.h" || \
|
|
{ echo "vllm-cpp: mlx==$(MLX_VERSION) did not provide lib/libmlx.dylib + include/mlx/array.h under $$root" >&2; exit 1; }
|
|
touch $@
|
|
else
|
|
MLX_STAMP=
|
|
MLX_CMAKE_ARGS=
|
|
endif
|
|
|
|
$(LIB): sources/vllm.cpp $(MLX_STAMP)
|
|
mkdir -p build && \
|
|
cd build && \
|
|
cmake ../sources/vllm.cpp $(CMAKE_ARGS) $(MLX_CMAKE_ARGS) && \
|
|
cmake --build . --config Release -j$(JOBS) --target vllm_shared
|
|
cp -fL build/$(LIB) ./$(LIB)
|
|
|
|
vllm-cpp: main.go govllmcpp.go backend.go options.go $(LIB)
|
|
CGO_ENABLED=0 $(GOCMD) build -tags "$(GO_TAGS)" -o vllm-cpp ./
|
|
|
|
package: vllm-cpp
|
|
MLX_ROOT="$(MLX_ROOT)" bash package.sh
|
|
|
|
build: package
|
|
|
|
clean: purge
|
|
rm -rf libvllm.so libvllm.dylib package sources/vllm.cpp vllm-cpp "$(MLX_VENV)"
|
|
|
|
purge:
|
|
rm -rf build
|
|
|
|
.NOTPARALLEL:
|
|
|
|
# The unit specs are pure Go (struct mirrors, option mapping, load
|
|
# validation): no libvllm build is needed. The e2e specs skip unless
|
|
# VLLM_CPP_MODEL points at a real model (then build the lib first).
|
|
test:
|
|
@echo "Running vllm-cpp tests..."
|
|
bash test.sh
|
|
@echo "vllm-cpp tests completed."
|
|
|
|
all: vllm-cpp package
|