Files
LocalAI/backend/go/vllm-cpp/package.sh
mudler's LocalAI [bot] 3f0db2a9c2 feat(vllm-cpp): enable and vendor the MLX GEMM provider on darwin/metal (#11137)
* 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]

* fix(vllm-cpp): default the MLX GEMM provider OFF on darwin

This branch opened with VLLM_CPP_MLX=on, justified by an A/B that measured the
MLX provider at 1.88x to 2.19x against the native MSL GEMM. That measurement was
correct when taken and is now stale: vllm.cpp's own Metal kernels have improved
several-fold since, through mma prefill attention, a vectorised decode V
accumulation, vectorised attention staging, a fused qk-norm-RoPE preamble and a
simdgroup-per-row softmax. The native path MLX was compared against no longer
exists.

Re-measured on the same Apple M4, in the same binary, with the arms toggled by
VT_OP_PROVIDER_DISABLE=mlx, on Qwen3-1.7B-bf16 warm at p=512 g=128:

  MLX provider ON   prefill TTFT 1370 ms   warm throughput 11.98 tok/s
  MLX provider OFF  prefill TTFT 1400 ms   warm throughput 22.06 tok/s

Shipping the previous default would have halved Apple Silicon throughput.

MLX's steel GEMM is still about 20% faster than ours in isolation, but the
provider pays a per-op mx::eval synchronisation plus an output memcpy, because it
cannot write into our buffer. Across prefill's roughly 112 GEMMs that overhead
leaves a 2% gain; on decode, where the same synchronisation is paid once per
matmul per token, it costs 46%. The option is kept for prefill-dominated
workloads, where the margin is small but real.

The README section is rewritten rather than patched: it previously presented the
stale table as the reason for the default, so leaving it in place would have made
the new default look arbitrary.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vllm-cpp): bump vllm.cpp and default MLX ON, gated to prefill

Bumps VLLM_CPP_VERSION from 9e1c9025 to eec09bed and turns VLLM_CPP_MLX back on.
These two must move together, which is why they are one commit.

Upstream now shape-gates the MLX provider to prefill: it declines m < 2, which is
exactly the decode GEMV. MLX's steel GEMM wins prefill, 524.5 ms of TTFT against
602 for the native path, but loses decode badly because the provider pays an
mx::eval synchronisation and an output memcpy on every call while decode makes
about 112 calls per token. Ungated it does both; gated it does only the good half.

Measured on an Apple M4 with Qwen3-1.7B-bf16 warm at p=512 g=128:

  MLX gated to prefill (pin >= 89c46aeb)   TTFT 524.5 ms   24.40 tok/s, 99.1% of MLX-LM
  MLX ungated (older pins)                 TTFT 537 ms     12.7 tok/s
  MLX off                                  TTFT 602 ms     23.9 tok/s

This branch briefly defaulted the provider off, which was the correct call for an
ungated provider at the old pin. The gate is what makes on correct again, so the
pin and the flag are coupled: rolling VLLM_CPP_VERSION back before 89c46aeb while
leaving MLX on would select the middle row and roughly halve throughput. Both the
Makefile comment and the README state that dependency explicitly.

The bump also brings six Metal kernels landed upstream since the old pin — mma
prefill attention, a vectorised decode V accumulation, vectorised attention
staging, a fused qk-norm-RoPE preamble, a simdgroup-per-row softmax and a
simdgroup-per-head preamble — which take the non-MLX Metal path from 89.4% to
96.4% of MLX-LM on their own.

One caveat, recorded in the README: MLX's GEMM is not bit-identical to the native
kernel, so an MLX build produces a different greedy sequence than a non-MLX build.
That is a property of the provider rather than of the gate and predates this
packaging.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(vllm-cpp): correct the MLX-gated figure to 97.6%, from 99.1%

The previous commit quoted 99.1% of MLX-LM for the prefill-gated MLX build. That
figure divided by a two-run MLX-LM baseline, 27.135 and 27.744 generation tok/s
averaged to 27.44. Re-measured interleaved with ours over four ABBA blocks,
MLX-LM's decode is 27.848 with a 0.34% spread across six runs, so the 27.135 was
an outlier and averaging it in overstated us by roughly 1.5 points.

Corrected: the gated configuration is 24.37 tok/s, or 97.6% of MLX-LM, and the
MLX-off build is 23.9 tok/s or 95.9%. Prefill TTFT is unchanged at 524.5 ms
against MLX-LM's 532.6, so we remain about 1.5% faster there.

