Project Info

MetalTranslate: CTranslate2 Unleashed on Apple Silicon

Devpost

Inspiration

I started MetalTranslate because I regularly use CTranslate2 for local translation and speech projects, especially for my locally trained Roman Pashto translation and Pashto speech-to-text models. CTranslate2 is a fast and easy-to-use C++ inference library for Transformer models. It supports models such as Marian and Whisper, and it is also used by popular projects such as Faster Whisper. Many transcription, translation, and local AI tools are built on top of CTranslate2 because it is lightweight, fast, and much easier to deploy than a full deep learning framework. The problem was that CTranslate2 supported optimized CPU inference and NVIDIA CUDA, but it had no native Apple Metal or MPS backend. This is also an issue currently open in its repository. This was a major limitation for anyone testing local models on a Mac. Apple Silicon devices have powerful and efficient integrated GPUs, but models running through CTranslate2 were forced to stay on the CPU. Other frameworks such as PyTorch could use MPS, but they introduce Python overhead, and CTranslate2 applications could not simply use: That meant projects based on Faster Whisper, Marian, and other CTranslate2-supported models could not take advantage of the GPU inside Apple Silicon devices. I originally started working on this backend several months ago, but it remained unfinished because adding MPS support required much more than writing one GPU kernel. It meant understanding a large C++ inference engine, Objective-C++ integration, Metal synchronization, tensor layouts, quantization, decoding, memory management, and many different operators. Codex helped me finally turn that unfinished experiment into a working native backend.

What it does

MetalTranslate adds native Apple Metal and Metal Performance Shaders support to CTranslate2. It works through the normal CTranslate2 API, so users do not need to move their models into PyTorch or rewrite their application around a different framework. Behind that small API change is a complete backend written in C++, Objective-C++, Metal Performance Shaders, and custom Metal kernels. The backend supports FP32, FP16, BF16, and INT8 inference. It also includes the main operations required for real Transformer inference, including matrix multiplication, matrix-vector multiplication, LayerNorm, RMSNorm, softmax, reductions, rotary embeddings, ALiBi, TopK, TopP, sampling, Conv1D, gather, split, concat, tile, transpose, quantization, dequantization, and decoder cache operations. It also supports asynchronous Metal execution, persistent command buffers, compute and blit encoder reuse, reduced synchronization, batched operations, transposed layouts, broadcast strides, odd dimensions, and unaligned tensor shapes. This is not a Python wrapper around another framework. MPS is integrated directly into the CTranslate2 runtime as a real device backend.

How we built it

The first step was understanding how CTranslate2 actually executes a model. A Transformer layer does not directly call a GPU kernel. The operation moves through multiple parts of the library, including model layers, operators, tensor storage, device primitives, memory allocators, quantization code, search logic, and backend-specific implementations. Codex helped me trace these paths and compare the existing CPU and CUDA backends. One of the most important parts was understanding autoregressive decoding. During single-token generation, many projection operations have a shape similar to: $$ A_{1 \times K} B_{K \times N} = C_{1 \times N} $$ Because (M = 1), this behaves more like a matrix-vector multiplication than a large matrix multiplication. Large GPU matrix multiplications are relatively easy to accelerate, but autoregressive decoding is different. It contains many small operations where command submission, memory movement, synchronization, temporary allocations, and cache updates are very important. Because of that, I implemented a specialized FP16 GEMV path for batch-size-one decoding, along with tiled GEMM for larger operations. The first version submitted a separate Metal command buffer for almost every operation. It worked, but it introduced too much overhead. A single generated token can require dozens of operations, so constantly creating and committing command buffers made the GPU wait for the CPU. I replaced that with a persistent asynchronous Metal execution stream. The backend keeps an active command buffer, reuses compute and blit encoders, and only synchronizes when the CPU actually needs the result. I also added configurable submission limits for compute and copy operations. One interesting result was that accumulating too many operations made performance worse because the GPU started executing later. On the tested Apple M1 workload, a threshold of around 16 compute operations produced the best result. I also added reduced-precision support. For INT8, values are quantized using symmetric scaling: $$ s = \frac{\max_j |x_j|}{127} $$ $$ q_i = \operatorname{round}\left(\frac{x_i}{s}\right) $$ The GPU performs signed INT8 multiplication with INT32 accumulation, followed by dequantization: $$y_{mn}=s_{A,m}s_{B,n}\sum_{k=1}^{K}q^A_{mk}q^B_{kn}$$ For BF16, tensors remain stored in BF16, while important accumulation operations are performed in FP32 before being converted back. The backend was developed by repeatedly inspecting an execution path, implementing one change, compiling it, running tests, comparing CPU and MPS output, and then benchmarking the result.

Challenges we ran into

