Skip to content

Add native Apple Metal/MPS backend - #2077

Open
TBO22 wants to merge 16 commits into
OpenNMT:masterfrom
TBO22:apple-mps-backend
Open

Add native Apple Metal/MPS backend#2077
TBO22 wants to merge 16 commits into
OpenNMT:masterfrom
TBO22:apple-mps-backend

Conversation

@TBO22

@TBO22 TBO22 commented Jul 21, 2026

Copy link
Copy Markdown

Summary

This PR adds an experimental native Metal/MPS backend for CTranslate2 on Apple Silicon. It is opt-in with -DWITH_MPS=ON and makes the existing C++ and Python APIs accept device="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::MPS support in device parsing, dispatch, storage, synchronization, the CLI, and Python bindings
  • ctranslate2.get_mps_device_count() and get_supported_compute_types("mps")
  • Source builds on Apple Silicon with Metal, Metal Performance Shaders, and Foundation
  • FP32, FP16, BF16, and signed INT8 execution; compute_type="auto" selects FP16
  • A thread-local persistent Metal execution stream that batches commands, reuses compute/blit encoders, supports several command buffers in flight, and only waits at host-visible synchronization points
  • Shared-memory Metal allocation with an ordered interior-pointer lookup and GPU-lifetime tracking for buffers and Objective-C objects
  • Cached Metal pipelines, MPS matrix multiplication objects, TopK objects, and persistent output-major decode weights
  • A specialized FP16 M == 1 GEMV path for autoregressive projections, including a fused bias/residual/activation epilogue
  • MPSMatrix and custom GEMM paths for prefill, transposed inputs, batched GEMM, broadcast strides, odd dimensions, and tails
  • GPU argmax and small TopK (k <= 8), with MPSMatrix TopK used for supported larger values
  • Metal implementations for the common elementwise, reduction, normalization, rotary, copy/layout, gather, concat/split/tile, and Conv1D paths needed by Transformer, Marian, and Whisper inference
  • BF16 storage with FP32 accumulation where required, plus INT8 quantize/GEMM/dequantize support
  • Optional MPS profiling, GEMM-path logging, synchronization logging, and command-buffer tuning environment variables
  • MPS correctness tests and standalone primitive/Marian benchmark tools

The 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 -j
import ctranslate2

translator = ctranslate2.Translator(
    "model",
    device="mps",
    compute_type="float16",
)

The 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 explicit device="mps" inference, and verifies that device="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-xlarge job 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 retains macos-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 --check also 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.

Benchmark CPU quality MPS quality Output agreement MPS speedup
LibriSpeech clean, 2,703 utterances, beam 5 2.9125% WER 2.9089% WER 99.63% normalized transcript agreement 1.93×
WMT14 En→De, 2,737 sentences, beam 4, batch 4 27.9152 BLEU 27.9332 BLEU 96.60% exact translation agreement 2.13×

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:

Backend Compute type Latency Output
CPU FP32 54.950 s reference
MPS FP16 15.531 s exact text match

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, and tests/benchmark_mps.cc for decode GEMM, vocabulary projection, prefill GEMM, TopK, and copy-heavy operations.

Current limitations

  • Apple Silicon and source builds only; no MPS-enabled release wheel in this PR
  • WITH_MPS cannot currently be combined with CUDA or HIP in one build
  • FlashAttention, AWQ INT4, INT16 GEMM, distributed collectives, and packed/shifted-u8 INT8 GEMM are not implemented on MPS
  • Metal kernels are currently compiled from source once and cached at runtime; a precompiled metallib can be added separately
  • INT8 is supported for compatibility and reduced weight memory, but FP16 is normally faster for batch-size-1 decoding and remains the automatic default

AI-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.

@nerln

nerln commented Aug 4, 2026

Copy link
Copy Markdown

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 apple-mps-backend at 2f6a066, CMake 4.1. Systran/faster-whisper-large-v3, beam 5, batch 1, three 30-second Whisper windows after a discarded warm-up window, each configuration in its own process.

