Skip to main content

fuel_dispatch/
topology.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! SystemTopology — single source of truth for what backends exist,
3//! what devices they target, which backends share Storage substrate,
4//! and what transfer paths connect devices.
5//!
6//! See `docs/session-prompts/system-topology-service.md` for the
7//! session prompt that scoped this module + the architectural
8//! decisions (TDP-1…TDP-7) the implementation resolves.
9//!
10//! # Design summary
11//!
12//! - **TDP-1** SystemTopology lives in `fuel-dispatch::topology` (moved here
13//!   from `fuel-core` in retirement B0.2c — it reads the dispatch overlay
14//!   `global_bindings()` / `global_registry()` / `topology_generation()`, all
15//!   of which are fuel-dispatch's own, and the discovery primitives
16//!   `ProbeReport` / `TransferCalibration` from `fuel-hardware`). It fuses the
17//!   hardware-discovery view with the dispatch overlay; vocabulary
18//!   (`DeviceLocation` / `BackendId` / `SubstrateClass`) comes from `fuel-ir`.
19//! - **TDP-2** Substrate is encoded as a new
20//!   [`fuel_ir::backend::SubstrateClass`] field on
21//!   `BackendCapabilities`. Backends self-declare. SystemTopology
22//!   falls back to a sensible per-`BackendId` default for backends
23//!   that haven't registered capabilities yet (CUDA/Vulkan today),
24//!   so the predicate is correct even before the full per-backend
25//!   capability-provider refactor lands.
26//! - **TDP-3** `shares_storage` is keyed by `(BackendId,
27//!   DeviceLocation)` so CUDA gpu_id=0 vs gpu_id=1 distinguish.
28//! - **TDP-4** TransferPath is returned as the enum discriminator.
29//!   Numeric cost estimates — deferred at TDP-4 time — shipped as
30//!   Stage 1 of the load-time incremental planner
31//!   (`docs/session-prompts/load-time-incremental-planner.md`):
32//!   [`SystemTopology::transfer_estimate`] /
33//!   [`SystemTopology::estimate_transfer_ns`] price every path,
34//!   backed by a lazily-probed once-per-generation
35//!   [`TransferCalibration`] with conservative per-path-class
36//!   fallbacks for unprobed paths.
37//! - **TDP-5** Lifecycle uses a generation counter
38//!   ([`crate::dispatch::topology_generation`]) plus an
39//!   `RwLock<Option<Arc<…>>>`. `current()` rebuilds atomically when
40//!   the counter advances; otherwise it returns the cached `Arc`.
41//! - **TDP-6** The build re-reads the generation counter *inside*
42//!   the build so a labelled-N-but-built-from-N+1 snapshot can't
43//!   happen. A stale build is fine (self-healing on next access);
44//!   a mislabelled build is not.
45//! - **TDP-7** The kernel binding table is the source of truth for
46//!   "which backends have kernels registered." Op-coverage advertised
47//!   in `BackendCapabilities` is cross-checked against it via
48//!   [`SystemTopology::capabilities_op_coverage_divergence`].
49
50use std::collections::{HashMap, HashSet};
51use std::sync::{Arc, OnceLock, RwLock};
52
53use crate::dispatch::{global_bindings, global_registry, topology_generation};
54use fuel_ir::backend::{BackendCapabilities, SubstrateClass, TransferPath};
55use fuel_ir::dispatch::OpKind;
56use fuel_ir::probe::BackendId;
57use fuel_ir::{DType, DeviceLocation};
58
59use fuel_hardware::probe::ProbeReport;
60use fuel_hardware::transfer_cost::{TransferCalibration, TransferEstimate};
61
62/// Process-wide cached topology. Holds at most one `Arc<SystemTopology>`
63/// at a time; replaced atomically when the generation counter advances.
64static CURRENT_TOPOLOGY: RwLock<Option<Arc<SystemTopology>>> = RwLock::new(None);
65
66/// Single-source-of-truth view of which backends are loaded in this
67/// process, which devices exist, which backends share storage
68/// substrate (so cross-backend on the same device is free), and what
69/// transfer paths connect devices.
70///
71/// Construct via [`SystemTopology::current`] — the result is an
72/// `Arc` so a long-running consumer (a picker walking a large graph)
73/// has a stable view for the duration of its call even if a backend
74/// registers mid-walk.
75#[derive(Debug)]
76pub struct SystemTopology {
77    /// Topology-generation counter the snapshot was built from. If
78    /// the live counter has moved past this value, the snapshot is
79    /// stale and the next [`current()`](SystemTopology::current)
80    /// call rebuilds.
81    generation: u64,
82    /// All distinct device locations seen by the loaded backends.
83    /// Sorted by `BackendId`-grouped order; CPU first.
84    devices: Vec<DeviceLocation>,
85    /// All distinct `BackendId`s that have at least one kernel in the
86    /// global binding table OR an entry in the global capability
87    /// registry. Sorted ascending by `as_str()`.
88    backends: Vec<BackendId>,
89    /// `device → [backend, ...]`. Built from probe + registry.
90    /// Order within the value preserves registration / probe order so
91    /// callers can use "first" as a deterministic default.
92    backends_for_device: HashMap<DeviceLocation, Vec<BackendId>>,
93    /// `backend → [device, ...]`. Inverse of the above.
94    devices_for_backend: HashMap<BackendId, Vec<DeviceLocation>>,
95    /// Substrate class declared by each backend. Populated from
96    /// registered `BackendCapabilities::storage_substrate` when the
97    /// backend has registered its capabilities, else from
98    /// [`default_substrate_for`] as a forward-compatible fallback.
99    substrate_for: HashMap<BackendId, SubstrateClass>,
100    /// Capability snapshot per backend. Populated only for backends
101    /// whose capabilities are in `global_registry()`. Backends that
102    /// register kernels into the binding table but never call
103    /// `register_backend_capabilities` are absent here — that's a
104    /// gap the picker / diagnostics surface, not an error.
105    capabilities: HashMap<BackendId, BackendCapabilities>,
106    /// Transfer-path matrix `(src, dst) → path`, consolidated from
107    /// each registered backend's `transfer_paths`. Missing entries
108    /// fall back to [`TransferPath::HostStaging`] (every backend
109    /// supports host staging by contract).
110    transfer_paths: HashMap<(DeviceLocation, DeviceLocation), TransferPath>,
111    /// `backend → set((op, dtype))` derived from the live binding
112    /// table. The source of truth for "what kernels exist." A
113    /// backend's `BackendCapabilities::op_dtype_support` advertisement
114    /// can be cross-checked against this for the TDP-7 divergence
115    /// guard.
116    binding_op_coverage: HashMap<BackendId, HashSet<(OpKind, DType)>>,
117    /// Numeric per-path transfer calibration (planner Stage 1).
118    /// Probed lazily on the first [`Self::transfer_calibration`] /
119    /// [`Self::transfer_estimate`] call and cached for the lifetime
120    /// of this snapshot — i.e. once per topology generation. Empty
121    /// (and free to build) on CPU-only hosts.
122    transfer_calibration: OnceLock<TransferCalibration>,
123}
124
125impl SystemTopology {
126    /// Return a snapshot of the current topology. Cheap when nothing
127    /// has changed since the last call (one atomic load + `Arc::clone`);
128    /// rebuilds + atomically swaps when the
129    /// [generation counter](crate::dispatch::topology_generation)
130    /// has advanced.
131    ///
132    /// The returned `Arc` lives independent of the cache lock — a
133    /// long-running consumer can hold the snapshot across calls that
134    /// might trigger a rebuild without risk of deadlock or torn reads.
135    ///
136    /// See `docs/session-prompts/system-topology-service.md` for the
137    /// full TDP-5/TDP-6 lifecycle contract.
138    pub fn current() -> Arc<SystemTopology> {
139        let cur_gen = topology_generation();
140        // Fast path: cached snapshot is current.
141        if let Some(t) = CURRENT_TOPOLOGY.read().unwrap().as_ref()
142            && t.generation == cur_gen
143        {
144            return Arc::clone(t);
145        }
146        // Slow path: rebuild. We may race with another rebuild; the
147        // last writer wins and both produce an internally-consistent
148        // view (the build re-reads the counter inside `build_at` —
149        // see TDP-6).
150        let fresh = Arc::new(SystemTopology::build_at(cur_gen));
151        let mut guard = CURRENT_TOPOLOGY.write().unwrap();
152        // Re-check under the write lock: another thread may have built
153        // a newer or equal snapshot already.
154        match guard.as_ref() {
155            Some(existing) if existing.generation >= fresh.generation => Arc::clone(existing),
156            _ => {
157                *guard = Some(Arc::clone(&fresh));
158                fresh
159            }
160        }
161    }
162
163    /// Force a rebuild on the next [`current()`](Self::current) call,
164    /// even if no registration site has bumped the generation
165    /// counter. Use in tests / advanced setups where a registration
166    /// path bypassed the standard helpers; production code should let
167    /// registration sites bump the counter via the
168    /// `register_backend_capabilities` / `extend_global_bindings`
169    /// helpers in fuel-storage.
170    pub fn refresh() {
171        crate::dispatch::bump_topology_generation();
172    }
173
174    /// Generation counter this snapshot was built against. Two
175    /// snapshots with equal generations report identical predicates.
176    pub fn generation(&self) -> u64 {
177        self.generation
178    }
179
180    /// Every distinct [`DeviceLocation`] visible on this host. Sorted
181    /// for deterministic iteration; CPU is always present.
182    pub fn devices(&self) -> &[DeviceLocation] {
183        &self.devices
184    }
185
186    /// Every [`BackendId`] with at least one registered kernel or
187    /// capability entry. Sorted ascending by `as_str()`.
188    pub fn backends(&self) -> &[BackendId] {
189        &self.backends
190    }
191
192    /// Which backends can target this device? CPU returns `[Cpu,
193    /// Reference, Aocl, Mkl]` (subset of compiled-in backends);
194    /// `Cuda { gpu_id: N }` returns `[Cuda]`; `Vulkan { gpu_id: N }`
195    /// returns `[Vulkan]`. Empty slice for an unknown device.
196    pub fn backends_for(&self, dev: DeviceLocation) -> &[BackendId] {
197        self.backends_for_device
198            .get(&dev)
199            .map(Vec::as_slice)
200            .unwrap_or(&[])
201    }
202
203    /// Which devices can this backend target? CUDA returns every
204    /// `Cuda { gpu_id }` visible on the host. Empty slice for an
205    /// unknown backend or one whose probe returned zero devices.
206    pub fn devices_for(&self, backend: BackendId) -> &[DeviceLocation] {
207        self.devices_for_backend
208            .get(&backend)
209            .map(Vec::as_slice)
210            .unwrap_or(&[])
211    }
212
213    /// The substrate class for a `(backend, device)` pair. Returns
214    /// `None` if the backend is unknown.
215    ///
216    /// Prefers the substrate declared in the backend's
217    /// [`BackendCapabilities`] when one is registered, otherwise
218    /// falls back to `default_substrate_for`. The `device` argument
219    /// is accepted for API symmetry with [`Self::shares_storage`]
220    /// (today's substrate classification doesn't vary per-device but
221    /// future NUMA-split CPU might).
222    pub fn substrate_class(
223        &self,
224        backend: BackendId,
225        _device: DeviceLocation,
226    ) -> Option<SubstrateClass> {
227        self.substrate_for.get(&backend).copied()
228    }
229
230    /// **Critical predicate** — do these two backends operate on the
231    /// same storage substrate when both target their given device?
232    ///
233    /// `shares_storage((Cpu, Cpu), (Aocl, Cpu))` is true — both
234    /// declare [`SubstrateClass::HostBytes`] and run on
235    /// `DeviceLocation::Cpu`, so a kernel from either backend can
236    /// consume the other's output with no copy.
237    ///
238    /// `shares_storage((Cuda, Cuda{0}), (Cuda, Cuda{1}))` is false —
239    /// same substrate class, different devices: a peer or
240    /// host-staging copy is required (see [`Self::transfer_path`]).
241    ///
242    /// `shares_storage((Cuda, Cuda{0}), (Vulkan, Vulkan{0}))` is
243    /// false — same physical silicon, but the substrates are
244    /// distinct (CUDA's allocator vs Vulkan's `VkBuffer`). External-
245    /// memory import is out of scope today; treat as host-staging.
246    ///
247    /// Returns false if either backend is unknown.
248    pub fn shares_storage(
249        &self,
250        a: (BackendId, DeviceLocation),
251        b: (BackendId, DeviceLocation),
252    ) -> bool {
253        let (a_backend, a_device) = a;
254        let (b_backend, b_device) = b;
255        if a_device != b_device {
256            return false;
257        }
258        match (
259            self.substrate_for.get(&a_backend),
260            self.substrate_for.get(&b_backend),
261        ) {
262            (Some(sa), Some(sb)) => sa == sb,
263            _ => false,
264        }
265    }
266
267    /// What's needed to move bytes from `src` to `dst`? Returns the
268    /// path advertised by the source backend's capabilities, or
269    /// [`TransferPath::SameDevice`] when `src == dst`, or
270    /// [`TransferPath::HostStaging`] as the universal fallback when
271    /// no specific path was advertised (every backend supports host
272    /// staging by contract).
273    pub fn transfer_path(&self, src: DeviceLocation, dst: DeviceLocation) -> TransferPath {
274        if src == dst {
275            return TransferPath::SameDevice;
276        }
277        self.transfer_paths
278            .get(&(src, dst))
279            .copied()
280            .unwrap_or(TransferPath::HostStaging)
281    }
282
283    /// Numeric per-path transfer calibration — Stage 1 of the
284    /// load-time incremental planner
285    /// (`docs/session-prompts/load-time-incremental-planner.md`).
286    ///
287    /// The first call on a snapshot runs the calibration probe
288    /// (timed H2D/D2H round-trips through the byte-storage substrate
289    /// APIs, a few tens of ms per GPU device); every later call —
290    /// from any thread — returns the cached result. Because the
291    /// snapshot itself is rebuilt at most once per topology
292    /// generation, the probe runs at most once per generation.
293    /// CPU-only hosts probe zero paths and return an empty
294    /// calibration immediately — no probe, no error.
295    ///
296    /// Most consumers want [`Self::transfer_estimate`] /
297    /// [`Self::estimate_transfer_ns`], which add the fallback
298    /// pricing for unprobed paths; this accessor exposes the raw
299    /// probed set for diagnostics and tests.
300    pub fn transfer_calibration(&self) -> &TransferCalibration {
301        self.transfer_calibration
302            .get_or_init(|| TransferCalibration::calibrate(&self.devices))
303    }
304
305    /// Numeric cost model for moving bytes `src → dst`, for the
306    /// planner's cost composer (Stage 2) and placement DP (Stage 3).
307    /// Never fails:
308    ///
309    /// 1. a probed estimate for the exact path wins;
310    /// 2. an unprobed device-to-device pair whose two host legs were
311    ///    both probed prices as their serial composition (today's
312    ///    cross-GPU transfers route through host staging — see
313    ///    [`Self::transfer_path`]);
314    /// 3. anything else uses the conservative per-path-class static
315    ///    defaults documented on [`TransferEstimate::fallback_for`].
316    ///
317    /// Triggers the lazy calibration probe on first use (see
318    /// [`Self::transfer_calibration`]).
319    pub fn transfer_estimate(&self, src: DeviceLocation, dst: DeviceLocation) -> TransferEstimate {
320        if src == dst {
321            return TransferEstimate::fallback_for(TransferPath::SameDevice);
322        }
323        let cal = self.transfer_calibration();
324        if let Some(e) = cal.probed(src, dst) {
325            return e;
326        }
327        if src != DeviceLocation::Cpu
328            && dst != DeviceLocation::Cpu
329            && let (Some(d2h), Some(h2d)) = (
330                cal.probed(src, DeviceLocation::Cpu),
331                cal.probed(DeviceLocation::Cpu, dst),
332            )
333        {
334            return TransferEstimate::compose_staged(d2h, h2d);
335        }
336        TransferEstimate::fallback_for(self.transfer_path(src, dst))
337    }
338
339    /// Estimated wall-clock nanoseconds to move `bytes` from `src`
340    /// to `dst`. Zero when `src == dst` (no transfer needed).
341    /// Saturating, monotonic in `bytes`, and never panics — unprobed
342    /// paths use the conservative fallbacks, never a garbage zero.
343    pub fn estimate_transfer_ns(
344        &self,
345        src: DeviceLocation,
346        dst: DeviceLocation,
347        bytes: u64,
348    ) -> u64 {
349        if src == dst {
350            return 0;
351        }
352        self.transfer_estimate(src, dst).estimate_ns(bytes)
353    }
354
355    /// Per-backend [`BackendCapabilities`] snapshot if the backend has
356    /// registered with [`crate::dispatch::register_backend_capabilities`].
357    /// Backends that only registered kernels into the binding table
358    /// (most production paths today) return `None`. The picker should
359    /// not assume capabilities are present; it can fall back to the
360    /// binding-table walk via [`Self::binding_op_coverage`].
361    pub fn capabilities(&self, backend: BackendId) -> Option<&BackendCapabilities> {
362        self.capabilities.get(&backend)
363    }
364
365    /// `(op, dtype)` pairs derived from the live binding table for a
366    /// backend — the source of truth for "what kernels exist." Empty
367    /// set if the backend has no registered kernels. Note that the
368    /// binding table key includes a per-operand dtype list; this
369    /// flattens it to the output dtype (the last in the list) for the
370    /// classic `(op, dtype)` shape consumers expect.
371    pub fn binding_op_coverage(&self, backend: BackendId) -> &HashSet<(OpKind, DType)> {
372        static EMPTY: std::sync::OnceLock<HashSet<(OpKind, DType)>> = std::sync::OnceLock::new();
373        self.binding_op_coverage
374            .get(&backend)
375            .unwrap_or_else(|| EMPTY.get_or_init(HashSet::new))
376    }
377
378    /// TDP-7 divergence guard: for every backend that advertises an
379    /// `op_dtype_support` set in [`BackendCapabilities`], assert
380    /// every advertised pair has a corresponding entry in the live
381    /// binding table. Returns the list of `(backend, op, dtype)`
382    /// pairs that were advertised but not registered. Empty list =
383    /// no divergence. Used by the topology divergence test.
384    pub fn capabilities_op_coverage_divergence(&self) -> Vec<(BackendId, OpKind, DType)> {
385        let mut missing = Vec::new();
386        for (backend, caps) in &self.capabilities {
387            let registered = self.binding_op_coverage(*backend);
388            for &(op, dtype) in &caps.op_dtype_support {
389                if !registered.contains(&(op, dtype)) {
390                    missing.push((*backend, op, dtype));
391                }
392            }
393        }
394        missing.sort_by(|a, b| {
395            a.0.as_str()
396                .cmp(b.0.as_str())
397                .then_with(|| format!("{:?}", a.1).cmp(&format!("{:?}", b.1)))
398        });
399        missing
400    }
401
402    /// Build a fresh topology snapshot, reading the generation
403    /// counter *inside* the build so the resulting snapshot is
404    /// labelled with the generation it actually reflects (TDP-6). A
405    /// concurrent registration that lands mid-build bumps the counter
406    /// further; the next `current()` call observes the higher value
407    /// and rebuilds again — self-healing.
408    fn build_at(_caller_gen: u64) -> SystemTopology {
409        let built_gen = topology_generation();
410
411        // Device enumeration: every compiled-in backend's probe.
412        // Reference + CPU are always present; other backends gate
413        // on cargo features.
414        let probe = ProbeReport::probe_all();
415
416        // Per-backend capability snapshots from the global registry.
417        // Today only CPU is auto-registered; future backends will
418        // join here when their capability-provider impls land.
419        let mut capabilities: HashMap<BackendId, BackendCapabilities> = HashMap::new();
420        {
421            let registry = global_registry();
422            for caps in registry.backends() {
423                // First-wins: matches CapabilityRegistry's lookup
424                // convention where the first-registered backend for
425                // a `(op, dtype)` wins ties.
426                capabilities
427                    .entry(caps.backend_id)
428                    .or_insert_with(|| caps.clone());
429            }
430        }
431
432        // Binding-table op-coverage per backend — source of truth for
433        // "which backends have kernels registered."
434        let mut binding_op_coverage: HashMap<BackendId, HashSet<(OpKind, DType)>> = HashMap::new();
435        {
436            let bindings = global_bindings();
437            for (op, dtypes, backend) in bindings.iter_keys() {
438                // The binding key carries per-operand dtypes (inputs +
439                // outputs). The classic `(op, dtype)` shape pulls the
440                // output dtype — the last entry in the list. Single-
441                // dtype keys (most CPU ops) are unambiguous; multi-
442                // dtype keys still produce a sensible entry (the
443                // output is what consumers care about).
444                if let Some(&output_dt) = dtypes.last() {
445                    binding_op_coverage
446                        .entry(backend)
447                        .or_default()
448                        .insert((op, output_dt));
449                }
450            }
451        }
452
453        // The union of backends — present if they have at least one
454        // registered kernel OR a capability entry. Sorted for
455        // determinism.
456        let mut backends: Vec<BackendId> = capabilities
457            .keys()
458            .copied()
459            .chain(binding_op_coverage.keys().copied())
460            .collect::<HashSet<_>>()
461            .into_iter()
462            .collect();
463        backends.sort_by(|a, b| a.as_str().cmp(b.as_str()));
464
465        // Per-backend substrate class. Prefer the registered
466        // declaration; otherwise fall back to the per-BackendId
467        // default. The fallback exists because CUDA / Vulkan etc.
468        // today register only kernels, not capabilities — they'll
469        // start declaring substrate explicitly as the per-backend
470        // capability-provider refactor lands.
471        let mut substrate_for: HashMap<BackendId, SubstrateClass> = HashMap::new();
472        for &b in &backends {
473            let cls = capabilities
474                .get(&b)
475                .map(|c| c.storage_substrate)
476                .unwrap_or_else(|| default_substrate_for(b));
477            substrate_for.insert(b, cls);
478        }
479
480        // Device set: union of probe-reported devices, registered
481        // BackendCapabilities device_locations, and a synthetic CPU
482        // entry to guarantee CPU is always present.
483        let mut device_set: HashSet<DeviceLocation> = HashSet::new();
484        device_set.insert(DeviceLocation::Cpu);
485        for d in &probe.devices {
486            device_set.insert(d.location);
487        }
488        for caps in capabilities.values() {
489            device_set.insert(caps.device_location);
490        }
491        // Synthesise a default device for any backend that has
492        // kernels but no probe entry (defensive — every real backend
493        // probe returns at least one device on a host that loaded
494        // its runtime, but tests with mock backends may skip probe
495        // entirely).
496        for &b in &backends {
497            device_set.insert(default_device_for(b));
498        }
499        // Stable order: CPU first, then sort by `Debug` repr.
500        let mut devices: Vec<DeviceLocation> = device_set.into_iter().collect();
501        devices.sort_by(|a, b| match (a, b) {
502            (DeviceLocation::Cpu, DeviceLocation::Cpu) => std::cmp::Ordering::Equal,
503            (DeviceLocation::Cpu, _) => std::cmp::Ordering::Less,
504            (_, DeviceLocation::Cpu) => std::cmp::Ordering::Greater,
505            (x, y) => format!("{x:?}").cmp(&format!("{y:?}")),
506        });
507
508        // `device → [backend]` and inverse.
509        let mut backends_for_device: HashMap<DeviceLocation, Vec<BackendId>> = HashMap::new();
510        let mut devices_for_backend: HashMap<BackendId, Vec<DeviceLocation>> = HashMap::new();
511
512        // First seed from probe — authoritative for which devices a
513        // backend can actually reach on this host.
514        let mut probe_devices_by_backend: HashMap<BackendId, Vec<DeviceLocation>> = HashMap::new();
515        for d in &probe.devices {
516            probe_devices_by_backend
517                .entry(d.backend)
518                .or_default()
519                .push(d.location);
520        }
521        for &b in &backends {
522            let probe_devs = probe_devices_by_backend.remove(&b);
523            let bd_devices = match probe_devs {
524                Some(v) if !v.is_empty() => v,
525                _ => {
526                    // No probe entries for this backend (mock /
527                    // incomplete probe). Synthesise a default-device
528                    // entry so the predicates still return sane
529                    // answers.
530                    if let Some(caps) = capabilities.get(&b) {
531                        vec![caps.device_location]
532                    } else {
533                        vec![default_device_for(b)]
534                    }
535                }
536            };
537            for &dev in &bd_devices {
538                backends_for_device.entry(dev).or_default().push(b);
539            }
540            devices_for_backend.insert(b, bd_devices);
541        }
542
543        // Ensure every `devices` entry has at least an empty bucket
544        // in `backends_for_device` so iteration is symmetric.
545        for &dev in &devices {
546            backends_for_device.entry(dev).or_default();
547        }
548
549        // Transfer-path matrix: union of every backend's advertised
550        // outbound transfer_paths. Backends that haven't registered
551        // capabilities contribute nothing here; `transfer_path()`
552        // falls back to HostStaging in that case.
553        let mut transfer_paths: HashMap<(DeviceLocation, DeviceLocation), TransferPath> =
554            HashMap::new();
555        for caps in capabilities.values() {
556            for (dst, path) in &caps.transfer_paths {
557                transfer_paths.insert((caps.device_location, *dst), *path);
558            }
559        }
560
561        SystemTopology {
562            generation: built_gen,
563            devices,
564            backends,
565            backends_for_device,
566            devices_for_backend,
567            substrate_for,
568            capabilities,
569            transfer_paths,
570            binding_op_coverage,
571            transfer_calibration: OnceLock::new(),
572        }
573    }
574}
575
576/// Planner Stage-2 adapter: `SystemTopology` IS the production
577/// [`crate::ranker::TransferEstimator`]. `compile_plan` consumes transfer
578/// pricing through the trait — threaded via `PlanOptions::with_transfer_estimator`
579/// in `pipelined_bridge::build_optimized_graph` — rather than naming
580/// `SystemTopology` directly, so the ranker stays decoupled from the concrete
581/// topology source; the numbers come from the Stage-1 [`TransferCalibration`] behind
582/// [`SystemTopology::estimate_transfer_ns`] (probed lazily once per
583/// generation; conservative per-path-class fallbacks otherwise).
584/// Same-device queries short-circuit to zero WITHOUT touching the
585/// calibration, so CPU-only plans never trigger a probe.
586impl crate::ranker::TransferEstimator for SystemTopology {
587    fn estimate_transfer_ns(&self, src: DeviceLocation, dst: DeviceLocation, bytes: u64) -> u64 {
588        // Inherent method (same name) takes resolution priority —
589        // no recursion.
590        SystemTopology::estimate_transfer_ns(self, src, dst, bytes)
591    }
592}
593
594/// Default substrate class for a backend. Used when a backend
595/// hasn't registered its [`BackendCapabilities`] yet (so we don't
596/// have an explicit declaration). Stays in lockstep with what each
597/// backend's storage type is: CPU-trio backends produce
598/// `CpuStorageBytes`, CUDA produces `CudaStorageBytes`, Vulkan
599/// produces `VulkanStorageBytes`. The future per-backend
600/// capability-provider refactor will let backends declare this
601/// explicitly and the fallback becomes dead code — but until then
602/// the fallback keeps `shares_storage` correct.
603fn default_substrate_for(backend: BackendId) -> SubstrateClass {
604    match backend {
605        BackendId::Cpu => SubstrateClass::HostBytes,
606        BackendId::Cuda => SubstrateClass::CudaUntyped,
607        BackendId::Vulkan => SubstrateClass::VulkanBuffer,
608        BackendId::Metal => SubstrateClass::MetalBuffer,
609        // `BackendId` is `#[non_exhaustive]`. A new BackendId variant
610        // added downstream defaults to HostBytes; the right
611        // long-term answer is for the new backend to declare its
612        // substrate explicitly in its `BackendCapabilities`.
613        _ => SubstrateClass::HostBytes,
614    }
615}
616
617/// Default device for a backend when probe data is unavailable. Real
618/// hosts will have probe-reported devices; this fallback exists for
619/// tests with mock backends and for the synthetic-fallback path in
620/// `build_at`.
621fn default_device_for(backend: BackendId) -> DeviceLocation {
622    match backend {
623        BackendId::Cpu => DeviceLocation::Cpu,
624        BackendId::Cuda => DeviceLocation::Cuda { gpu_id: 0 },
625        BackendId::Vulkan => DeviceLocation::Vulkan { gpu_id: 0 },
626        BackendId::Metal => DeviceLocation::Metal { gpu_id: 0 },
627        // `BackendId` is `#[non_exhaustive]`; an unknown new variant
628        // defaults to CPU. Real backends should declare their
629        // device_location in `BackendCapabilities` so the topology
630        // gets the right device without consulting this fallback.
631        _ => DeviceLocation::Cpu,
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    /// Always-on baseline: CPU is in the topology regardless of which
640    /// optional backends are compiled in.
641    #[test]
642    fn cpu_always_present() {
643        let topology = SystemTopology::current();
644        assert!(topology.devices().contains(&DeviceLocation::Cpu));
645        assert!(topology.backends().contains(&BackendId::Cpu));
646        let backends_at_cpu = topology.backends_for(DeviceLocation::Cpu);
647        assert!(
648            backends_at_cpu.contains(&BackendId::Cpu),
649            "Cpu should be among the backends at DeviceLocation::Cpu, got: {:?}",
650            backends_at_cpu,
651        );
652    }
653
654    /// Substrate predicate: CPU shares with itself; HostBytes-class
655    /// backends share at DeviceLocation::Cpu.
656    #[test]
657    fn cpu_shares_with_itself() {
658        let topology = SystemTopology::current();
659        assert!(topology.shares_storage(
660            (BackendId::Cpu, DeviceLocation::Cpu),
661            (BackendId::Cpu, DeviceLocation::Cpu),
662        ));
663    }
664
665    /// Transfer path: same device → SameDevice; missing entry →
666    /// HostStaging fallback.
667    #[test]
668    fn transfer_path_defaults() {
669        let topology = SystemTopology::current();
670        assert_eq!(
671            topology.transfer_path(DeviceLocation::Cpu, DeviceLocation::Cpu),
672            TransferPath::SameDevice,
673        );
674    }
675
676    /// Stage 1 cache contract: the calibration probe runs at most
677    /// once per snapshot (and snapshots rebuild at most once per
678    /// generation) — two calls on the same snapshot return the same
679    /// cached instance.
680    #[test]
681    fn transfer_calibration_cached_per_snapshot() {
682        let topology = SystemTopology::current();
683        let a = topology.transfer_calibration();
684        let b = topology.transfer_calibration();
685        assert!(
686            std::ptr::eq(a, b),
687            "transfer_calibration must be probed once and cached on the snapshot",
688        );
689        // A fresh generation gets a fresh snapshot and therefore a
690        // fresh (lazy) calibration slot.
691        SystemTopology::refresh();
692        let next = SystemTopology::current();
693        assert!(
694            !std::ptr::eq(a, next.transfer_calibration()),
695            "a new generation's snapshot must carry its own calibration",
696        );
697    }
698
699    /// Same-device moves cost zero; cross-device estimates are never
700    /// garbage-zero and grow monotonically with bytes.
701    #[test]
702    fn transfer_estimate_same_device_zero_and_fallback_monotonic() {
703        let topology = SystemTopology::current();
704        assert_eq!(
705            topology.estimate_transfer_ns(DeviceLocation::Cpu, DeviceLocation::Cpu, 64 << 20),
706            0,
707        );
708        // gpu_id 7 exists on no probe report — this pair is
709        // guaranteed unprobed in every build configuration, so it
710        // exercises the HostStaging fallback deterministically.
711        let phantom = DeviceLocation::Cuda { gpu_id: 7 };
712        let est = topology.transfer_estimate(DeviceLocation::Cpu, phantom);
713        assert_eq!(
714            est,
715            fuel_hardware::transfer_cost::TransferEstimate::fallback_for(TransferPath::HostStaging),
716            "unprobed path must price via the per-path-class fallback",
717        );
718        let small = topology.estimate_transfer_ns(DeviceLocation::Cpu, phantom, 64 * 1024);
719        let large = topology.estimate_transfer_ns(DeviceLocation::Cpu, phantom, 64 << 20);
720        assert!(
721            small > 0,
722            "unprobed cross-device transfer must never cost zero"
723        );
724        assert!(large > small, "estimate must be monotonic in bytes");
725    }
726
727    /// Live RTX 4070 calibration over the CUDA byte-storage path.
728    /// Run: `cargo test -p fuel-core --lib --features cuda
729    /// topology::tests::live_cuda_transfer_calibration -- --ignored`
730    #[cfg(feature = "cuda")]
731    #[test]
732    #[ignore = "requires a live CUDA GPU"]
733    fn live_cuda_transfer_calibration_sane() {
734        let topology = SystemTopology::current();
735        let cuda0 = DeviceLocation::Cuda { gpu_id: 0 };
736        assert!(
737            topology.devices().contains(&cuda0),
738            "live test expects a CUDA device, got {:?}",
739            topology.devices(),
740        );
741        let cal = topology.transfer_calibration();
742        let h2d = cal
743            .probed(DeviceLocation::Cpu, cuda0)
744            .expect("H2D path must be probed on a CUDA host");
745        let d2h = cal
746            .probed(cuda0, DeviceLocation::Cpu)
747            .expect("D2H path must be probed on a CUDA host");
748        const GB: u64 = 1_000_000_000;
749        for (label, est) in [("H2D", h2d), ("D2H", d2h)] {
750            assert!(
751                (GB..=100 * GB).contains(&est.bandwidth_bytes_per_sec),
752                "{label} bandwidth out of sane bounds (1-100 GB/s): {est:?}",
753            );
754        }
755        // estimate_ns monotonic across the calibration sizes, and
756        // strictly larger for a 1000× larger transfer.
757        let est = topology.transfer_estimate(DeviceLocation::Cpu, cuda0);
758        let ns_small = est.estimate_ns(64 * 1024);
759        let ns_mid = est.estimate_ns(4 << 20);
760        let ns_large = est.estimate_ns(64 << 20);
761        assert!(ns_small <= ns_mid && ns_mid <= ns_large);
762        assert!(ns_large > ns_small, "64 MB must cost more than 64 KB");
763    }
764
765    /// Live Vulkan sibling of the CUDA calibration test, with
766    /// direction-asymmetric bounds reflecting what the byte-storage
767    /// path actually costs:
768    ///
769    /// - **H2D** is write-combined host→staging plus a device copy —
770    ///   GB/s-class (0.2–100 GB/s bound; staging-buffer creation +
771    ///   a fenced one-shot submit per call keep it below CUDA's).
772    /// - **D2H** reads back through a host-visible mapped pointer
773    ///   that on NVIDIA Windows is typically *not* HOST_CACHED, so
774    ///   the CPU-side copy runs at uncached-read speeds — measured
775    ///   ~140 MB/s on the RTX 4070 box. That slowness is real (it's
776    ///   what the planner must price), so the bound only guards
777    ///   against garbage: 10 MB/s–100 GB/s.
778    #[cfg(feature = "vulkan")]
779    #[test]
780    #[ignore = "requires a live Vulkan GPU"]
781    fn live_vulkan_transfer_calibration_sane() {
782        let topology = SystemTopology::current();
783        let vk0 = DeviceLocation::Vulkan { gpu_id: 0 };
784        assert!(
785            topology.devices().contains(&vk0),
786            "live test expects a Vulkan device, got {:?}",
787            topology.devices(),
788        );
789        let cal = topology.transfer_calibration();
790        let h2d = cal
791            .probed(DeviceLocation::Cpu, vk0)
792            .expect("H2D path must be probed on a Vulkan host");
793        let d2h = cal
794            .probed(vk0, DeviceLocation::Cpu)
795            .expect("D2H path must be probed on a Vulkan host");
796        const GB: u64 = 1_000_000_000;
797        assert!(
798            (GB / 5..=100 * GB).contains(&h2d.bandwidth_bytes_per_sec),
799            "H2D bandwidth out of sane bounds (0.2-100 GB/s): {h2d:?}",
800        );
801        assert!(
802            (10_000_000..=100 * GB).contains(&d2h.bandwidth_bytes_per_sec),
803            "D2H bandwidth out of sane bounds (10 MB/s-100 GB/s): {d2h:?}",
804        );
805        let est = topology.transfer_estimate(DeviceLocation::Cpu, vk0);
806        assert!(est.estimate_ns(64 << 20) > est.estimate_ns(64 * 1024));
807    }
808
809    /// CPU's BackendCapabilities is always registered, so the
810    /// capabilities lookup must succeed.
811    #[test]
812    fn cpu_capabilities_present() {
813        let topology = SystemTopology::current();
814        let caps = topology
815            .capabilities(BackendId::Cpu)
816            .expect("CPU caps always registered via default_cpu_caps");
817        assert_eq!(caps.backend_id, BackendId::Cpu);
818        assert_eq!(caps.storage_substrate, SubstrateClass::HostBytes);
819    }
820
821    /// TDP-7 divergence guard: every (op, dtype) the CPU backend
822    /// advertises in its op_dtype_support must have a corresponding
823    /// binding-table entry.
824    #[test]
825    fn cpu_op_coverage_no_divergence() {
826        let topology = SystemTopology::current();
827        let divergence = topology.capabilities_op_coverage_divergence();
828        let cpu_divergence: Vec<_> = divergence
829            .iter()
830            .filter(|(b, _, _)| *b == BackendId::Cpu)
831            .collect();
832        assert!(
833            cpu_divergence.is_empty(),
834            "CPU advertises ops it didn't register: {:#?}",
835            cpu_divergence,
836        );
837    }
838
839    /// Live-update: a fresh bump invalidates the cached snapshot.
840    /// Two `current()` calls with no intervening change return the
841    /// same Arc. Because the topology cache is process-wide and
842    /// parallel tests may bump the generation, we loop until we
843    /// observe two consecutive calls where the generation didn't
844    /// advance, then assert Arc identity over that window.
845    #[test]
846    fn no_change_reuses_arc() {
847        use crate::dispatch::topology_generation;
848        for _ in 0..32 {
849            let gen_before = topology_generation();
850            let a = SystemTopology::current();
851            let b = SystemTopology::current();
852            let gen_after = topology_generation();
853            if gen_before == gen_after && a.generation() == b.generation() {
854                assert!(
855                    Arc::ptr_eq(&a, &b),
856                    "two current() calls with no intervening generation \
857                     change should return the same Arc (gen={})",
858                    gen_before,
859                );
860                return;
861            }
862        }
863        panic!(
864            "Could not observe a stable-generation window across 32 \
865             attempts — parallel tests may be saturating the counter; \
866             rerun with --test-threads=1 if this persists",
867        );
868    }
869
870    /// Live-update: bumping the generation forces a rebuild.
871    #[test]
872    fn bump_forces_rebuild() {
873        let before = SystemTopology::current();
874        SystemTopology::refresh();
875        let after = SystemTopology::current();
876        assert!(
877            after.generation() > before.generation(),
878            "refresh() should advance the generation: before={}, after={}",
879            before.generation(),
880            after.generation(),
881        );
882        // The two snapshots are distinct Arc allocations after the
883        // rebuild — the cache was swapped, not mutated.
884        assert!(
885            !Arc::ptr_eq(&before, &after),
886            "refresh() should produce a fresh Arc",
887        );
888    }
889
890    /// Concurrent access: spawning N threads that hammer current()
891    /// while another bumps the generation must not panic, and every
892    /// snapshot must answer its predicates consistently with its
893    /// reported generation.
894    #[test]
895    fn concurrent_access_is_safe() {
896        use std::sync::Arc as StdArc;
897        use std::sync::atomic::{AtomicBool, AtomicUsize};
898        use std::thread;
899        use std::time::Duration;
900
901        let stop = StdArc::new(AtomicBool::new(false));
902        let max_backends = StdArc::new(AtomicUsize::new(0));
903
904        let mut readers = Vec::new();
905        for _ in 0..8 {
906            let stop = StdArc::clone(&stop);
907            let max_backends = StdArc::clone(&max_backends);
908            readers.push(thread::spawn(move || {
909                while !stop.load(std::sync::atomic::Ordering::Acquire) {
910                    let t = SystemTopology::current();
911                    // Consistency check: every reported backend must
912                    // resolve in devices_for_backend.
913                    for &b in t.backends() {
914                        let devs = t.devices_for(b);
915                        assert!(
916                            !devs.is_empty(),
917                            "backend {:?} has no devices in snapshot gen={}",
918                            b,
919                            t.generation(),
920                        );
921                    }
922                    let n = t.backends().len();
923                    let mut cur = max_backends.load(std::sync::atomic::Ordering::Acquire);
924                    while n > cur {
925                        match max_backends.compare_exchange_weak(
926                            cur,
927                            n,
928                            std::sync::atomic::Ordering::AcqRel,
929                            std::sync::atomic::Ordering::Acquire,
930                        ) {
931                            Ok(_) => break,
932                            Err(prev) => cur = prev,
933                        }
934                    }
935                }
936            }));
937        }
938
939        // Bump the generation periodically from another thread.
940        let bumper_stop = StdArc::clone(&stop);
941        let bumper = thread::spawn(move || {
942            for _ in 0..50 {
943                if bumper_stop.load(std::sync::atomic::Ordering::Acquire) {
944                    return;
945                }
946                SystemTopology::refresh();
947                thread::sleep(Duration::from_micros(50));
948            }
949        });
950
951        bumper.join().expect("bumper thread");
952        stop.store(true, std::sync::atomic::Ordering::Release);
953        for r in readers {
954            r.join().expect("reader thread");
955        }
956    }
957
958    /// CUDA cfg-gated check: when the cuda feature is on, the
959    /// topology must report Cuda as a backend and CUDA's device must
960    /// be in `backends_for(Cuda { gpu_id: 0 })`. We don't assume the
961    /// host actually has a CUDA-capable GPU — the binding-table walk
962    /// is what surfaces the backend (kernels are auto-registered at
963    /// `global_bindings()` init unconditionally on the cuda feature).
964    #[cfg(feature = "cuda")]
965    #[test]
966    fn cuda_backend_discovered_when_feature_enabled() {
967        let topology = SystemTopology::current();
968        assert!(
969            topology.backends().contains(&BackendId::Cuda),
970            "cuda feature is on; topology should report Cuda. Got: {:?}",
971            topology.backends(),
972        );
973        // CUDA's default device gets a slot in backends_for even if
974        // probe found nothing (synthetic fallback).
975        let cuda_dev = DeviceLocation::Cuda { gpu_id: 0 };
976        let here = topology.backends_for(cuda_dev);
977        assert!(
978            here.contains(&BackendId::Cuda),
979            "Cuda should be among the backends targeting {:?}, got {:?}",
980            cuda_dev,
981            here,
982        );
983    }
984
985    /// Vulkan cfg-gated counterpart of the CUDA test.
986    #[cfg(feature = "vulkan")]
987    #[test]
988    fn vulkan_backend_discovered_when_feature_enabled() {
989        let topology = SystemTopology::current();
990        assert!(
991            topology.backends().contains(&BackendId::Vulkan),
992            "vulkan feature is on; topology should report Vulkan. Got: {:?}",
993            topology.backends(),
994        );
995        let vk_dev = DeviceLocation::Vulkan { gpu_id: 0 };
996        let here = topology.backends_for(vk_dev);
997        assert!(
998            here.contains(&BackendId::Vulkan),
999            "Vulkan should be among the backends targeting {:?}, got {:?}",
1000            vk_dev,
1001            here,
1002        );
1003    }
1004
1005    /// CUDA + Vulkan on the same physical GPU don't share storage —
1006    /// CUDA pointers and Vulkan buffers live in distinct allocators
1007    /// even when they target the same silicon. External-memory
1008    /// import is deliberately out of scope (see session prompt).
1009    #[cfg(all(feature = "cuda", feature = "vulkan"))]
1010    #[test]
1011    fn cuda_and_vulkan_dont_share_storage() {
1012        let topology = SystemTopology::current();
1013        let shares = topology.shares_storage(
1014            (BackendId::Cuda, DeviceLocation::Cuda { gpu_id: 0 }),
1015            (BackendId::Vulkan, DeviceLocation::Vulkan { gpu_id: 0 }),
1016        );
1017        assert!(
1018            !shares,
1019            "CUDA and Vulkan on the same silicon must not share storage substrate",
1020        );
1021        let path = topology.transfer_path(
1022            DeviceLocation::Cuda { gpu_id: 0 },
1023            DeviceLocation::Vulkan { gpu_id: 0 },
1024        );
1025        // External-memory import is out of scope; expect HostStaging
1026        // (the universal fallback) unless someone advertises a more
1027        // specific path — they shouldn't today.
1028        assert_eq!(
1029            path,
1030            TransferPath::HostStaging,
1031            "Cross-vendor GPU transfer should fall back to host-staging today",
1032        );
1033    }
1034}