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}