Skip to content

Commit ac6ff03

Browse files
juntaoclaude
andcommitted
Add Apple MLX backend for native Metal GPU acceleration on macOS
Introduce a dual-backend architecture (tch-backend vs mlx) with a unified Tensor abstraction, following the pattern from qwen3_tts_rs. The MLX backend builds mlx-c from a git submodule and uses Metal GPU natively on Apple Silicon, eliminating the libtorch dependency on macOS. - Add src/tensor.rs unified Tensor type wrapping tch::Tensor or MlxArray via #[cfg] - Add src/backend/mlx/ with FFI bindings, RAII array wrapper, ops, I/O, signal processing - Add build.rs for CMake-based mlx-c compilation - Port all model code (layers, encoder, decoder, inference) to use unified Tensor - Update CI to test both tch and mlx backends on macOS - Update release workflow to build mlx for macOS, tch for Linux - Fix release zip structure to expand into a named directory - Update skills/ for macOS MLX (no libtorch needed) and 0.6B model only Signed-off-by: Michael Yuan <michael@secondstate.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a52e59d commit ac6ff03

29 files changed

Lines changed: 3181 additions & 306 deletions

.github/workflows/ci.yml

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,34 @@ jobs:
1212
include:
1313
- os: ubuntu-latest
1414
name: Linux x86_64
15+
backend: tch
1516
libtorch-url: https://download.pytorch.org/libtorch/cpu/libtorch-cxx11-abi-shared-with-deps-2.7.1%2Bcpu.zip
1617
libtorch-archive: libtorch.zip
1718
- os: ubuntu-24.04-arm
1819
name: Linux ARM64
20+
backend: tch
1921
libtorch-url: https://github.com/second-state/libtorch-releases/releases/download/v2.7.1/libtorch-cxx11-abi-aarch64-2.7.1.tar.gz
2022
libtorch-archive: libtorch.tar.gz
2123
- os: macos-latest
22-
name: macOS ARM64
24+
name: macOS ARM64 (tch)
25+
backend: tch
2326
libtorch-url: https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.7.1.zip
2427
libtorch-archive: libtorch.zip
28+
- os: macos-latest
29+
name: macOS ARM64 (mlx)
30+
backend: mlx
2531

2632
runs-on: ${{ matrix.os }}
2733
name: Build (${{ matrix.name }})
2834

2935
steps:
3036
- uses: actions/checkout@v4
37+
if: matrix.backend == 'tch'
38+
39+
- uses: actions/checkout@v4
40+
if: matrix.backend == 'mlx'
41+
with:
42+
submodules: recursive
3143

3244
- name: Install build tools (Linux)
3345
if: runner.os == 'Linux'
@@ -42,14 +54,16 @@ jobs:
4254
path: |
4355
~/.cargo/registry
4456
~/.cargo/git
45-
key: ${{ runner.os }}-${{ runner.arch }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
57+
key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.backend }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
4658
restore-keys: |
47-
${{ runner.os }}-${{ runner.arch }}-cargo-registry-
59+
${{ runner.os }}-${{ runner.arch }}-${{ matrix.backend }}-cargo-registry-
4860
4961
- name: Download libtorch
62+
if: matrix.backend == 'tch'
5063
run: curl -Lo ${{ matrix.libtorch-archive }} "${{ matrix.libtorch-url }}"
5164

5265
- name: Extract libtorch
66+
if: matrix.backend == 'tch'
5367
run: |
5468
if [[ "${{ matrix.libtorch-archive }}" == *.zip ]]; then
5569
unzip -q ${{ matrix.libtorch-archive }}
@@ -58,21 +72,26 @@ jobs:
5872
fi
5973
6074
- name: Set linker rpath-link (Linux only)
61-
if: runner.os == 'Linux'
75+
if: matrix.backend == 'tch' && runner.os == 'Linux'
6276
run: echo "RUSTFLAGS=-C link-arg=-Wl,-rpath-link,${{ github.workspace }}/libtorch/lib" >> "$GITHUB_ENV"
6377

6478
- name: Cap libtorch CPU ISA to AVX2 (x86_64 only)
65-
if: runner.arch == 'X64'
79+
if: matrix.backend == 'tch' && runner.arch == 'X64'
6680
run: |
6781
echo "DNNL_MAX_CPU_ISA=AVX2" >> "$GITHUB_ENV"
6882
echo "ATEN_CPU_CAPABILITY=avx2" >> "$GITHUB_ENV"
6983
70-
- name: Build
84+
- name: Build (tch)
85+
if: matrix.backend == 'tch'
7186
env:
7287
LIBTORCH: ${{ github.workspace }}/libtorch
7388
LIBTORCH_BYPASS_VERSION_CHECK: "1"
7489
run: cargo build --release --features build-ffmpeg
7590

