Skip to main content

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