The hardest problem was not making the backend fast. It was making it fast while keeping the output correct. At one point, the MPS backend appeared to be more than twice as fast as CPU inference. The problem was that the translations were completely wrong. The model produced repeated unrelated words, different output lengths, and sometimes continued generating until the maximum token limit. The backend was fast, but some tensors were being corrupted during asynchronous execution. I treated every performance result as invalid until the output matched the CPU backend. The issue made me investigate command ordering, tensor broadcasting, shared-memory visibility, buffer offsets, buffer lifetimes, gather operations, encoder transitions, aliasing, and synchronization boundaries. I added tests for transposed matrices, odd dimensions, unaligned tensor sizes, batched strides, broadcast strides, interior buffer offsets, TopK ties, quantization, grouped Conv1D, sampling, and deterministic translation output. Another challenge was that lower precision did not always mean better performance. INT8 worked correctly and reduced model storage, but it did not outperform FP16 during batch-size-one Marian decoding on my M1 MacBook Air. Because of that, MetalTranslate selects FP16 for automatic compute mode while still allowing users to choose INT8 manually. This project made it very clear that a fast backend is useless if the output is wrong.

Accomplishments we're proud of

The biggest accomplishment is that MPS now works through the normal CTranslate2 API. Users do not need to rewrite their model, move their application into PyTorch, or change the overall structure of their inference pipeline. On an Apple M1 MacBook Air with a 7-core GPU, using a Release build and a real FP16 Marian Roman Pashto translation model, MetalTranslate achieved the following result: The throughput improvement was: $$ \frac{187.21}{114.32} \approx 1.64 $$ The latency reduction was: $$ \frac{306.15 - 186.96}{306.15} \times 100 \approx 38.9% $$ That means MetalTranslate achieved up to 1.64 times higher throughput and around 39 percent lower latency than optimized CPU inference on this workload. The final output was correct and deterministic. The backend passed all 175 MPS tests, including FP32, FP16, BF16, INT8, quantization, dequantization, quantized Conv1D, grouped Conv1D, transposed matrices, odd dimensions, batched operations, buffer offset handling, TopK, sampling, and CPU versus MPS translation comparisons. A separate CPU-only build also passed 190 tests. I am also proud that the project was driven by a real low-resource language use case. The model used during development was a Roman Pashto translation model. Pashto has far fewer local translation and speech tools than major languages. Making these models faster and easier to run on everyday Apple Silicon hardware has practical value beyond a synthetic benchmark.

What we learned

I learned that Apple unified memory does not remove the need for synchronization. The CPU and GPU may share physical memory, but the order in which operations happen still has to be controlled carefully. I learned that autoregressive inference is often limited by latency, memory movement, and command submission rather than theoretical GPU compute performance. I also learned that one incorrect broadcast or stale buffer can pass through many Transformer layers before finally destroying the output logits. Another important lesson was that INT8 is not automatically faster on every architecture or workload. Most importantly, I learned not to trust a performance result until the output is correct, deterministic, and tested against a known-good backend. Codex also changed how I approached the project. Instead of using it only to generate code, I used it to inspect the codebase, compare implementations, form hypotheses, debug incorrect output, write tests, analyze benchmarks, resolve merge conflicts, and rebase several months of work onto the latest CTranslate2 version.

What's next

MetalTranslate is functional, but there is still a lot of room for improvement. The next steps include better packed-weight layouts, more GEMV tuning for newer Apple chips, fused attention kernels, fused KV-cache operations, FlashAttention-style Metal kernels, larger-vocabulary GPU TopP, precompiled Metal libraries, and native Apple Silicon Python wheels. I also want to test the backend on M2, M3, and M4 devices. Another major goal is benchmarking Whisper and Faster Whisper workloads. Since Faster Whisper is built on CTranslate2, native MPS support could eventually allow many transcription tools to use the Apple GPU without switching frameworks. The long-term goal is simple: Apple Silicon should become a first-class inference target for CTranslate2. Built with Codex, C++, Objective-C++, Metal, Metal Performance Shaders, Metal Shading Language, Apple Silicon, CTranslate2, Python, Pybind11, CMake, GoogleTest, Marian, FP16, BF16, INT8 quantization, machine translation, natural language processing, GPU computing, and high-performance computing. Try it out Source code View the MetalTranslate source code MetalTranslate documentation Read the Apple Silicon MPS backend documentation Original CTranslate2 project View the original CTranslate2 project

Analysis

Compare with all teams

View

Metric

Figures cover GitHub contributors during the hackathon window. A co-authored commit counts in full for each author, so per-member totals add up to more than the whole-team figures.

Technology

Found in codeClaimed only
  • CIn code
  • C++In code
  • CSSIn code
  • Hugging FaceIn code
  • PythonIn code
  • PyTorchIn code

6 of 6 appear in the indexed code.

AI coding agents

No AI coding agent signals were found in this repository.

Detected from committed agent config files and commit authorship. Absence of a signal is not proof an agent was unused.

Codebase size

Source size

3.3 MB

Source files

378

Counts recognized source files only; vendored directories, binaries and lockfiles are excluded, so this is smaller than the repository on disk.

0 stars