Skip to content

Commit b97f35a

Browse files
authored
Merge pull request #89 from JRufer/claude/windows-gpu-acceleration
Windows GPU acceleration for Moonshine, via WebGPU rather than DirectML
2 parents 0e04b85 + af18daa commit b97f35a

11 files changed

Lines changed: 293 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ name: CI
33
on:
44
push:
55
branches: [master]
6+
# Every pull request, whatever it targets — deliberately unfiltered.
7+
#
8+
# This used to be `branches: [master]`, which meant a pull request based on
9+
# another branch got no checks at all: not a compile, not a test, nothing. A
10+
# stacked change could then be merged down into its base and reach master
11+
# having never been built on its own. That is the same shape of hole as
12+
# excluding crates from the Windows check — work that looks reviewed because a
13+
# PR exists, and was never compiled.
614
pull_request:
7-
branches: [master]
815

916
env:
1017
CARGO_HTTP_MULTIPLEXING: false
@@ -75,6 +82,19 @@ jobs:
7582
- name: cargo check (workspace, default features)
7683
run: cargo check --workspace --all-targets
7784

85+
# The GPU features are off by default, so the workspace check above never
86+
# compiles a line of their execution-provider code — and the first thing
87+
# that would is a release build, where a mistake costs a whole run. That
88+
# is the same shape of gap that let the Windows backend ship unbuildable,
89+
# so each shipped GPU variant gets checked here.
90+
#
91+
# `moonshine-webgpu` is what the Windows GPU release artifact is built
92+
# with; `vulkan` is what the Linux GPU AppImage is built with. Both pull
93+
# their own prebuilt ONNX Runtime / compile their own shaders, so this is
94+
# not free, but it is far cheaper than finding out at release time.
95+
- name: cargo check (moonshine-webgpu)
96+
run: cargo check -p voxctrl-app --features moonshine-webgpu
97+
7898
# ── Unit tests ────────────────────────────────────────────────────────────────
7999
cargo-test:
80100
name: cargo test

.github/workflows/release.yml

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,22 @@ jobs:
110110
artifact_label: windows-x86_64
111111
cuda: false
112112

113+
# ── Windows GPU ────────────────────────────────────────────────────
114+
# Moonshine on the GPU via ONNX Runtime's WebGPU execution provider —
115+
# Dawn over D3D12 on Windows, so NVIDIA, AMD and Intel alike, with no
116+
# vendor SDK at build time and nothing extra to ship.
117+
#
118+
# This accelerates Moonshine, not whisper.cpp: ggml's Vulkan backend
119+
# is the only GPU path whisper.cpp has here and it does not register
120+
# on Windows MSVC static builds (ggml-org/whisper.cpp#3750). Someone
121+
# reading "Windows GPU" should know which engine gets the GPU, which
122+
# is why the app reports the two separately.
123+
- name: windows-webgpu
124+
os: windows-2022
125+
features: "moonshine-webgpu"
126+
artifact_label: windows-x86_64-webgpu
127+
cuda: false
128+
113129
steps:
114130
- uses: actions/checkout@v4
115131

@@ -332,9 +348,15 @@ jobs:
332348
# the MSI is a secondary, unadvertised artifact. Call tauri directly so
333349
# `--bundles nsis` reaches tauri instead of cargo.
334350
- name: Build installer (Windows, CPU)
335-
if: runner.os == 'Windows'
351+
if: runner.os == 'Windows' && matrix.features == ''
336352
run: npx tauri build --bundles nsis
337353