91+
- name: Build (mlx)
92+
if: matrix.backend == 'mlx'
93+
run: cargo build --release --no-default-features --features mlx,build-ffmpeg
94+
7695
- name: Set up Python
7796
uses: actions/setup-python@v5
7897
with:
@@ -82,11 +101,11 @@ jobs:
82101
run: pip install huggingface_hub transformers
83102

84103
- name: Set library path (Linux)
85-
if: runner.os == 'Linux'
104+
if: matrix.backend == 'tch' && runner.os == 'Linux'
86105
run: echo "LD_LIBRARY_PATH=${{ github.workspace }}/libtorch/lib:$LD_LIBRARY_PATH" >> "$GITHUB_ENV"
87106

88107
- name: Set library path (macOS)
89-
if: runner.os == 'macOS'
108+
if: matrix.backend == 'tch' && runner.os == 'macOS'
90109
run: echo "DYLD_LIBRARY_PATH=${{ github.workspace }}/libtorch/lib:$DYLD_LIBRARY_PATH" >> "$GITHUB_ENV"
91110

92111
- name: Cache 0.6B model weights
@@ -146,5 +165,5 @@ jobs:
146165
- name: Upload binary
147166
uses: actions/upload-artifact@v4
148167
with:
149-
name: qwen3-asr-${{ matrix.os }}-${{ runner.arch }}.zip
168+
name: qwen3-asr-${{ matrix.os }}-${{ runner.arch }}-${{ matrix.backend }}.zip
150169
path: target/release/asr

.github/workflows/release.yml

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,20 @@ jobs:
1212
include:
1313
- os: ubuntu-latest
1414
name: Linux x86_64
15+
backend: tch
1516
asset-name: asr-linux-x86_64
1617
libtorch-url: https://download.pytorch.org/libtorch/cpu/libtorch-cxx11-abi-shared-with-deps-2.7.1%2Bcpu.zip
1718
libtorch-archive: libtorch.zip
1819
- os: ubuntu-24.04-arm
1920
name: Linux ARM64
21+
backend: tch
2022
asset-name: asr-linux-aarch64
2123
libtorch-url: https://github.com/second-state/libtorch-releases/releases/download/v2.7.1/libtorch-cxx11-abi-aarch64-2.7.1.tar.gz
2224
libtorch-archive: libtorch.tar.gz
2325
- os: macos-latest
2426
name: macOS ARM64
27+
backend: mlx
2528
asset-name: asr-macos-aarch64
26-
libtorch-url: https://download.pytorch.org/libtorch/cpu/libtorch-macos-arm64-2.7.1.zip
27-
libtorch-archive: libtorch.zip
2829

2930
runs-on: ${{ matrix.os }}
3031
name: Build (${{ matrix.name }})
@@ -34,6 +35,12 @@ jobs:
3435

3536
steps:
3637
- uses: actions/checkout@v4
38+
if: matrix.backend == 'tch'
39+
40+
- uses: actions/checkout@v4
41+
if: matrix.backend == 'mlx'
42+
with:
43+
submodules: recursive
3744

3845
- name: Install build tools (Linux)
3946
if: runner.os == 'Linux'
@@ -48,14 +55,16 @@ jobs:
4855
path: |
4956
~/.cargo/registry
5057
~/.cargo/git
51-
key: ${{ runner.os }}-${{ runner.arch }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
58+
key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.backend }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
5259
restore-keys: |
53-
${{ runner.os }}-${{ runner.arch }}-cargo-registry-
60+
${{ runner.os }}-${{ runner.arch }}-${{ matrix.backend }}-cargo-registry-
5461
5562
- name: Download libtorch
63+
if: matrix.backend == 'tch'
5664
run: curl -Lo ${{ matrix.libtorch-archive }} "${{ matrix.libtorch-url }}"
5765

5866
- name: Extract libtorch
67+
if: matrix.backend == 'tch'
5968
run: |
6069
if [[ "${{ matrix.libtorch-archive }}" == *.zip ]]; then
6170
unzip -q ${{ matrix.libtorch-archive }}
@@ -64,19 +73,25 @@ jobs:
6473
fi
6574
6675
- name: Set linker rpath-link (Linux only)
67-
if: runner.os == 'Linux'
76+
if: matrix.backend == 'tch' && runner.os == 'Linux'
6877
run: echo "RUSTFLAGS=-C link-arg=-Wl,-rpath-link,${{ github.workspace }}/libtorch/lib" >> "$GITHUB_ENV"
6978

