Skip to main content

rlx_runtime/
device_policy.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//! Backend allowlists, env-driven defaults, and selection introspection.
6
7use rlx_driver::Device;
8use rlx_ir::Graph;
9
10use crate::cost::fastest_device_for_with_policy;
11use crate::device_ext::{DEVICE_PRIORITY, is_available, supports_graph};
12use crate::device_parse::{device_label, parse_device, parse_device_list};
13use crate::registry::backend_for;
14
15/// How `GraphDevices::resolve_with_inputs` picks a backend when no hint is set.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum DevicePickStrategy {
18    /// Rank via calibrated cost models + platform priority (default).
19    #[default]
20    CostModel,
21    /// Run a short [`crate::benchmark_devices`] once and cache the winner.
22    Benchmark { runs: usize },
23}
24
25/// Which backends a process may use — intersected with compile-time features
26/// and runtime availability.
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct DevicePolicy {
29    allow: Option<Vec<Device>>,
30    deny: Vec<Device>,
31    prefer: Vec<Device>,
32    pick: DevicePickStrategy,
33}
34
35impl DevicePolicy {
36    /// Allow every compiled-in backend (default).
37    pub fn all() -> Self {
38        Self::default()
39    }
40
41    /// Restrict to an explicit backend set the developer ships.
42    pub fn only(devices: impl IntoIterator<Item = Device>) -> Self {
43        Self {
44            allow: Some(devices.into_iter().collect()),
45            ..Self::default()
46        }
47    }
48
49    /// Exclude specific backends from consideration.
50    pub fn with_deny(mut self, devices: impl IntoIterator<Item = Device>) -> Self {
51        self.deny.extend(devices);
52        self
53    }
54
55    /// Prefer these backends when cost models tie or are unavailable.
56    pub fn with_prefer(mut self, devices: impl IntoIterator<Item = Device>) -> Self {
57        self.prefer.extend(devices);
58        self
59    }
60
61    /// Pick the fastest backend via a one-time micro-benchmark (needs inputs at resolve time).
62    pub fn with_benchmark_pick(mut self, runs: usize) -> Self {
63        self.pick = DevicePickStrategy::Benchmark { runs: runs.max(1) };
64        self
65    }
66
67    pub fn pick_strategy(&self) -> DevicePickStrategy {
68        self.pick
69    }
70
71    /// Read policy from process env (see [`Self::from_env_key`]).
72    pub fn from_env() -> Self {
73        Self::from_env_key("RLX")
74    }
75
76    /// Read `PREFIX_DEVICES`, `PREFIX_DENY_DEVICES`, `PREFIX_PREFER_DEVICES`.
77    pub fn from_env_key(prefix: &str) -> Self {
78        let mut policy = Self::default();
79        let devices_key = format!("{prefix}_DEVICES");
80        let deny_key = format!("{prefix}_DENY_DEVICES");
81        let prefer_key = format!("{prefix}_PREFER_DEVICES");
82
83        if let Some(raw) = rlx_ir::env::var(&devices_key) {
84            if let Ok(list) = parse_device_list(&raw) {
85                policy.allow = Some(list);
86            }
87        }
88        if let Some(raw) = rlx_ir::env::var(&deny_key) {
89            if let Ok(list) = parse_device_list(&raw) {
90                policy.deny = list;
91            }
92        }
93        if let Some(raw) = rlx_ir::env::var(&prefer_key) {
94            if let Ok(list) = parse_device_list(&raw) {
95                policy.prefer = list;
96            }
97        }
98        let bench_key = format!("{prefix}_BENCHMARK_PICK");
99        if let Some(raw) = rlx_ir::env::var(&bench_key) {
100            if let Ok(runs) = raw.trim().parse::<usize>() {
101                policy.pick = DevicePickStrategy::Benchmark { runs: runs.max(1) };
102            }
103        }
104        policy
105    }
106
107    /// Devices to show in reports when no allow-list is set.
108    pub fn probe_set(&self) -> Vec<Device> {
109        self.allow.clone().unwrap_or_else(|| Device::all().to_vec())
110    }
111
112    /// Filter and order `candidates` according to this policy.
113    pub fn apply(&self, mut candidates: Vec<Device>) -> Vec<Device> {
114        if let Some(allow) = &self.allow {
115            candidates.retain(|d| allow.contains(d));
116        }
117        candidates.retain(|d| !self.deny.contains(d));
118        candidates.sort_by_key(|d| self.rank_key(*d));
119        candidates
120    }
121
122    fn rank_key(&self, device: Device) -> (u8, u8) {
123        let prefer = self
124            .prefer
125            .iter()
126            .position(|d| *d == device)
127            .map(|i| i as u8)
128            .unwrap_or(u8::MAX);
129        let platform = DEVICE_PRIORITY
130            .iter()
131            .position(|d| *d == device)
132            .map(|i| i as u8)
133            .unwrap_or(u8::MAX);
134        (prefer, platform)
135    }
136}
137
138/// Default device hint from `RLX_DEVICE` (or `PREFIX_DEVICE` via [`device_from_env_key`]).
139pub fn device_from_env() -> Option<Device> {
140    device_from_env_key("RLX")
141}
142
143/// Read `PREFIX_DEVICE` env var.
144pub fn device_from_env_key(prefix: &str) -> Option<Device> {
145    let key = format!("{prefix}_DEVICE");
146    rlx_ir::env::var(&key).and_then(|raw| parse_device(&raw).ok())
147}
148
149/// Backends on this host that can lower `graph`, filtered by `policy`.
150pub fn devices_for_with_policy(graph: &Graph, policy: &DevicePolicy) -> Vec<Device> {
151    policy.apply(
152        crate::available_devices()
153            .into_iter()
154            .filter(|d| supports_graph(*d, graph))
155            .collect(),
156    )
157}
158
159/// One row of backend introspection for UIs and logs.
160#[derive(Debug, Clone, PartialEq)]
161pub struct DeviceCandidate {
162    pub device: Device,
163    pub label: &'static str,
164    pub available: bool,
165    pub registered: bool,
166    pub supports_graph: bool,
167    pub recommended: bool,
168    pub blocker: Option<String>,
169    /// Advisory feature names this device's executables typically expose
170    /// (see [`crate::ExecutableCapabilities`]). Confirmed after compile via
171    /// [`crate::ExecutableGraph::capabilities`].
172    pub capabilities: Vec<&'static str>,
173}
174
175/// Advisory capability bits matching what each backend's
176/// [`crate::ExecutableGraph::capabilities`] override usually reports.
177pub fn advisory_capabilities(device: Device) -> crate::ExecutableCapabilities {
178    use crate::ExecutableCapabilities as C;
179    match device {
180        Device::Cpu => C {
181            clone: true,
182            moe: true,
183            persistent_handles: true,
184            typed_io: true,
185            active_extent: true,
186            ..C::NONE
187        },
188        Device::Metal => C {
189            clone: true,
190            gpu_handles: true,
191            kv_resident: true,
192            typed_io: true,
193            async_pipeline: true,
194            active_extent: true,
195            ..C::NONE
196        },
197        Device::Mlx => C {
198            clone: true,
199            persistent_handles: true,
200            gpu_handles: true,
201            kv_resident: true,
202            typed_io: true,
203            async_pipeline: true,
204            active_extent: true,
205            ..C::NONE
206        },
207        Device::Ane => C {
208            clone: true,
209            typed_io: true,
210            ..C::NONE
211        },
212        Device::Cuda => C {
213            clone: true,
214            moe: true,
215            gpu_handles: true,
216            kv_resident: true,
217            typed_io: true,
218            active_extent: true,
219            ..C::NONE
220        },
221        Device::Rocm => C {
222            clone: true,
223            moe: true,
224            gpu_handles: true,
225            kv_resident: true,
226            typed_io: true,
227            active_extent: true,
228            ..C::NONE
229        },
230        Device::Gpu | Device::WebGpu => C {
231            clone: true,
232            gpu_handles: true,
233            kv_resident: true,
234            typed_io: true,
235            active_extent: true,
236            ..C::NONE
237        },
238        Device::Vulkan => C {
239            clone: true,
240            gpu_handles: true,
241            kv_resident: true,
242            typed_io: true,
243            active_extent: true,
244            ..C::NONE
245        },
246        Device::OneApi => C {
247            clone: true,
248            typed_io: true,
249            ..C::NONE
250        },
251        Device::Tpu => C {
252            clone: true,
253            typed_io: true,
254            ..C::NONE
255        },
256        Device::OpenGl | Device::DirectX => C {
257            typed_io: true,
258            ..C::NONE
259        },
260        // NPUs with no host-resident buffer/handle model in rlx (Hexagon via
261        // QNN; XDNA has no executor yet), and the eGPU, whose device bring-up
262        // is not implemented.
263        Device::Hexagon | Device::Xdna | Device::Egpu => C::NONE,
264    }
265}
266
267/// Explain which backends are viable for `graph` under `policy`.
268pub fn device_report(graph: &Graph, policy: &DevicePolicy) -> Vec<DeviceCandidate> {
269    let recommended = fastest_device_for_with_policy(graph, policy);
270    policy
271        .probe_set()
272        .into_iter()
273        .map(|device| {
274            let available = is_available(device);
275            let registered = backend_for(device).is_some();
276            let supports = available && supports_graph(device, graph);
277            let blocker = if !available {
278                Some("not available on this host or in this build".into())
279            } else if !supports {
280                crate::first_unsupported_op(device, graph)
281                    .map(|(idx, op)| format!("unsupported op at node {idx}: {op:?}"))
282            } else if policy.deny.contains(&device) {
283                Some("denied by DevicePolicy".into())
284            } else if policy
285                .allow
286                .as_ref()
287                .is_some_and(|allow| !allow.contains(&device))
288            {
289                Some("not in DevicePolicy allow-list".into())
290            } else {
291                None
292            };
293            DeviceCandidate {
294                device,
295                label: device_label(device),
296                available,
297                registered,
298                supports_graph: supports,
299                recommended: device == recommended,
300                blocker,
301                capabilities: advisory_capabilities(device).enabled_names(),
302            }
303        })
304        .collect()
305}
306
307/// Resolve the backend to use: explicit hint → env → fastest for `graph`.
308pub fn resolve_device(
309    graph: &Graph,
310    hint: Option<Device>,
311    policy: &DevicePolicy,
312) -> Result<Device, String> {
313    let candidates = devices_for_with_policy(graph, policy);
314    if candidates.is_empty() {
315        return Err(
316            "no backend can lower this graph under the current policy — \
317             widen DevicePolicy or enable additional Cargo features"
318                .into(),
319        );
320    }
321
322    if let Some(device) = hint {
323        return pick_from_candidates(device, &candidates, "hint");
324    }
325    if let Some(device) = device_from_env() {
326        if let Ok(device) = pick_from_candidates(device, &candidates, "RLX_DEVICE") {
327            return Ok(device);
328        }
329    }
330    Ok(fastest_device_for_with_policy(graph, policy))
331}
332
333fn pick_from_candidates(
334    device: Device,
335    candidates: &[Device],
336    source: &str,
337) -> Result<Device, String> {
338    if candidates.contains(&device) {
339        return Ok(device);
340    }
341    Err(format!(
342        "{source} requested {device} but viable backends are [{}]",
343        candidates
344            .iter()
345            .map(|d| device_label(*d))
346            .collect::<Vec<_>>()
347            .join(", ")
348    ))
349}
350
351/// Ordered fallback chain from `RLX_DEVICE_CHAIN` (`cuda,gpu,cpu`).
352pub fn device_chain_from_env() -> Vec<Device> {
353    device_chain_from_env_key("RLX")
354}
355
356/// Read `PREFIX_DEVICE_CHAIN`.
357pub fn device_chain_from_env_key(prefix: &str) -> Vec<Device> {
358    let key = format!("{prefix}_DEVICE_CHAIN");
359    rlx_ir::env::var(&key)
360        .and_then(|raw| parse_device_list(&raw).ok())
361        .unwrap_or_default()
362}
363
364/// First device in `chain` that is viable under `policy` for `graph`.
365pub fn resolve_device_chain(
366    graph: &Graph,
367    chain: &[Device],
368    policy: &DevicePolicy,
369) -> Result<Device, String> {
370    let viable = devices_for_with_policy(graph, policy);
371    for &device in chain {
372        if viable.contains(&device) {
373            return Ok(device);
374        }
375    }
376    Err(format!(
377        "no device in chain [{}] can run this graph — viable: [{}]",
378        chain
379            .iter()
380            .map(|d| device_label(*d))
381            .collect::<Vec<_>>()
382            .join(", "),
383        viable
384            .iter()
385            .map(|d| device_label(*d))
386            .collect::<Vec<_>>()
387            .join(", ")
388    ))
389}
390
391/// Errors collected when every backend in the chain fails at run time.
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct DeviceFallbackError {
394    pub attempts: Vec<(Device, String)>,
395}
396
397impl std::fmt::Display for DeviceFallbackError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        write!(f, "all backends failed:")?;
400        for (d, e) in &self.attempts {
401            write!(f, "\n  {}: {e}", device_label(*d))?;
402        }
403        Ok(())
404    }
405}
406
407impl std::error::Error for DeviceFallbackError {}
408
409impl From<String> for DeviceFallbackError {
410    fn from(msg: String) -> Self {
411        Self {
412            attempts: vec![(Device::Cpu, msg)],
413        }
414    }
415}
416
417/// Try `chain` in order; return the first successful result from `run`.
418pub fn run_with_fallback<T, F>(
419    graph: &Graph,
420    policy: &DevicePolicy,
421    chain: &[Device],
422    mut run: F,
423) -> Result<(Device, T), DeviceFallbackError>
424where
425    F: FnMut(Device) -> Result<T, String>,
426{
427    let viable = devices_for_with_policy(graph, policy);
428    let mut attempts = Vec::new();
429    for &device in chain {
430        if !viable.contains(&device) {
431            attempts.push((device, "not viable for this graph under policy".into()));
432            continue;
433        }
434        match run(device) {
435            Ok(value) => return Ok((device, value)),
436            Err(err) => attempts.push((device, err)),
437        }
438    }
439    if attempts.is_empty() {
440        attempts.push((Device::Cpu, "empty fallback chain".into()));
441    }
442    Err(DeviceFallbackError { attempts })
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use rlx_ir::{DType, Shape};
449
450    fn tiny_graph() -> Graph {
451        let mut g = Graph::new("tiny");
452        let x = g.input("x", Shape::new(&[2], DType::F32));
453        g.set_outputs(vec![x]);
454        g
455    }
456
457    #[test]
458    fn only_policy_restricts_devices_for() {
459        let g = tiny_graph();
460        let all = devices_for_with_policy(&g, &DevicePolicy::default());
461        let cpu_only = devices_for_with_policy(&g, &DevicePolicy::only([Device::Cpu]));
462        assert_eq!(cpu_only, vec![Device::Cpu]);
463        assert!(all.contains(&Device::Cpu));
464    }
465
466    #[test]
467    fn resolve_honors_hint_then_env() {
468        let g = tiny_graph();
469        let policy = DevicePolicy::only([Device::Cpu]);
470        assert_eq!(
471            resolve_device(&g, Some(Device::Cpu), &policy).unwrap(),
472            Device::Cpu
473        );
474
475        rlx_ir::env::set("RLX_DEVICE", "cpu");
476        assert_eq!(resolve_device(&g, None, &policy).unwrap(), Device::Cpu);
477        rlx_ir::env::unset("RLX_DEVICE");
478    }
479
480    #[test]
481    fn device_report_marks_recommended() {
482        let g = tiny_graph();
483        let policy = DevicePolicy::only([Device::Cpu]);
484        let rows = device_report(&g, &policy);
485        assert_eq!(rows.len(), 1);
486        assert!(rows[0].recommended);
487        assert!(rows[0].supports_graph);
488        assert!(
489            rows[0].capabilities.contains(&"clone"),
490            "cpu advisory caps: {:?}",
491            rows[0].capabilities
492        );
493    }
494}