Skip to main content

rlx_runtime/
device_ext.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Engine-layer extensions for [`rlx_driver::Device`] (plan #58).
17//!
18//! `is_available` and `available_devices` consult the runtime's
19//! backend registry + Cargo features, both of which are
20//! engine-layer concerns. Keeping them here preserves the
21//! one-way dep direction (driver doesn't know about engine).
22
23use rlx_driver::Device;
24use rlx_ir::{Graph, Op};
25
26use crate::CompileOptions;
27
28/// Preferred probe order for ML workloads (highest throughput first).
29///
30/// Used by [`fastest_device`] and by [`crate::cost::fastest_device_for`] when
31/// calibrated cost models are unavailable for every candidate backend.
32pub(crate) const DEVICE_PRIORITY: &[Device] = &[
33    Device::Tpu,
34    Device::Cuda,
35    Device::Rocm,
36    Device::OneApi,
37    Device::Mlx,
38    Device::Metal,
39    Device::Ane,
40    Device::Hexagon,
41    Device::Gpu,
42    Device::Vulkan,
43    Device::DirectX,
44    Device::OpenGl,
45    Device::WebGpu,
46    Device::Cpu,
47];
48
49/// Check whether `device` has a compiled-in backend or has been
50/// registered by an external crate.
51///
52/// GPU-family builtins (CUDA / ROCm / wgpu / TPU) additionally probe
53/// for a live driver or adapter at runtime so CI hosts that compile
54/// with `--features cuda` but have no NVIDIA stack don't report
55/// false positives. Other devices are Cargo-feature-gated; externally
56/// registered backends are discovered via the registry.
57/// Whether [`crate::CompiledGraph::run_slots`] + [`crate::CompiledGraph::arena_ptr`]
58/// are implemented (host readback layout; not a GPU-mapped arena on CUDA).
59pub fn supports_run_slots(device: Device) -> bool {
60    matches!(
61        device,
62        Device::Cpu | Device::Metal | Device::Mlx | Device::Cuda | Device::Rocm
63    )
64}
65
66/// Whether `device`'s RoPE kernel indexes its cos/sin table **per token**
67/// (one row per batch·seq element) instead of per sequence position.
68///
69/// Required for *ragged* batched decode, where each sequence in the batch sits
70/// at a different absolute position and so needs its own RoPE row.
71///
72/// Validated against the CPU reference on:
73///   - **CPU** (`rlx-cpu` executor + thunk), and
74///   - **Metal** (`cos_per_token` kernel path; `metal_rope_parity` ragged test).
75///
76/// The remaining GPU kernels still index by seq position (CUDA `rope.cu`, wgpu
77/// `rope.wgsl`), which collapses for decode (seq = 1) and would apply row 0's
78/// position to the whole batch — so callers (e.g. the server's fused batcher)
79/// fall back to per-length **uniform** grouping there. Add a device here only
80/// once its rope + rope_backward kernels are fixed *and* validated against the
81/// CPU reference.
82pub fn supports_ragged_rope(device: Device) -> bool {
83    matches!(device, Device::Cpu | Device::Metal)
84}
85
86pub fn is_available(device: Device) -> bool {
87    #[cfg(feature = "cuda")]
88    if device == Device::Cuda {
89        return rlx_cuda::is_available();
90    }
91    #[cfg(feature = "rocm")]
92    if device == Device::Rocm {
93        return rlx_rocm::is_available();
94    }
95    #[cfg(feature = "gpu")]
96    if device == Device::Gpu {
97        return rlx_wgpu::is_available();
98    }
99    #[cfg(feature = "vulkan")]
100    if device == Device::Vulkan {
101        return rlx_vulkan::is_available();
102    }
103    #[cfg(feature = "oneapi")]
104    if device == Device::OneApi {
105        return rlx_oneapi::is_available();
106    }
107    #[cfg(feature = "tpu")]
108    if device == Device::Tpu {
109        return rlx_tpu::is_available();
110    }
111    // Metal / MLX probe the live device, not just the Cargo cfg: a build can
112    // be Metal-capable yet run where no Metal device exists (e.g. a headless
113    // iOS-simulator process), and runtime selection must not pick a dead
114    // backend.
115    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
116    if device == Device::Metal {
117        return rlx_metal::is_available();
118    }
119    #[cfg(all(feature = "mlx", rlx_mlx_host))]
120    if device == Device::Mlx {
121        return rlx_mlx::is_available();
122    }
123
124    let feature_gated = match device {
125        Device::Cpu => cfg!(feature = "cpu"),
126        Device::Metal => cfg!(all(
127            feature = "metal",
128            target_vendor = "apple",
129            not(target_os = "watchos")
130        )),
131        Device::Mlx => cfg!(feature = "mlx"),
132        Device::Ane => cfg!(any(feature = "coreml", feature = "ane")),
133        Device::Cuda => cfg!(feature = "cuda"),
134        Device::Rocm => cfg!(feature = "rocm"),
135        Device::OneApi => cfg!(feature = "oneapi"),
136        Device::Tpu => cfg!(feature = "tpu"),
137        Device::Hexagon => cfg!(feature = "qnn"),
138        Device::Gpu => cfg!(feature = "gpu"),
139        Device::Vulkan => cfg!(feature = "vulkan"),
140        Device::OpenGl => cfg!(feature = "opengl"),
141        Device::DirectX => cfg!(feature = "directx"),
142        Device::WebGpu => cfg!(feature = "webgpu"),
143    };
144    if feature_gated {
145        return true;
146    }
147    crate::registry::registered_devices().contains(&device)
148}
149
150/// Apple backends enabled in this build (`metal`, `mlx`, `gpu`, `ane`) on
151/// any Apple platform. `is_available` filters per-device, so e.g. watchOS
152/// (no Metal) yields just the CoreML/ANE entry.
153#[cfg(all(feature = "apple", target_vendor = "apple"))]
154pub fn available_apple_devices() -> Vec<Device> {
155    [Device::Metal, Device::Mlx, Device::Gpu, Device::Ane]
156        .into_iter()
157        .filter(|d| is_available(*d))
158        .collect()
159}
160
161/// Every variant currently available — Cargo-feature-gated or
162/// runtime-registered.
163pub fn available_devices() -> Vec<Device> {
164    Device::all()
165        .iter()
166        .copied()
167        .filter(|d| is_available(*d))
168        .collect()
169}
170
171/// Intersection of [`available_devices`] and [`supports_graph`]. Use with
172/// [`crate::GraphDevices`] or [`crate::DevicePolicy`] to restrict the set.
173pub fn devices_for(graph: &Graph) -> Vec<Device> {
174    crate::device_policy::devices_for_with_policy(graph, &crate::DevicePolicy::default())
175}
176
177/// Highest-priority backend that is compiled in and live on this host.
178///
179/// Probes [`DEVICE_PRIORITY`] in order (TPU → CUDA → ROCm → MLX → Metal → …
180/// → CPU). Use this when you want a sensible default `Session` target without
181/// building a graph first. For workload-specific selection, prefer
182/// [`crate::cost::fastest_device_for`].
183pub fn fastest_device() -> Device {
184    fastest_among(&available_devices())
185}
186
187/// Pick the highest-priority entry from `candidates` (see [`DEVICE_PRIORITY`]).
188pub fn fastest_among(candidates: &[Device]) -> Device {
189    for &d in DEVICE_PRIORITY {
190        if candidates.contains(&d) {
191            return d;
192        }
193    }
194    candidates.first().copied().unwrap_or(Device::Cpu)
195}
196
197/// Pretty name with engine-known BLAS variant for the CPU device.
198/// Gives `"CPU (Accelerate)"` etc. when the relevant feature is
199/// on; falls back to the bare driver-side `Device::name()` when
200/// no BLAS feature is selected.
201pub fn full_name(device: Device) -> &'static str {
202    if let Device::Cpu = device {
203        if cfg!(feature = "blas-accelerate") {
204            return "CPU (Accelerate)";
205        }
206        if cfg!(feature = "blas-mkl") {
207            return "CPU (MKL)";
208        }
209        if cfg!(feature = "blas-openblas") {
210            return "CPU (OpenBLAS)";
211        }
212    }
213    device.name()
214}
215
216// ── Per-device op-support introspection ──────────────────────────
217//
218// Callers that want to dispatch graphs to a particular device need
219// to know up front whether the device's backend has every op the
220// graph uses wired up. Before this API, the only signal was a
221// runtime panic ("not yet implemented"), which forced downstream
222// crates (e.g. `eda-magnetics::graph::pick_device_for`) to bake
223// hand-maintained "what's missing on X" tables into their own
224// source — those drift the moment a backend lands the missing op.
225//
226// [`supports`] consults the backend-side knowledge (CPU is the
227// reference and assumed complete; MLX / Metal each name the ops
228// they don't yet lower) so consumers can ask once and stop
229// re-implementing the table.
230
231/// Is `op` lowerable by the backend for `device` *in this build*?
232///
233/// - CPU is the reference; always returns `true`.
234/// - GPU backends return `false` only for the specific ops/variants
235///   their lowering currently rejects. As backends close gaps, the
236///   matches here shrink and consumers automatically pick them up.
237/// - For devices not feature-gated in, returns `false` (you can't
238///   dispatch to a backend that isn't compiled in regardless).
239pub fn supports(device: Device, op: &Op) -> bool {
240    if !is_available(device) {
241        return false;
242    }
243    match device {
244        Device::Cpu => true, // reference backend; ground truth
245        Device::Mlx => mlx_supports(op),
246        Device::Metal => metal_supports(op),
247        Device::Ane => coreml_supports(op),
248        Device::Gpu | Device::Cuda | Device::Rocm => gpu_family_supports(op),
249        #[cfg(feature = "vulkan")]
250        Device::Vulkan => vulkan_supports(op),
251        #[cfg(feature = "oneapi")]
252        Device::OneApi => oneapi_supports(op),
253        Device::Hexagon => qnn_supports(op),
254        // Other backends not yet characterised here. Conservative:
255        // assume `false` so callers won't dispatch blind; tighten as
256        // each backend grows a `<x>_supports` arm below.
257        _ => false,
258    }
259}
260
261/// Per-op support for the QNN (Hexagon NPU) backend — the ops the FFI runtime
262/// (`rlx_qnn::runtime`) lowers to QNN: MatMul, element-wise binary, a few
263/// activations, plus the structural input/param/constant tensors.
264fn qnn_supports(op: &Op) -> bool {
265    use rlx_ir::op::Activation;
266    match op {
267        Op::Input { .. }
268        | Op::Param { .. }
269        | Op::Constant { .. }
270        | Op::MatMul
271        | Op::Binary(_)
272        | Op::Softmax { .. }
273        | Op::Reshape { .. }
274        | Op::Transpose { .. }
275        | Op::LayerNorm { .. }
276        | Op::RmsNorm { .. }
277        | Op::Concat { .. }
278        | Op::Narrow { .. }
279        | Op::Rope { .. }
280        | Op::Attention { .. }
281        | Op::Reduce { .. }
282        | Op::Conv { .. }
283        | Op::Gather { .. }
284        | Op::Quantize { .. }
285        | Op::Dequantize { .. } => true,
286        Op::Activation(a) => matches!(
287            a,
288            Activation::Relu
289                | Activation::Gelu
290                | Activation::Sigmoid
291                | Activation::Tanh
292                | Activation::Neg
293        ),
294        _ => false,
295    }
296}
297
298/// Per-op heuristic for the native Vulkan backend: native primitive set plus
299/// the op families `legalize_or_rewrite_for_backend` decomposes into it. The
300/// authoritative check is `supports_graph` (runs the real legalize probe);
301/// this is the cheap single-op approximation.
302#[cfg(feature = "vulkan")]
303fn vulkan_supports(op: &Op) -> bool {
304    use rlx_ir::OpKind::*;
305    let k = op.kind();
306    rlx_vulkan::backend::SUPPORTED_OPS.contains(&k)
307        || matches!(
308            k,
309            // Decomposed to the primitive set by the rewrite pass.
310            DotGeneral
311                | Fma
312                | GroupNorm
313                | BatchNormInference
314                | ResizeNearest2x
315                | ElementwiseRegion
316                | FusedMatMulBiasAct
317                | FusedResidualLN
318                | FusedResidualRmsNorm
319                | FusedSwiGLU
320                | FusedAttentionBlock
321                | FusedTransformerLayer
322        )
323}
324
325/// Per-op heuristic for the native oneAPI (Level Zero) backend — the same
326/// primitive claim set as rlx-vulkan (the rewrite pass decomposes the rest).
327/// The authoritative check remains `supports_graph` (real legalize probe).
328#[cfg(feature = "oneapi")]
329fn oneapi_supports(op: &Op) -> bool {
330    use rlx_ir::OpKind::*;
331    let k = op.kind();
332    rlx_oneapi::backend::SUPPORTED_OPS.contains(&k)
333        || matches!(
334            k,
335            DotGeneral
336                | Fma
337                | GroupNorm
338                | BatchNormInference
339                | ResizeNearest2x
340                | ElementwiseRegion
341                | FusedMatMulBiasAct
342                | FusedResidualLN
343                | FusedResidualRmsNorm
344                | FusedSwiGLU
345                | FusedAttentionBlock
346                | FusedTransformerLayer
347        )
348}
349
350/// Is every op in `graph` lowerable by `device`?
351///
352/// When a backend is registered, uses the same rewrite + legalization probe as
353/// [`legalize_graph_for_device`] (see [`KernelDispatchReport::compile_ready`]).
354/// Otherwise falls back to per-op [`supports`] heuristics.
355pub fn supports_graph(device: Device, graph: &Graph) -> bool {
356    supports_graph_with_options(device, graph, &CompileOptions::default())
357}
358
359/// Like [`supports_graph`] with explicit [`CompileOptions::kernel_dispatch`].
360pub fn supports_graph_with_options(
361    device: Device,
362    graph: &Graph,
363    options: &CompileOptions,
364) -> bool {
365    if !is_available(device) {
366        return false;
367    }
368    if let Some(backend) = crate::registry::backend_for(device) {
369        let (_, report) = rlx_opt::prepare_graph_for_backend_with_report(
370            graph.clone(),
371            device.name(),
372            backend.supported_ops(),
373            options.kernel_dispatch,
374        );
375        return report.compile_ready;
376    }
377    graph.nodes().iter().all(|n| supports(device, &n.op))
378}
379
380/// Legalize `graph` for `device` using that backend's claimed [`OpKind`] set.
381///
382/// Applies the same rewrite + legalization path as [`Backend::compile`] (e.g.
383/// CUDA/ROCm rewrites before the legality check). Returns an error when the
384/// backend feature is not enabled or the graph contains unsupported ops.
385///
386/// Does not require a live GPU/TPU driver — only that the backend crate is
387/// compiled in.
388pub fn legalize_graph_for_device(graph: Graph, device: Device) -> Result<Graph, String> {
389    let (graph, _report) = legalize_graph_for_device_with_report(graph, device)?;
390    Ok(graph)
391}
392
393/// Like [`legalize_graph_for_device`] but returns a [`KernelDispatchReport`] for tooling.
394pub fn legalize_graph_for_device_with_report(
395    graph: Graph,
396    device: Device,
397) -> Result<(Graph, rlx_opt::KernelDispatchReport), String> {
398    legalize_graph_for_device_with_options(graph, device, &CompileOptions::default())
399}
400
401/// Like [`legalize_graph_for_device_with_report`] using [`CompileOptions::kernel_dispatch`]
402/// (and the same rewrite path as [`Backend::compile`]).
403pub fn legalize_graph_for_device_with_options(
404    graph: Graph,
405    device: Device,
406    options: &CompileOptions,
407) -> Result<(Graph, rlx_opt::KernelDispatchReport), String> {
408    let backend = crate::registry::backend_for(device).ok_or_else(|| {
409        format!(
410            "no backend registered for {device:?} — enable the matching \
411             `rlx-runtime` Cargo feature (e.g. `metal`, `gpu`, `cuda`)"
412        )
413    })?;
414    let ops = backend.supported_ops();
415    let (graph, report) = rlx_opt::prepare_graph_for_backend_with_report(
416        graph,
417        device.name(),
418        ops,
419        options.kernel_dispatch,
420    );
421    if !report.compile_ready {
422        return Err(format!(
423            "{}\n{}",
424            rlx_opt::format_legalize_error(device.name(), &report.still_unsupported),
425            rlx_opt::format_dispatch_report(&report)
426        ));
427    }
428    Ok((graph, report))
429}
430
431/// Dispatch report for `graph` on `device` without mutating the graph (static common-ir probe).
432pub fn dispatch_report_for_device(
433    graph: &Graph,
434    device: Device,
435) -> Result<rlx_opt::KernelDispatchReport, String> {
436    dispatch_report_for_device_with_options(graph, device, &CompileOptions::default())
437}
438
439/// Like [`dispatch_report_for_device`] with explicit [`CompileOptions::kernel_dispatch`].
440pub fn dispatch_report_for_device_with_options(
441    graph: &Graph,
442    device: Device,
443    options: &CompileOptions,
444) -> Result<rlx_opt::KernelDispatchReport, String> {
445    let backend = crate::registry::backend_for(device)
446        .ok_or_else(|| format!("no backend registered for {device:?}"))?;
447    Ok(rlx_opt::analyze_dispatch(
448        graph,
449        device.name(),
450        backend.supported_ops(),
451        options.kernel_dispatch,
452    ))
453}
454
455/// First op in `graph` that `device` cannot lower after rewrite, or `None`.
456///
457/// Prefer the backend claim-set probe when registered; otherwise [`supports`].
458pub fn first_unsupported_op(device: Device, graph: &Graph) -> Option<(usize, &Op)> {
459    first_unsupported_op_with_options(device, graph, &CompileOptions::default())
460}
461
462/// Like [`first_unsupported_op`] with explicit [`CompileOptions::kernel_dispatch`].
463pub fn first_unsupported_op_with_options<'a>(
464    device: Device,
465    graph: &'a Graph,
466    options: &CompileOptions,
467) -> Option<(usize, &'a Op)> {
468    if !is_available(device) {
469        return graph.nodes().first().map(|n| (0, &n.op));
470    }
471    if let Some(backend) = crate::registry::backend_for(device) {
472        let (_, report) = rlx_opt::prepare_graph_for_backend_with_report(
473            graph.clone(),
474            device.name(),
475            backend.supported_ops(),
476            options.kernel_dispatch,
477        );
478        if let Some((id, kind)) = report.still_unsupported.first() {
479            let idx = graph.nodes().iter().position(|n| n.id == *id).unwrap_or(0);
480            let op = graph
481                .nodes()
482                .iter()
483                .find(|n| n.id == *id)
484                .map(|n| &n.op)
485                .unwrap_or(&graph.nodes()[0].op);
486            let _ = kind;
487            return Some((idx, op));
488        }
489        return None;
490    }
491    graph
492        .nodes()
493        .iter()
494        .enumerate()
495        .find_map(|(i, n)| (!supports(device, &n.op)).then_some((i, &n.op)))
496}
497
498#[allow(unused_variables)]
499fn mlx_supports(op: &Op) -> bool {
500    // After Sin/Cos wiring (forward + backward), MLX's `Activation`
501    // dispatch is complete for every variant in `rlx_ir::Activation`.
502    // Add narrow guards here only when a future Op or Activation
503    // variant lands without an MLX lowering.
504    true
505}
506
507#[allow(unused_variables)]
508fn metal_supports(op: &Op) -> bool {
509    // No characterized gaps for the activations rlx-eda exercises.
510    // The Sin/Cos/Tan/Atan MSL kernels landed in `rlx-metal/src/kernels.rs`
511    // (`{sin,cos,tan,atan}_inplace`) alongside the dispatch slots in
512    // `backend.rs:1764`. Narrow this back down if a future Op or
513    // Activation variant lands without a Metal kernel.
514    let _ = op;
515    true
516}
517
518/// CoreML / ANE lowers a fixed, declared op set (see `rlx_coreml::mil`).
519/// Unlike the GPU backends — whose lowering covers the whole IR surface —
520/// CoreML is an inference compiler with a finite op claim, so we check
521/// membership directly against the backend's published list.
522///
523/// Under the `training` feature the claim also covers the backward ops that the
524/// legalize/rewrite pass decomposes into supported primitives (or, once landed,
525/// lower through native MIL backward kernels) — so device selection picks
526/// `Device::Ane` for autodiff-produced backward graphs. See
527/// [`crate::backend::COREML_BACKWARD_OPS`].
528fn coreml_supports(op: &Op) -> bool {
529    let kind = op.kind();
530    if crate::backend::COREML_SUPPORTED_OPS.contains(&kind) {
531        return true;
532    }
533    #[cfg(feature = "training")]
534    if crate::backend::COREML_BACKWARD_OPS.contains(&kind)
535        || crate::backend::COREML_NATIVE_BACKWARD_OPS.contains(&kind)
536    {
537        return true;
538    }
539    false
540}
541
542#[allow(unused_variables)]
543fn gpu_family_supports(op: &Op) -> bool {
544    // CUDA / ROCm / wgpu share the same IR surface area as CPU for the
545    // ops V-JEPA2 and other vision models exercise. Narrow when a backend
546    // reports a concrete lowering gap.
547    let _ = op;
548    true
549}
550
551/// Block until `device`'s queue is idle. Metal drains the global queue;
552/// other backends are no-ops.
553pub fn drain_device(device: Device) {
554    #[cfg(all(target_vendor = "apple", not(target_os = "watchos"), feature = "metal"))]
555    {
556        if device == Device::Metal {
557            rlx_metal::device::drain_command_queue();
558        }
559    }
560    #[cfg(not(all(target_vendor = "apple", not(target_os = "watchos"), feature = "metal")))]
561    let _ = device;
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use rlx_ir::op::{Activation, BinaryOp};
568    use rlx_ir::{DType, Graph, Shape};
569
570    fn scalar_shape() -> Shape {
571        Shape::new(&[1], DType::F32)
572    }
573
574    #[test]
575    fn cpu_supports_everything_built_in() {
576        assert!(supports(Device::Cpu, &Op::Activation(Activation::Sin)));
577        assert!(supports(Device::Cpu, &Op::Activation(Activation::Cos)));
578        assert!(supports(Device::Cpu, &Op::Activation(Activation::Exp)));
579        assert!(supports(Device::Cpu, &Op::Binary(BinaryOp::Add)));
580    }
581
582    #[test]
583    fn unbuilt_device_supports_nothing() {
584        // OpenGl isn't a workspace feature; should report false.
585        assert!(!supports(Device::OpenGl, &Op::Activation(Activation::Relu)));
586    }
587
588    #[test]
589    #[cfg(all(feature = "metal", target_vendor = "apple", not(target_os = "watchos")))]
590    fn metal_supports_full_activation_set() {
591        // After the {sin,cos,tan,atan}_inplace MSL kernels landed in
592        // rlx-metal/src/kernels.rs, Metal has every Activation variant
593        // rlx-eda exercises.
594        for act in [
595            Activation::Sin,
596            Activation::Cos,
597            Activation::Tan,
598            Activation::Atan,
599            Activation::Exp,
600        ] {
601            assert!(
602                supports(Device::Metal, &Op::Activation(act)),
603                "Metal should support Activation::{act:?}"
604            );
605        }
606    }
607
608    #[test]
609    fn graph_walk_reports_first_blocker() {
610        let mut g = Graph::new("walk");
611        let s = scalar_shape();
612        let x = g.input("x", s.clone());
613        let _e = g.activation(Activation::Exp, x, s.clone());
614        let _sin = g.activation(Activation::Sin, x, s);
615        // CPU always supports.
616        assert!(supports_graph(Device::Cpu, &g));
617        assert!(first_unsupported_op(Device::Cpu, &g).is_none());
618    }
619
620    #[test]
621    fn fastest_device_returns_cpu_when_only_cpu_is_available() {
622        let pick = fastest_device();
623        assert!(is_available(pick));
624        assert_eq!(pick, fastest_among(&available_devices()));
625    }
626
627    #[test]
628    fn fastest_among_respects_priority_order() {
629        let pick = fastest_among(&[Device::Cpu, Device::Metal, Device::Mlx]);
630        assert_eq!(pick, Device::Mlx);
631    }
632
633    #[test]
634    fn devices_for_is_subset_of_available() {
635        let mut g = Graph::new("id");
636        let x = g.input("x", scalar_shape());
637        g.set_outputs(vec![x]);
638        for d in devices_for(&g) {
639            assert!(is_available(d));
640            assert!(supports_graph(d, &g));
641        }
642    }
643}