Skip to main content

ferrox_core/
expert_pool.rs

1//! Device-side homes for the expert slot pool
2//! [`crate::expert_slots::ExpertSlots`] governs.
3//!
4//! `crate::expert_cache` decides which experts are resident and validates the
5//! plans that make them so, but holds no device memory by design. This
6//! is the other side of that line: the allocations, and the
7//! [`SlotDevice`] implementations that write into them.
8//!
9//! # What is verified, and what is not
10//!
11//! The policy is verified: every rule about plans, occupancy,
12//! attribution of failures and the zero-copy warm step is tested in
13//! `crate::expert_slots` on any host.
14//!
15//! [`CudaExpertPool`] is **compile-verified only**. ferrox holds CUDA
16//! to a must-compile bar and its hardware tests stay `#[ignore]`d, and
17//! no benchmark host has run this. It is written out rather than
18//! stubbed because its correctness is mostly the type system's to
19//! check -- an allocation per slot, a bounds-checked index, a driver
20//! copy -- unlike a timing loop, where writing one without a machine to
21//! run it on would put a number nobody measured into a profile. Where
22//! this file makes a *performance* claim it says so is unmeasured.
23//!
24//! # One allocation per slot, not one per bank
25//!
26//! The obvious layout is one contiguous `slots * row_bytes` buffer per
27//! bank. This allocates each slot separately instead, for a reason
28//! that is not aesthetic: a slot-to-slot copy needs a shared borrow of
29//! the source and a mutable borrow of the destination at the same
30//! time, and two sub-views of one `CudaSlice` cannot provide that.
31//! Separate allocations can, via `split_at_mut`, so the gather path is
32//! ordinary safe code rather than raw driver pointer arithmetic
33//! written against hardware nobody here can run.
34//!
35//! The cost is `slots * banks` allocations at startup instead of
36//! `banks`. It is paid once, and it buys back the thing each slot is
37//! for: one expert row, one device pointer, which is exactly what a
38//! matvec launch wants.
39
40#[cfg(feature = "cuda")]
41use std::sync::Arc;
42
43#[cfg(feature = "cuda")]
44use crate::expert_slots::{SlotDevice, SlotGeometry};
45#[cfg(feature = "cuda")]
46use crate::residency::CopyRoute;
47#[cfg(feature = "cuda")]
48use cudarc::driver::{CudaDevice, CudaSlice, DeviceSlice};
49
50/// Borrows two distinct slots of one bank at once: the source shared,
51/// the destination mutable.
52///
53/// This is the whole reason a slot is its own allocation. A
54/// device-to-device copy needs both borrows live simultaneously, which
55/// two sub-views of one buffer cannot give; `split_at_mut` over
56/// separate allocations can. Splitting at the HIGHER of the two indices
57/// is what puts exactly one of the pair in each half.
58///
59/// Lives outside the `cuda` feature gate so the index arithmetic --
60/// the one part of the copy path that a compiler cannot check and a
61/// GPU-less host can -- is exercised by the ordinary test run. Returns
62/// `None` when the two are equal (a self-copy, which every caller
63/// should have short-circuited) or when either is out of range.
64pub fn split_pair<T>(slots: &mut [T], dst: usize, src: usize) -> Option<(&T, &mut T)> {
65    if dst == src || dst.max(src) >= slots.len() {
66        return None;
67    }
68    let (low, high) = slots.split_at_mut(dst.max(src));
69    if dst < src {
70        // low = [0, src), high = [src, ..): the destination is in low.
71        let target = &mut low[dst];
72        Some((&high[0], target))
73    } else {
74        // low = [0, dst), high = [dst, ..): the source is in low.
75        Some((&low[src], &mut high[0]))
76    }
77}
78
79/// Expert slots in CUDA device memory.
80///
81/// Built from the same [`SlotGeometry`] the
82/// [`ExpertSlots`](crate::expert_slots::ExpertSlots) governing it was built
83/// from, so the two cannot disagree about how many slots exist or
84/// how wide a row is.
85#[cfg(feature = "cuda")]
86pub struct CudaExpertPool {
87    dev: Arc<CudaDevice>,
88    /// `[bank][slot]`, one allocation per slot -- see the module
89    /// docs for why this is not one buffer per bank.
90    banks: Vec<Vec<CudaSlice<u8>>>,
91    row_bytes: Vec<usize>,
92}
93
94#[cfg(feature = "cuda")]
95impl CudaExpertPool {
96    /// Allocates the whole pool up front.
97    ///
98    /// Up front and never on demand: the point of a bounded pool is
99    /// that its footprint is known before serving starts, and a
100    /// pool that grew as experts were routed to would reintroduce
101    /// exactly the unbounded device-memory growth it exists to
102    /// prevent.
103    pub fn new(dev: Arc<CudaDevice>, geometry: &SlotGeometry) -> Result<Self, String> {
104        let mut banks = Vec::with_capacity(geometry.banks());
105        for &row_bytes in &geometry.row_bytes {
106            let mut slots = Vec::with_capacity(geometry.slots);
107            for slot in 0..geometry.slots {
108                slots.push(dev.alloc_zeros::<u8>(row_bytes).map_err(|e| {
109                    format!(
110                        "allocating slot {slot} of {} x {row_bytes} bytes: {e:?}",
111                        geometry.slots
112                    )
113                })?);
114            }
115            banks.push(slots);
116        }
117        Ok(CudaExpertPool {
118            dev,
119            banks,
120            row_bytes: geometry.row_bytes.clone(),
121        })
122    }
123
124    /// The device buffer holding one expert row, for a launch that
125    /// wants to read it.
126    pub fn slot(&self, bank: usize, slot: u32) -> Option<&CudaSlice<u8>> {
127        self.banks.get(bank)?.get(slot as usize)
128    }
129
130    /// Device bytes this pool holds.
131    pub fn bytes(&self) -> u64 {
132        self.row_bytes
133            .iter()
134            .zip(self.banks.iter())
135            .map(|(w, slots)| *w as u64 * slots.len() as u64)
136            .sum()
137    }
138}
139
140#[cfg(feature = "cuda")]
141impl SlotDevice for CudaExpertPool {
142    /// Nothing to arrange: every copy below is already synchronous,
143    /// so neither route needs a different mode. A future version
144    /// that captures decode into a CUDA graph must start refusing
145    /// [`CopyRoute::WholeLayerPageable`] here.
146    fn begin_plan(&mut self, route: CopyRoute) -> Result<(), String> {
147        let _ = route;
148        Ok(())
149    }
150
151    /// Copies one expert row from host memory into its slot.
152    ///
153    /// Synchronous, and that is a real cost rather than a
154    /// simplification: it stalls the host once per row where a
155    /// pinned staging buffer plus an async copy on a dedicated
156    /// stream would not. The unmeasured claim this file will not
157    /// make is which of the two is faster on any given machine --
158    /// that is what `ferrox bench-bw` and a benchmark host are for.
159    /// The synchronous form is the one whose correctness needs no
160    /// hardware to reason about, so it is what lands first.
161    fn write_slot(&mut self, bank: usize, dst_slot: u32, src: &[u8]) -> Result<(), String> {
162        let dev = Arc::clone(&self.dev);
163        let dst = self
164            .banks
165            .get_mut(bank)
166            .and_then(|b| b.get_mut(dst_slot as usize))
167            .ok_or_else(|| format!("no slot {dst_slot} in bank {bank}"))?;
168        // cudarc `assert_eq!`s the two lengths, and a panic here takes
169        // the whole server down mid-decode. `ExpertSlots` already
170        // refuses a row of the wrong width, but this type is public and
171        // reachable without it, so the mismatch becomes an error the
172        // caller can act on rather than an abort.
173        if src.len() != dst.len() {
174            return Err(format!(
175                "bank {bank} slot {dst_slot} is {} bytes, but the row offered is {}",
176                dst.len(),
177                src.len()
178            ));
179        }
180        dev.htod_sync_copy_into(src, dst)
181            .map_err(|e| format!("host-to-device copy into bank {bank} slot {dst_slot}: {e:?}"))
182    }
183
184    /// Copies one slot onto another without touching the host.
185    ///
186    /// `dtod_copy` asserts the two lengths match too, but unlike
187    /// [`write_slot`](Self::write_slot) nothing external can make them
188    /// differ: every slot in a bank is allocated at that bank's single
189    /// `row_bytes`, so the equality is a property of construction.
190    fn copy_slot(&mut self, bank: usize, dst_slot: u32, src_slot: u32) -> Result<(), String> {
191        if dst_slot == src_slot {
192            return Ok(());
193        }
194        let dev = Arc::clone(&self.dev);
195        let slots = self
196            .banks
197            .get_mut(bank)
198            .ok_or_else(|| format!("no bank {bank}"))?;
199        let len = slots.len();
200        let (source, target) =
201            split_pair(slots, dst_slot as usize, src_slot as usize).ok_or_else(|| {
202                format!("bank {bank} holds {len} slots, not {dst_slot} <- {src_slot}")
203            })?;
204        dev.dtod_copy(source, target).map_err(|e| {
205            format!("device-to-device copy {src_slot} -> {dst_slot} in bank {bank}: {e:?}")
206        })
207    }
208
209    /// Nothing is deferred, so there is nothing to complete. If
210    /// `write_slot` ever becomes asynchronous this must start
211    /// synchronizing the stream and reporting its error -- the
212    /// caller forgets every slot a plan wrote when this fails, and
213    /// silently succeeding would leave it trusting copies that
214    /// never landed.
215    fn flush(&mut self) -> Result<(), String> {
216        Ok(())
217    }
218}
219
220#[cfg(test)]
221mod split_tests {
222    use super::split_pair;
223
224    /// The one line of the device-to-device path a compiler cannot
225    /// check: which half of the split holds the source and which the
226    /// destination. Getting it backwards copies the wrong slot, and a
227    /// wrong expert multiplies without complaining -- so it is checked
228    /// here, on a host with no GPU, rather than left to a card nobody
229    /// has run this on.
230    #[test]
231    fn the_split_hands_back_the_source_and_destination_the_caller_asked_for() {
232        for (dst, src) in [(0usize, 3usize), (3, 0), (1, 2), (2, 1), (0, 1), (3, 2)] {
233            let mut slots: Vec<u32> = (0..4).collect();
234            let (source, target) = split_pair(&mut slots, dst, src).expect("distinct, in range");
235            assert_eq!(*source, src as u32, "source for {dst} <- {src}");
236            assert_eq!(*target, dst as u32, "destination for {dst} <- {src}");
237            *target = *source;
238            assert_eq!(slots[dst], src as u32, "the copy landed in {dst}");
239            assert_eq!(slots[src], src as u32, "and left the source alone");
240        }
241    }
242
243    /// A self-copy and an out-of-range slot both come back `None`
244    /// rather than panicking: the caller turns them into an error that
245    /// names the bank, and a panic in a decode step takes the server
246    /// down instead.
247    #[test]
248    fn a_self_copy_or_an_out_of_range_slot_is_not_a_pair() {
249        let mut slots: Vec<u32> = (0..4).collect();
250        assert!(split_pair(&mut slots, 2, 2).is_none());
251        assert!(split_pair(&mut slots, 4, 0).is_none());
252        assert!(split_pair(&mut slots, 0, 4).is_none());
253        assert!(split_pair::<u32>(&mut [], 0, 1).is_none());
254    }
255}
256
257#[cfg(all(test, feature = "cuda"))]
258mod tests {
259    use super::*;
260    use crate::expert_cache::{CopyPlan, ExpertId, GatherPlan};
261    use crate::expert_slots::{ExpertRows, ExpertSlots};
262
263    /// Host rows whose bytes name the expert they belong to, so a slot
264    /// read back from the device can be checked for *which* expert it
265    /// holds rather than only for having been written.
266    struct NamedRows {
267        rows: Vec<Vec<u8>>,
268    }
269
270    impl NamedRows {
271        fn new(experts: usize, width: usize) -> Self {
272            let rows = (0..experts)
273                .map(|e| (0..width).map(|i| (e as u8) << 4 | i as u8).collect())
274                .collect();
275            NamedRows { rows }
276        }
277    }
278
279    impl ExpertRows for NamedRows {
280        fn row(&self, bank: usize, _layer: u32, row: u32) -> Option<&[u8]> {
281            if bank > 0 {
282                return None;
283            }
284            self.rows.get(row as usize).map(|r| r.as_slice())
285        }
286    }
287
288    /// The whole pool end to end on a real card: a plan lands the bytes
289    /// the planner named in the slots it named, a gather moves one slot
290    /// onto another without touching the host, and a warm step copies
291    /// nothing.
292    ///
293    /// This is the hardware half of `persistent-gpu-expert-cache`'s
294    /// acceptance. The policy half is proven on any host in
295    /// `crate::expert_slots`; what needs a GPU is that these
296    /// driver calls do what they are read as doing.
297    #[test]
298    #[ignore = "requires real CUDA hardware -- NOT yet run on a GPU; run with --ignored on a CUDA-capable machine"]
299    fn a_cuda_pool_lands_each_expert_in_the_slot_the_plan_named() {
300        const EXPERTS: usize = 4;
301        const WIDTH: usize = 64;
302
303        let dev = CudaDevice::new(0).expect("a CUDA device");
304        let geometry = SlotGeometry {
305            num_layers: 1,
306            slots: EXPERTS,
307            row_bytes: vec![WIDTH],
308        };
309        let mut pool = CudaExpertPool::new(dev.clone(), &geometry).unwrap();
310        assert_eq!(pool.bytes(), (EXPERTS * WIDTH) as u64);
311
312        let mut slots = ExpertSlots::new(geometry).unwrap();
313        let rows = NamedRows::new(EXPERTS, WIDTH);
314
315        let plan = CopyPlan {
316            dst_slots: vec![0, 2],
317            src_rows: vec![3, 1],
318        };
319        let applied = slots.apply_copy_plan(0, &plan, &rows, &mut pool).unwrap();
320        assert_eq!(applied.rows, 2);
321        assert_eq!(applied.bytes as usize, 2 * WIDTH);
322
323        for (&slot, &row) in plan.dst_slots.iter().zip(plan.src_rows.iter()) {
324            let got = dev.dtoh_sync_copy(pool.slot(0, slot).unwrap()).unwrap();
325            assert_eq!(got, rows.rows[row as usize], "slot {slot} holds row {row}");
326        }
327
328        // Device to device: slot 0 onto slot 1, no host traffic.
329        let gather = GatherPlan {
330            dst_slots: vec![1],
331            src_slots: vec![0],
332        };
333        slots.apply_gather_plan(&gather, &mut pool).unwrap();
334        assert_eq!(
335            dev.dtoh_sync_copy(pool.slot(0, 1).unwrap()).unwrap(),
336            rows.rows[3],
337            "the gather must carry slot 0's contents, not its neighbour's"
338        );
339        assert_eq!(
340            slots.occupant(1),
341            Some(ExpertId {
342                layer: 0,
343                expert: 3
344            })
345        );
346
347        let stats = slots.stats();
348        assert_eq!(stats.host_bytes as usize, 2 * WIDTH);
349        assert_eq!(
350            stats.device_bytes as usize, WIDTH,
351            "a device-to-device copy must not be billed to the link"
352        );
353
354        // The acceptance property, on hardware: an empty plan issues no
355        // copies at all and moves no bytes.
356        let warm = slots
357            .apply_copy_plan(0, &CopyPlan::default(), &rows, &mut pool)
358            .unwrap();
359        assert!(warm.warm && warm.bytes == 0);
360        assert_eq!(slots.stats().host_bytes, stats.host_bytes);
361    }
362}