native cpu + gpu inference support#3871
Open
Lazarus-931 wants to merge 7 commits into
Open
Conversation
Newer SDKs drop simd::convert support for NEON __fp16 vectors, so constructing Simd<float, N> from Simd<float16_t, N> fails to compile with deployment targets below 15. Route float16 through its NEON conversion operators instead, matching the existing bfloat16 handling.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This began when I realized that cpu + gpu inference is possible with llama.cpp but insanely slow using mlx-lm on recent mlx main branch, all due to how slow it is for cpu.
This pr has several cpu paths to support cpu inference on par with llama.cpp and even cpu and gpu simultaneous inference is on par with llama.cpp.
For starters, this introduces a bf16 SIMD support to cpu, in
neon_bf16_simd.h. This was because for models in this numerical percision(bf16), cpu didn't have native bf16 arithmetic, and prior to this, cpu was doing a scalar loop one element at a time, converting to float and back. So this does upcast to fp32, by widening 8 values to compute normal float SIMD, round back with same rounding as the scalar code, making it bit identical.Secondly, while profiling quantized matmuls, I saw that against llama.cpp mlx uses single cores on cpu as against to 8, so
parallel.hhandles this, allowing for multiple workers to be launched(8 by default), created and reused for every matmul. If matmuls under 2m ops exist, they aren't routed to this and instead run on the calling thread.Third, while profiling against llama.cpp, mlx unpacked 4-bit weights into floats and did float multiply-adds; llama.cpp rounds the activations to int8 and uses the CPU's SDOT instruction, so this PR also includes that support on mlx's own quantization format. Activations are quantized to int8 per 64-element group right before the matmul (with the scale kept on the side so results come out correct), and the 4-bit weights are recentered to signed values so the whole dot product runs on vdotq_s32 — 16 multiply-accumulates per instruction, about 3.5x over the float path. The rounding this introduces (~0.5%) is smaller than the error 4-bit weights already carry, and it's the same tradeoff llama.cpp makes on every CPU inference.
Lastly, two extensions to the int8 kernel. It now also covers 8-bit quantized weights, which matters because a bf16 checkpoint converted to 8-bit goes from 7.4 to 59 tok/s with near-lossless quality, and prefill used to re-read the entire weight matrix once per prompt token, now processing 4 prompt rows per weight pass.
All numbers are decode tokens/sec on a single M4 Mac mini (16GB, macOS), generating ~100 tokens from the same prompt per run, using mlx-community 4-bit checkpoints via mlx-lm (CPU-after runs with MLX_CPU_INT8_QMM=1, threading at the default MLX_CPU_THREADS=8) against llama.cpp build 9110 with matching Q4_K_M GGUFs (llama-bench -t 8 for CPU, -ngl 99 for GPU), and simultaneous rows run the same model concurrently on both devices in two separate processes.
Table 1 — CPU: before vs after this PR
Table 2 — simultaneous CPU+GPU: before vs after this PR
Table 3 — CPU: mlx (with PR) vs llama.cpp
Table 4 — GPU: mlx vs llama.cpp
Table 5 — simultaneous CPU+GPU: mlx vs llama.cpp
Also, argmax over a large vocab compared scores one element at a time in arg_reduce.cpp (~0.4ms per token on a 262k vocab). This adds a SIMD fast path for contiguous float rows, rows containing NaN fall back to the original loop, so results are always identical to before.
All local tests green
The int8 path is opt-in (MLX_CPU_INT8_QMM=1) so default numerics are unchanged and the full test suite passes as-is.