Add native Apple Metal/MPS backend - #2077
Conversation
|
I built this branch on an M4 to check the numbers, since the ones in the description come from an M1. They hold, though the ratio is lower here. Setup: Apple M4 (10 CPU / 10 GPU, 16 GB), macOS 26, Release build of The C++ suite gives 370 passed and 2 skipped (
MPS float16 and MPS float32 both produced text identical to CPU float32 over the whole workload. Three things came out of it that may be worth folding into the PR.
>>> ctranslate2.get_supported_compute_types("cpu")
{'float32'}
# ctranslate2 4.8.1 from PyPI
>>> ctranslate2.get_supported_compute_types("cpu")
{'float32', 'int8', 'int8_float32'}I would put
I did not test Marian, batch sizes above 1, multi-GPU, or memory pressure on an 8 GB machine. |
ctranslate2 has no Metal backend in any released version, which is the entire reason a seven-minute recording cost seven minutes of CPU on a machine with an idle GPU. OpenNMT/CTranslate2#2077 adds one. Built from that branch and installed, the reference recording: CPU int8 443.1 s 725 words Metal float16 80.4 s 724 words Same text, five and a half times faster. asr_device decides: auto asks the library whether it has the backend, so a stock wheel keeps behaving exactly as before, and mps insists and explains itself rather than quietly running slowly. int8 becomes float16 on the GPU, because carrying the CPU's default over picks the slowest configuration the build offers, 57.6 s against 24.9 s, while looking like a request for the fast one. The device is in the cache fingerprint: float16 on the GPU is not the same transcript as int8 on the CPU, and a cache that ignored that would hand back the other one and call it a hit. WITH_RUY is not optional when building that branch, even though its own instructions omit it: without it there is no int8 on the CPU at all, so the fallback stops working. That and the CMake 4 flag the vendored dependency needs are in the README. Live text does not go through any of this. It runs on Apple's model on the Neural Engine, which is neither the CPU nor the GPU.
|
@nerln |
|
Thanks a lot for your work My initial feedback:
|
798c17d to
11d9bd4
Compare
|
@jordimas Thanks for the feedback. I have addressed the requested items in the latest update.
Full evaluation results:
CPU used FP32 and MPS used FP16 with identical models and decoding settings. Model loading and warm-up were excluded from inference timing. Exact commands, hardware details, and methodology are documented in the benchmark README. |
|
The Successful run: https://github.com/TBO22/CTranslate2/actions/runs/32951573494/job/98123905546 |
Summary
This PR adds an experimental native Metal/MPS backend for CTranslate2 on Apple Silicon. It is opt-in with
-DWITH_MPS=ONand makes the existing C++ and Python APIs acceptdevice="mps"without changing the default CPU, CUDA, or HIP behavior.I started this port about six months ago because CTranslate2 could use Apple CPUs efficiently but had no path to the Apple GPU. The first versions could run a few operations, but real Marian and Whisper inference exposed missing operators, excessive synchronization, lifetime bugs, and poor batch-size-1 performance. I used those end-to-end workloads—not only large synthetic GEMMs—to guide the implementation in this PR.
What is included
Device::MPSsupport in device parsing, dispatch, storage, synchronization, the CLI, and Python bindingsctranslate2.get_mps_device_count()andget_supported_compute_types("mps")compute_type="auto"selects FP16M == 1GEMV path for autoregressive projections, including a fused bias/residual/activation epiloguek <= 8), with MPSMatrix TopK used for supported larger valuesThe dispatch changes also preserve non-MPS builds. In particular, FP16/BF16 dispatch is restricted to enabled GPU backends so MSVC does not instantiate unsupported CPU half-precision symbols in CUDA wheel builds.
Build and use
cmake -S . -B build-mps \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ -DWITH_MPS=ON \ -DWITH_ACCELERATE=ON \ -DWITH_MKL=OFF \ -DWITH_RUY=ON \ -DOPENMP_RUNTIME=NONE cmake --build build-mps -jThe minimum deployment target is macOS 11. MPS is not enabled in prebuilt wheels by this PR and currently has to be built from source.
Correctness and CI validation
Local validation and the reportable benchmarks were run on a MacBook Air with an Apple M1 (7-core GPU, 8 GB unified memory), arm64, macOS 26.5.2, using Release builds.
The CI configuration includes a dedicated real Apple Silicon MPS GPU job. It builds with
-DWITH_MPS=ON, runs the MPS runtime suite, and then runs the normal CPU/shared suite from the same MPS-enabled build. It also builds the Python bindings, tests explicitdevice="mps"inference, and verifies thatdevice="auto"remains on CPU so the experimental backend is opt-in.The equivalent workflow passed on the self-hosted Apple M1 runner: 192 MPS C++ tests passed, 198 CPU/shared C++ tests passed with one existing CPU-only skip, and both Python MPS integration tests passed.
https://github.com/TBO22/CTranslate2/actions/runs/32951573494/job/98123905546
The upstream
macos-15-xlargejob could not be scheduled because larger-runner billing is not enabled for the repository. No test process started in that failed job; this was a runner availability/billing issue rather than a test failure. The upstream workflow retainsmacos-15-xlarge, so the merged CI configuration does not depend on contributor-owned hardware.MPS coverage includes FP32/FP16/BF16, INT8, odd sizes, tails, broadcast and nonzero batch strides, transposed GEMM, alpha/beta, interior offsets, dependent asynchronous dispatches, TopK tie behavior, Gather, Conv1D, and translation output comparison.
git diff --checkalso passes.Quality Validation
The reproducible evaluation scripts and methodology are under
tools/benchmark/quality/. CPU used FP32 and MPS used FP16 with the same models, inputs, sample order, and decoding settings. Model loading and one warm-up inference were excluded from inference timing.Performance
Performance varies by model and sequence length, so this is not intended as a universal claim. One representative end-to-end result from the M1 test machine was a 12.52-second, batch-size-1 Whisper transcription/translation workload:
This is a 3.54x end-to-end speedup for that workload. The PR also includes
tools/benchmark_mps_marian.py, which runs CPU and MPS in separate processes with warmups and repeated measurements across source lengths and beam sizes, andtests/benchmark_mps.ccfor decode GEMM, vocabulary projection, prefill GEMM, TopK, and copy-heavy operations.Current limitations
WITH_MPScannot currently be combined with CUDA or HIP in one buildAI-assisted development disclosure
I used Codex with GPT-5.6 as a pair-programming tool for repository navigation, repetitive implementation work, test scaffolding, diff review, and tracing build and lifetime failures. I did not treat generated suggestions as authoritative. I researched the execution model and kernel design myself, including how Apple MLX kernels differ from ggml/PyTorch and CUDA implementations and how Apple unified memory affects synchronization and ownership. I selected the design, ran the benchmarks, reproduced correctness failures, reviewed the changes, and remain responsible for the implementation and results in this PR.
I understand that this is a large, performance-sensitive change and am happy to respond to detailed review or split follow-up work where maintainers prefer a different boundary.