Skip to content

Add native Apple Metal/MPS backend - #2077

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

TBO22 wants to merge 28 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 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.

Comment thread .github/workflows/ci.yml Outdated
# depending on infrastructure owned by the contributor.
build-and-test-mps:
if: github.repository != 'TBO22/CTranslate2' || github.event_name == 'push'
runs-on: ${{ github.repository == 'TBO22/CTranslate2' && 'self-hosted' || 'macos-15-xlarge' }}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the latest PR head (dcf9306f) on GitHub’s standard macos-15 runner. The validation run passed:

  • Metal computation executed successfully using Apple Paravirtual device.
  • MPS C++ suite: 193 passed.
  • CPU/shared suite from the MPS-enabled build: 198 passed, with one existing CPU-only skip.
  • Python integration tests: 5 passed.

Please update the existing build-and-test-mps job to use runs-on: macos-15 and remove the contributor-specific runner selection and condition. This runner is free for public repositories, so we can maintain runtime coverage without macos-15-xlarge.

Please retain all three test stages, including verification that device="auto" stays on CPU. The job should fail if Metal is unavailable rather than silently skipping MPS tests.

A 30-minute timeout and uploading test reports with if: always() would also help with diagnosing future failures.

Comment thread src/devices.cc Outdated
#ifdef CT2_WITH_MPS
if (device == Device::MPS) {
const ScopedDeviceSetter scoped_device_setter(device, index);
mps::synchronize();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make device synchronization wait for work from all MPS threads. The regression run queues fill(42) on one thread; synchronize_device(MPS, 0) on another returns while the value is still 0. This call currently synchronizes only the caller's stream. Please fix this and retain the regression test.

return device_to_str(view.device());
},
"Device where the storage is allocated (\"cpu\" or \"cuda\").")
"Device where the storage is allocated (\"cpu\", \"cuda\", or \"mps\").")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the __cuda_array_interface__ guard below to reject view.device() != Device::CUDA. MPS currently exposes Metal memory as CUDA. This regression run reproduces it; please retain a test asserting that MPS raises AttributeError.

@TBO22

TBO22 commented Sep 20, 2026

Copy link
Copy Markdown
Author

@jordimas
Thanks for testing this on the standard macos-15 runner and for providing the regression cases. I have updated the MPS job to use macos-15, retained the MPS, CPU/shared, and Python test stages, added a real Metal compute probe, set a 30-minute timeout, and enabled test-report uploads with if: always().

Device synchronization now waits for pending work across all registered MPS thread-local streams. I retained the cross-thread regression test and also fixed the related array-interface issue so MPS storage no longer exposes __cuda_array_interface__.

Locally, 194 MPS tests, 198 CPU/shared tests, and all 6 Python integration tests pass. The synchronization regression also passed 100 consecutive runs.

@nerln

nerln commented Sep 22, 2026

Copy link
Copy Markdown

Retested at 7b390fb6 on an M4 (macOS 27), built with -DWITH_MPS=ON -DWITH_ACCELERATE=ON -DWITH_MKL=OFF -DWITH_RUY=ON -DOPENMP_RUNTIME=NONE.

The three review points hold here. 397 C++ tests pass and 1 is skipped. The six Python MPS tests pass with CT2_TEST_MPS=1, including the new __cuda_array_interface__ one. DeviceSynchronizeWaitsForAnotherThreadsWork passes 200 runs out of 200.

The device-wide synchronization costs nothing measurable. Same protocol as my 6 September run, both wheels measured back to back this afternoon on the same machine, three passes each, medians:

Backend Requested 7b390fb6 dcf9306f
MPS float16 15.09 s 16.10 s
MPS int8 15.20 s 16.10 s
MPS int8, cache off 33.36 s 34.13 s
CPU int8 37.24 s 38.52 s
CPU float32 67.80 s 69.53 s

Every MPS configuration and CPU float32 produced the same 157 words on both builds, token for token.