354+
# Split from the step above rather than interpolating a possibly-empty
355+
# `--features`, which passes tauri a bare flag and fails the build.
356+
- name: Build installer (Windows, GPU)
357+
if: runner.os == 'Windows' && matrix.features != ''
358+
run: npx tauri build --bundles nsis --features ${{ matrix.features }}
359+
338360
# ── Collect and rename Linux artifacts ──────────────────────────────────
339361
# Search the whole workspace so the path is correct regardless of whether
340362
# cargo uses a workspace-level or package-level target directory.
@@ -516,16 +538,26 @@ jobs:
516538
| Linux | `-linux-x86_64.AppImage` | None |
517539
| Linux (GPU) | `-linux-x86_64-vulkan.AppImage` | Any Vulkan GPU |
518540
| Windows | `-windows-x86_64.exe` | None |
541+
| Windows (GPU) | `-windows-x86_64-webgpu.exe` | Any Direct3D 12 GPU |
519542
520543
**The Vulkan AppImage** is a portable GPU build: it accelerates any
521544
NVIDIA/AMD/Intel GPU via the host Vulkan driver, and falls back to
522545
CPU when no GPU is present.
523546
524547
**CPU builds** run on any hardware, including AMD, Intel, and ARM.
525548
526-
**The Windows build** is an NSIS installer and runs inference on the
527-
CPU. It is not code-signed yet, so SmartScreen will warn that the
528-
publisher is unknown: choose **More info → Run anyway**.
549+
**The Windows builds** are NSIS installers. Neither is code-signed
550+
yet, so SmartScreen will warn that the publisher is unknown: choose
551+
**More info → Run anyway**.
552+
553+
**The Windows GPU build** accelerates the **Moonshine** speech
554+
engine on any Direct3D 12 GPU — NVIDIA, AMD or Intel — through ONNX
555+
Runtime's WebGPU provider. Select Moonshine in **Settings → Engine**;
556+
the tab says which backend the build actually has. Whisper.cpp still
557+
runs on the CPU on Windows either way, because its only GPU path
558+
here is ggml's Vulkan backend, which does not register on Windows
559+
MSVC static builds. If your GPU is not usable the app says so in the
560+
log and falls back to the CPU rather than pretending.
529561
530562
### Windows — first run
531563

Cargo.lock

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

crates/voxctrl-inference/Cargo.toml

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,35 @@ moonshine = ["dep:ort", "dep:tokenizers"]
5050
# runtime at build *and* run time, which is exactly what the Vulkan AppImage
5151
# exists to avoid shipping.
5252
#
53-
# There is no `moonshine-vulkan` because ONNX Runtime has no Vulkan execution
54-
# provider. So the standard Vulkan build — where whisper.cpp *does* get the GPU
55-
# — runs Moonshine on the CPU, and holds its weights in RAM as fp32 rather than
56-
# in VRAM. `moonshine_gpu_backend()` reports which of these is true so the UI
57-
# can say so instead of leaving the user to measure it.
53+
# There is still no `moonshine-vulkan`: ONNX Runtime has no Vulkan execution
54+
# provider, so a `vulkan` build accelerates whisper.cpp and nothing else. What
55+
# closes that gap is `moonshine-webgpu` below, which reaches the same
56+
# vendor-neutral GPUs by a different road. `moonshine_gpu_backend()` reports
57+
# which of these a build actually has, so the UI can say so instead of leaving
58+
# the user to measure it.
5859
moonshine-cuda = ["moonshine", "ort/cuda"]
5960
moonshine-coreml = ["moonshine", "ort/coreml"]
6061