The C++ suite gives 370 passed and 2 skipped (Conv1DDilation and Conv1DGroupNoBiasQuantized, both pre-existing CPU skips), matching what you report.

Backend Compute type Latency vs CPU float32 vs CPU int8
CPU float32 59.82 s 1.00x 0.84x
CPU int8 50.53 s 1.18x 1.00x
MPS float16 24.91 s 2.40x 2.03x
MPS float32 33.93 s 1.76x 1.49x
MPS int8 57.61 s 1.04x 0.88x

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.

  1. The documented build has no int8 on CPU. Following the cmake line in the description:
>>> ctranslate2.get_supported_compute_types("cpu")
{'float32'}

WITH_MKL=OFF is required on Apple Silicon and Accelerate registers only a float32 GEMM, so get_gemm_backend(INT8) returns NONE and compute_type="int8" raises instead of falling back. Anyone benchmarking the way the description documents can only use the float32 baseline. Adding -DWITH_RUY=ON restores int8 and produces the 2.03x column, which is what somebody moving from the published wheel would actually see:

# ctranslate2 4.8.1 from PyPI
>>> ctranslate2.get_supported_compute_types("cpu")
{'float32', 'int8', 'int8_float32'}

I would put -DWITH_RUY=ON in the documented command and quote both baselines.

  1. -DWITH_RUY=ON does not configure on CMake 4. third_party/ruy/third_party/cpuinfo and its deps/clog declare cmake_minimum_required below 3.5, and -DCMAKE_POLICY_VERSION_MINIMUM=3.5 gets past it. The PR does not cause this, but it stops the build immediately once int8 is enabled, so a line in the build section would save the next person the hour it cost me.

  2. MPS int8 is slower than CPU int8, 57.61 s against 50.53 s, and less than half the speed of MPS float16. compute_type="auto" picks float16, so the default path is fine. The catch is that int8 is what many Whisper users carry over from their CPU settings, and they would land on the slowest configuration in the build while believing they had asked for the fast one. A fallback to float16 on MPS would cover it, or a line under limitations.

I did not test Marian, batch sizes above 1, multi-GPU, or memory pressure on an 8 GB machine.

nerln added a commit to nerln/scriba that referenced this pull request Aug 4, 2026
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.
@TBO22

TBO22 commented Aug 4, 2026

Copy link
Copy Markdown
Author

@nerln
Thanks a lot for taking the time to build and test this on your M4. The benchmarks and validation were really helpful, especially confirming that the MPS output matches CPU float32.
I also appreciate the suggestions. I’ll update the build instructions for Ruy and CMake 4, and improve how int8 is handled on MPS.
Also, seeing it already integrated into Scriba was honestly great. Thanks again for testing it so thoroughly.

@jordimas

jordimas commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks a lot for your work

My initial feedback:

  • Please add a dedicated CI job that builds with -DWITH_MPS=ON and runs the MPS test suite on a runner with a real Apple Silicon GPU. A build-only job is not enough here; we need runtime coverage for device="mps".
    • Please also run the normal CPU test suite on the same -DWITH_MPS=ON build, to catch regressions in shared dispatch, build flags, Python bindings, and compute type selection.
  • Please include quality benchmarks in addition to latency benchmarks:
    • For Whisper, report WER on a known benchmark and compare CPU vs MPS with the same model/settings. The faster-whisper benchmark setup is a useful reference:
      https://github.com/SYSTRAN/faster-whisper/tree/master/benchmark
    • For translation, report BLEU using the existing translation benchmarks in this repository if they are still applicable.
  • When CTranslate2 is built with -DWITH_MPS=ON, MPS should remain voluntary opt-in. Please make sure device="auto" does not select MPS by default while this backend is experimental

@TBO22
TBO22 force-pushed the apple-mps-backend branch from 798c17d to 11d9bd4 Compare August 26, 2026 08:10
@TBO22

TBO22 commented Aug 26, 2026

Copy link
Copy Markdown
Author

