Skip to main content

rlx_runtime/
device_ext.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Engine-layer extensions for [`rlx_driver::Device`] (plan #58).
6//!
7//! `is_available` and `available_devices` consult the runtime's
8//! backend registry + Cargo features, both of which are
9//! engine-layer concerns. Keeping them here preserves the
10//! one-way dep direction (driver doesn't know about engine).
11
12use rlx_driver::Device;
13use rlx_ir::{Graph, Op};
14
15use crate::CompileOptions;
16
17/// Preferred probe order for ML workloads (highest throughput first).
18///
19/// Used by [`fastest_device`] and by [`crate::cost::fastest_device_for`] when
20/// calibrated cost models are unavailable for every candidate backend.
21pub(crate) const DEVICE_PRIORITY: &[Device] = &[
22    Device::Tpu,
23    Device::Cuda,
24    Device::Rocm,
25    Device::OneApi,
26    Device::Mlx,
27    Device::Metal,
28    Device::Ane,
29    Device::Hexagon,
30    Device::Xdna,
31    Device::Egpu,
32    Device::Gpu,
33    Device::Vulkan,
34    Device::DirectX,
35    Device::OpenGl,
36    Device::WebGpu,
37    Device::Cpu,
38];
39
40/// Browser backend probe order: WebGPU first, WebGL fallback, then CPU.
41pub const BROWSER_DEVICE_PRIORITY: &[Device] = &[Device::WebGpu, Device::OpenGl, Device::Cpu];
42
43/// Check whether `device` has a compiled-in backend or has been
44/// registered by an external crate.
45///
46/// GPU-family builtins (CUDA / ROCm / wgpu / TPU) additionally probe
47/// for a live driver or adapter at runtime so CI hosts that compile
48/// with `--features cuda` but have no NVIDIA stack don't report
49/// false positives. Other devices are Cargo-feature-gated; externally
50/// registered backends are discovered via the registry.
51/// Whether [`crate::CompiledGraph::run_slots`] + [`crate::CompiledGraph::arena_ptr`]
52/// are implemented (host readback layout; not a GPU-mapped arena on CUDA).
53pub fn supports_run_slots(device: Device) -> bool {
54    matches!(
55        device,
56        Device::Cpu | Device::Metal | Device::Mlx | Device::Cuda | Device::Rocm
57    )
58}
59
60/// Whether `device`'s RoPE kernel indexes its cos/sin table **per token**
61/// (one row per batch·seq element) instead of per sequence position.
62///
63/// Required for *ragged* batched decode, where each sequence in the batch sits
64/// at a different absolute position and so needs its own RoPE row.
65///
66/// Validated against the CPU reference on:
67///   - **CPU** (`rlx-cpu` executor + thunk), and
68///   - **Metal** (`cos_per_token` kernel path; `metal_rope_parity` ragged test).
69///
70/// The remaining GPU kernels still index by seq position (CUDA `rope.cu`, wgpu
71/// `rope.wgsl`), which collapses for decode (seq = 1) and would apply row 0's
72/// position to the whole batch — so callers (e.g. the server's fused batcher)
73/// fall back to per-length **uniform** grouping there. Add a device here only
74/// once its rope + rope_backward kernels are fixed *and* validated against the
75/// CPU reference.
76pub fn supports_ragged_rope(device: Device) -> bool {
77    matches!(device, Device::Cpu | Device::Metal)
78}
79
80/// Drop backend-held device arena pools between large compiles (CUDA).
81/// No-op on backends without a pool (CPU, Metal unified memory, ROCm, …).
82pub fn trim_accelerator_arena_pool(device: Device) {
83    #[cfg(feature = "cuda")]
84    if device == Device::Cuda {
85        rlx_cuda::trim_device_memory_pool();
86    }
87    #[cfg(not(feature = "cuda"))]
88    let _ = device;
89}
90
91/// Whether this build was *compiled* with the backend feature for `device`.
92///
93/// Distinct from [`is_available`], which additionally requires the hardware /
94/// driver to be present. Splitting the two lets callers say which of the
95/// two is actually missing: "enable the `gpu` feature" is actively misleading
96/// on a headless box where the feature IS on and there is simply no adapter.
97pub fn feature_compiled(device: Device) -> bool {
98    match device {
99        Device::Cpu => true,
100        Device::Metal => cfg!(feature = "metal"),
101        Device::Mlx => cfg!(feature = "mlx"),
102        Device::Ane => cfg!(feature = "ane"),
103        Device::Cuda => cfg!(feature = "cuda"),
104        Device::Rocm => cfg!(feature = "rocm"),
105        Device::Xdna => cfg!(feature = "xdna"),
106        Device::Egpu => cfg!(feature = "egpu"),
107        Device::OneApi => cfg!(feature = "oneapi"),
108        Device::Tpu => cfg!(feature = "tpu"),
109        Device::Hexagon => cfg!(feature = "qnn"),
110        Device::Gpu => cfg!(feature = "gpu"),
111        Device::Vulkan => cfg!(feature = "vulkan"),
112        Device::OpenGl => cfg!(feature = "opengl"),
113        Device::DirectX => cfg!(feature = "directx"),
114        Device::WebGpu => cfg!(feature = "webgpu"),
115    }
116}
117
118pub fn is_available(device: Device) -> bool {
119    #[cfg(feature = "cuda")]
120    if device == Device::Cuda {
121        return rlx_cuda::is_available();
122    }
123    #[cfg(feature = "rocm")]
124    if device == Device::Rocm {
125        return rlx_rocm::is_available();
126    }
127    // XDNA is honest-gated: true only when the NPU hardware AND a userspace
128    // runtime are both present (rlx_xdna probes /dev/accel* + XRT). Hardware
129    // present but no runtime → false, so selection never picks a backend that
130    // can't run. See `detected_unavailable_devices` for the inventory signal.
131    #[cfg(feature = "xdna")]
132    if device == Device::Xdna {
133        return rlx_xdna::is_available();
134    }
135    // The eGPU is gated the same way: the PCIe tunnel enumerating a supported
136    // card is not enough — rlx_egpu::is_available() additionally requires the
137    // device-bring-up path, so a discovered-but-undriveable card never becomes
138    // a dispatch target. See `detected_unavailable_devices`.
139    #[cfg(feature = "egpu")]
140    if device == Device::Egpu {
141        return rlx_egpu::is_available();
142    }
143    #[cfg(feature = "gpu")]
144    if device == Device::Gpu {
145        #[cfg(target_arch = "wasm32")]
146        {
147            return rlx_wgpu::device::wgpu_device().is_some();
148        }
149        #[cfg(not(target_arch = "wasm32"))]
150        {
151            return rlx_wgpu::is_available();
152        }
153    }
154    #[cfg(feature = "webgpu")]
155    if device == Device::WebGpu {
156        #[cfg(target_arch = "wasm32")]
157        {
158            return rlx_wgpu::device::wgpu_device().is_some();
159        }
160        #[cfg(not(target_arch = "wasm32"))]
161        {
162            return rlx_wgpu::is_available();
163        }
164    }
165    #[cfg(feature = "opengl")]
166    if device == Device::OpenGl {
167        return true;
168    }
169    #[cfg(feature = "vulkan")]
170    if device == Device::Vulkan {
171        return rlx_vulkan::is_available();
172    }
173    #[cfg(feature = "oneapi")]
174    if device == Device::OneApi {
175        return rlx_oneapi::is_available();
176    }
177    #[cfg(feature = "tpu")]
178    if device == Device::Tpu {
179        return rlx_tpu::is_available();
180    }
181    // Metal / MLX probe the live device, not just the Cargo cfg: a build can
182    // be Metal-capable yet run where no Metal device exists (e.g. a headless
183    // iOS-simulator process), and runtime selection must not pick a dead
184    // backend.
185    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
186    if device == Device::Metal {
187        return rlx_metal::is_available();
188    }
189    #[cfg(all(feature = "mlx", rlx_mlx_host))]
190    if device == Device::Mlx {
191        return rlx_mlx::is_available();
192    }
193
194    let feature_gated = match device {
195        Device::Cpu => cfg!(feature = "cpu"),
196        Device::Metal => cfg!(all(
197            feature = "metal",
198            target_vendor = "apple",
199            not(target_os = "watchos")
200        )),
201        Device::Mlx => cfg!(feature = "mlx"),
202        // The Neural Engine is Apple hardware, so — like Metal above — the
203        // feature being compiled in is not enough. Workspace feature
204        // unification switches `coreml` on for a Linux build, and without the
205        // target gate every `is_available(Ane)` guard silently passes there.
206        Device::Ane => cfg!(all(
207            any(feature = "coreml", feature = "ane"),
208            target_vendor = "apple"
209        )),
210        Device::Cuda => cfg!(feature = "cuda"),
211        Device::Rocm => cfg!(feature = "rocm"),
212        // Only reached when the `xdna` feature is OFF (the probe above owns the
213        // feature-ON case): no backend compiled in → unavailable.
214        Device::Xdna => false,
215        // Same as XDNA: only reached with the `egpu` feature OFF.
216        Device::Egpu => false,
217        Device::OneApi => cfg!(feature = "oneapi"),
218        Device::Tpu => cfg!(feature = "tpu"),
219        Device::Hexagon => cfg!(feature = "qnn"),
220        Device::Gpu => cfg!(feature = "gpu"),
221        Device::Vulkan => cfg!(feature = "vulkan"),
222        Device::OpenGl => cfg!(feature = "opengl"),
223        Device::DirectX => cfg!(feature = "directx"),
224        Device::WebGpu => cfg!(feature = "webgpu"),
225    };
226    if feature_gated {
227        return true;
228    }
229    crate::registry::registered_devices().contains(&device)
230}
231
232/// Apple backends enabled in this build (`metal`, `mlx`, `gpu`, `ane`) on
233/// any Apple platform. `is_available` filters per-device, so e.g. watchOS
234/// (no Metal) yields just the CoreML/ANE entry.
235#[cfg(all(feature = "apple", target_vendor = "apple"))]
236pub fn available_apple_devices() -> Vec<Device> {
237    [Device::Metal, Device::Mlx, Device::Gpu, Device::Ane]
238        .into_iter()
239        .filter(|d| is_available(*d))
240        .collect()
241}
242
243/// Every variant currently available — Cargo-feature-gated or
244/// runtime-registered.
245pub fn available_devices() -> Vec<Device> {
246    Device::all()
247        .iter()
248        .copied()
249        .filter(|d| is_available(*d))
250        .collect()
251}
252
253/// Devices whose **hardware is physically present but not executable** — the
254/// backend is compiled in and detects the device, yet a required userspace
255/// runtime is missing (so [`is_available`] is `false`). Each entry pairs the
256/// device with a human diagnostic naming what's missing.
257///
258/// Distinct from [`available_devices`] (which lists only *runnable* backends):
259/// this lets fleet inventory / topology tooling surface an accelerator that is
260/// installed but idle — e.g. an AMD XDNA NPU with no XRT runtime — without ever
261/// letting device selection dispatch to it.
262pub fn detected_unavailable_devices() -> Vec<(Device, String)> {
263    #[allow(unused_mut)]
264    let mut out: Vec<(Device, String)> = Vec::new();
265    #[cfg(feature = "xdna")]
266    if rlx_xdna::hardware_present() && !is_available(Device::Xdna) {
267        out.push((Device::Xdna, rlx_xdna::diagnostic()));
268    }
269    #[cfg(feature = "egpu")]
270    if rlx_egpu::hardware_present() && !is_available(Device::Egpu) {
271        out.push((Device::Egpu, rlx_egpu::diagnostic()));
272    }
273    out
274}
275
276/// Browser backends currently runnable (`WebGpu` → `OpenGl` → `Cpu`).
277pub fn available_browser_devices() -> Vec<Device> {
278    BROWSER_DEVICE_PRIORITY
279        .iter()
280        .copied()
281        .filter(|d| is_available(*d))
282        .collect()
283}
284
285/// Highest-priority browser backend, or `None` when no browser path is live.
286pub fn preferred_browser_device() -> Option<Device> {
287    available_browser_devices().into_iter().next()
288}
289
290/// Intersection of [`available_devices`] and [`supports_graph`]. Use with
291/// [`crate::GraphDevices`] or [`crate::DevicePolicy`] to restrict the set.
292pub fn devices_for(graph: &Graph) -> Vec<Device> {
293    crate::device_policy::devices_for_with_policy(graph, &crate::DevicePolicy::default())
294}
295
296/// Highest-priority backend that is compiled in and live on this host.
297///
298/// Probes `DEVICE_PRIORITY` in order (TPU → CUDA → ROCm → MLX → Metal → …
299/// → CPU). Use this when you want a sensible default `Session` target without
300/// building a graph first. For workload-specific selection, prefer
301/// [`crate::cost::fastest_device_for`].
302pub fn fastest_device() -> Device {
303    fastest_among(&available_devices())
304}
305
306/// Pick the highest-priority entry from `candidates` (see `DEVICE_PRIORITY`).
307pub fn fastest_among(candidates: &[Device]) -> Device {
308    for &d in DEVICE_PRIORITY {
309        if candidates.contains(&d) {
310            return d;
311        }
312    }
313    candidates.first().copied().unwrap_or(Device::Cpu)
314}
315
316/// Pretty name with engine-known BLAS variant for the CPU device.
317/// Gives `"CPU (Accelerate)"` etc. when the relevant feature is
318/// on; falls back to the bare driver-side `Device::name()` when
319/// no BLAS feature is selected.
320pub fn full_name(device: Device) -> &'static str {
321    if let Device::Cpu = device {
322        if cfg!(feature = "blas-accelerate") {
323            return "CPU (Accelerate)";
324        }
325        if cfg!(feature = "blas-mkl") {
326            return "CPU (MKL)";
327        }
328        if cfg!(feature = "blas-openblas") {
329            return "CPU (OpenBLAS)";
330        }
331    }
332    device.name()
333}
334
335// ── Per-device op-support introspection ──────────────────────────
336//
337// Callers that want to dispatch graphs to a particular device need
338// to know up front whether the device's backend has every op the
339// graph uses wired up. Before this API, the only signal was a
340// runtime panic ("not yet implemented"), which forced downstream
341// crates (e.g. `eda-magnetics::graph::pick_device_for`) to bake
342// hand-maintained "what's missing on X" tables into their own
343// source — those drift the moment a backend lands the missing op.
344//
345// [`supports`] consults the backend-side knowledge (CPU is the
346// reference and assumed complete; MLX / Metal each name the ops
347// they don't yet lower) so consumers can ask once and stop
348// re-implementing the table.
349
350/// Is `op` lowerable by the backend for `device` *in this build*?
351///
352/// - CPU is the reference; always returns `true`.
353/// - GPU backends return `false` only for the specific ops/variants
354///   their lowering currently rejects. As backends close gaps, the
355///   matches here shrink and consumers automatically pick them up.
356/// - For devices not feature-gated in, returns `false` (you can't
357///   dispatch to a backend that isn't compiled in regardless).
358pub fn supports(device: Device, op: &Op) -> bool {
359    if !is_available(device) {
360        return false;
361    }
362    match device {
363        Device::Cpu => true, // reference backend; ground truth
364        Device::Mlx => mlx_supports(op),
365        Device::Metal => metal_supports(op),
366        Device::Ane => coreml_supports(op),
367        Device::Gpu | Device::Cuda | Device::Rocm => gpu_family_supports(device, op),
368        #[cfg(feature = "vulkan")]
369        Device::Vulkan => vulkan_supports(op),
370        #[cfg(feature = "oneapi")]
371        Device::OneApi => oneapi_supports(op),
372        Device::Hexagon => qnn_supports(op),
373        // Other backends not yet characterised here. Conservative:
374        // assume `false` so callers won't dispatch blind; tighten as
375        // each backend grows a `<x>_supports` arm below.
376        _ => false,
377    }
378}
379
380/// Per-op support for the QNN (Hexagon NPU) backend — the ops the FFI runtime
381/// (`rlx_qnn::runtime`) lowers to QNN, plus fused forms decomposed at compile
382/// (`FusedAttentionBlock`).
383fn qnn_supports(op: &Op) -> bool {
384    use rlx_ir::op::Activation;
385    match op {
386        Op::Input { .. }
387        | Op::Param { .. }
388        | Op::Constant { .. }
389        | Op::MatMul
390        | Op::Binary(_)
391        | Op::Softmax { .. }
392        | Op::Reshape { .. }
393        | Op::Transpose { .. }
394        | Op::LayerNorm { .. }
395        | Op::RmsNorm { .. }
396        | Op::Concat { .. }
397        | Op::Narrow { .. }
398        | Op::Rope { .. }
399        | Op::Attention { .. }
400        | Op::FusedAttentionBlock { .. }
401        | Op::Expand { .. }
402        | Op::Reduce { .. }
403        | Op::Conv { .. }
404        | Op::Gather { .. }
405        | Op::Quantize { .. }
406        | Op::Dequantize { .. }
407        | Op::DequantMatMul { .. } => true,
408        Op::Activation(a) => matches!(
409            a,
410            Activation::Relu
411                | Activation::Gelu
412                | Activation::Sigmoid
413                | Activation::Tanh
414                | Activation::Neg
415                | Activation::Silu
416        ),
417        _ => false,
418    }
419}
420
421/// Per-op heuristic for the native Vulkan backend: native primitive set plus
422/// the op families `legalize_or_rewrite_for_backend` decomposes into it. The
423/// authoritative check is `supports_graph` (runs the real legalize probe);
424/// this is the cheap single-op approximation.
425#[cfg(feature = "vulkan")]
426fn vulkan_supports(op: &Op) -> bool {
427    use rlx_ir::OpKind::*;
428    let k = op.kind();
429    rlx_vulkan::backend::SUPPORTED_OPS.contains(&k)
430        || matches!(
431            k,
432            // Decomposed to the primitive set by the rewrite pass.
433            DotGeneral
434                | Fma
435                | GroupNorm
436                | BatchNormInference
437                | ResizeNearest2x
438                | ElementwiseRegion
439                | FusedMatMulBiasAct
440                | FusedResidualLN
441                | FusedResidualRmsNorm
442                | FusedSwiGLU
443                | FusedAttentionBlock
444                | FusedTransformerLayer
445        )
446}
447
448/// Per-op heuristic for the native oneAPI (Level Zero) backend — the same
449/// primitive claim set as rlx-vulkan (the rewrite pass decomposes the rest).
450/// The authoritative check remains `supports_graph` (real legalize probe).
451#[cfg(feature = "oneapi")]
452fn oneapi_supports(op: &Op) -> bool {
453    use rlx_ir::OpKind::*;
454    let k = op.kind();
455    rlx_oneapi::backend::SUPPORTED_OPS.contains(&k)
456        || matches!(
457            k,
458            DotGeneral
459                | Fma
460                | GroupNorm
461                | BatchNormInference
462                | ResizeNearest2x
463                | ElementwiseRegion
464                | FusedMatMulBiasAct
465                | FusedResidualLN
466                | FusedResidualRmsNorm
467                | FusedSwiGLU
468                | FusedAttentionBlock
469                | FusedTransformerLayer
470        )
471}
472
473/// Is every op in `graph` lowerable by `device`?
474///
475/// When a backend is registered, uses the same rewrite + legalization probe as
476/// [`legalize_graph_for_device`] (see [`KernelDispatchReport::compile_ready`](rlx_compile::KernelDispatchReport::compile_ready)).
477/// Otherwise falls back to per-op [`supports`] heuristics.
478pub fn supports_graph(device: Device, graph: &Graph) -> bool {
479    supports_graph_with_options(device, graph, &CompileOptions::default())
480}
481
482/// Like [`supports_graph`] with explicit [`CompileOptions::kernel_dispatch`].
483pub fn supports_graph_with_options(
484    device: Device,
485    graph: &Graph,
486    options: &CompileOptions,
487) -> bool {
488    if !is_available(device) {
489        return false;
490    }
491    if let Some(backend) = crate::registry::backend_for(device) {
492        let (_, report) = rlx_opt::prepare_graph_for_backend_with_report(
493            graph.clone(),
494            device.name(),
495            backend.supported_ops(),
496            options.kernel_dispatch,
497        );
498        return report.compile_ready;
499    }
500    graph.nodes().iter().all(|n| supports(device, &n.op))
501}
502
503/// Legalize `graph` for `device` using that backend's claimed `OpKind` set.
504///
505/// Applies the same rewrite + legalization path as `Backend::compile` (e.g.
506/// CUDA/ROCm rewrites before the legality check). Returns an error when the
507/// backend feature is not enabled or the graph contains unsupported ops.
508///
509/// Does not require a live GPU/TPU driver — only that the backend crate is
510/// compiled in.
511pub fn legalize_graph_for_device(graph: Graph, device: Device) -> Result<Graph, String> {
512    let (graph, _report) = legalize_graph_for_device_with_report(graph, device)?;
513    Ok(graph)
514}
515
516/// Like [`legalize_graph_for_device`] but returns a [`KernelDispatchReport`](rlx_compile::KernelDispatchReport) for tooling.
517pub fn legalize_graph_for_device_with_report(
518    graph: Graph,
519    device: Device,
520) -> Result<(Graph, rlx_opt::KernelDispatchReport), String> {
521    legalize_graph_for_device_with_options(graph, device, &CompileOptions::default())
522}
523
524/// Like [`legalize_graph_for_device_with_report`] using [`CompileOptions::kernel_dispatch`]
525/// (and the same rewrite path as `Backend::compile`).
526pub fn legalize_graph_for_device_with_options(
527    graph: Graph,
528    device: Device,
529    options: &CompileOptions,
530) -> Result<(Graph, rlx_opt::KernelDispatchReport), String> {
531    let backend = crate::registry::backend_for(device).ok_or_else(|| {
532        format!(
533            "no backend registered for {device:?} — enable the matching \
534             `rlx-runtime` Cargo feature (e.g. `metal`, `gpu`, `cuda`)"
535        )
536    })?;
537    let ops = backend.supported_ops();
538    let (graph, report) = rlx_opt::prepare_graph_for_backend_with_report(
539        graph,
540        device.name(),
541        ops,
542        options.kernel_dispatch,
543    );
544    if !report.compile_ready {
545        return Err(format!(
546            "{}\n{}",
547            rlx_opt::format_legalize_error(device.name(), &report.still_unsupported),
548            rlx_opt::format_dispatch_report(&report)
549        ));
550    }
551    Ok((graph, report))
552}
553
554/// Dispatch report for `graph` on `device` without mutating the graph (static common-ir probe).
555pub fn dispatch_report_for_device(
556    graph: &Graph,
557    device: Device,
558) -> Result<rlx_opt::KernelDispatchReport, String> {
559    dispatch_report_for_device_with_options(graph, device, &CompileOptions::default())
560}
561
562/// Like [`dispatch_report_for_device`] with explicit [`CompileOptions::kernel_dispatch`].
563pub fn dispatch_report_for_device_with_options(
564    graph: &Graph,
565    device: Device,
566    options: &CompileOptions,
567) -> Result<rlx_opt::KernelDispatchReport, String> {
568    let backend = crate::registry::backend_for(device)
569        .ok_or_else(|| format!("no backend registered for {device:?}"))?;
570    Ok(rlx_opt::analyze_dispatch(
571        graph,
572        device.name(),
573        backend.supported_ops(),
574        options.kernel_dispatch,
575    ))
576}
577
578/// First op in `graph` that `device` cannot lower after rewrite, or `None`.
579///
580/// Prefer the backend claim-set probe when registered; otherwise [`supports`].
581pub fn first_unsupported_op(device: Device, graph: &Graph) -> Option<(usize, &Op)> {
582    first_unsupported_op_with_options(device, graph, &CompileOptions::default())
583}
584
585/// Like [`first_unsupported_op`] with explicit [`CompileOptions::kernel_dispatch`].
586pub fn first_unsupported_op_with_options<'a>(
587    device: Device,
588    graph: &'a Graph,
589    options: &CompileOptions,
590) -> Option<(usize, &'a Op)> {
591    if !is_available(device) {
592        return graph.nodes().first().map(|n| (0, &n.op));
593    }
594    if let Some(backend) = crate::registry::backend_for(device) {
595        let (_, report) = rlx_opt::prepare_graph_for_backend_with_report(
596            graph.clone(),
597            device.name(),
598            backend.supported_ops(),
599            options.kernel_dispatch,
600        );
601        if let Some((id, kind)) = report.still_unsupported.first() {
602            let idx = graph.nodes().iter().position(|n| n.id == *id).unwrap_or(0);
603            let op = graph
604                .nodes()
605                .iter()
606                .find(|n| n.id == *id)
607                .map(|n| &n.op)
608                .unwrap_or(&graph.nodes()[0].op);
609            let _ = kind;
610            return Some((idx, op));
611        }
612        return None;
613    }
614    graph
615        .nodes()
616        .iter()
617        .enumerate()
618        .find_map(|(i, n)| (!supports(device, &n.op)).then_some((i, &n.op)))
619}
620
621#[allow(unused_variables)]
622fn mlx_supports(op: &Op) -> bool {
623    // After Sin/Cos wiring (forward + backward), MLX's `Activation`
624    // dispatch is complete for every variant in `rlx_ir::Activation`.
625    // Add narrow guards here only when a future Op or Activation
626    // variant lands without an MLX lowering.
627    true
628}
629
630#[allow(unused_variables)]
631fn metal_supports(op: &Op) -> bool {
632    // No characterized gaps for the activations rlx-eda exercises.
633    // The Sin/Cos/Tan/Atan MSL kernels landed in `rlx-metal/src/kernels.rs`
634    // (`{sin,cos,tan,atan}_inplace`) alongside the dispatch slots in
635    // `backend.rs:1764`. Narrow this back down if a future Op or
636    // Activation variant lands without a Metal kernel.
637    let _ = op;
638    true
639}
640
641/// CoreML / ANE lowers a fixed, declared op set (see `rlx_coreml::mil`).
642/// Unlike the GPU backends — whose lowering covers the whole IR surface —
643/// CoreML is an inference compiler with a finite op claim, so we check
644/// membership directly against the backend's published list.
645///
646/// Under the `training` feature the claim also covers the backward ops that the
647/// legalize/rewrite pass decomposes into supported primitives (or, once landed,
648/// lower through native MIL backward kernels) — so device selection picks
649/// `Device::Ane` for autodiff-produced backward graphs. See
650/// [`rlx_coreml::BACKWARD_OPS`].
651fn coreml_supports(op: &Op) -> bool {
652    // `coreml` may be enabled transitively on non-Apple targets (a no-op there —
653    // `rlx-coreml` is an Apple-only dependency), so gate on the target too, matching
654    // where the crate actually exists (same pattern as `metal` above).
655    #[cfg(all(
656        feature = "coreml",
657        target_vendor = "apple",
658        not(target_os = "watchos")
659    ))]
660    {
661        let kind = op.kind();
662        if rlx_coreml::SUPPORTED_OPS.contains(&kind) {
663            return true;
664        }
665        #[cfg(feature = "training")]
666        if rlx_coreml::BACKWARD_OPS.contains(&kind)
667            || rlx_coreml::NATIVE_BACKWARD_OPS.contains(&kind)
668        {
669            return true;
670        }
671        false
672    }
673    #[cfg(not(all(
674        feature = "coreml",
675        target_vendor = "apple",
676        not(target_os = "watchos")
677    )))]
678    {
679        let _ = op;
680        false
681    }
682}
683
684#[allow(unused_variables)]
685fn gpu_family_supports(device: Device, op: &Op) -> bool {
686    // These used to answer `true` for everything, on the grounds that CUDA /
687    // ROCm / wgpu cover the same IR surface as CPU for vision workloads. That
688    // makes `supports` useless for deciding anything: `Op::GatedDeltaNetBackward`
689    // is claimed by neither CUDA nor ROCm, and a caller that trusted the `true`
690    // got `backend "cuda" doesn't claim support for 1 op kind(s)` at compile
691    // time instead of a chance to pick a fallback.
692    //
693    // The per-backend `SUPPORTED_OPS` lists are authoritative — they are what
694    // `legalize_or_rewrite_for_backend` and the compiler's own check consult —
695    // so answer from them. A backend whose feature is off keeps the permissive
696    // answer, since there is no list to consult and nothing will dispatch there.
697    let kind = op.kind();
698    let _ = kind;
699    match device {
700        #[cfg(feature = "cuda")]
701        Device::Cuda => rlx_cuda::SUPPORTED_OPS.contains(&kind),
702        #[cfg(feature = "rocm")]
703        Device::Rocm => rlx_rocm::SUPPORTED_OPS.contains(&kind),
704        #[cfg(feature = "gpu")]
705        Device::Gpu => rlx_wgpu::SUPPORTED_OPS.contains(&kind),
706        _ => true,
707    }
708}
709
710/// Block until `device`'s queue is idle. Metal drains the global queue;
711/// other backends are no-ops.
712pub fn drain_device(device: Device) {
713    #[cfg(all(target_vendor = "apple", not(target_os = "watchos"), feature = "metal"))]
714    {
715        if device == Device::Metal {
716            rlx_metal::device::drain_command_queue();
717        }
718    }
719    #[cfg(not(all(target_vendor = "apple", not(target_os = "watchos"), feature = "metal")))]
720    let _ = device;
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use rlx_ir::op::{Activation, BinaryOp};
727    use rlx_ir::{DType, Graph, Shape};
728
729    fn scalar_shape() -> Shape {
730        Shape::new(&[1], DType::F32)
731    }
732
733    #[test]
734    fn cpu_supports_everything_built_in() {
735        assert!(supports(Device::Cpu, &Op::Activation(Activation::Sin)));
736        assert!(supports(Device::Cpu, &Op::Activation(Activation::Cos)));
737        assert!(supports(Device::Cpu, &Op::Activation(Activation::Exp)));
738        assert!(supports(Device::Cpu, &Op::Binary(BinaryOp::Add)));
739    }
740
741    #[test]
742    fn unbuilt_device_supports_nothing() {
743        // OpenGl isn't a workspace feature; should report false.
744        assert!(!supports(Device::OpenGl, &Op::Activation(Activation::Relu)));
745    }
746
747    #[test]
748    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
749    fn metal_supports_full_activation_set() {
750        // After the {sin,cos,tan,atan}_inplace MSL kernels landed in
751        // rlx-metal/src/kernels.rs, Metal has every Activation variant
752        // rlx-eda exercises.
753        for act in [
754            Activation::Sin,
755            Activation::Cos,
756            Activation::Tan,
757            Activation::Atan,
758            Activation::Recip,
759            Activation::Exp,
760        ] {
761            assert!(
762                supports(Device::Metal, &Op::Activation(act)),
763                "Metal should support Activation::{act:?}"
764            );
765        }
766    }
767
768    #[test]
769    fn graph_walk_reports_first_blocker() {
770        let mut g = Graph::new("walk");
771        let s = scalar_shape();
772        let x = g.input("x", s.clone());
773        let _e = g.activation(Activation::Exp, x, s.clone());
774        let _sin = g.activation(Activation::Sin, x, s);
775        // CPU always supports.
776        assert!(supports_graph(Device::Cpu, &g));
777        assert!(first_unsupported_op(Device::Cpu, &g).is_none());
778    }
779
780    #[test]
781    fn fastest_device_returns_cpu_when_only_cpu_is_available() {
782        let pick = fastest_device();
783        assert!(is_available(pick));
784        assert_eq!(pick, fastest_among(&available_devices()));
785    }
786
787    #[test]
788    fn fastest_among_respects_priority_order() {
789        let pick = fastest_among(&[Device::Cpu, Device::Metal, Device::Mlx]);
790        assert_eq!(pick, Device::Mlx);
791    }
792
793    #[test]
794    fn devices_for_is_subset_of_available() {
795        let mut g = Graph::new("id");
796        let x = g.input("x", scalar_shape());
797        g.set_outputs(vec![x]);
798        for d in devices_for(&g) {
799            assert!(is_available(d));
800            assert!(supports_graph(d, &g));
801        }
802    }
803}