Skip to main content

qualia_core_db/net/
host_topology.rs

1//! STELLAR §A AH-track H0 — **host topology + capability sensor** (decision D23/D24).
2//!
3//! The engine must *sense then route* instead of grabbing one adapter. Today `gpu_context::
4//! shared_gpu()` requests a single `PowerPreference::HighPerformance` device — on a discrete +
5//! integrated box that silently takes only the discrete GPU and ignores the integrated GPU and all
6//! of system RAM. This module enumerates **every** adapter, classifies the memory topology
7//! (discrete vs unified), reads host RAM, and computes the bounded OS floor (D24). The residency
8//! planner (H2) and heterogeneous/cluster dispatch (H3/H5) consume this; it makes no routing
9//! decision itself and changes no existing behaviour.
10//!
11//! Native only — `enumerate_adapters` is not available on the wasm/WebGPU path.
12#![cfg(not(target_arch = "wasm32"))]
13
14use serde::{Deserialize, Serialize};
15
16/// Coarse adapter class (maps `wgpu::DeviceType`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum AdapterClass {
19    Discrete,
20    Integrated,
21    Cpu,
22    Virtual,
23    Other,
24}
25
26impl AdapterClass {
27    fn from_wgpu(t: wgpu::DeviceType) -> Self {
28        match t {
29            wgpu::DeviceType::DiscreteGpu => Self::Discrete,
30            wgpu::DeviceType::IntegratedGpu => Self::Integrated,
31            wgpu::DeviceType::Cpu => Self::Cpu,
32            wgpu::DeviceType::VirtualGpu => Self::Virtual,
33            wgpu::DeviceType::Other => Self::Other,
34        }
35    }
36}
37
38/// Whole-host memory topology: is there a dedicated VRAM pool, or one shared pool?
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum HostMemoryTopology {
41    /// At least one discrete GPU with its own VRAM (PCIe boundary matters).
42    Discrete,
43    /// Only integrated/CPU adapters — VRAM and system RAM are one pool (Apple Silicon, iGPU-only, phone).
44    Unified,
45}
46
47/// One enumerated adapter.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AdapterDesc {
50    pub name: String,
51    pub backend: String,
52    pub class: AdapterClass,
53    pub vendor: u32,
54    pub device: u32,
55    /// Best-effort dedicated VRAM (Windows/DXGI only; 0 = unknown on this platform).
56    pub dedicated_vram_bytes: u64,
57}
58
59/// The sensed host: adapters, topology, host memory, and the bounded OS floor (D24).
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct HostTopology {
62    pub adapters: Vec<AdapterDesc>,
63    pub topology: HostMemoryTopology,
64    pub host_ram_bytes: u64,
65    pub host_ram_available_bytes: u64,
66    pub cpu_cores: usize,
67    /// Hard floor reserved for the OS/display (D24): discrete → VRAM display floor; unified → host floor.
68    pub os_floor_bytes: u64,
69    /// Best-effort memory the LLM may inhabit after the floor (discrete: VRAM−floor; unified: RAM−floor).
70    pub usable_model_budget_bytes: u64,
71}
72
73const DISCRETE_VRAM_FLOOR: u64 = 1_500 * 1024 * 1024; // ~1.5 GB for the display server
74const UNIFIED_HOST_FLOOR_MAX: u64 = 6 * 1024 * 1024 * 1024; // cap the unified host reservation
75
76/// Rank backends so the same physical GPU (enumerated once per backend) collapses to the preferred one.
77fn backend_rank(b: wgpu::Backend) -> u8 {
78    match b {
79        // Prefer the backend each OS actually runs the engine on.
80        wgpu::Backend::Metal => 0,
81        wgpu::Backend::Dx12 => 1,
82        wgpu::Backend::Vulkan => 2,
83        wgpu::Backend::Gl => 3,
84        _ => 4,
85    }
86}
87
88/// Probe the host: enumerate all adapters (deduped across backends), classify, size the floor.
89pub fn probe_host_topology() -> HostTopology {
90    use sysinfo::System;
91    let sys = System::new_all();
92    let host_ram_bytes = sys.total_memory(); // bytes (sysinfo ≥ 0.30)
93    let host_ram_available_bytes = sys.available_memory();
94    let cpu_cores = num_cpus::get();
95
96    // Enumerate every adapter across every backend, then dedup by physical (vendor, device).
97    let instance = wgpu::Instance::default();
98    let raw = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all()));
99    let mut best: std::collections::HashMap<(u32, u32), (u8, AdapterDesc)> =
100        std::collections::HashMap::new();
101    for adapter in raw {
102        let info = adapter.get_info();
103        let rank = backend_rank(info.backend);
104        let desc = AdapterDesc {
105            name: info.name.clone(),
106            backend: format!("{:?}", info.backend),
107            class: AdapterClass::from_wgpu(info.device_type),
108            vendor: info.vendor,
109            device: info.device,
110            dedicated_vram_bytes: 0,
111        };
112        best.entry((info.vendor, info.device))
113            .and_modify(|(r, d)| {
114                if rank < *r {
115                    *r = rank;
116                    *d = desc.clone();
117                }
118            })
119            .or_insert((rank, desc));
120    }
121    let mut adapters: Vec<AdapterDesc> = best.into_values().map(|(_, d)| d).collect();
122    // Drop phantom duplicates: some backends (notably GL) report device id 0 for a card already
123    // enumerated with a real id on another backend. (Limitation: two *identical-model* cards share
124    // (vendor, device) and collapse to one — precise multi-GPU counting of identical cards needs a
125    // per-OS PCI-bus / LUID probe that wgpu doesn't expose; deferred to H3/H5.)
126    let vendors_with_real_dev: std::collections::HashSet<u32> = adapters
127        .iter()
128        .filter(|a| a.device != 0)
129        .map(|a| a.vendor)
130        .collect();
131    adapters.retain(|a| a.device != 0 || !vendors_with_real_dev.contains(&a.vendor));
132    // Discrete first, then integrated, then the rest — deterministic order for the planner.
133    adapters.sort_by_key(|a| (a.class as u8, a.vendor, a.device));
134
135    let has_discrete = adapters.iter().any(|a| a.class == AdapterClass::Discrete);
136    let topology = if has_discrete {
137        HostMemoryTopology::Discrete
138    } else {
139        HostMemoryTopology::Unified
140    };
141
142    // Best-effort dedicated VRAM for the discrete card (Windows/DXGI).
143    #[cfg(target_os = "windows")]
144    if has_discrete {
145        if let Ok(mem) = crate::directml_bridge::probe_best_adapter_memory() {
146            let vram = mem.dedicated_vram_bytes.max(mem.local_budget_bytes);
147            if let Some(disc) = adapters
148                .iter_mut()
149                .find(|a| a.class == AdapterClass::Discrete)
150            {
151                disc.dedicated_vram_bytes = vram;
152            }
153        }
154    }
155
156    let (os_floor_bytes, usable_model_budget_bytes) = match topology {
157        HostMemoryTopology::Discrete => {
158            let vram = adapters
159                .iter()
160                .filter(|a| a.class == AdapterClass::Discrete)
161                .map(|a| a.dedicated_vram_bytes)
162                .max()
163                .unwrap_or(0);
164            let floor = DISCRETE_VRAM_FLOOR.min(vram / 4); // never reserve more than ¼ of a tiny card
165            (floor, vram.saturating_sub(floor))
166        }
167        HostMemoryTopology::Unified => {
168            // Reserve up to 6 GB or a quarter of RAM, whichever is smaller, for the host OS.
169            let floor = UNIFIED_HOST_FLOOR_MAX.min(host_ram_bytes / 4);
170            (floor, host_ram_bytes.saturating_sub(floor))
171        }
172    };
173
174    HostTopology {
175        adapters,
176        topology,
177        host_ram_bytes,
178        host_ram_available_bytes,
179        cpu_cores,
180        os_floor_bytes,
181        usable_model_budget_bytes,
182    }
183}
184
185impl HostTopology {
186    /// True when there is both a discrete GPU and a (system-RAM-backed) integrated GPU —
187    /// the heterogeneous-overflow opportunity (H3): the iGPU can host overflow layers in system RAM.
188    pub fn has_heterogeneous_overflow(&self) -> bool {
189        self.topology == HostMemoryTopology::Discrete
190            && self
191                .adapters
192                .iter()
193                .any(|a| a.class == AdapterClass::Integrated)
194    }
195
196    /// One-line-per-field human summary for logs / the progress record.
197    pub fn summary(&self) -> String {
198        let mut s = format!(
199            "HostTopology: {:?} | host RAM {:.1} GB ({:.1} GB free) | {} cores | OS floor {:.1} GB | model budget {:.1} GB | heterogeneous={}\n",
200            self.topology,
201            self.host_ram_bytes as f64 / 1e9,
202            self.host_ram_available_bytes as f64 / 1e9,
203            self.cpu_cores,
204            self.os_floor_bytes as f64 / 1e9,
205            self.usable_model_budget_bytes as f64 / 1e9,
206            self.has_heterogeneous_overflow(),
207        );
208        for a in &self.adapters {
209            s.push_str(&format!(
210                "  - {:?} [{}] {} (vendor 0x{:04x} dev 0x{:04x}){}\n",
211                a.class,
212                a.backend,
213                a.name,
214                a.vendor,
215                a.device,
216                if a.dedicated_vram_bytes > 0 {
217                    format!(" VRAM {:.1} GB", a.dedicated_vram_bytes as f64 / 1e9)
218                } else {
219                    String::new()
220                },
221            ));
222        }
223        s
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn h0_probes_host_topology() {
233        let topo = probe_host_topology();
234        eprintln!("{}", topo.summary());
235
236        // Host memory + CPU are always available.
237        assert!(topo.host_ram_bytes > 0, "host RAM must be sensed");
238        assert!(topo.cpu_cores > 0, "cpu cores must be sensed");
239
240        if topo.adapters.is_empty() {
241            eprintln!("[h0] no wgpu adapters (headless CI) — RAM/CPU still sensed");
242            return;
243        }
244
245        // Topology classification is consistent with the enumerated adapters.
246        let any_discrete = topo
247            .adapters
248            .iter()
249            .any(|a| a.class == AdapterClass::Discrete);
250        assert_eq!(
251            any_discrete,
252            topo.topology == HostMemoryTopology::Discrete,
253            "topology must match presence of a discrete adapter"
254        );
255        // The model budget never exceeds the relevant pool.
256        assert!(
257            topo.usable_model_budget_bytes
258                <= topo
259                    .host_ram_bytes
260                    .max(
261                        topo.adapters
262                            .iter()
263                            .map(|a| a.dedicated_vram_bytes)
264                            .max()
265                            .unwrap_or(0)
266                    )
267                    .max(1)
268        );
269    }
270}