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}