70-
- name: Build
79+
- name: Build (tch)
80+
if: matrix.backend == 'tch'
7181
env:
7282
LIBTORCH: ${{ github.workspace }}/libtorch
7383
LIBTORCH_BYPASS_VERSION_CHECK: "1"
7484
run: cargo build --release --features build-ffmpeg
7585

86+
- name: Build (mlx)
87+
if: matrix.backend == 'mlx'
88+
run: cargo build --release --no-default-features --features mlx,build-ffmpeg
89+
7690
- name: Package binary
7791
run: |
78-
cp target/release/asr ${{ matrix.asset-name }}
79-
zip ${{ matrix.asset-name }}.zip ${{ matrix.asset-name }}
92+
mkdir ${{ matrix.asset-name }}
93+
cp target/release/asr ${{ matrix.asset-name }}/asr
94+
zip -r ${{ matrix.asset-name }}.zip ${{ matrix.asset-name }}
8095
8196
- name: Upload release asset
8297
env:

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "mlx-c"]
2+
path = mlx-c
3+
url = https://github.com/ml-explore/mlx-c.git

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ name = "asr"
1010
path = "src/main.rs"
1111

1212
[dependencies]
13-
tch = "0.20"
13+
tch = { version = "0.20", optional = true }
1414
tokenizers = "0.21"
1515
serde = { version = "1.0", features = ["derive"] }
1616
serde_json = "1.0"
@@ -24,7 +24,12 @@ safetensors = "0.5"
2424
ffmpeg-next = "8"
2525
ffmpeg-sys-next = "8"
2626

27+
[build-dependencies]
28+
cmake = "0.1"
29+
2730
[features]
28-
default = []
31+
default = ["tch-backend"]
32+
tch-backend = ["dep:tch"]
33+
mlx = []
2934
static-ffmpeg = ["ffmpeg-sys-next/static"]
3035
build-ffmpeg = ["ffmpeg-sys-next/build", "static-ffmpeg"]

README.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Qwen3 ASR Rust
22

