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