Skip to main content

vyre_runtime/megakernel/
ring.rs

1//! Ring producer / consumer traits for the megakernel host protocol.
2//!
3//! T036 / T037 in `VyreOffload/RELEASE_PLAN.md`. Today the protocol
4//! module ships byte-oriented `encode_*` / `decode_*` helpers and the
5//! consumer (host) drives a `Vec<u8>` ring directly. To make the ring
6//! source swappable  -  in-process host, out-of-process broker, or a
7//! GPU-direct producer  -  we lift the two halves of that contract behind
8//! traits and keep the existing path as the default in-process impl.
9//!
10//! The wire format is owned by [`super::protocol`]; this module sits
11//! one level above it (publishing/observation surface, not bytes).
12//!
13//! ### Producer
14//!
15//! [`RingProducer::publish`] writes one encoded slot. The encoded bytes
16//! come from a `protocol::encode_*` helper; the producer never inspects
17//! them beyond their length. Producers are responsible for the
18//! visibility/fence semantics the GPU expects (atomic store of the
19//! status word last); the default in-process producer does this via the
20//! protocol codec's byte ordering and memcpy.
21//!
22//! ### Consumer
23//!
24//! [`RingConsumer::read_slot`] is a read-only view of one slot's bytes.
25//! Consumers may decode with `protocol::decode_*`. A consumer is
26//! decoupled from where the bytes are stored (host RAM, GPU mirror,
27//! shared-mem broker)  -  only the byte layout matters.
28//!
29//! ### Boundary
30//!
31//! Neither trait names a consumer-specific concept (no "expert", no
32//! "MoE", no "shard"). The two traits are vyre-generic  -  see the
33//! boundary rule in `AGENTS.md`.
34
35use super::protocol::{self, ProtocolError};
36
37const SLOT_WORDS_USIZE: usize = 16;
38const STATUS_WORD_USIZE: usize = 0;
39/// Bytes per slot in the megakernel ring buffer (= `SLOT_WORDS * 4`).
40pub const SLOT_BYTES: usize = SLOT_WORDS_USIZE * 4;
41
42/// Producer half of the megakernel ring contract.
43///
44/// Implementations write encoded slot bytes (from
45/// [`protocol::encode_load_miss`] et al.) into a ring of `slot_count`
46/// fixed-size slots. The mapping from logical slot index to physical
47/// storage is the implementation's concern; consumers only see slot
48/// indices and the byte layout the protocol module defines.
49pub trait RingProducer {
50    /// Publish `encoded` into `slot_idx`. `encoded` must be exactly
51    /// [`SLOT_BYTES`] long; otherwise returns
52    /// [`ProtocolError::MisalignedByteLength`].
53    fn publish(&mut self, slot_idx: u32, encoded: &[u8]) -> Result<(), ProtocolError>;
54
55    /// Number of slots in the underlying ring.
56    fn slot_count(&self) -> u32;
57
58    /// Stable identifier for telemetry (e.g. `"in-process-host"`,
59    /// `"uring-cmd-nvme"`, `"gds"`).
60    fn name(&self) -> &'static str;
61}
62
63/// Consumer half of the megakernel ring contract.
64pub trait RingConsumer {
65    /// Copy slot `slot_idx`'s bytes into `out`. `out` must be exactly
66    /// [`SLOT_BYTES`] long; otherwise returns
67    /// [`ProtocolError::MisalignedByteLength`].
68    fn read_slot(&self, slot_idx: u32, out: &mut [u8]) -> Result<(), ProtocolError>;
69
70    /// Fallibly count slots currently in `DONE` status.
71    ///
72    /// The default implementation walks the ring through [`Self::read_slot`].
73    /// Specialized consumers backed by a device/control-buffer counter may
74    /// override this method to avoid host reads. Malformed slot bytes and host
75    /// arithmetic overflow remain observable as [`ProtocolError`].
76    fn try_done_count(&self) -> Result<u32, ProtocolError> {
77        let mut acc = 0u32;
78        let mut buf = [0u8; SLOT_BYTES];
79        for slot in 0..self.slot_count() {
80            self.read_slot(slot, &mut buf)?;
81            if read_slot_status_word(&buf)? == protocol::slot::DONE {
82                acc = acc
83                    .checked_add(1)
84                    .ok_or(ProtocolError::ByteLengthOverflow {
85                        buffer: "ring done count",
86                        fix: "shard the ring before host observation",
87                    })?;
88            }
89        }
90        Ok(acc)
91    }
92
93    /// Number of slots in the underlying ring.
94    fn slot_count(&self) -> u32;
95}
96
97/// Default in-process ring backed by a `Vec<u8>`. Both [`RingProducer`]
98/// and [`RingConsumer`] are implemented on a single `&mut` /`&` borrow
99/// so the producer-consumer parity test can drive both halves with the
100/// same buffer.
101pub struct HostRing {
102    bytes: Vec<u8>,
103    slot_count: u32,
104}
105
106impl HostRing {
107    /// Allocate a new ring of `slot_count` empty slots.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`ProtocolError::ByteLengthOverflow`] if `slot_count`
112    /// exceeds [`protocol::MAX_ENCODED_RING_SLOTS`].
113    pub fn new(slot_count: u32) -> Result<Self, ProtocolError> {
114        let bytes = protocol::try_encode_empty_ring(slot_count)?;
115        Ok(Self { bytes, slot_count })
116    }
117
118    /// Borrow the underlying ring bytes (for the dispatch path that
119    /// still consumes `&[u8]` directly).
120    #[must_use]
121    pub fn as_bytes(&self) -> &[u8] {
122        &self.bytes
123    }
124
125    /// Mutably borrow the underlying ring bytes.
126    #[must_use]
127    pub fn as_bytes_mut(&mut self) -> &mut [u8] {
128        &mut self.bytes
129    }
130}
131
132fn ring_slot_base(slot_idx: u32) -> Result<usize, ProtocolError> {
133    usize::try_from(slot_idx)
134        .map_err(|_| ProtocolError::MissingWord {
135            buffer: "ring slot",
136            word_idx: usize::MAX,
137            byte_len: 0,
138            fix: "slot_idx cannot fit host usize; shard the megakernel ring before host access",
139        })?
140        .checked_mul(SLOT_BYTES)
141        .ok_or(ProtocolError::MissingWord {
142            buffer: "ring slot",
143            word_idx: usize::MAX,
144            byte_len: 0,
145            fix: "slot byte offset overflowed usize; shard the megakernel ring before host access",
146        })
147}
148
149fn ring_slot_word_index(slot_idx: u32) -> Result<usize, ProtocolError> {
150    usize::try_from(slot_idx)
151        .map_err(|_| ProtocolError::MissingWord {
152            buffer: "ring slot",
153            word_idx: usize::MAX,
154            byte_len: 0,
155            fix: "slot_idx cannot fit host usize; shard the megakernel ring before host access",
156        })?
157        .checked_mul(SLOT_WORDS_USIZE)
158        .ok_or(ProtocolError::MissingWord {
159            buffer: "ring slot",
160            word_idx: usize::MAX,
161            byte_len: 0,
162            fix: "slot word offset overflowed usize; shard the megakernel ring before host access",
163        })
164}
165
166fn read_slot_status_word(slot_bytes: &[u8]) -> Result<u32, ProtocolError> {
167    let status_offset =
168        STATUS_WORD_USIZE
169            .checked_mul(4)
170            .ok_or(ProtocolError::ByteLengthOverflow {
171                buffer: "ring slot status",
172                fix: "keep ring status word indices within host address space",
173            })?;
174    let status_end = status_offset
175        .checked_add(4)
176        .ok_or(ProtocolError::ByteLengthOverflow {
177            buffer: "ring slot status",
178            fix: "keep ring status word indices within host address space",
179        })?;
180    let bytes = slot_bytes
181        .get(status_offset..status_end)
182        .ok_or(ProtocolError::MissingWord {
183            buffer: "ring slot",
184            word_idx: STATUS_WORD_USIZE,
185            byte_len: slot_bytes.len(),
186            fix: "read a complete SLOT_BYTES slot before counting DONE status",
187        })?;
188    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
189}
190
191impl RingProducer for HostRing {
192    fn publish(&mut self, slot_idx: u32, encoded: &[u8]) -> Result<(), ProtocolError> {
193        if encoded.len() != SLOT_BYTES {
194            return Err(ProtocolError::MisalignedByteLength {
195                buffer: "ring slot",
196                byte_len: encoded.len(),
197                fix: "encoded slot must be exactly SLOT_BYTES (64) long",
198            });
199        }
200        if slot_idx >= self.slot_count {
201            return Err(ProtocolError::MissingWord {
202                buffer: "ring slot",
203                word_idx: ring_slot_word_index(slot_idx)?,
204                byte_len: self.bytes.len(),
205                fix: "slot_idx must be < slot_count",
206            });
207        }
208        let base = ring_slot_base(slot_idx)?;
209        self.bytes[base..base + SLOT_BYTES].copy_from_slice(encoded);
210        Ok(())
211    }
212
213    fn slot_count(&self) -> u32 {
214        self.slot_count
215    }
216
217    fn name(&self) -> &'static str {
218        "in-process-host"
219    }
220}
221
222impl RingConsumer for HostRing {
223    fn read_slot(&self, slot_idx: u32, out: &mut [u8]) -> Result<(), ProtocolError> {
224        if out.len() != SLOT_BYTES {
225            return Err(ProtocolError::MisalignedByteLength {
226                buffer: "ring slot",
227                byte_len: out.len(),
228                fix: "out slice must be exactly SLOT_BYTES (64) long",
229            });
230        }
231        if slot_idx >= self.slot_count {
232            return Err(ProtocolError::MissingWord {
233                buffer: "ring slot",
234                word_idx: ring_slot_word_index(slot_idx)?,
235                byte_len: self.bytes.len(),
236                fix: "slot_idx must be < slot_count",
237            });
238        }
239        let base = ring_slot_base(slot_idx)?;
240        out.copy_from_slice(&self.bytes[base..base + SLOT_BYTES]);
241        Ok(())
242    }
243
244    fn try_done_count(&self) -> Result<u32, ProtocolError> {
245        let status_word_offset = STATUS_WORD_USIZE * 4;
246        let mut done = 0u32;
247        let slot_count =
248            usize::try_from(self.slot_count).map_err(|_| ProtocolError::ByteLengthOverflow {
249                buffer: "ring slot count",
250                fix: "shard the ring before host observation",
251            })?;
252        for slot in 0..slot_count {
253            let base = slot
254                .checked_mul(SLOT_BYTES)
255                .and_then(|offset| offset.checked_add(status_word_offset))
256                .ok_or(ProtocolError::ByteLengthOverflow {
257                    buffer: "ring status offset",
258                    fix: "shard the ring before host observation",
259                })?;
260            let end = base
261                .checked_add(4)
262                .ok_or(ProtocolError::ByteLengthOverflow {
263                    buffer: "ring status offset",
264                    fix: "shard the ring before host observation",
265                })?;
266            let word = read_slot_status_word(self.bytes.get(base..end).ok_or(
267                ProtocolError::MissingWord {
268                    buffer: "ring slot",
269                    word_idx: slot
270                        .checked_mul(SLOT_WORDS_USIZE)
271                        .and_then(|word| word.checked_add(STATUS_WORD_USIZE))
272                        .unwrap_or(usize::MAX),
273                    byte_len: self.bytes.len(),
274                    fix: "slot_count and ring byte length disagree; rebuild HostRing through HostRing::new",
275                },
276            )?)?;
277            if word == protocol::slot::DONE {
278                done = done
279                    .checked_add(1)
280                    .ok_or(ProtocolError::ByteLengthOverflow {
281                        buffer: "ring done count",
282                        fix: "shard the ring before host observation",
283                    })?;
284            }
285        }
286        Ok(done)
287    }
288
289    fn slot_count(&self) -> u32 {
290        self.slot_count
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    /// Parity: a slot published via the trait must round-trip through
299    /// the consumer trait and decode identically via the existing
300    /// `protocol::decode_load_miss` helper.
301    #[test]
302    fn host_ring_publishes_and_round_trips_a_load_miss() {
303        let mut ring = HostRing::new(4).expect("Fix: ring constructs");
304        let encoded = protocol::encode_load_miss(123, true);
305
306        RingProducer::publish(&mut ring, 1, &encoded).expect("Fix: publish");
307
308        let mut slot_bytes = [0u8; SLOT_BYTES];
309        RingConsumer::read_slot(&ring, 1, &mut slot_bytes).expect("Fix: read_slot");
310        assert_eq!(slot_bytes.as_slice(), encoded.as_slice());
311
312        // And, importantly, the existing decoder must read it back from
313        // the ring bytes at slot 1.
314        let decoded = protocol::decode_load_miss(ring.as_bytes(), 1);
315        assert_eq!(decoded, Some((123, true)));
316    }
317
318    #[test]
319    fn host_ring_rejects_out_of_range_slot() {
320        let mut ring = HostRing::new(2).unwrap();
321        let encoded = protocol::encode_load_miss(0, false);
322        let err_hi = RingProducer::publish(&mut ring, 2, &encoded).expect_err("slot 2 OOB");
323        assert!(
324            matches!(err_hi, ProtocolError::MissingWord { .. }),
325            "OOB publish error: {err_hi}"
326        );
327        let err_max =
328            RingProducer::publish(&mut ring, u32::MAX, &encoded).expect_err("slot MAX OOB");
329        assert!(
330            matches!(err_max, ProtocolError::MissingWord { .. }),
331            "MAX slot publish error: {err_max}"
332        );
333
334        let mut buf = [0u8; SLOT_BYTES];
335        let read_err = RingConsumer::read_slot(&ring, 2, &mut buf).expect_err("read OOB");
336        assert!(
337            matches!(read_err, ProtocolError::MissingWord { .. }),
338            "OOB read error: {read_err}"
339        );
340    }
341
342    #[test]
343    fn host_ring_rejects_mis_sized_encoded() {
344        let mut ring = HostRing::new(2).unwrap();
345        let short = [0u8; SLOT_BYTES - 1];
346        let short_pub = RingProducer::publish(&mut ring, 0, &short).expect_err("short publish");
347        assert!(
348            matches!(short_pub, ProtocolError::MisalignedByteLength { .. }),
349            "short publish error: {short_pub}"
350        );
351        let long = [0u8; SLOT_BYTES + 1];
352        let long_pub = RingProducer::publish(&mut ring, 0, &long).expect_err("long publish");
353        assert!(
354            matches!(long_pub, ProtocolError::MisalignedByteLength { .. }),
355            "long publish error: {long_pub}"
356        );
357
358        let mut short_out = [0u8; SLOT_BYTES - 1];
359        let short_read =
360            RingConsumer::read_slot(&ring, 0, &mut short_out).expect_err("short read buffer");
361        assert!(
362            matches!(short_read, ProtocolError::MisalignedByteLength { .. }),
363            "short read error: {short_read}"
364        );
365    }
366
367    /// Default try_done_count walks the ring; if we stamp DONE into a slot's
368    /// status word manually it must show up in the count.
369    #[test]
370    fn default_try_done_count_walks_the_ring() {
371        let mut ring = HostRing::new(4).unwrap();
372        // Empty ring: done count is 0.
373        assert_eq!(RingConsumer::try_done_count(&ring).unwrap(), 0);
374
375        // Stamp DONE into slot 0's status word.
376        let bytes = ring.as_bytes_mut();
377        let status_offset = STATUS_WORD_USIZE * 4;
378        bytes[status_offset..status_offset + 4]
379            .copy_from_slice(&protocol::slot::DONE.to_le_bytes());
380
381        // And into slot 2's status word.
382        let status_offset_2 = 2 * SLOT_BYTES + STATUS_WORD_USIZE * 4;
383        bytes[status_offset_2..status_offset_2 + 4]
384            .copy_from_slice(&protocol::slot::DONE.to_le_bytes());
385
386        assert_eq!(RingConsumer::try_done_count(&ring).unwrap(), 2);
387    }
388
389    #[test]
390    fn try_done_count_rejects_inconsistent_host_ring_bytes() {
391        let ring = HostRing {
392            bytes: vec![0u8; SLOT_BYTES],
393            slot_count: 2,
394        };
395
396        let error = RingConsumer::try_done_count(&ring)
397            .expect_err("Fix: malformed ring snapshots must not panic in fallible DONE count");
398        assert!(
399            matches!(error, ProtocolError::MissingWord { .. }),
400            "Fix: malformed ring error must explain the slot-count/byte mismatch: {error}"
401        );
402    }
403}