@jordimas Thanks for the feedback. I have addressed the requested items in the latest update.

  • Added a dedicated macos-15-xlarge CI job that builds with -DWITH_MPS=ON and runs the MPS tests on an Apple Silicon GPU.
  • The normal CPU/shared C++ suite runs from the same MPS-enabled build.
  • Added Python tests for explicit MPS inference and verified that device="auto" still selects CPU.
  • Added reproducible WER and BLEU benchmarks under tools/benchmark/quality/.

Full evaluation results:

Benchmark CPU MPS Speedup
LibriSpeech clean, 2,703 utterances, beam 5 2.9125% WER 2.9089% WER 1.93×
WMT14 en-de, 2,737 sentences, beam 4, batch 4 27.9152 BLEU 27.9332 BLEU 2.13×

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.

@TBO22

TBO22 commented Aug 26, 2026

Copy link
Copy Markdown
Author

The macos-15-xlarge job could not start because larger-runner billing is unavailable for the upstream PR. I therefore ran the complete WITH_MPS=ON test job on my self-hosted M1 runner. The MPS runtime tests, CPU/shared suite, Python bindings, explicit MPS inference, and device="auto" CPU-selection tests all passed. The upstream workflow still retains macos-15-xlarge.

Successful run: https://github.com/TBO22/CTranslate2/actions/runs/32951573494/job/98123905546

@TBO22

TBO22 commented Aug 31, 2026

Copy link
Copy Markdown
Author

@nerln The latest commits now resolve generic MPS int8 to int8_float16 and cache the expanded FP16 weights once, so warm inference uses the optimized FP16 kernels instead of repeatedly quantizing activations and running the slower INT32 path. Based on your measurements, this should reduce the previous 57.61 s result toward 24.91 seconds which is about 2.31× faster than the old MPS INT8 path and roughly 2.03× faster than CPU INT8. Could you retest the latest PR head (74b510a) on your M4? The Ruy and CMake 4 build flags are now documented as well.

@nerln

nerln commented Aug 31, 2026

Copy link
Copy Markdown

Rebuilt at 74b510a on the same M4 as before. The build side is clean; the latency re-test is in a separate comment below.

The documented cmake line now works as written. -DWITH_RUY=ON and -DCMAKE_POLICY_VERSION_MINIMUM=3.5 are both in the docs, and with them the configure step goes through on CMake 4.1 with no intervention.

The C++ suite, run from the -DWITH_MPS=ON build:

[  PASSED  ] 390 tests.
[  SKIPPED ] 1 test: CPU/OpDeviceFPTest.Conv1DDilation/float32

In August at 2f6a066 the same suite gave 370 passed and 2 skipped. Conv1DGroupNoBiasQuantized runs now, which follows from Ruy being in the build.

Compute types from that build, which is the check that the earlier documented line was failing:

>>> ctranslate2.get_supported_compute_types("cpu")
{'float32', 'int8', 'int8_float32'}
>>> ctranslate2.get_supported_compute_types("mps")
{'bfloat16', 'float16', 'float32', 'int8', 'int8_bfloat16', 'int8_float16', 'int8_float32'}
>>> ctranslate2.get_mps_device_count()
1

CPU int8 is there, so the fallback path a Whisper user drops onto is intact.

@nerln

nerln commented Aug 31, 2026

Copy link
Copy Markdown

Retested at 74b510a on the M4. Short answer: the resolution landed, the speed did not, and there is a correctness difference that I think matters more than either.

Systran/faster-whisper-large-v3, beam 5, batch 1, three 30-second windows after a discarded warm-up, loading and warm-up outside the clock, each configuration in its own process.

Backend Requested Resolved Total vs CPU int8
CPU float32 float32 48.52 s 0.72x
CPU int8 int8_float32 35.12 s 1.00x
MPS float16 float16 16.21 s 2.17x
MPS float32 float32 21.33 s 1.65x
MPS int8 int8_float16 39.85 s 0.88x
MPS int8, CT2_MPS_CACHE_INT8_FP16=0 int8_float16 40.45 s 0.87x