62+
# Cross-vendor GPU offload for Moonshine, via ONNX Runtime's WebGPU execution
63+
# provider. This is the Windows counterpart to what `vulkan` does for
64+
# whisper.cpp: any modern GPU, no vendor SDK at build time, no runtime to ship.
65+
# On Windows ORT's WebGPU EP runs on Dawn over D3D12, so it covers NVIDIA, AMD
66+
# and Intel alike.
67+
#
68+
# Chosen over DirectML, which is the obvious-looking answer and does not work
69+
# here. `ort` exposes an `ort::ep::DirectML` and a `directml` feature, but
70+
# `ort-sys`'s prebuilt table (build/download/dist.txt) ships no DirectML binary
71+
# for any target, and its `resolve_dist()` never consults the `directml` feature
72+
# when choosing which prebuilt to fetch. Enabling it therefore downloads the
73+
# plain CPU runtime, compiles fine, and then fails to register the provider at
74+
# run time — forever. Reaching real DirectML means switching to `load-dynamic`
75+
# and shipping Microsoft's own onnxruntime.dll and DirectML.dll, which gives up
76+
# the self-contained binary the rest of this file is arranged around.
77+
#
78+
# `wgpu` is a tag `resolve_dist()` does understand, and
79+
# `x86_64-pc-windows-msvc+wgpu` is a prebuilt that exists.
80+
moonshine-webgpu = ["moonshine", "ort/webgpu"]
81+
6182
[dependencies.ort]
6283
version = "2.0.0-rc.12"
6384
optional = true

crates/voxctrl-inference/src/lib.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,97 @@ pub fn whisper_gpu_backend() -> Option<&'static str> {
3939
/// [`whisper_gpu_backend`] rather than inferred from it: they are genuinely
4040
/// different answers in the build most people run.
4141
pub fn moonshine_gpu_backend() -> Option<&'static str> {
42+
// Same order as `MoonshineBackend::with_gpu` registers them in. The two
43+
// being one edit apart is the whole risk here, so a test holds them
44+
// together rather than a comment alone.
4245
if cfg!(feature = "moonshine-cuda") {
4346
Some("cuda")
4447
} else if cfg!(feature = "moonshine-coreml") {
4548
Some("coreml")
49+
} else if cfg!(feature = "moonshine-webgpu") {
50+
Some("webgpu")
4651
} else {
4752
None
4853
}
4954
}
5055

56+
/// The same provider, spelled the way ONNX Runtime spells it, for registration
57+
/// and logging.
58+
///
59+
/// A pure mapping over [`moonshine_gpu_backend`] rather than a second `cfg`
60+
/// cascade: the Engine tab and the session builder then cannot name different
61+
/// providers, which is the failure mode worth designing out. `with_gpu` still
62+
/// selects the provider *type* by `cfg`, but on exactly the conditions above.
63+
// Its only non-test caller is the branch of `with_gpu` that registers a
64+
// provider, which is compiled out when no GPU feature is on — the `moonshine`
65+
// feature alone is not enough to reach it, which is what the first version of
66+
// this attribute got wrong. The tests below still exercise it either way.
67+
#[cfg_attr(
68+
not(any(
69+
feature = "moonshine-cuda",
70+
feature = "moonshine-coreml",
71+
feature = "moonshine-webgpu"
72+
)),
73+
allow(dead_code)
74+
)]
75+
pub(crate) fn moonshine_gpu_provider() -> Option<&'static str> {
76+
match moonshine_gpu_backend() {
77+
Some("cuda") => Some("CUDA"),
78+
Some("coreml") => Some("CoreML"),
79+
Some("webgpu") => Some("WebGPU"),
80+
_ => None,
81+
}
82+
}
83+
84+
#[cfg(test)]
85+
mod gpu_backend_tests {
86+
use super::*;
87+
88+
#[test]
89+
fn every_reported_backend_has_a_provider_spelling() {
90+
// A backend the mapping does not know would make `with_gpu` log a bare
91+
// "GPU" while registering something specific — the Engine tab and the
92+
// log disagreeing about what this build does. Runs in every feature
93+
// configuration CI builds, including the default CPU one.
94+
match moonshine_gpu_backend() {
95+
Some(backend) => assert!(
96+
moonshine_gpu_provider().is_some(),
97+
"{backend} is reported to the UI but has no ONNX Runtime spelling"
98+
),
99+
None => assert_eq!(
100+
moonshine_gpu_provider(),
101+
None,
102+
"a CPU-only build named a GPU provider"
103+
),
104+
}
105+
}
106+
107+
#[test]
108+
fn a_build_with_no_gpu_feature_reports_none() {
109+
if cfg!(not(any(
110+
feature = "moonshine-cuda",
111+
feature = "moonshine-coreml",
112+
feature = "moonshine-webgpu"
113+
))) {
114+
assert_eq!(moonshine_gpu_backend(), None);
115+
}
116+
}
117+
118+
#[test]
119+
fn the_webgpu_build_reports_webgpu() {
120+
// Guards the release artifact: a `moonshine-webgpu` build that reported
121+
// None would show "CPU only" in the Engine tab on the very build that
122+
// exists to use the GPU.
123+
if cfg!(all(
124+
feature = "moonshine-webgpu",
125+
not(any(feature = "moonshine-cuda", feature = "moonshine-coreml"))
126+
)) {
127+
assert_eq!(moonshine_gpu_backend(), Some("webgpu"));
128+
assert_eq!(moonshine_gpu_provider(), Some("WebGPU"));
129+
}
130+
}
131+
}
132+
51133
use std::sync::Arc;
52134