One thing to look at before merging. synchronize_all (src/mps/utils.mm:498) calls stream->synchronize() on streams owned by other threads. That method ends the active encoder, commits _command_buffer and resets the stream's members (utils.mm:449). The registry mutex guards the set, not those members, so a thread encoding while another calls synchronize_device puts two threads inside one MTLCommandBuffer.

One worker running MatMul on MPS in a loop, one thread calling synchronize_device(Device::MPS, 0) in a loop: at this head the process dies in 5 runs out of 5, none of them reaching a 100 ms heartbeat. Signatures seen across those runs and a ThreadSanitizer one:

-[IOGPUMetalCommandBuffer validate]:215: failed assertion `commit command buffer with uncommitted encoder'
failed assertion _status < MTLCommandBufferStatusCommitted at line 323 in -[IOGPUMetalCommandBuffer setCurrentCommandEncoder:]
libc++abi: terminating due to uncaught exception of type std::runtime_error: MPS compute encoder creation failed
libc++abi: terminating due to uncaught exception of type std::runtime_error: MPS command buffer creation failed
SIGSEGV

The same program against dcf9306f runs 20 seconds clean, twice. That is not an argument for the old behaviour, which is the bug that was reported; it only places the new one.

I could not reach it from Python: two threads, one translating on MPS and one loading and dropping a second model so Model::~Model (src/models/model.cc:166) synchronizes the device, ran three times for 8 seconds with no crash.

The regression test parks its worker after queueing, so the device sync is the only thread touching that stream. Since flushing another thread's un-submitted work is what that test asks for, the two uses need serialising rather than separating: a per-stream mutex taken by the encode path and by synchronize() would be uncontended in the normal one-thread-per-stream case. Happy to test a patch.

Repro, 40 lines:

#include <atomic>
#include <chrono>
#include <cstdio>
#include <thread>

#include <ctranslate2/devices.h>
#include <ctranslate2/ops/ops.h>
#include <ctranslate2/storage_view.h>

using namespace ctranslate2;

int main(int argc, char** argv) {
  const int seconds = argc > 1 ? std::atoi(argv[1]) : 10;
  std::atomic<bool> stop{false};
  std::atomic<long> ops{0}, syncs{0};

  std::thread worker([&] {
    StorageView a({256, 256}, static_cast<float>(1), Device::MPS);
    StorageView b({256, 256}, static_cast<float>(2), Device::MPS);
    StorageView c(Device::MPS);
    const ops::MatMul matmul;
    while (!stop.load()) {
      matmul(a, b, c);
      ops.fetch_add(1);
    }
  });

  std::thread syncer([&] {
    while (!stop.load()) {
      synchronize_device(Device::MPS, 0);
      syncs.fetch_add(1);
    }
  });

  std::this_thread::sleep_for(std::chrono::seconds(seconds));
  stop.store(true);
  worker.join();
  syncer.join();
  std::printf("ops=%ld syncs=%ld\n", ops.load(), syncs.load());
  return 0;
}
clang++ -std=c++17 -O1 -g -I <prefix>/include race.cpp -L <prefix>/lib -lctranslate2 -o race
DYLD_LIBRARY_PATH=<prefix>/lib ./race 5

@TBO22

TBO22 commented Sep 23, 2026

Copy link
Copy Markdown
Author

@nerln Thanks for the clear reproducer. I reproduced the crash on my M1 in 5/5 runs, including the same Metal command-buffer and encoder assertions.

I implemented per-stream locking and adapted a new regression test from your 40-line reproducer. The issue was that device-wide synchronization could commit another thread's command buffer while that thread was still encoding work. The stream is now locked for the full encoding operation, including flush and synchronization. Normal inference should see no meaningful lock contention since each worker uses its own thread-local stream.

After the fix, the standalone reproducer completed five consecutive 5-second runs without crashing. The new regression test passed 20/20 runs, the existing cross-thread synchronization test passed 200/200, and the full MPS, CPU, and Python test suites passed.

Marian FP16 latency remained effectively unchanged at 154.77 ms compared with 153.91 ms before the fix.

Thanks again for identifying the race and providing a reproducer that made it straightforward to reproduce and verify the fix.

@nerln

nerln commented Sep 27, 2026

Copy link
Copy Markdown

Confirmed on the M4 at c9600d57.

  • The two-thread reproducer runs five 5-second runs clean, a 20-second run clean, and a 10-second run under ThreadSanitizer with no reports. At 7b390fb6 it died within 100 ms every time.
  • DeviceSynchronize* tests: 200 of 200 across 100 repeats. Full C++ suite 398 passed, 1 skipped. Python MPS tests 6 passed.
  • I checked that every MPS kernel entry point that encodes takes the StreamGuard. Of the 49 declared in kernels.h, the four without one (invalidate_packed_weight_cache, supports_gemm_type, supports_topk, max_top_p_classes) do not touch a stream, and MPS copies go through tile, which is guarded.

The lock costs Whisper nothing measurable. large-v3, beam 5, same protocol as before, the two wheels interleaved in one session, three passes, medians:

Requested 7b390fb6 c9600d57
float16 15.78 s 16.29 s
int8 (resolves to float16) 16.65 s 16.19 s

Both differences are inside the run-to-run spread, in opposite directions. All twelve runs produced the same 157 words.

@jordimas

Copy link
Copy Markdown
Collaborator

Hello. I would like to merge this in the next major version.
Would be possible to address these issues please?

Resolve merge conflicts with master.
• Investigate the failed Linux x86_64 Python wheel tests and rerun CI.
• Verify synchronization waits for work from all MPS threads, with a regression test.
• Verify MPS rejects cuda_array_interface, with a regression test.
• Confirm MPS CI runs all three test stages on macos-15 and fails if Metal is unavailable.
• Complete the concurrency and GPU memory lifetime review.
• Validate the final commit on Apple hardware with translation, Whisper, repeated inference, and concurrent inference.
• Keep the initial release experimental and opt-in.

Thanks!

nerln and others added 4 commits October 13, 2026 01:11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Master's TopKWithExhaustedCandidates test (OpenNMT#2104) fails on MPS for k=16:
rows with fewer than k finite candidates came back with 0 in the
exhausted slots. For 8 < k <= 16 with 16-byte aligned result rows the
backend used MPSMatrixFindTopK, which pads exhausted slots with 0 and a
bogus index instead of returning the masked input; a beam search with
2 * beam_size = 16 would read that 0 as the best score.

small_topk already handles exhaustion (masked candidates beat the
-INFINITY/UINT_MAX initial slots on index), so it now covers every
1 < k <= 16 for float32, float16 and bfloat16, and the MPSMatrixFindTopK
path and its thread-local kernel cache are removed. k > 16 keeps the CPU
fallback.

Adds FusedBeamSearchTopKWithExhaustedCandidates, which checks the opt-in
fused beam search TopK against the materialized path on rows with fewer
than k finite candidates, for (beam, k) = (4, 8), (5, 10), (8, 16).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A translator on MPS with inter_threads=4 and four callers crashed with
SIGSEGV inside MPSSetNDArraysOnComputeEncoder, from mps::gemm.
gemm_kernel() kept one MPSMatrixMultiplication per shape for the whole
process; the thread_local map only cached pointers to those shared
objects. Apple documents MPSKernel objects as unsafe to use from several
threads at once, and workers of one translator run the same shapes.

The cache is now thread_local and owns its kernels, released when the
thread ends. Each encoded kernel is recorded as in use so it outlives the
command buffer even if its thread exits first. A nil kernel now throws
instead of being cached and silently doing nothing.

Adds ConcurrentGemmFloat16/Float32WithTheSameShape and
ConcurrentBatchedMatMulFloat16WithTheSameShape: four threads, the same
64x512 by 512x2048 shape, 300 iterations each, checked against the CPU.
They crashed 10 times out of 10 before this change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…from idle workers or destructors

A failed asynchronous command buffer recorded its error in one
process-wide slot, and whichever thread synchronized next threw it: an
innocent replica could fail with another one's error. Worse, the throw
could come from ReplicaWorker::idle(), outside any job, ending the worker
thread and the process with std::terminate, or from Model::~Model through
synchronize_device. CUDA ignores errors on both paths.

Each stream now owns its error in a small shared state captured by the
completion handler, so a handler that runs after its stream is gone is
safe, and only that stream's next command_buffer() or synchronize()
throws it, once. A per-command-buffer token stops a late handler from
recording a failure a second time. synchronize_all() reports one stream's
error and leaves the others to their owners.

idle() calls the new synchronize_stream_nothrow(), identical to
synchronize_stream() on CPU and CUDA; on MPS it logs once and keeps the
error for the stream's next operation. synchronize_device(MPS), which only
~Model and move_variables call, uses the same no-throw barrier; the wait
itself is unchanged.

tests/mps_error_test.cc injects errors through an internal hook
(src/mps/testing.h). On the previous code 5 of its 10 tests fail and 3
abort with std::terminate (ReplicaWorker::idle, a Translator, ~Model).

The backend stays experimental and opt-in: a warning when a model loads
on MPS, "mps (experimental)" in the device docstrings and the CLI help, a
note in README.md and docs/environment_variables.md, and a CHANGELOG
entry. WITH_MPS stays OFF by default and out of the wheel scripts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nerln

nerln commented Oct 14, 2026

Copy link
Copy Markdown

@jordimas I went through the list on an M4 (macOS 27). The branch with the result is nerln/CTranslate2:apple-mps-backend-update (head 35d1ecd), opened against @TBO22's branch in TBO22#1 so it can be pulled into this PR.

  1. Merge conflicts. Resolved against master 51526bf (src/devices.cc, tests/ops_test.cc). The merge brought fix(cuda): guard exhausted candidates in TopK stage 2 #2104's TopKWithExhaustedCandidates, which failed on MPS for k=16: MPSMatrixFindTopK fills the slots of a row with fewer than k finite candidates with 0, which a beam search of 8 would take for the best score. Fixed by using the backend's own TopK kernel up to k=16.

  2. Linux x86_64 wheel tests. The failure at c9600d5 is a segfault (exit 139) in test_transformers_wav2vec2[...-cpu]. The same segfault, same test and line, appears in 7 of the 69 Linux wheel jobs since 21 August, on six branches, five of which have no MPS code, and nothing this PR compiles into the Linux wheel is on wav2vec2's CPU path. On the merged head it passes (https://github.com/nerln/CTranslate2/actions/runs/38498483339/job/115657671626), and the whole CI run is green: https://github.com/nerln/CTranslate2/actions/runs/38498483339

  3. Synchronization waits for all MPS threads. DeviceSynchronizeWaitsForAnotherThreadsWork (tests/ops_test.cc) fails deterministically on a caller-only sync; DeviceSynchronizeIsSafeDuringConcurrentEncoding stresses the encode/sync race from September. Both pass.

  4. MPS rejects __cuda_array_interface__. python/cpp/storage_view.cc raises AttributeError for any non-CUDA device; python/tests/test_mps.py checks it and runs in the MPS job with CT2_TEST_MPS=1. Passes.

  5. MPS CI. build-and-test-mps runs on macos-15 with a 30-minute timeout, probes Metal first (no device fails the job rather than skipping it), then runs the MPS C++ tests, the CPU/shared tests and the Python MPS tests, and uploads reports with if: always(). On the merged head it passes, with the new tests: https://github.com/nerln/CTranslate2/actions/runs/38498483339/job/115639581792

  6. Concurrency and GPU memory lifetime. Done, and it found two crashes, both fixed in the branch with regression tests:

    • gemm_kernel() shared one MPSMatrixMultiplication per shape across threads. A translator with inter_threads=4 and four callers died with SIGSEGV inside MPSSetNDArraysOnComputeEncoder every time. Kernels are now per thread; three Concurrent*WithTheSameShape tests crashed 10 out of 10 runs before the change.
    • A failed command buffer stored its error in one process-wide slot, so it could be raised on another thread, and from ReplicaWorker::idle() or Model::~Model it ended the process with std::terminate (CUDA ignores errors on both paths). Errors are now kept per stream and raised by that stream's next operation; idle() and ~Model no longer throw. This adds one public function, synchronize_stream_nothrow(Device), identical to synchronize_stream on CPU and CUDA; happy to move it if you prefer it internal. tests/mps_error_test.cc covers it: 8 of its 10 tests fail or abort on the previous code.

    Checked and sound: stream-guard coverage of every encoding entry point, stream registry against thread exit, completion handlers (they capture shared state, not the stream), deferred buffer frees, retain/release balance, no lock-order cycle. Left for your call: ~Model calls a device-wide synchronize_all(), so unloading one model waits for and commits every other model's work (no deadlock, and MPS frees are already deferred per buffer, so the barrier buys nothing there); ScopeProfiler::~ScopeProfiler still uses the throwing sync (profiling only); the per-thread kernel cache grows with threads × distinct GEMM shapes until the thread ends; out-of-memory is reported asynchronously through the command buffer error rather than at allocation.

  7. Validation on Apple hardware, at 35d1ecd. C++ suite 417 tests, 416 passed, 1 skipped; Python MPS tests 6 passed. Against CPU float32 on the same inputs:

    • Translation, Marian en→es, 20 sentences: float32, float16 and int8 on MPS at beam 1, 4 and 8 all identical to CPU (20 of 20 each).
    • Repeated inference: 50 runs on one model, identical outputs. 20 load/unload cycles: identical outputs, and peak RSS did not grow (917 MB after 5 cycles and after 20).
    • Concurrent inference: one translator with inter_threads=4 and four calling threads; two translators (float16 and float32) at beam 8 at once; Whisper and Marian on MPS at once; translation while another thread loads and drops a model (device sync). All outputs identical, no crash.
    • Whisper large-v3, beam 5, Spanish FLEURS sample, the protocol from my September runs: 157 words, character for character the same text as the c9600d5 build. Speed against c9600d5, six interleaved runs each in one session: median 21.4 s against 21.0 s, inside the spread of either.
  8. Experimental and opt-in. WITH_MPS stays OFF and out of the wheel scripts, and device="auto" never selects MPS (covered by tests). The branch adds a warning when a model is loaded on MPS, "mps (experimental)" in the device docstrings and CLI help, a note in README.md and docs/environment_variables.md, and a CHANGELOG entry under Unreleased.

TBO22 added 3 commits October 15, 2026 10:08
Merge master, fix MPS TopK at k=16, a GEMM kernel race and GPU error handling
@TBO22

TBO22 commented Oct 15, 2026

Copy link
Copy Markdown
Author

Thanks @nerln for the detailed review and contribution. I have integrated your changes while preserving the original commits and authorship. They address the shared MPSMatrixMultiplication race, per-stream asynchronous error handling, GPU object lifetime, non-throwing cleanup synchronization, and exhausted-row TopK correctness through k=16.

During benchmarking, I found that using 16 private slots for every TopK call slowed the common beam-4 k=8 path from approximately 2.19 ms to 3.16 ms. I added a separate follow-up commit with dedicated 8-slot and 16-slot kernels. The corrected path now takes approximately 2.15 ms while retaining FP32, FP16, and BF16 correctness through k=16.

The complete C++ suite passes with 416 tests and one existing skip.

@jordimas , the requested concurrency, synchronization, error-handling, GPU-lifetime, and regression test updates have been integrated.

@TBO22

TBO22 commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

@jordimas, could you rerun the failed Linux x86-64 wheel test when you get a chance? I cannot rerun jobs in the upstream repository due to admin rights required.

It crashed during the Wav2Vec2 test while a Hugging Face background download was still running. The same test passed on Nerln’s branch, and nothing affecting the Linux or CPU path changed afterward, so this looks like CI flakiness rather than an MPS regression. If it fails again, I’ll disable the background safetensors / Xet download for the test job.

This branch has not been deployed

No deployments
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