3-
Pure Rust implementation of [Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR) automatic speech recognition using libtorch. Loads model weights directly from safetensors files and re-implements the complete neural network forward pass in Rust.
3+
Pure Rust implementation of [Qwen3-ASR](https://github.com/QwenLM/Qwen3-ASR) automatic speech recognition. Supports two backends: **libtorch** (via the `tch` crate, cross-platform with optional CUDA) and **MLX** (Apple Silicon native via Metal GPU). Loads model weights directly from safetensors files and re-implements the complete neural network forward pass in Rust.
44

55
## Architecture
66

@@ -19,7 +19,16 @@ The implementation ports the Qwen3-ASR encoder-decoder architecture from PyTorch
1919

2020
## Prerequisites
2121

22-
### libtorch
22+
### Backend
23+
24+
Choose one backend:
25+
26+
| Backend | Feature flag | Platforms | GPU |
27+
|---------|-------------|-----------|-----|
28+
| libtorch | `tch-backend` (default) | Linux, macOS, Windows | CUDA |
29+
| MLX | `mlx` | macOS Apple Silicon | Metal |
30+
31+
### libtorch (for `tch-backend`)
2332

2433
The `tch` crate (v0.20) requires **libtorch 2.7.1**. Download and extract for your platform:
2534

@@ -77,6 +86,8 @@ tok.backend_tokenizer.save('Qwen3-ASR-0.6B/tokenizer.json')
7786

7887
## Build
7988

89+
### libtorch backend (default)
90+
8091
```bash
8192
# Set environment
8293
export LIBTORCH=$(pwd)/libtorch
@@ -94,6 +105,19 @@ cargo build --release --features static-ffmpeg
94105
cargo build --release --features build-ffmpeg
95106
```
96107

108+
### MLX backend (macOS Apple Silicon)
109+
110+
```bash
111+
# Initialize mlx-c submodule
112+
git submodule update --init --recursive
113+
114+
# Build with MLX (no libtorch needed)
115+
cargo build --release --no-default-features --features mlx
116+
117+
# With statically linked FFmpeg
118+
cargo build --release --no-default-features --features mlx,static-ffmpeg
119+
```
120+
97121
## Usage
98122

99123
```bash
@@ -134,6 +158,7 @@ Qwen3-ASR supports 30 languages: Chinese, English, Cantonese, Arabic, German, Fr
134158
src/
135159
├── main.rs # CLI binary entry point
136160
├── lib.rs # Library module declarations
161+
├── tensor.rs # Unified Tensor abstraction (tch/MLX backend)
137162
├── config.rs # Model configuration (from config.json)
138163
├── error.rs # Error types
139164
├── audio.rs # FFmpeg-based audio loading and format conversion
@@ -144,7 +169,15 @@ src/
144169
├── audio_encoder.rs # Whisper-style audio encoder (Conv2d + Transformer)
145170
├── text_decoder.rs # Qwen3 text decoder with KV cache
146171
├── tokenizer.rs # HuggingFace tokenizer wrapper
147-
└── inference.rs # End-to-end ASR inference pipeline
172+
├── inference.rs # End-to-end ASR inference pipeline
173+
└── backend/
174+
└── mlx/ # Apple MLX backend (Metal GPU)
175+
├── ffi.rs # Raw C FFI bindings to mlx-c
176+
├── array.rs # Safe RAII MlxArray wrapper
177+
├── ops.rs # Safe operation wrappers
178+
├── io.rs # Safetensors loading via mlx-c
179+
├── signal.rs # STFT, mel spectrogram signal processing
180+
└── stream.rs # Device/stream management
148181
```
149182

150183
## License

build.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Build script for qwen3_asr.
2+
//
3+
// When the `mlx` feature is enabled (macOS only), this script:
4+
// 1. Builds the mlx-c library from the git submodule via CMake
5+
// 2. Emits linker directives for mlx-c, MLX, Metal, and system frameworks
6+
7+
fn main() {
8+
#[cfg(feature = "mlx")]
9+
build_mlx();
10+
}
11+
12+
#[cfg(feature = "mlx")]
13+
fn build_mlx() {
14+
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
15+
let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
16+
17+
if target_os != "macos" {
18+
panic!("The `mlx` feature is only supported on macOS. Current target OS: {target_os}");
19+
}
20+
if target_arch != "aarch64" {
21+
eprintln!(
22+
"Warning: MLX is optimized for Apple Silicon (aarch64). \
23+
Current target arch: {target_arch}. Metal GPU acceleration may not be available."
24+
);
25+
}
26+
27+
let mlx_c_dir = std::path::PathBuf::from("mlx-c");
28+
if !mlx_c_dir.join("CMakeLists.txt").exists() {
29+
panic!(
30+
"mlx-c submodule not found. Please run:\n\
31+
\n\
32+
git submodule update --init --recursive\n\
33+
\n\
34+
to clone the mlx-c dependency."
35+
);
36+
}
37+
38+
// Build mlx-c via CMake
39+
let dst = cmake::Config::new(&mlx_c_dir)
40+
.define("MLX_BUILD_TESTS", "OFF")
41+
.define("MLX_BUILD_EXAMPLES", "OFF")
42+
.define("MLX_BUILD_BENCHMARKS", "OFF")
43+
.define("BUILD_SHARED_LIBS", "OFF")
44+
.build();
45+
46+
// Link paths
47+
let lib_dir = dst.join("lib");
48+
println!("cargo:rustc-link-search=native={}", lib_dir.display());
49+
50+
// Also check lib64 (some CMake configs use this)
51+
let lib64_dir = dst.join("lib64");
52+
if lib64_dir.exists() {
53+
println!("cargo:rustc-link-search=native={}", lib64_dir.display());
54+
}
55+
56+
// Link mlx-c and mlx static libraries
57+
println!("cargo:rustc-link-lib=static=mlxc");
58+
println!("cargo:rustc-link-lib=static=mlx");
59+
60+
// Link macOS system frameworks required by MLX
61+
println!("cargo:rustc-link-lib=framework=Metal");
62+
println!("cargo:rustc-link-lib=framework=Foundation");
63+
println!("cargo:rustc-link-lib=framework=Accelerate");
64+
println!("cargo:rustc-link-lib=framework=MetalPerformanceShaders");
65+
66+
// Link C++ standard library
67+
println!("cargo:rustc-link-lib=c++");
68+
69+
// Rerun if mlx-c sources change
70+
println!("cargo:rerun-if-changed=mlx-c/CMakeLists.txt");
71+
println!("cargo:rerun-if-changed=mlx-c/mlx/c/");
72+
}

mlx-c

Submodule mlx-c added at a1290d2

0 commit comments

Comments
 (0)