53135
use anyhow::Result;

crates/voxctrl-inference/src/moonshine.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,12 +241,34 @@ impl MoonshineBackend {
241241
/// point. On failure the builder is recovered from the error and returned
242242
/// unchanged, so a machine with no usable CUDA still loads the model.
243243
fn with_gpu(builder: SessionBuilder) -> SessionBuilder {
244-
#[cfg(any(feature = "moonshine-cuda", feature = "moonshine-coreml"))]
244+
#[cfg(any(
245+
feature = "moonshine-cuda",
246+
feature = "moonshine-coreml",
247+
feature = "moonshine-webgpu"
248+
))]
245249
{
250+
// One provider, picked in the order they are worth having: CUDA
251+
// beats a generic device where it exists, CoreML beats WebGPU on
252+
// Apple hardware, and WebGPU covers everything else. This order is
253+
// mirrored by `moonshine_gpu_backend()`, which is where the name
254+
// below comes from.
246255
#[cfg(feature = "moonshine-cuda")]
247-
let (name, ep) = ("CUDA", ort::ep::CUDA::default().build().error_on_failure());
256+
let ep = ort::ep::CUDA::default().build().error_on_failure();
248257
#[cfg(all(feature = "moonshine-coreml", not(feature = "moonshine-cuda")))]
249-
let (name, ep) = ("CoreML", ort::ep::CoreML::default().build().error_on_failure());
258+
let ep = ort::ep::CoreML::default().build().error_on_failure();
259+
#[cfg(all(
260+
feature = "moonshine-webgpu",
261+
not(any(feature = "moonshine-cuda", feature = "moonshine-coreml"))
262+
))]
263+
// Defaults deliberately: on Windows ORT's WebGPU EP runs on Dawn,
264+
// which selects D3D12 there on its own. Naming a backend type would
265+
// only be a way to get it wrong on someone else's machine.
266+
let ep = ort::ep::WebGPU::default().build().error_on_failure();
267+
268+
// Not a second cascade: the name is derived from the same function
269+
// that tells the UI which backend this build has, so the log and
270+
// the Engine tab cannot name different providers.
271+
let name = crate::moonshine_gpu_provider().unwrap_or("GPU");
250272

251273
match builder.with_execution_providers([ep]) {
252274
Ok(with_ep) => {
@@ -266,7 +288,11 @@ impl MoonshineBackend {
266288
}
267289
}
268290

269-
#[cfg(not(any(feature = "moonshine-cuda", feature = "moonshine-coreml")))]
291+
#[cfg(not(any(
292+
feature = "moonshine-cuda",
293+
feature = "moonshine-coreml",
294+
feature = "moonshine-webgpu"
295+
)))]
270296
builder
271297
}
272298
}
@@ -596,6 +622,8 @@ fn threads() -> usize {
596622

597623
#[cfg(test)]
598624
mod tests {
625+
626+
599627
use super::*;
600628

601629
#[test]

0 commit comments

Comments
 (0)