Nothing else changes. MLX still wins prefill and loses decode, the shape gate is
still the right disposition, and the pin and the flag are still coupled. The gate
is worth about 1.7 points over the MLX-off build rather than 2.7.

Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(vllm-cpp): pin MLX gate from mainline

The previous pin was a merge commit from the experimental C ABI v9 branch. Pin the same MLX prefill gate on upstream main so the backend build does not pull unrelated ABI v9 work into every platform variant.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): restore backend build portability

Keep the current master pin when enabling MLX so every backend variant builds against the known-good vllm.cpp revision. Suppress Apple clang’s GNU constant-folding diagnostic for Objective-C++ Metal compilation only, since upstream treats warnings as errors.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): demote MLX header VLA warning

MLX 0.29.3 headers trigger Apple clang's gnu-folding-constant diagnostic in the Objective-C++ provider. Keep the diagnostic visible while exempting only it from vllm.cpp's global warnings-as-errors policy.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): suppress MLX header VLA warning

Target-level Objective-C++ -Werror is appended after the directory flags, so a no-error demotion is re-promoted. Disable this single warning for the MLX header while keeping every other warning fatal.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): pin source-scoped MLX warning fix

Move the AppleClang warning exception into vllm.cpp where its target warning policy is defined, and pin LocalAI to that source-scoped fix.

Assisted-by: Codex:gpt-5

* fix(vllm-cpp): pin effective MLX warning suppression

The source-scoped no-error flag was overridden by the target warning policy. Pin the companion vllm.cpp change that disables only the MLX header diagnostic for its Objective-C++ translation unit.

Assisted-by: Codex:gpt-5

* fix(vllm-cpp): pin diagnostic pragma fix

Pin the companion vllm.cpp correction that scopes the AppleClang folding warning suppression inside the MLX translation unit, after command-line warning policy.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): pin remaining Darwin build fixes

Advance the MLX-enabled backend to the vllm.cpp revision already validated by the dependency update branch. This includes the feature guards and AppleClang pragma boundary needed by the Darwin build.

Assisted-by: Codex:gpt-5 [systematic-debugging]

* fix(vllm-cpp): pin MLX system dependency boundary

Pin the companion vllm.cpp change that models MLX as an imported system dependency, keeping third-party header diagnostics out of the project's warnings-as-errors policy while retaining fatal warnings for project sources.

Assisted-by: Codex:gpt-5 [Codex]

* fix(vllm-cpp): pin scoped MLX warning guard

Advance vllm.cpp to the companion fix that keeps MLX headers on a SYSTEM dependency and scopes AppleClang folding-constant suppression to the external includes.

Assisted-by: Codex:gpt-5 [systematic-debugging] [test-driven-development]

* fix(vllm-cpp): use available MLX wheel

MLX 0.29.3 is no longer available to the Darwin runner, so the backend build stopped before CMake. Pin the first available compatible wheel and keep the documented default in sync.

Assisted-by: Codex:gpt-5

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-08-04 15:41:39 +02:00

106 lines
5.4 KiB
Bash

