Skip to main content

qualia_core_db/
gpu_context.rs

1//! Shared GPU context and VRAM ledger for LLM + tensor + render coexistence.
2//!
3//! **Compute universes** (Track B2): logical partitions on one physical adapter —
4//! pinned ledger slots and queue lanes, not multiple `GPUDevice` instances.
5//!
6//! Qualia-native bindings (graph–tensor SOA, Phase-8 SPSC, Sentinel, ledger pins)
7//! live in `compute_universe.rs` — this module owns VRAM accounting and `shared_gpu()`.
8//!
9//! Operational modes: **Full**, **Eco**, **Reserve** (no heap in hot-path accounting).
10
11use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
12
13#[cfg(not(target_arch = "wasm32"))]
14use std::sync::OnceLock;
15
16// `caps` reports wgpu adapter capabilities, so it only compiles where the wgpu
17// dependency is present: native always, or wasm with the `gpu-runtime` feature.
18// Without this gate the module fails the `wasm-logic` build (no wgpu crate).
19#[cfg(any(not(target_arch = "wasm32"), feature = "wasm-llm"))]
20mod caps;
21#[cfg(not(target_arch = "wasm32"))]
22pub(crate) use caps::experimental_features_allowed;
23#[cfg(any(not(target_arch = "wasm32"), feature = "wasm-llm"))]
24pub(crate) use caps::requested_native_llm_features;
25#[cfg(not(target_arch = "wasm32"))]
26pub use caps::{
27    qualia_backend_override, recommend_inference_backend, GpuAdapterCaps, GpuFeatureCaps,
28    GpuLimitCaps,
29};
30
31/// Device-per-circuit registry — obtain a `wgpu::Device` for a SPECIFIC adapter/circuit
32/// (e.g. the integrated GPU), not just the single process-wide primary (STELLAR H3 foundation).
33/// Native only; mirrors [`try_shared_gpu`] — never panics on a missing/failed device.
34#[cfg(not(target_arch = "wasm32"))]
35pub mod device_registry;
36
37/// Desktop / portal operational mode (thermal + VRAM driven).
38#[repr(u8)]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum OperationalMode {
41    #[default]
42    Full = 0,
43    Eco = 1,
44    /// Engine-only: inference + queries; viewport particles/bloom off.
45    Reserve = 2,
46}
47
48impl OperationalMode {
49    #[inline]
50    pub fn from_pressure(pressure: f32) -> Self {
51        if pressure >= 0.92 {
52            Self::Reserve
53        } else if pressure >= 0.72 {
54            Self::Eco
55        } else {
56            Self::Full
57        }
58    }
59
60    #[inline]
61    pub fn max_particles(self) -> u32 {
62        match self {
63            Self::Full => 50_000,
64            Self::Eco => 8_000,
65            Self::Reserve => 0,
66        }
67    }
68
69    #[inline]
70    pub fn bloom_enabled(self) -> bool {
71        matches!(self, Self::Full)
72    }
73
74    /// Whether this tier can render the full 3D scene. Only [`Self::Full`] does; `Eco`
75    /// (VRAM-conservation) and `Reserve` (engine-only) degrade 3D → 2D — the affordability rail.
76    /// Single source of the Phase-5 budget rule, shared by `render::authoring` and the portal.
77    #[inline]
78    pub fn supports_3d(self) -> bool {
79        matches!(self, Self::Full)
80    }
81}
82
83/// Parallel compute plane on shared silicon (maps to 10D **q** / **w** semantics).
84#[repr(u8)]
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ComputeUniverse {
87    /// U0 — LLM forward pass, KV cache, weight staging.
88    LlmInference = 0,
89    /// U1 — baked 10D tensor SOA, kNN / spatial filters.
90    Tensor10D = 1,
91    /// U2 — projector, ambient, bloom.
92    Viewport = 2,
93    /// U3 — AcousticPlane; read-only pin on U1 SOA + sonic token SPSC (no extra ledger slice yet).
94    AcousticPlane = 3,
95}
96
97impl ComputeUniverse {
98    pub const ALL: [Self; 4] = [
99        Self::LlmInference,
100        Self::Tensor10D,
101        Self::Viewport,
102        Self::AcousticPlane,
103    ];
104
105    /// Physical ledger partition index (U3 aliases U1 until acoustic sidecar pins land).
106    #[inline]
107    pub fn partition_index(self) -> usize {
108        match self {
109            Self::AcousticPlane => Self::Tensor10D as usize,
110            u => u as usize,
111        }
112    }
113
114    #[inline]
115    pub fn label(self) -> &'static str {
116        match self {
117            Self::LlmInference => "U0 LLM",
118            Self::Tensor10D => "U1 Tensor10D",
119            Self::Viewport => "U2 Viewport",
120            Self::AcousticPlane => "U3 AcousticPlane",
121        }
122    }
123
124    #[inline]
125    pub fn default_queue_lane(self) -> QueueLane {
126        match self {
127            Self::LlmInference => QueueLane::LlmCompute,
128            Self::Tensor10D => QueueLane::TensorCompute,
129            Self::Viewport | Self::AcousticPlane => QueueLane::ViewportRender,
130        }
131    }
132
133    #[inline]
134    pub fn ledger_slots(self) -> &'static [VramLedgerSlot] {
135        match self {
136            Self::LlmInference => &[VramLedgerSlot::LlmKvCache, VramLedgerSlot::LlmWeightStaging],
137            Self::Tensor10D | Self::AcousticPlane => &[VramLedgerSlot::Tensor10D],
138            Self::Viewport => &[VramLedgerSlot::Viewport],
139        }
140    }
141}
142
143/// Pinned VRAM accounting bucket (zero-copy crossover between universes reads, not writes).
144#[repr(u8)]
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146pub enum VramLedgerSlot {
147    LlmKvCache = 0,
148    LlmWeightStaging = 1,
149    Tensor10D = 2,
150    Viewport = 3,
151}
152
153/// Preferred async queue on the single `wgpu::Device` (spatial concurrency, not MIG).
154#[repr(u8)]
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum QueueLane {
157    LlmCompute = 0,
158    TensorCompute = 1,
159    ViewportRender = 2,
160}
161
162/// Immutable consecutive byte range in the logical VRAM ledger (no overlap between universes).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct VramByteRange {
165    pub offset: u64,
166    pub size: u64,
167}
168
169impl VramByteRange {
170    #[inline]
171    pub const fn empty() -> Self {
172        Self { offset: 0, size: 0 }
173    }
174
175    #[inline]
176    pub fn end(&self) -> u64 {
177        self.offset.saturating_add(self.size)
178    }
179
180    /// True when `[offset, offset+size)` lies entirely inside this partition pin.
181    #[inline]
182    pub fn contains(&self, alloc_offset: u64, alloc_size: u64) -> bool {
183        alloc_size == 0
184            || alloc_offset >= self.offset && alloc_offset.saturating_add(alloc_size) <= self.end()
185    }
186}
187
188/// Hermetic partition: universe ↔ ledger bounds ↔ queue preference.
189#[derive(Debug, Clone, Copy)]
190pub struct UniversePartition {
191    pub universe: ComputeUniverse,
192    pub mode: OperationalMode,
193    /// Hard cap for this universe (sum of its ledger slots).
194    pub vram_budget_bytes: u64,
195    /// Pinned consecutive ledger slice — U2 cannot grow into U0's range.
196    pub ledger_range: VramByteRange,
197    pub queue_lane: QueueLane,
198}
199
200/// Orchestrator enforces pinned boundaries on one adapter (Full / Eco / Reserve degrade per universe).
201#[derive(Debug, Clone)]
202pub struct UniverseOrchestrator {
203    pub active_mode: OperationalMode,
204    pub partitions: [UniversePartition; 3],
205}
206
207impl UniverseOrchestrator {
208    /// VRAM split for the active operational mode (consecutive pins, no thrashing).
209    #[inline]
210    pub fn budget_triplet(total_bytes: u64, mode: OperationalMode) -> (u64, u64, u64) {
211        match mode {
212            OperationalMode::Reserve => {
213                let u2 = total_bytes / 10;
214                let rem = total_bytes.saturating_sub(u2);
215                let u0 = rem / 2;
216                let u1 = rem.saturating_sub(u0);
217                (u0, u1, u2)
218            }
219            OperationalMode::Eco => {
220                let u2 = total_bytes / 4;
221                let rem = total_bytes.saturating_sub(u2);
222                let u0 = rem.saturating_mul(6) / 10;
223                let u1 = rem.saturating_sub(u0);
224                (u0, u1, u2)
225            }
226            OperationalMode::Full => {
227                let u0 = total_bytes.saturating_mul(55) / 100;
228                let u1 = total_bytes.saturating_mul(25) / 100;
229                let u2 = total_bytes.saturating_mul(15) / 100;
230                (u0, u1, u2)
231            }
232        }
233    }
234
235    fn partition_at_offset(
236        universe: ComputeUniverse,
237        mode: OperationalMode,
238        offset: u64,
239        size: u64,
240    ) -> UniversePartition {
241        UniversePartition {
242            universe,
243            mode,
244            vram_budget_bytes: size,
245            ledger_range: VramByteRange { offset, size },
246            queue_lane: universe.default_queue_lane(),
247        }
248    }
249
250    /// Mode-aware ledger partition (Track B2.2).
251    pub fn from_total_budget(total_bytes: u64, mode: OperationalMode) -> Self {
252        let (u0, u1, u2) = Self::budget_triplet(total_bytes, mode);
253        Self {
254            active_mode: mode,
255            partitions: [
256                Self::partition_at_offset(ComputeUniverse::LlmInference, mode, 0, u0),
257                Self::partition_at_offset(ComputeUniverse::Tensor10D, mode, u0, u1),
258                Self::partition_at_offset(
259                    ComputeUniverse::Viewport,
260                    mode,
261                    u0.saturating_add(u1),
262                    u2,
263                ),
264            ],
265        }
266    }
267
268    /// Default split at **Full** fidelity: 55% U0 / 25% U1 / 15% U2 (~5% headroom implicit).
269    #[inline]
270    pub fn from_total_budget_full(total_bytes: u64) -> Self {
271        Self::from_total_budget(total_bytes, OperationalMode::Full)
272    }
273
274    #[inline]
275    pub fn partition(&self, universe: ComputeUniverse) -> &UniversePartition {
276        &self.partitions[universe.partition_index()]
277    }
278
279    /// Global mode mapped per universe — LLM (U0) wins under pressure.
280    #[inline]
281    pub fn effective_mode(
282        &self,
283        universe: ComputeUniverse,
284        global: OperationalMode,
285    ) -> OperationalMode {
286        match global {
287            OperationalMode::Full => OperationalMode::Full,
288            OperationalMode::Eco => match universe {
289                ComputeUniverse::Viewport | ComputeUniverse::AcousticPlane => OperationalMode::Eco,
290                _ => OperationalMode::Eco,
291            },
292            OperationalMode::Reserve => match universe {
293                ComputeUniverse::LlmInference => OperationalMode::Full,
294                ComputeUniverse::Tensor10D => OperationalMode::Eco,
295                ComputeUniverse::Viewport | ComputeUniverse::AcousticPlane => {
296                    OperationalMode::Reserve
297                }
298            },
299        }
300    }
301
302    #[inline]
303    pub fn max_particles(&self, universe: ComputeUniverse, global: OperationalMode) -> u32 {
304        self.effective_mode(universe, global).max_particles()
305    }
306
307    #[inline]
308    pub fn bloom_enabled(&self, universe: ComputeUniverse, global: OperationalMode) -> bool {
309        universe == ComputeUniverse::Viewport
310            && self.effective_mode(universe, global).bloom_enabled()
311    }
312}
313
314/// Universe map derived from adapter budget + live operational mode (recomputed; 3 partitions).
315#[inline]
316pub fn universe_orchestrator() -> UniverseOrchestrator {
317    let ledger = global_vram_ledger();
318    UniverseOrchestrator::from_total_budget(ledger.budget().max(1), ledger.mode())
319}
320
321/// Alias retained for orchestration call sites.
322#[inline]
323pub fn global_universe_orchestrator() -> UniverseOrchestrator {
324    universe_orchestrator()
325}
326
327/// U2-effective operational mode from live `VramLedger` pressure.
328#[inline]
329pub fn viewport_operational_mode() -> OperationalMode {
330    let ledger = global_vram_ledger();
331    universe_orchestrator().effective_mode(ComputeUniverse::Viewport, ledger.mode())
332}
333
334/// Zero-heap ambient draw throttle — static SSBO, dynamic `instance_count` (instant step-down).
335#[inline]
336pub fn ambient_draw_instances_for_mode(resident: u32, global: OperationalMode) -> u32 {
337    let cap = universe_orchestrator()
338        .effective_mode(ComputeUniverse::Viewport, global)
339        .max_particles();
340    resident.min(cap)
341}
342
343/// Live ledger hook for per-frame draw throttling (no buffer resize).
344#[inline]
345pub fn ambient_draw_instances(resident: u32) -> u32 {
346    ambient_draw_instances_for_mode(resident, global_vram_ledger().mode())
347}
348
349/// Zero-heap VRAM budget tracker (bytes, atomics).
350#[derive(Debug, Default)]
351pub struct VramLedger {
352    budget_bytes: AtomicU64,
353    tensor_bytes: AtomicU64,
354    kv_cache_bytes: AtomicU64,
355    render_bytes: AtomicU64,
356    llm_weight_staging_bytes: AtomicU64,
357    mode: AtomicU32,
358}
359
360impl VramLedger {
361    pub const KV_CACHE_CAP_BYTES: u64 = 448 * 1024 * 1024;
362
363    #[inline]
364    fn load_slot(&self, slot: VramLedgerSlot) -> u64 {
365        match slot {
366            VramLedgerSlot::LlmKvCache => self.kv_cache_bytes.load(Ordering::Relaxed),
367            VramLedgerSlot::LlmWeightStaging => {
368                self.llm_weight_staging_bytes.load(Ordering::Relaxed)
369            }
370            VramLedgerSlot::Tensor10D => self.tensor_bytes.load(Ordering::Relaxed),
371            VramLedgerSlot::Viewport => self.render_bytes.load(Ordering::Relaxed),
372        }
373    }
374
375    #[inline]
376    fn store_slot(&self, slot: VramLedgerSlot, bytes: u64) {
377        let bytes = match slot {
378            VramLedgerSlot::LlmKvCache => bytes.min(Self::KV_CACHE_CAP_BYTES),
379            _ => bytes,
380        };
381        match slot {
382            VramLedgerSlot::LlmKvCache => self.kv_cache_bytes.store(bytes, Ordering::Relaxed),
383            VramLedgerSlot::LlmWeightStaging => self
384                .llm_weight_staging_bytes
385                .store(bytes, Ordering::Relaxed),
386            VramLedgerSlot::Tensor10D => self.tensor_bytes.store(bytes, Ordering::Relaxed),
387            VramLedgerSlot::Viewport => self.render_bytes.store(bytes, Ordering::Relaxed),
388        }
389        self.refresh_mode();
390    }
391
392    #[inline]
393    pub fn budget(&self) -> u64 {
394        self.budget_bytes.load(Ordering::Relaxed)
395    }
396
397    #[inline]
398    pub fn used_in_slot(&self, slot: VramLedgerSlot) -> u64 {
399        self.load_slot(slot)
400    }
401
402    #[inline]
403    pub fn record_slot(&self, slot: VramLedgerSlot, bytes: u64) {
404        self.store_slot(slot, bytes);
405    }
406
407    #[inline]
408    pub fn universe_used_bytes(&self, universe: ComputeUniverse) -> u64 {
409        universe
410            .ledger_slots()
411            .iter()
412            .map(|s| self.load_slot(*s))
413            .sum()
414    }
415
416    #[inline]
417    pub fn universe_pressure(
418        &self,
419        orchestrator: &UniverseOrchestrator,
420        universe: ComputeUniverse,
421    ) -> f32 {
422        let cap = orchestrator.partition(universe).vram_budget_bytes;
423        if cap == 0 {
424            return 0.0;
425        }
426        (self.universe_used_bytes(universe) as f32 / cap as f32).clamp(0.0, 1.25)
427    }
428
429    #[inline]
430    pub fn can_allocate_in_universe(
431        &self,
432        orchestrator: &UniverseOrchestrator,
433        universe: ComputeUniverse,
434        extra_bytes: u64,
435    ) -> bool {
436        let part = orchestrator.partition(universe);
437        let used = self.universe_used_bytes(universe);
438        if used.saturating_add(extra_bytes) > part.vram_budget_bytes {
439            return false;
440        }
441        part.ledger_range
442            .contains(part.ledger_range.offset.saturating_add(used), extra_bytes)
443            && self.can_allocate(extra_bytes)
444    }
445
446    /// Map a ledger slot to its pinned byte offset inside the adapter ledger.
447    #[inline]
448    pub fn slot_byte_offset(orchestrator: &UniverseOrchestrator, slot: VramLedgerSlot) -> u64 {
449        let universe = match slot {
450            VramLedgerSlot::LlmKvCache | VramLedgerSlot::LlmWeightStaging => {
451                ComputeUniverse::LlmInference
452            }
453            VramLedgerSlot::Tensor10D => ComputeUniverse::Tensor10D,
454            VramLedgerSlot::Viewport => ComputeUniverse::Viewport,
455        };
456        let used_before = match slot {
457            VramLedgerSlot::LlmWeightStaging => {
458                orchestrator
459                    .partition(ComputeUniverse::LlmInference)
460                    .ledger_range
461                    .offset
462                    + global_vram_ledger().used_in_slot(VramLedgerSlot::LlmKvCache)
463            }
464            _ => orchestrator.partition(universe).ledger_range.offset,
465        };
466        used_before
467    }
468
469    #[inline]
470    pub fn record_universe(&self, universe: ComputeUniverse, slot: VramLedgerSlot, bytes: u64) {
471        debug_assert!(universe.ledger_slots().contains(&slot));
472        self.store_slot(slot, bytes);
473    }
474
475    #[inline]
476    pub fn new(budget_bytes: u64) -> Self {
477        Self {
478            budget_bytes: AtomicU64::new(budget_bytes),
479            ..Default::default()
480        }
481    }
482
483    #[inline]
484    pub fn set_budget(&self, bytes: u64) {
485        self.budget_bytes.store(bytes, Ordering::Relaxed);
486    }
487
488    #[inline]
489    pub fn record_tensor(&self, bytes: u64) {
490        self.record_universe(ComputeUniverse::Tensor10D, VramLedgerSlot::Tensor10D, bytes);
491    }
492
493    #[inline]
494    pub fn record_kv_cache(&self, bytes: u64) {
495        self.record_universe(
496            ComputeUniverse::LlmInference,
497            VramLedgerSlot::LlmKvCache,
498            bytes,
499        );
500    }
501
502    #[inline]
503    pub fn record_render(&self, bytes: u64) {
504        self.record_universe(ComputeUniverse::Viewport, VramLedgerSlot::Viewport, bytes);
505    }
506
507    #[inline]
508    pub fn record_llm_staging(&self, bytes: u64) {
509        self.record_universe(
510            ComputeUniverse::LlmInference,
511            VramLedgerSlot::LlmWeightStaging,
512            bytes,
513        );
514    }
515
516    #[inline]
517    pub fn used_bytes(&self) -> u64 {
518        self.tensor_bytes.load(Ordering::Relaxed)
519            + self.kv_cache_bytes.load(Ordering::Relaxed)
520            + self.render_bytes.load(Ordering::Relaxed)
521            + self.llm_weight_staging_bytes.load(Ordering::Relaxed)
522    }
523
524    #[inline]
525    pub fn pressure(&self) -> f32 {
526        let budget = self.budget_bytes.load(Ordering::Relaxed);
527        if budget == 0 {
528            return 0.0;
529        }
530        (self.used_bytes() as f32 / budget as f32).clamp(0.0, 1.25)
531    }
532
533    #[inline]
534    pub fn mode(&self) -> OperationalMode {
535        match self.mode.load(Ordering::Relaxed) {
536            1 => OperationalMode::Eco,
537            2 => OperationalMode::Reserve,
538            _ => OperationalMode::Full,
539        }
540    }
541
542    #[inline]
543    pub fn refresh_mode(&self) {
544        let m = OperationalMode::from_pressure(self.pressure());
545        self.mode.store(m as u32, Ordering::Relaxed);
546    }
547
548    #[inline]
549    pub fn can_allocate(&self, extra_bytes: u64) -> bool {
550        let budget = self.budget_bytes.load(Ordering::Relaxed);
551        self.used_bytes() + extra_bytes <= budget
552    }
553}
554
555/// Process-wide ledger (lazy static).
556static LEDGER: std::sync::OnceLock<VramLedger> = std::sync::OnceLock::new();
557
558#[inline]
559pub fn global_vram_ledger() -> &'static VramLedger {
560    LEDGER.get_or_init(|| VramLedger::new(6 * 1024 * 1024 * 1024))
561}
562
563// ── Ambient telemetry pulses (atomics, zero-heap) ─────────────────────────────
564
565static LLM_HEAT_MILLI: AtomicU32 = AtomicU32::new(0);
566static LOGIC_FLASH_MILLI: AtomicU32 = AtomicU32::new(0);
567static BAKE_PULSE_MILLI: AtomicU32 = AtomicU32::new(0);
568static NETWORK_RIPPLE_MILLI: AtomicU32 = AtomicU32::new(0);
569static PRODUCER_CYCLE_MILLI: AtomicU32 = AtomicU32::new(0);
570static CONTEXT_RING_DROP_MILLI: AtomicU32 = AtomicU32::new(0);
571static DRAFT_ACCEPT_MILLI: AtomicU32 = AtomicU32::new(0);
572static DRAFT_LEN_CUR: AtomicU32 = AtomicU32::new(0);
573
574#[inline]
575fn pulse(atom: &AtomicU32, strength_milli: u32) {
576    let cur = atom.load(Ordering::Relaxed);
577    atom.store(cur.max(strength_milli), Ordering::Relaxed);
578}
579
580#[inline]
581fn sample_decay(atom: &AtomicU32, decay: u32) -> f32 {
582    let v = atom.load(Ordering::Relaxed);
583    atom.store(v.saturating_sub(decay), Ordering::Relaxed);
584    (v as f32 / 1000.0).clamp(0.0, 1.0)
585}
586
587/// Called once per autoregressive decode step (gguf_bridge hot loop).
588#[inline]
589pub fn record_llm_decode_step() {
590    pulse(&LLM_HEAT_MILLI, 1000);
591}
592
593/// SPARQL / GeoSPARQL / rule resolution flash.
594#[inline]
595pub fn record_logic_flash() {
596    pulse(&LOGIC_FLASH_MILLI, 900);
597}
598
599/// Tensor / Quin bake or encode event.
600#[inline]
601pub fn record_bake_pulse() {
602    pulse(&BAKE_PULSE_MILLI, 850);
603    global_vram_ledger().refresh_mode();
604}
605
606/// Mesh / network I/O ripple (daemon fetch, torrent, etc.).
607#[inline]
608pub fn record_network_ripple() {
609    pulse(&NETWORK_RIPPLE_MILLI, 700);
610}
611
612/// U1 background producer completed a kNN inject cycle (B3.3).
613#[inline]
614pub fn record_producer_cycle(pushed_tokens: u32) {
615    let strength = (500 + pushed_tokens.min(16) * 25).min(1000);
616    pulse(&PRODUCER_CYCLE_MILLI, strength);
617}
618
619/// Context inject ring full — lossy drop rather than stalling U0.
620#[inline]
621pub fn record_context_ring_drop() {
622    pulse(&CONTEXT_RING_DROP_MILLI, 800);
623}
624
625/// Topological speculative decode: accepted draft tokens this step (B3.1e).
626#[inline]
627pub fn record_draft_acceptance(accepted: u32, draft_len: u32) {
628    if draft_len > 0 {
629        let rate = ((accepted as u64) * 1000 / draft_len as u64).min(1000) as u32;
630        DRAFT_LEN_CUR.store(draft_len, Ordering::Relaxed);
631        pulse(&DRAFT_ACCEPT_MILLI, rate);
632    }
633}
634
635/// Portal + desktop ambient field sampling (48 B contract subset).
636#[inline]
637pub fn sample_ambient_telemetry() -> [f32; 11] {
638    let ledger = global_vram_ledger();
639    [
640        ledger.pressure(),
641        sample_decay(&NETWORK_RIPPLE_MILLI, 25),
642        sample_decay(&BAKE_PULSE_MILLI, 20),
643        sample_decay(&LOGIC_FLASH_MILLI, 35),
644        sample_decay(&LLM_HEAT_MILLI, 30),
645        sample_decay(&PRODUCER_CYCLE_MILLI, 18),
646        sample_decay(&CONTEXT_RING_DROP_MILLI, 22),
647        0.08,
648        0.25,
649        ledger.pressure() * 0.5,
650        ledger.mode() as u32 as f32,
651    ]
652}
653
654// ── Shared wgpu device (native: one device per process) ───────────────────────
655
656#[cfg(not(target_arch = "wasm32"))]
657pub struct SharedGpuContext {
658    pub device: wgpu::Device,
659    pub queue: wgpu::Queue,
660    /// The wgpu instance — needed to create surfaces (must be same instance as adapter).
661    pub instance: wgpu::Instance,
662    /// The wgpu adapter — needed to create surfaces and query capabilities.
663    pub adapter: wgpu::Adapter,
664    /// Immutable adapter capability snapshot for diagnostics and feature negotiation.
665    pub adapter_caps: GpuAdapterCaps,
666    /// Feature subset actually requested on the process-wide device.
667    pub enabled_features: GpuFeatureCaps,
668    /// Whether `TIMESTAMP_QUERY` was negotiated on this device (adapter-dependent).
669    /// When false, the LLM GPU profiler degrades to a no-op (CPU wall-clock only).
670    pub timestamps_supported: bool,
671    /// Nanoseconds per timestamp tick (`Queue::get_timestamp_period`); 0.0 when unsupported.
672    pub timestamp_period_ns: f32,
673}
674
675#[cfg(not(target_arch = "wasm32"))]
676impl SharedGpuContext {
677    /// Logical queue lane for universe-tagged dispatch (B2.3).
678    /// Single physical `wgpu::Queue` today; lane tags preserve driver scheduling intent.
679    #[inline]
680    pub fn queue_for_lane(&self, lane: QueueLane) -> &wgpu::Queue {
681        let _ = lane;
682        &self.queue
683    }
684
685    #[inline]
686    pub fn queue_for_universe(&self, universe: ComputeUniverse) -> &wgpu::Queue {
687        self.queue_for_lane(universe.default_queue_lane())
688    }
689}
690
691#[cfg(not(target_arch = "wasm32"))]
692static SHARED_GPU: OnceLock<Option<SharedGpuContext>> = OnceLock::new();
693
694/// Choose the DX12 shader compiler. DX12's legacy FXC compiler cannot compile our flash-attention
695/// shader (`fused_attention.wgsl`, X4026) — DXC (the modern compiler) can. Resolution order:
696///   1. `QUALIA_DXC_PATH` → `DynamicDxc` at that explicit `dxcompiler.dll` (bespoke override).
697///   2. `dxcompiler.dll` beside the current executable (where `build.rs` copies the vendored
698///      `vendor/dxc/` DLLs) → `DynamicDxc` at that path (turnkey — no env var needed).
699///   3. Otherwise `Auto` (static-DXC → PATH-DXC → FXC) — graceful fallback (Vulkan stays default).
700#[cfg(not(target_arch = "wasm32"))]
701fn resolve_dx12_compiler() -> wgpu::Dx12Compiler {
702    if let Ok(p) = std::env::var("QUALIA_DXC_PATH") {
703        if !p.trim().is_empty() {
704            log::info!("shared_gpu|dx12_compiler|DynamicDxc(env)={p}");
705            return wgpu::Dx12Compiler::DynamicDxc { dxc_path: p };
706        }
707    }
708    if let Ok(exe) = std::env::current_exe() {
709        if let Some(dir) = exe.parent() {
710            let dll = dir.join("dxcompiler.dll");
711            if dll.exists() {
712                log::info!(
713                    "shared_gpu|dx12_compiler|DynamicDxc(vendored)={}",
714                    dll.display()
715                );
716                return wgpu::Dx12Compiler::DynamicDxc {
717                    dxc_path: dll.to_string_lossy().into_owned(),
718                };
719            }
720        }
721    }
722    wgpu::Dx12Compiler::Auto
723}
724
725#[cfg(not(target_arch = "wasm32"))]
726async fn init_shared_gpu_async() -> Result<SharedGpuContext, String> {
727    // Inference-pipeline (GPU backend) selection. Default = wgpu's own pick; `QUALIA_WGPU_BACKEND`
728    // pins it (e.g. =vulkan for the vendor-neutral path). The capability checker then reports what
729    // was actually selected + the recommendation, so "which pipeline is this machine on" is visible.
730    // DX12 shader compiler: the legacy FXC (D3DCompile) compiler CANNOT compile our flash-attention
731    // shader (`fused_attention.wgsl` — barriers after a per-thread varying-length SDPA loop; FXC
732    // error X4026), which is what the long-mislabelled "DX12 decode deadlock" actually was. DXC (the
733    // modern DirectX Shader Compiler) compiles it correctly. wgpu's own default is `Auto`
734    // (static-DXC → DXC-on-PATH → FXC), so DX12 silently falls back to FXC unless `dxcompiler.dll`
735    // is discoverable. `QUALIA_DXC_PATH` points wgpu straight at a `dxcompiler.dll` (with `dxil.dll`
736    // alongside it, for DXIL signing) so DX12 uses DXC without needing it on PATH. Absent the var we
737    // keep `Auto` (Vulkan stays the working default backend regardless).
738    let dx12_compiler = resolve_dx12_compiler();
739    let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
740    desc.backend_options.dx12.shader_compiler = dx12_compiler;
741    if let Some(backends) = caps::qualia_backend_override() {
742        log::info!("shared_gpu|backend_override|QUALIA_WGPU_BACKEND={backends:?}");
743        desc.backends = backends;
744    } else if cfg!(target_os = "windows") {
745        // Windows default = DX12. It is the verified-reliable native path: the DXC compiler fix
746        // builds the fused-attention shader, and DX12 decodes Q4_K_M / large models (e.g.
747        // llama-3.2-3b) that the Vulkan/SPIR-V path currently HANGS on (tracked bug). Vulkan is
748        // still the default off-Windows and remains selectable anywhere via QUALIA_WGPU_BACKEND=vulkan.
749        desc.backends = wgpu::Backends::DX12;
750        log::info!(
751            "shared_gpu|backend_default|windows->dx12 (override with QUALIA_WGPU_BACKEND=vulkan)"
752        );
753    }
754    let instance = wgpu::Instance::new(desc);
755    let adapter = instance
756        .request_adapter(&wgpu::RequestAdapterOptions {
757            power_preference: wgpu::PowerPreference::HighPerformance,
758            ..Default::default()
759        })
760        .await
761        .map_err(|e| format!("Failed to find wgpu adapter: {e}"))?;
762
763    // Primary circuit only: size the process-wide VRAM ledger from the best adapter's dedicated
764    // memory (Windows/DXGI). The global ledger models the PRIMARY LLM device's budget, so the
765    // per-circuit `device_registry` path (auxiliary GPUs) deliberately does NOT touch it.
766    #[cfg(target_os = "windows")]
767    if let Ok(memory) = crate::directml_bridge::probe_best_adapter_memory() {
768        let total_local = memory
769            .local_budget_bytes
770            .max(memory.dedicated_vram_bytes)
771            .max(memory.available_local_bytes());
772        if total_local > 0 {
773            global_vram_ledger().set_budget(total_local);
774        }
775    }
776
777    init_shared_gpu_for_adapter(instance, adapter).await
778}
779
780/// Build a [`SharedGpuContext`] from a GIVEN instance + adapter — the reusable core shared by the
781/// process-wide primary device ([`init_shared_gpu_async`], which requests its own HighPerformance
782/// adapter then delegates here) and the per-circuit [`device_registry`]. Requests only
783/// adapter-advertised features, raises buffer-size limits to the adapter maximum, and negotiates
784/// timestamps. Never panics; returns `Err` on device-request failure so callers can fall back.
785#[cfg(not(target_arch = "wasm32"))]
786pub(crate) async fn init_shared_gpu_for_adapter(
787    instance: wgpu::Instance,
788    adapter: wgpu::Adapter,
789) -> Result<SharedGpuContext, String> {
790    let adapter_caps = GpuAdapterCaps::from_adapter(&adapter);
791    log::info!(
792        "shared_gpu|adapter|{}|{}",
793        adapter_caps.summary_line(),
794        adapter_caps.llm_feature_line()
795    );
796    log::info!(
797        "shared_gpu|inference_backend|{}|recommend: {}",
798        adapter_caps.backend_label(),
799        caps::recommend_inference_backend(&adapter_caps)
800    );
801    if adapter_caps.is_integrated_gpu()
802        && std::env::var("QUALIA_LLM_ALLOW_IGPU").ok().as_deref() != Some("1")
803    {
804        log::warn!(
805            "shared_gpu|adapter|integrated_gpu_selected|set QUALIA_LLM_ALLOW_IGPU=1 to acknowledge this for native LLM runs"
806        );
807    }
808
809    // Request only features the adapter advertises, and keep the selector in the caps module so
810    // native feature policy stays visible. Today only timestamps are used by default; f16,
811    // subgroup, pipeline-cache/statistics, and cooperative matrix are enabled for the optimized
812    // native shader variants that follow.
813    let required_features = requested_native_llm_features(adapter.features());
814    let enabled_features = GpuFeatureCaps::from_features(required_features);
815    log::info!(
816        "shared_gpu|enabled_features|{}",
817        enabled_features.compact_flags()
818    );
819    if adapter_caps.features.cooperative_matrix && !enabled_features.cooperative_matrix {
820        log::info!(
821            "shared_gpu|cooperative_matrix_supported_but_disabled|set QUALIA_WGPU_EXPERIMENTAL_FEATURES=1 to request it"
822        );
823    }
824    let ts_supported = enabled_features.timestamp_query;
825    // Modern weight tensors blow past the wgpu DEFAULTS (max_buffer_size = 256 MiB,
826    // max_storage_buffer_binding_size = 128 MiB): the all-F16 Llama-3.2-3B tied lm_head
827    // (token_embd, 3072×128256×2 = 751 MiB) is a single resident buffer that the defaults reject
828    // ("Buffer size 788004864 > maximum buffer size 268435456"). Raise both caps to the adapter's
829    // reported maximum — always valid for request_device, so this never fails on weaker GPUs (they
830    // simply get their own, smaller, max). Vendor-neutral: pure wgpu limits, no CUDA / no extra
831    // device feature. Other limits stay at the conservative defaults.
832    let adapter_limits = adapter.limits();
833    let required_limits = wgpu::Limits {
834        max_buffer_size: adapter_limits.max_buffer_size,
835        max_storage_buffer_binding_size: adapter_limits.max_storage_buffer_binding_size,
836        ..wgpu::Limits::default()
837    };
838    let experimental_features = if required_features.intersects(
839        wgpu::Features::EXPERIMENTAL_COOPERATIVE_MATRIX | wgpu::Features::EXPERIMENTAL_RAY_QUERY,
840    ) {
841        // Safety: experimental capabilities are requested only after intersecting
842        // with the selected adapter's advertised feature set. Callers must also
843        // explicitly opt in through QUALIA_WGPU_EXPERIMENTAL_FEATURES.
844        unsafe { wgpu::ExperimentalFeatures::enabled() }
845    } else {
846        wgpu::ExperimentalFeatures::disabled()
847    };
848    let (device, queue) = adapter
849        .request_device(&wgpu::DeviceDescriptor {
850            required_features,
851            required_limits,
852            experimental_features,
853            ..Default::default()
854        })
855        .await
856        .map_err(|e| e.to_string())?;
857
858    let timestamp_period_ns = if ts_supported {
859        queue.get_timestamp_period()
860    } else {
861        0.0
862    };
863
864    Ok(SharedGpuContext {
865        device,
866        queue,
867        instance,
868        adapter,
869        adapter_caps,
870        enabled_features,
871        timestamps_supported: ts_supported,
872        timestamp_period_ns,
873    })
874}
875
876/// Process-wide wgpu device + queue, or `None` when no usable GPU adapter exists
877/// (headless / integrated-only / GPU-less machine, or the tokio runtime can't start).
878///
879/// Callers that can degrade to CPU MUST use this and fall back on `None`, rather than
880/// `shared_gpu()` which aborts the process. The `None` result is cached, so a GPU-less
881/// machine probes once. (Init is lazy and reused by QTensorEngine + render.)
882#[cfg(not(target_arch = "wasm32"))]
883pub fn try_shared_gpu() -> Option<&'static SharedGpuContext> {
884    SHARED_GPU
885        .get_or_init(|| {
886            let handle = match tokio::runtime::Handle::try_current() {
887                Ok(h) => h,
888                Err(_) => match tokio::runtime::Runtime::new() {
889                    Ok(rt) => Box::leak(Box::new(rt)).handle().clone(),
890                    Err(_) => return None,
891                },
892            };
893            tokio::task::block_in_place(|| handle.block_on(init_shared_gpu_async()).ok())
894        })
895        .as_ref()
896}
897
898/// Process-wide wgpu device + queue (lazy init). **Panics** if no GPU is available —
899/// use only where a device is genuinely mandatory. Prefer [`try_shared_gpu`] on any
900/// path that can fall back to CPU.
901#[cfg(not(target_arch = "wasm32"))]
902pub fn shared_gpu() -> &'static SharedGpuContext {
903    try_shared_gpu().expect("shared wgpu init failed")
904}
905
906#[cfg(all(test, not(target_arch = "wasm32")))]
907mod shared_gpu_robustness_tests {
908    use super::*;
909
910    #[test]
911    fn try_shared_gpu_never_panics_and_is_idempotent() {
912        // The point of the fix: probing for a GPU must NOT abort the process on a
913        // GPU-less / headless machine, and the None/Some result is cached so repeated
914        // calls agree (a GPU-less box probes exactly once).
915        let first = try_shared_gpu().is_some();
916        let second = try_shared_gpu().is_some();
917        assert_eq!(first, second, "try_shared_gpu must be cached + consistent");
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    /// Reports which GPU backend the engine's shared device actually selected for inference on this
926    /// machine (default, or pinned by `QUALIA_WGPU_BACKEND`). Run default vs `QUALIA_WGPU_BACKEND=vulkan`
927    /// in separate processes to confirm the override drives the real device.
928    #[test]
929    #[serial_test::serial(gpu)]
930    fn report_inference_backend() {
931        if !crate::wgsl_forge::test_gpu_available() {
932            return;
933        }
934        let g = shared_gpu();
935        eprintln!(
936            "[inference-backend] selected = {} | recommend: {}",
937            g.adapter_caps.backend_label(),
938            recommend_inference_backend(&g.adapter_caps),
939        );
940        eprintln!("[inference-backend] {}", g.adapter_caps.summary_line());
941        // The device must really exist (a backend was selected and an adapter acquired).
942        assert!(!g.adapter_caps.backend_label().is_empty());
943    }
944
945    #[test]
946    fn pressure_triggers_eco_and_reserve() {
947        let ledger = VramLedger::new(1000);
948        ledger.record_tensor(500);
949        assert_eq!(ledger.mode(), OperationalMode::Full);
950        ledger.record_kv_cache(300);
951        assert_eq!(ledger.mode(), OperationalMode::Eco);
952        ledger.record_render(200);
953        assert_eq!(ledger.mode(), OperationalMode::Reserve);
954    }
955
956    #[test]
957    fn universe_partitions_sum_below_total() {
958        let orch = UniverseOrchestrator::from_total_budget_full(10_000);
959        let sum: u64 = orch.partitions.iter().map(|p| p.vram_budget_bytes).sum();
960        assert!(sum <= 10_000);
961        assert_eq!(
962            orch.partition(ComputeUniverse::LlmInference)
963                .vram_budget_bytes,
964            5500
965        );
966    }
967
968    #[test]
969    fn reserve_mode_caps_u2_at_ten_percent() {
970        let orch = UniverseOrchestrator::from_total_budget(10_000, OperationalMode::Reserve);
971        assert_eq!(
972            orch.partition(ComputeUniverse::Viewport).vram_budget_bytes,
973            1_000
974        );
975        assert_eq!(
976            orch.partition(ComputeUniverse::LlmInference)
977                .vram_budget_bytes,
978            4_500
979        );
980        assert_eq!(orch.active_mode, OperationalMode::Reserve);
981    }
982
983    #[test]
984    fn ledger_ranges_are_consecutive_non_overlapping() {
985        let orch = UniverseOrchestrator::from_total_budget(10_000, OperationalMode::Eco);
986        let u0 = orch.partition(ComputeUniverse::LlmInference).ledger_range;
987        let u1 = orch.partition(ComputeUniverse::Tensor10D).ledger_range;
988        let u2 = orch.partition(ComputeUniverse::Viewport).ledger_range;
989        assert_eq!(u0.offset, 0);
990        assert_eq!(u1.offset, u0.end());
991        assert_eq!(u2.offset, u1.end());
992        assert!(u2.end() <= 10_000);
993    }
994
995    #[test]
996    fn u2_cannot_evict_u0_kv_under_reserve() {
997        let orch = UniverseOrchestrator::from_total_budget_full(10_000);
998        assert_eq!(
999            orch.effective_mode(ComputeUniverse::LlmInference, OperationalMode::Reserve),
1000            OperationalMode::Full
1001        );
1002        assert_eq!(
1003            orch.effective_mode(ComputeUniverse::Viewport, OperationalMode::Reserve),
1004            OperationalMode::Reserve
1005        );
1006    }
1007
1008    #[test]
1009    fn universe_budget_isolated() {
1010        let ledger = VramLedger::new(10_000);
1011        let orch = UniverseOrchestrator::from_total_budget_full(10_000);
1012        ledger.record_render(2000);
1013        assert!(ledger.can_allocate_in_universe(&orch, ComputeUniverse::LlmInference, 5000));
1014        assert!(!ledger.can_allocate_in_universe(&orch, ComputeUniverse::Viewport, 2000));
1015    }
1016
1017    #[test]
1018    fn ambient_draw_instant_step_by_mode() {
1019        let orch =
1020            UniverseOrchestrator::from_total_budget(6 * 1024 * 1024 * 1024, OperationalMode::Full);
1021        assert_eq!(
1022            orch.max_particles(ComputeUniverse::Viewport, OperationalMode::Full),
1023            50_000
1024        );
1025        assert_eq!(
1026            orch.max_particles(ComputeUniverse::Viewport, OperationalMode::Eco),
1027            8_000
1028        );
1029        assert_eq!(
1030            orch.max_particles(ComputeUniverse::Viewport, OperationalMode::Reserve),
1031            0
1032        );
1033
1034        let resident = 50_000_u32;
1035        assert_eq!(
1036            ambient_draw_instances_for_mode(resident, OperationalMode::Full),
1037            50_000
1038        );
1039        assert_eq!(
1040            ambient_draw_instances_for_mode(resident, OperationalMode::Eco),
1041            8_000
1042        );
1043        assert_eq!(
1044            ambient_draw_instances_for_mode(resident, OperationalMode::Reserve),
1045            0
1046        );
1047        assert_eq!(
1048            ambient_draw_instances_for_mode(3_000, OperationalMode::Eco),
1049            3_000
1050        );
1051    }
1052}