qualia_core_db/platform/hetero_dispatch.rs
1//! Vendor-neutral heterogeneous compute dispatch + storage / precision policy.
2//!
3//! This module **replaces the former CUDA/cuFile GPUDirect-Storage bridge**
4//! (`cuda_bridge.rs`, removed). That bridge was the engine's one vendor-locked
5//! appendage — NVIDIA-only, Linux-only, and unverifiable without specific hardware.
6//! Removing it dissolves the hardware boundary entirely. The four capabilities it
7//! was meant to provide are delivered here on the engine's GENERAL stack — portable
8//! `wgpu` ([`super::gpu`]) + memory-mapped I/O ([`super::host`]) — so they build,
9//! run, and verify on any backend (Vulkan / DX12 / Metal / WebGPU), no vendor SDK:
10//!
11//! 1. **Unified-memory zero-copy** ([`ZeroCopyStrategy`]) — choose mmap-direct on
12//! integrated / unified-memory GPUs (Apple Silicon via Metal, which `wgpu` maps
13//! transparently — CPU and GPU share physical RAM, so the mmap'd region is GPU
14//! visible with no copy) vs a one-time staging upload on discrete GPUs.
15//! 2. **Hardware-agnostic fallback dispatcher** ([`HeterogeneousDispatcher`]) —
16//! route a job to the GPU (`wgpu`) when it fits, else NPU, else CPU; and tile a
17//! matmul across passes when VRAM is exhausted instead of hard-failing.
18//! 3. **Kernel stream fusion** ([`plan_fusion`]) — group consecutive same-shape
19//! element-wise tensor ops into a single `wgpu` compute pass (the portable
20//! analogue of CUDA stream fusion; the engine's fused shaders already do this at
21//! the shader level). Fewer passes ⇒ fewer dispatch / PCIe round-trips.
22//! 4. **Mixed-precision policy** ([`select_precision`]) — pick f32/f16/q8/q4 from
23//! the host's VRAM / power / thermal budget.
24//!
25//! ## The one capability deliberately NOT ported
26//! NVIDIA GPUDirect-Storage's *true* NVMe→VRAM DMA (bypassing system RAM) has **no
27//! portable `wgpu` equivalent**. The vendor-neutral substitute (and the engine's
28//! actual path) is `mmap` + OS page cache + a one-time staging upload — zero-heap,
29//! standard OS mechanics, identical across an A2000 / Apple M-series / generic Linux
30//! box. GDS-class throughput only matters when streaming a 70B model off an NVMe
31//! array into an 80 GB datacenter GPU — the deployment the affordability rail
32//! explicitly does not target, so nothing on the critical path is lost.
33//!
34//! ## Future, optional Vulkan zero-copy fast-path (documented, NOT built)
35//! If a specific deployment ever justifies skipping the staging copy, the
36//! vendor-neutral way is a `wgpu-hal` Vulkan fast-path that imports the mmap'd
37//! weights as device memory via `VK_EXT_external_memory_host` (broadly supported,
38//! cross-vendor) + Resizable-BAR — lit up ONLY on the Vulkan backend, behind a
39//! `vulkan_zero_copy_import` feature, additive over the portable path. It is
40//! deliberately unbuilt: it needs `unsafe` wgpu-hal and is backend-specific
41//! (DX12/Metal have their own external-memory mechanisms), which would re-introduce
42//! exactly the per-backend coupling that removing CUDA just eliminated. Build it
43//! only when a real deployment needs it; never as the core storage dependency.
44//!
45//! All routines here are pure-scalar policy / planning logic — **zero heap**, no
46//! recursion, run anywhere.
47
48// ── 1. Unified-memory zero-copy strategy ───────────────────────────────────────
49
50/// How weight data reaches the GPU for a given device.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ZeroCopyStrategy {
53 /// Integrated / unified-memory GPU (Apple Silicon via Metal; integrated
54 /// Intel/AMD): the `mmap`'d region is directly GPU-visible — no host→device copy.
55 MmapDirect,
56 /// Discrete GPU: upload the `mmap`'d region once via a staging buffer.
57 StagingUpload,
58}
59
60impl ZeroCopyStrategy {
61 /// Pick the strategy from whether the adapter has unified memory.
62 pub fn for_device(is_unified_memory: bool) -> Self {
63 if is_unified_memory {
64 Self::MmapDirect
65 } else {
66 Self::StagingUpload
67 }
68 }
69
70 /// Map a `wgpu` device type to the strategy: integrated GPUs share memory with
71 /// the host (unified) ⇒ mmap-direct; discrete GPUs ⇒ staging upload. CPU/other
72 /// adapters are treated as unified (the "device" buffer is host memory).
73 pub fn for_wgpu_device_type(device_type: wgpu::DeviceType) -> Self {
74 match device_type {
75 wgpu::DeviceType::DiscreteGpu | wgpu::DeviceType::VirtualGpu => Self::StagingUpload,
76 _ => Self::MmapDirect,
77 }
78 }
79
80 /// Whether a host→device copy is required (false on unified memory).
81 pub fn requires_host_copy(self) -> bool {
82 matches!(self, Self::StagingUpload)
83 }
84}
85
86// ── 2. Hardware-agnostic fallback dispatcher ────────────────────────────────────
87
88/// The compute backend a job is routed to.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum ComputeBackend {
91 /// Portable `wgpu` GPU path ([`super::gpu::WebGpuIntegrator`]).
92 Gpu,
93 /// A neural-processing unit, when present.
94 Npu,
95 /// CPU fallback — always available, never hard-fails.
96 Cpu,
97}
98
99/// What the host can offer the dispatcher.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct HostCapabilities {
102 pub gpu_available: bool,
103 /// Usable VRAM in bytes (e.g. `wgpu` `limits().max_buffer_size`).
104 pub vram_available: u64,
105 pub npu_available: bool,
106 pub cpu_threads: u32,
107}
108
109/// Routes compute jobs across GPU / NPU / CPU and degrades gracefully under VRAM
110/// pressure instead of hard-failing (the behaviour the CUDA bridge lacked).
111#[derive(Debug, Clone, Copy)]
112pub struct HeterogeneousDispatcher {
113 caps: HostCapabilities,
114}
115
116impl HeterogeneousDispatcher {
117 pub fn new(caps: HostCapabilities) -> Self {
118 Self { caps }
119 }
120
121 /// Choose a backend for a job needing `vram_required` bytes: GPU if present and
122 /// it fits → else NPU if present → else CPU (always works). When the GPU is
123 /// present but the job is larger than VRAM, the caller should GPU-tile (see
124 /// [`Self::gpu_tiles`]) rather than fall straight to CPU.
125 pub fn select_backend(&self, vram_required: u64) -> ComputeBackend {
126 if self.caps.gpu_available {
127 if vram_required <= self.caps.vram_available || self.caps.vram_available > 0 {
128 // GPU present: it fits, or it can be tiled to fit (see gpu_tiles).
129 return ComputeBackend::Gpu;
130 }
131 }
132 if self.caps.npu_available {
133 ComputeBackend::Npu
134 } else {
135 ComputeBackend::Cpu
136 }
137 }
138
139 /// How many sequential tiles a `total_bytes` GPU job must be split into so each
140 /// tile fits in available VRAM — graceful degradation instead of an OOM hard
141 /// fail. Returns 1 when it already fits (or when there's no GPU/VRAM to tile
142 /// into, in which case the job runs on the CPU as a single pass).
143 pub fn gpu_tiles(&self, total_bytes: u64) -> u32 {
144 if !self.caps.gpu_available || self.caps.vram_available == 0 {
145 return 1;
146 }
147 // ceil(total / vram), clamped to ≥ 1.
148 let tiles = total_bytes.div_ceil(self.caps.vram_available);
149 tiles.max(1).min(u32::MAX as u64) as u32
150 }
151
152 pub fn capabilities(&self) -> HostCapabilities {
153 self.caps
154 }
155}
156
157// ── 3. Kernel / stream fusion planning ──────────────────────────────────────────
158
159/// The fusability class of a tensor op in a dispatch sequence.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum TensorOpKind {
162 /// Element-wise (add, mul, activation…): fusable with adjacent same-shape
163 /// element-wise ops into one compute pass.
164 Elementwise,
165 /// A fusion barrier (reduction, matmul, reshape): forces a new pass.
166 Barrier,
167}
168
169/// One op in a planned dispatch sequence. `shape` is a shape token; element-wise ops
170/// only fuse with neighbours of the same shape.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct TensorOp {
173 pub kind: TensorOpKind,
174 pub shape: u64,
175}
176
177/// Plan stream fusion: the number of `wgpu` compute passes a sequence needs after
178/// fusing each maximal run of same-shape element-wise ops into a single pass.
179/// Barriers each take their own pass. Result is in `1..=ops.len()`; fewer passes ⇒
180/// fewer dispatch / PCIe round-trips. Zero-heap (single linear scan).
181pub fn plan_fusion(ops: &[TensorOp]) -> u32 {
182 let mut passes = 0u32;
183 let mut i = 0usize;
184 while i < ops.len() {
185 passes += 1;
186 if ops[i].kind == TensorOpKind::Barrier {
187 i += 1;
188 continue;
189 }
190 // Absorb the maximal run of same-shape element-wise ops into this pass.
191 let shape = ops[i].shape;
192 i += 1;
193 while i < ops.len() && ops[i].kind == TensorOpKind::Elementwise && ops[i].shape == shape {
194 i += 1;
195 }
196 }
197 passes
198}
199
200// ── 4. Mixed-precision policy ────────────────────────────────────────────────────
201
202/// Numeric precision for weights / activations.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum Precision {
205 F32,
206 F16,
207 Q8,
208 Q4,
209}
210
211impl Precision {
212 /// Bytes per weight element.
213 pub fn bytes_per_weight(self) -> f64 {
214 match self {
215 Self::F32 => 4.0,
216 Self::F16 => 2.0,
217 Self::Q8 => 1.0,
218 Self::Q4 => 0.5,
219 }
220 }
221}
222
223/// The host's power / thermal / memory envelope for the precision decision.
224#[derive(Debug, Clone, Copy, PartialEq)]
225pub struct PowerThermalBudget {
226 pub vram_budget_bytes: u64,
227 /// Available power headroom, milliwatts.
228 pub power_budget_mw: u32,
229 /// Degrees C below the thermal-throttle point.
230 pub thermal_headroom_c: f32,
231}
232
233/// Pick the precision tuned to the host's thermodynamic / power / memory constraints:
234/// choose the coarsest precision that still fits `param_count` weights in the VRAM
235/// budget, and — when power or thermal headroom is tight — never run the heavy
236/// precisions (f32/f16 draw more power and generate more heat). Returns `Q4` if even
237/// q4 overflows VRAM (the caller must then tile or offload).
238pub fn select_precision(param_count: u64, budget: &PowerThermalBudget) -> Precision {
239 let fits = |p: Precision| {
240 (param_count as f64 * p.bytes_per_weight()) as u64 <= budget.vram_budget_bytes
241 };
242 // Tight envelope ⇒ cap the *maximum* precision we'll consider (lower precision =
243 // fewer FLOPs = less heat/draw). Thresholds: < 5°C headroom or < 15 W budget.
244 let throttling = budget.thermal_headroom_c < 5.0 || budget.power_budget_mw < 15_000;
245 let candidates: &[Precision] = if throttling {
246 &[Precision::Q8, Precision::Q4]
247 } else {
248 &[Precision::F32, Precision::F16, Precision::Q8, Precision::Q4]
249 };
250 for &p in candidates {
251 if fits(p) {
252 return p;
253 }
254 }
255 Precision::Q4
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn zero_copy_strategy_follows_memory_architecture() {
264 // Unified memory (Apple Silicon / integrated) → no host copy.
265 assert_eq!(
266 ZeroCopyStrategy::for_device(true),
267 ZeroCopyStrategy::MmapDirect
268 );
269 assert!(!ZeroCopyStrategy::for_device(true).requires_host_copy());
270 // Discrete GPU → staging upload.
271 assert_eq!(
272 ZeroCopyStrategy::for_device(false),
273 ZeroCopyStrategy::StagingUpload
274 );
275 assert!(ZeroCopyStrategy::for_device(false).requires_host_copy());
276 // wgpu device-type mapping.
277 assert_eq!(
278 ZeroCopyStrategy::for_wgpu_device_type(wgpu::DeviceType::IntegratedGpu),
279 ZeroCopyStrategy::MmapDirect
280 );
281 assert_eq!(
282 ZeroCopyStrategy::for_wgpu_device_type(wgpu::DeviceType::DiscreteGpu),
283 ZeroCopyStrategy::StagingUpload
284 );
285 }
286
287 #[test]
288 fn dispatcher_routes_and_falls_back() {
289 // GPU present and job fits → GPU.
290 let d = HeterogeneousDispatcher::new(HostCapabilities {
291 gpu_available: true,
292 vram_available: 8 << 30,
293 npu_available: true,
294 cpu_threads: 16,
295 });
296 assert_eq!(d.select_backend(1 << 30), ComputeBackend::Gpu);
297
298 // No GPU, NPU present → NPU.
299 let d2 = HeterogeneousDispatcher::new(HostCapabilities {
300 gpu_available: false,
301 vram_available: 0,
302 npu_available: true,
303 cpu_threads: 8,
304 });
305 assert_eq!(d2.select_backend(1 << 30), ComputeBackend::Npu);
306
307 // No GPU, no NPU → CPU (always works, never hard-fails).
308 let d3 = HeterogeneousDispatcher::new(HostCapabilities {
309 gpu_available: false,
310 vram_available: 0,
311 npu_available: false,
312 cpu_threads: 4,
313 });
314 assert_eq!(d3.select_backend(1 << 30), ComputeBackend::Cpu);
315 }
316
317 #[test]
318 fn vram_exhaustion_tiles_instead_of_failing() {
319 let d = HeterogeneousDispatcher::new(HostCapabilities {
320 gpu_available: true,
321 vram_available: 2 << 30, // 2 GiB
322 npu_available: false,
323 cpu_threads: 8,
324 });
325 assert_eq!(d.gpu_tiles(1 << 30), 1, "fits in one tile");
326 assert_eq!(d.gpu_tiles(2 << 30), 1, "exactly fits");
327 assert_eq!(
328 d.gpu_tiles(5 << 30),
329 3,
330 "5 GiB / 2 GiB → 3 tiles, no OOM hard-fail"
331 );
332 // No GPU → single CPU pass.
333 let cpu = HeterogeneousDispatcher::new(HostCapabilities {
334 gpu_available: false,
335 vram_available: 0,
336 npu_available: false,
337 cpu_threads: 4,
338 });
339 assert_eq!(cpu.gpu_tiles(99 << 30), 1);
340 }
341
342 #[test]
343 fn fusion_collapses_elementwise_runs() {
344 let ew = |s| TensorOp {
345 kind: TensorOpKind::Elementwise,
346 shape: s,
347 };
348 let barrier = |s| TensorOp {
349 kind: TensorOpKind::Barrier,
350 shape: s,
351 };
352
353 // Three same-shape element-wise ops fuse into one pass.
354 assert_eq!(plan_fusion(&[ew(1), ew(1), ew(1)]), 1);
355 // A barrier (matmul/reduction) splits the run: EW | barrier | EW = 3 passes.
356 assert_eq!(plan_fusion(&[ew(1), barrier(1), ew(1)]), 3);
357 // Different shapes don't fuse.
358 assert_eq!(plan_fusion(&[ew(1), ew(2)]), 2);
359 // Mixed run: (EW EW) | barrier | (EW EW EW) = 3 passes.
360 assert_eq!(
361 plan_fusion(&[ew(1), ew(1), barrier(9), ew(2), ew(2), ew(2)]),
362 3
363 );
364 assert_eq!(plan_fusion(&[]), 0);
365 }
366
367 #[test]
368 fn precision_fits_budget_and_respects_thermals() {
369 let gib = 1u64 << 30;
370 // 1B params, roomy VRAM + headroom → f32.
371 let roomy = PowerThermalBudget {
372 vram_budget_bytes: 8 * gib,
373 power_budget_mw: 60_000,
374 thermal_headroom_c: 30.0,
375 };
376 assert_eq!(select_precision(1_000_000_000, &roomy), Precision::F32);
377
378 // 1B params, only ~1.5 GiB VRAM → must drop to q8 (1 GB) — f32/f16 overflow.
379 let tight_vram = PowerThermalBudget {
380 vram_budget_bytes: gib + gib / 2,
381 power_budget_mw: 60_000,
382 thermal_headroom_c: 30.0,
383 };
384 assert_eq!(select_precision(1_000_000_000, &tight_vram), Precision::Q8);
385
386 // Throttling (low thermal headroom) → never f32/f16 even with VRAM to spare.
387 let throttling = PowerThermalBudget {
388 vram_budget_bytes: 64 * gib,
389 power_budget_mw: 60_000,
390 thermal_headroom_c: 2.0,
391 };
392 assert_eq!(select_precision(1_000_000_000, &throttling), Precision::Q8);
393
394 // Model too big for any precision → Q4 (caller tiles/offloads).
395 let huge = PowerThermalBudget {
396 vram_budget_bytes: gib,
397 power_budget_mw: 60_000,
398 thermal_headroom_c: 30.0,
399 };
400 assert_eq!(select_precision(10_000_000_000, &huge), Precision::Q4);
401 }
402}