#!/bin/bash
# Script to copy the appropriate libraries based on architecture
# This script is used in the final stage of the Dockerfile
set -e
CURDIR=$(dirname "$(realpath $0)")
REPO_ROOT="${CURDIR}/../../.."
# Create lib directory
mkdir -p $CURDIR/package/lib
cp -avf $CURDIR/vllm-cpp $CURDIR/package/
cp -fLv $CURDIR/libvllm.so $CURDIR/package/ 2>/dev/null || true
cp -fLv $CURDIR/libvllm.dylib $CURDIR/package/ 2>/dev/null || true
cp -fv $CURDIR/run.sh $CURDIR/package/
# Detect architecture and copy appropriate libraries
if [ -f "/lib64/ld-linux-x86-64.so.2" ]; then
# x86_64 architecture
echo "Detected x86_64 architecture, copying x86_64 libraries..."
cp -arfLv /lib64/ld-linux-x86-64.so.2 $CURDIR/package/lib/ld.so
cp -arfLv /lib/x86_64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/x86_64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/x86_64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/x86_64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/x86_64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/x86_64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/x86_64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ -f "/lib/ld-linux-aarch64.so.1" ]; then
# ARM64 architecture
echo "Detected ARM64 architecture, copying ARM64 libraries..."
cp -arfLv /lib/ld-linux-aarch64.so.1 $CURDIR/package/lib/ld.so
cp -arfLv /lib/aarch64-linux-gnu/libc.so.6 $CURDIR/package/lib/libc.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgcc_s.so.1 $CURDIR/package/lib/libgcc_s.so.1
cp -arfLv /lib/aarch64-linux-gnu/libstdc++.so.6 $CURDIR/package/lib/libstdc++.so.6
cp -arfLv /lib/aarch64-linux-gnu/libm.so.6 $CURDIR/package/lib/libm.so.6
cp -arfLv /lib/aarch64-linux-gnu/libgomp.so.1 $CURDIR/package/lib/libgomp.so.1
cp -arfLv /lib/aarch64-linux-gnu/libdl.so.2 $CURDIR/package/lib/libdl.so.2
cp -arfLv /lib/aarch64-linux-gnu/librt.so.1 $CURDIR/package/lib/librt.so.1
cp -arfLv /lib/aarch64-linux-gnu/libpthread.so.0 $CURDIR/package/lib/libpthread.so.0
elif [ $(uname -s) = "Darwin" ]; then
echo "Detected Darwin"
# Vendor the optional MLX GEMM provider, when libvllm was built against it.
# Three facts drive every line below, each verified on an Apple M4 before it
# was written:
# 1. libvllm.dylib carries an LC_LOAD_DYLIB on @rpath/libmlx.dylib, and its
# build-time LC_RPATH points inside the build venv. That path does not
# exist on a user's machine, so it must become @loader_path/lib.
# 2. MLX finds its ~100 MB mlx.metallib beside its OWN dylib, so the two
# files have to land in the same directory or every Metal op dies with
# "Failed to load the default metallib".
# 3. install_name_tool invalidates the code signature, and macOS refuses to
# load an arm64 image whose signature does not match, so the patched
# library must be re-signed ad-hoc afterwards.
if otool -L "$CURDIR/package/libvllm.dylib" 2>/dev/null | grep -q "libmlx.dylib"; then
MLX_LIB_DIR="${MLX_ROOT}/lib"
if [ ! -f "$MLX_LIB_DIR/libmlx.dylib" ] || [ ! -f "$MLX_LIB_DIR/mlx.metallib" ]; then
echo "Error: libvllm.dylib links libmlx.dylib but $MLX_LIB_DIR is missing libmlx.dylib/mlx.metallib" >&2
exit 1
fi
echo "Vendoring the MLX GEMM provider from $MLX_LIB_DIR"
cp -fLv "$MLX_LIB_DIR/libmlx.dylib" "$CURDIR/package/lib/"
cp -fLv "$MLX_LIB_DIR/mlx.metallib" "$CURDIR/package/lib/"
# MLX is MIT and we redistribute its binaries, so its license ships with
# them. mlx-metal is the wheel carrying the dylib and the metallib.
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx_metal-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
if [ -z "$MLX_LICENSE" ]; then
MLX_LICENSE=$(ls "${MLX_ROOT}"/../mlx-*.dist-info/licenses/LICENSE 2>/dev/null | head -1)
fi
if [ -z "$MLX_LICENSE" ]; then
echo "Error: could not find the MLX LICENSE to redistribute alongside libmlx.dylib" >&2
exit 1
fi
cp -fLv "$MLX_LICENSE" "$CURDIR/package/lib/LICENSE.mlx"
# Drop every build-tree rpath, then point at the packaged copy.
otool -l "$CURDIR/package/libvllm.dylib" | awk '/LC_RPATH/{f=1;next} f&&/ path /{print $2;f=0}' | while read -r rp; do
install_name_tool -delete_rpath "$rp" "$CURDIR/package/libvllm.dylib" 2>/dev/null || true
done
install_name_tool -add_rpath "@loader_path/lib" "$CURDIR/package/libvllm.dylib"
codesign -f -s - "$CURDIR/package/libvllm.dylib"
# A broken rpath must fail the BUILD, not the user's first inference.
if ! otool -l "$CURDIR/package/libvllm.dylib" | grep -q "@loader_path/lib"; then
echo "Error: libvllm.dylib did not get the @loader_path/lib rpath" >&2
exit 1
fi
fi
else
echo "Error: Could not detect architecture"
exit 1
fi
# Package GPU libraries based on BUILD_TYPE
GPU_LIB_SCRIPT="${REPO_ROOT}/scripts/build/package-gpu-libs.sh"
if [ -f "$GPU_LIB_SCRIPT" ]; then
echo "Packaging GPU libraries for BUILD_TYPE=${BUILD_TYPE:-cpu}..."
source "$GPU_LIB_SCRIPT" "$CURDIR/package/lib"
package_gpu_libs
fi
echo "Packaging completed successfully"
ls -liah $CURDIR/package/
ls -liah $CURDIR/package/lib/