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