A generic int8 on MPS now reports int8_float16, so that half works.

It is still the slowest path in the build. 2.46x slower than MPS float16, and 0.88x against CPU int8, which is exactly where it sat in August. A second pass gave 16.03, 37.95 and 36.03 for MPS float16, MPS int8 and CPU int8, and a second audio file gave 6.14, 18.66 and 16.96, so the ordering is stable.

Switching the weight cache off costs 1.5%, inside the window-to-window spread. If expanding the weights once were what the time was going into, that switch should have hurt far more, so the remaining cost looks like it is per call on the activations rather than in the weights. You can see the profile and I cannot.

The part I would chase first is not the timing. On one recording the MPS int8 path produces different words from the other four configurations: two words missing from the start of a window, reproducible across both passes and with the cache off. CPU float32, CPU int8, MPS float16 and MPS float32 agree with each other word for word on that same window. It did not reproduce on a short public clip, so it may depend on content, and the recording is private so I cannot attach it. A public file where MPS int8 and CPU int8 disagree on the text is probably the same bug.

One caveat on the table: every row is lower than my August numbers, CPU rows included, and I no longer have that script, so I cannot promise the input was identical. Compare within this table, and across the two only as ratios.

@TBO22

TBO22 commented Sep 6, 2026

Copy link
Copy Markdown
Author

@nerln Thank you for retesting this and sharing the detailed results. Profiling showed that the main overhead comes from quantizing activations on every call and running the native INT8 matrix operations. This path can also introduce enough numerical variation to change token selection during decoding.

I have addressed this in commit dcf9306f. On MPS, requests for int8 or int8_float16 now use the optimized FP16 path by default and correctly report the resolved compute type as float16. Models with stored INT8 weights are converted once during loading. Native compressed INT8 remains available as an experimental lower memory option by setting CT2_MPS_CACHE_INT8_FP16=0.

In my local Whisper beam size 5 test with a 12.5 second audio, the updated path completed in 5.11 seconds, compared with 10.53 seconds for native INT8 and 5.21 seconds for explicit FP16. The generated tokens matched the FP16 result exactly. The complete MPS enabled C++ suite and all Python MPS tests also passed.

It would be helpful if you could repeat the M4 test using the latest commit.

@nerln

nerln commented Sep 6, 2026

Copy link
Copy Markdown

Retested at dcf9306f on the M4. All three hold: int8 on MPS resolves to float16, matches explicit float16 in speed, and produces identical tokens.

Public audio this time, so the numbers can be checked: first 16 clips of the FLEURS es_419 test split concatenated to 2m57s, 16 kHz mono. Systran/faster-whisper-large-v3, beam 5, batch 1, three 30-second windows after a discarded warm-up, load and warm-up outside the clock, each configuration in its own process. Three passes; median with range.

Backend Requested Resolved Median Range
CPU float32 float32 299.34 s 289.66 to 451.40
CPU int8 int8_float32 73.60 s 72.49 to 89.02
MPS float16 float16 24.11 s 23.68 to 30.33
MPS int8 float16 24.45 s 23.29 to 25.00
MPS int8, CT2_MPS_CACHE_INT8_FP16=0 int8_float16 42.87 s 41.64 to 43.73

MPS int8 and float16 are now the same speed within the spread, against a factor of 2.5 apart before. The native path is 1.8x slower than either.

Text: CPU float32, MPS float16, MPS int8 and MPS int8 with the cache off produced the same 157 words, token for token, in all three passes. The divergence I reported last time is gone. CPU int8 differs by two commas and one dropped article.

The CPU rows were taken under memory pressure, hence the 160-second range on float32. Read them as an upper bound; the MPS rows are tight.

One trap for anyone else rebuilding this. My first retest reported nothing had changed, from a stale binary: pip wheel reuses python/build/ even with --no-cache-dir and relinks the previous library. Delete python/build/ first, and check the size of the shipped .dylib against your install prefix.

C++ suite from the same -DWITH_MPS=ON build: 391 passed, 1 skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants