Skip to main content

molgfx_render/
residency_machine.rs

1//! Deterministic lifecycle tracking for bounded resident resources.
2
3use molgfx_core::{ChunkFootprint, ChunkId};
4use thiserror::Error;
5
6/// Lifecycle of one resource moving toward GPU residency.
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
8pub enum ResidencyState {
9    /// No slot is associated with the resource.
10    #[default]
11    Absent,
12    /// Residency was requested but no host payload is ready.
13    Requested,
14    /// Host data is ready to stage.
15    ReadyCpu,
16    /// An upload command is consuming staged bytes.
17    Uploading,
18    /// The GPU resource contains current data.
19    Resident,
20}
21
22/// Generation-checked handle for one machine slot.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct ResidencyTicket {
25    slot: u32,
26    generation: u64,
27    chunk: ChunkId,
28}
29
30/// Current machine gauges and cumulative transition counts.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct ResidencyMachineMetrics {
33    /// Slots currently in the resident state.
34    pub resident_resources: u64,
35    /// GPU bytes declared by resident resource footprints.
36    pub resident_payload_bytes: u64,
37    /// Successful state transitions.
38    pub transitions: u64,
39    /// Requests rejected because every slot was occupied.
40    pub capacity_stalls: u64,
41}
42
43/// Invalid lifecycle operation or exhausted fixed capacity.
44#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
45pub enum ResidencyMachineError {
46    /// Every preallocated lifecycle slot is occupied.
47    #[error("residency machine capacity is exhausted")]
48    Capacity,
49    /// The ticket is stale or the requested transition is invalid.
50    #[error("invalid residency lifecycle transition")]
51    InvalidTransition,
52}
53
54#[derive(Clone, Copy, Debug)]
55struct Entry {
56    chunk: ChunkId,
57    footprint: ChunkFootprint,
58    generation: u64,
59    state: ResidencyState,
60}
61
62impl Entry {
63    const fn vacant() -> Self {
64        Self {
65            chunk: ChunkId::new(0),
66            footprint: ChunkFootprint::new(0, 0, 0, 0),
67            generation: 0,
68            state: ResidencyState::Absent,
69        }
70    }
71}
72
73/// Fixed-capacity state machine with stable slot-order decisions.
74#[derive(Debug)]
75pub struct ResidencyMachine {
76    entries: Vec<Entry>,
77    next_generation: u64,
78    metrics: ResidencyMachineMetrics,
79}
80
81impl ResidencyMachine {
82    /// Preallocates all lifecycle slots.
83    #[must_use]
84    pub fn new(capacity: usize) -> Self {
85        Self {
86            entries: vec![Entry::vacant(); capacity],
87            next_generation: 1,
88            metrics: ResidencyMachineMetrics::default(),
89        }
90    }
91
92    /// Associates the lowest vacant slot with one chunk.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`ResidencyMachineError::Capacity`] when no slot is vacant.
97    pub fn request(
98        &mut self,
99        chunk: ChunkId,
100        footprint: ChunkFootprint,
101    ) -> Result<ResidencyTicket, ResidencyMachineError> {
102        let Some((index, entry)) = self
103            .entries
104            .iter_mut()
105            .enumerate()
106            .find(|(_, entry)| entry.state == ResidencyState::Absent)
107        else {
108            self.metrics.capacity_stalls = self.metrics.capacity_stalls.saturating_add(1);
109            return Err(ResidencyMachineError::Capacity);
110        };
111        let generation = self.next_generation;
112        self.next_generation = self.next_generation.wrapping_add(1).max(1);
113        *entry = Entry {
114            chunk,
115            footprint,
116            generation,
117            state: ResidencyState::Requested,
118        };
119        self.metrics.transitions = self.metrics.transitions.saturating_add(1);
120        let slot = u32::try_from(index).map_err(|_| ResidencyMachineError::Capacity)?;
121        Ok(ResidencyTicket {
122            slot,
123            generation,
124            chunk,
125        })
126    }
127
128    /// Marks the caller payload ready for staging.
129    ///
130    /// # Errors
131    ///
132    /// The ticket must currently be requested.
133    pub fn ready_cpu(&mut self, ticket: ResidencyTicket) -> Result<(), ResidencyMachineError> {
134        self.transition(ticket, ResidencyState::Requested, ResidencyState::ReadyCpu)
135    }
136
137    /// Marks staging as consumed by an upload command.
138    ///
139    /// # Errors
140    ///
141    /// The ticket must currently be ready on the CPU.
142    pub fn uploading(&mut self, ticket: ResidencyTicket) -> Result<(), ResidencyMachineError> {
143        self.transition(ticket, ResidencyState::ReadyCpu, ResidencyState::Uploading)
144    }
145
146    /// Marks the completed upload GPU-resident.
147    ///
148    /// # Errors
149    ///
150    /// The ticket must currently be uploading.
151    pub fn resident(&mut self, ticket: ResidencyTicket) -> Result<(), ResidencyMachineError> {
152        self.transition(ticket, ResidencyState::Uploading, ResidencyState::Resident)?;
153        let resident_bytes = self.entry(ticket)?.footprint.gpu_bytes;
154        self.metrics.resident_resources = self.metrics.resident_resources.saturating_add(1);
155        self.metrics.resident_payload_bytes = self
156            .metrics
157            .resident_payload_bytes
158            .saturating_add(resident_bytes);
159        Ok(())
160    }
161
162    /// Current state of a live ticket.
163    #[must_use]
164    pub fn state(&self, ticket: ResidencyTicket) -> Option<ResidencyState> {
165        self.entry(ticket).ok().map(|entry| entry.state)
166    }
167
168    /// Current gauges and cumulative counters.
169    #[must_use]
170    pub const fn metrics(&self) -> ResidencyMachineMetrics {
171        self.metrics
172    }
173
174    fn transition(
175        &mut self,
176        ticket: ResidencyTicket,
177        from: ResidencyState,
178        to: ResidencyState,
179    ) -> Result<(), ResidencyMachineError> {
180        let entry = self.entry_mut(ticket)?;
181        if entry.state != from {
182            return Err(ResidencyMachineError::InvalidTransition);
183        }
184        entry.state = to;
185        self.metrics.transitions = self.metrics.transitions.saturating_add(1);
186        Ok(())
187    }
188
189    fn entry(&self, ticket: ResidencyTicket) -> Result<&Entry, ResidencyMachineError> {
190        let Some(entry) = self.entries.get(ticket.slot as usize) else {
191            return Err(ResidencyMachineError::InvalidTransition);
192        };
193        if entry.generation != ticket.generation || entry.chunk != ticket.chunk {
194            return Err(ResidencyMachineError::InvalidTransition);
195        }
196        Ok(entry)
197    }
198
199    fn entry_mut(&mut self, ticket: ResidencyTicket) -> Result<&mut Entry, ResidencyMachineError> {
200        let Some(entry) = self.entries.get_mut(ticket.slot as usize) else {
201            return Err(ResidencyMachineError::InvalidTransition);
202        };
203        if entry.generation != ticket.generation || entry.chunk != ticket.chunk {
204            return Err(ResidencyMachineError::InvalidTransition);
205        }
206        Ok(entry)
207    }
208}
209
210#[cfg(test)]
211#[path = "residency_machine_tests.rs"]
212mod tests;