Skip to main content

fuel_parallel/
distributed_cache.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Distributed KV cache coordination for multi-GPU inference.
3//!
4//! When running pipeline- or tensor-parallel inference, the KV cache for each
5//! attention layer must be kept in sync across devices. This module provides
6//! coordination types and protocols — **not** the actual cache storage (which
7//! lives in `fuel_inference`).
8//!
9//! ## Concepts
10//!
11//! - **Cache shard**: One device's portion of the KV cache, identified by rank.
12//! - **Sync event**: A broadcast notification that a shard has been updated.
13//! - **Distributed prefix**: For prefix caching, all ranks must agree on which
14//!   prefix tokens are cached before reusing entries.
15//!
16//! # Example
17//!
18//! ```rust
19//! use fuel_parallel::distributed_cache::{
20//!     CacheShardInfo, CacheSyncProtocol, SyncEvent,
21//! };
22//!
23//! // Build shard info for rank 0 of 4
24//! let shard = CacheShardInfo::new(0, 4, 32); // rank=0, world=4, num_layers=32
25//! assert_eq!(shard.layers().len(), 8); // 32 layers / 4 ranks = 8 per shard
26//! assert_eq!(shard.layers(), &[0, 1, 2, 3, 4, 5, 6, 7]);
27//!
28//! // Protocol tracks sync state
29//! let mut protocol = CacheSyncProtocol::new(4, 32);
30//! protocol.record_event(SyncEvent::ShardUpdated { rank: 0, layer: 3, seq_pos: 42 });
31//! assert_eq!(protocol.latest_position(0, 3), Some(42));
32//! ```
33
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37/// Identifies a shard of the distributed cache.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct CacheShardInfo {
40    /// Rank of the device holding this shard.
41    rank: usize,
42    /// Total number of devices.
43    world_size: usize,
44    /// Total number of attention layers in the model.
45    num_layers: usize,
46    /// Which layers this shard is responsible for (in pipeline-parallel mode).
47    layers: Vec<usize>,
48}
49
50impl CacheShardInfo {
51    /// Create a shard info that assigns layers evenly across ranks.
52    ///
53    /// Layers are assigned contiguously: rank 0 gets layers `[0..per_rank)`,
54    /// rank 1 gets `[per_rank..2*per_rank)`, etc.
55    pub fn new(rank: usize, world_size: usize, num_layers: usize) -> Self {
56        assert!(world_size > 0, "world_size must be > 0");
57        assert!(rank < world_size, "rank must be < world_size");
58        let per_rank = num_layers / world_size;
59        let remainder = num_layers % world_size;
60        let start = per_rank * rank + rank.min(remainder);
61        let count = per_rank + if rank < remainder { 1 } else { 0 };
62        let layers: Vec<usize> = (start..start + count).collect();
63        Self {
64            rank,
65            world_size,
66            num_layers,
67            layers,
68        }
69    }
70
71    /// Create a shard with explicit layer assignments.
72    pub fn with_layers(
73        rank: usize,
74        world_size: usize,
75        num_layers: usize,
76        layers: Vec<usize>,
77    ) -> Self {
78        Self {
79            rank,
80            world_size,
81            num_layers,
82            layers,
83        }
84    }
85
86    /// Rank of this shard.
87    pub fn rank(&self) -> usize {
88        self.rank
89    }
90
91    /// World size.
92    pub fn world_size(&self) -> usize {
93        self.world_size
94    }
95
96    /// Total layers in the model.
97    pub fn num_layers(&self) -> usize {
98        self.num_layers
99    }
100
101    /// Layers this shard is responsible for.
102    pub fn layers(&self) -> &[usize] {
103        &self.layers
104    }
105
106    /// Whether this shard owns a given layer.
107    pub fn owns_layer(&self, layer: usize) -> bool {
108        self.layers.contains(&layer)
109    }
110}
111
112/// Cache synchronisation events exchanged between ranks.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub enum SyncEvent {
115    /// A shard has appended KV entries up to `seq_pos`.
116    ShardUpdated {
117        rank: usize,
118        layer: usize,
119        seq_pos: usize,
120    },
121    /// A prefix has been confirmed as shared across all ranks.
122    PrefixConfirmed {
123        /// Token hash or prefix ID.
124        prefix_id: u64,
125        /// Number of tokens in the prefix.
126        num_tokens: usize,
127    },
128    /// A shard has evicted entries, invalidating positions beyond `seq_pos`.
129    ShardEvicted {
130        rank: usize,
131        layer: usize,
132        /// All positions > seq_pos are now invalid.
133        seq_pos: usize,
134    },
135    /// All shards should flush their caches (e.g., on model swap).
136    FlushAll,
137}
138
139/// Tracks distributed cache state across all ranks.
140///
141/// This is a metadata-only coordinator. It does not hold actual tensors —
142/// tensors live in the inference engine's KV cache on each device.
143#[derive(Debug, Clone)]
144pub struct CacheSyncProtocol {
145    /// World size.
146    world_size: usize,
147    /// Total layers.
148    _num_layers: usize,
149    /// Latest known sequence position per `(rank, layer)`.
150    positions: HashMap<(usize, usize), usize>,
151    /// Confirmed shared prefix IDs and their token counts.
152    confirmed_prefixes: HashMap<u64, usize>,
153    /// Event log (bounded).
154    event_log: Vec<SyncEvent>,
155    /// Maximum number of events to retain.
156    max_log_size: usize,
157}
158
159impl CacheSyncProtocol {
160    /// Create a new protocol tracker.
161    pub fn new(world_size: usize, num_layers: usize) -> Self {
162        Self {
163            world_size,
164            _num_layers: num_layers,
165            positions: HashMap::new(),
166            confirmed_prefixes: HashMap::new(),
167            event_log: Vec::new(),
168            max_log_size: 10_000,
169        }
170    }
171
172    /// Set the maximum event log size (oldest events are dropped).
173    pub fn with_max_log_size(mut self, max: usize) -> Self {
174        self.max_log_size = max;
175        self
176    }
177
178    /// Record a synchronisation event.
179    pub fn record_event(&mut self, event: SyncEvent) {
180        match &event {
181            SyncEvent::ShardUpdated {
182                rank,
183                layer,
184                seq_pos,
185            } => {
186                self.positions.insert((*rank, *layer), *seq_pos);
187            }
188            SyncEvent::PrefixConfirmed {
189                prefix_id,
190                num_tokens,
191            } => {
192                self.confirmed_prefixes.insert(*prefix_id, *num_tokens);
193            }
194            SyncEvent::ShardEvicted {
195                rank,
196                layer,
197                seq_pos,
198            } => {
199                self.positions.insert((*rank, *layer), *seq_pos);
200            }
201            SyncEvent::FlushAll => {
202                self.positions.clear();
203                self.confirmed_prefixes.clear();
204            }
205        }
206        self.event_log.push(event);
207        if self.event_log.len() > self.max_log_size {
208            let drain = self.event_log.len() - self.max_log_size;
209            self.event_log.drain(..drain);
210        }
211    }
212
213    /// Latest known sequence position for a (rank, layer).
214    pub fn latest_position(&self, rank: usize, layer: usize) -> Option<usize> {
215        self.positions.get(&(rank, layer)).copied()
216    }
217
218    /// Minimum sequence position across all ranks for a given layer.
219    ///
220    /// Returns `None` if any rank hasn't reported for that layer.
221    /// This is the "safe" position up to which all ranks are in sync.
222    pub fn min_synced_position(&self, layer: usize) -> Option<usize> {
223        let mut min_pos: Option<usize> = None;
224        for rank in 0..self.world_size {
225            match self.positions.get(&(rank, layer)) {
226                Some(&pos) => {
227                    min_pos = Some(min_pos.map_or(pos, |m: usize| m.min(pos)));
228                }
229                None => return None,
230            }
231        }
232        min_pos
233    }
234
235    /// Whether a prefix is confirmed across all ranks.
236    pub fn is_prefix_confirmed(&self, prefix_id: u64) -> bool {
237        self.confirmed_prefixes.contains_key(&prefix_id)
238    }
239
240    /// Confirmed prefix token count—`None` if not confirmed.
241    pub fn prefix_tokens(&self, prefix_id: u64) -> Option<usize> {
242        self.confirmed_prefixes.get(&prefix_id).copied()
243    }
244
245    /// Number of events recorded.
246    pub fn event_count(&self) -> usize {
247        self.event_log.len()
248    }
249
250    /// Recent events (up to `n`).
251    pub fn recent_events(&self, n: usize) -> &[SyncEvent] {
252        let start = self.event_log.len().saturating_sub(n);
253        &self.event_log[start..]
254    }
255}
256
257/// Describes which ranks need cache data for a given request.
258///
259/// Used by the inference scheduler to decide which ranks to notify
260/// when a new request arrives that can reuse cached prefixes.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct CacheRoutingHint {
263    /// The prefix ID to look up.
264    pub prefix_id: u64,
265    /// Ranks that hold the relevant cache shards.
266    pub relevant_ranks: Vec<usize>,
267    /// Number of tokens that can be skipped (reused from cache).
268    pub reusable_tokens: usize,
269}
270
271impl CacheRoutingHint {
272    /// Create a routing hint.
273    pub fn new(prefix_id: u64, relevant_ranks: Vec<usize>, reusable_tokens: usize) -> Self {
274        Self {
275            prefix_id,
276            relevant_ranks,
277            reusable_tokens,
278        }
279    }
280
281    /// Whether this hint has any ranks to notify.
282    pub fn has_targets(&self) -> bool {
283        !self.relevant_ranks.is_empty()
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn shard_info_even_split() {
293        let shard = CacheShardInfo::new(0, 4, 32);
294        assert_eq!(shard.layers().len(), 8);
295        assert_eq!(shard.layers(), &[0, 1, 2, 3, 4, 5, 6, 7]);
296
297        let shard3 = CacheShardInfo::new(3, 4, 32);
298        assert_eq!(shard3.layers(), &[24, 25, 26, 27, 28, 29, 30, 31]);
299    }
300
301    #[test]
302    fn shard_info_uneven_split() {
303        let shard0 = CacheShardInfo::new(0, 3, 10);
304        let shard1 = CacheShardInfo::new(1, 3, 10);
305        let shard2 = CacheShardInfo::new(2, 3, 10);
306        // 10/3 = 3r1 → first rank gets 4, rest get 3
307        assert_eq!(shard0.layers().len(), 4);
308        assert_eq!(shard1.layers().len(), 3);
309        assert_eq!(shard2.layers().len(), 3);
310        // No overlap
311        let mut all: Vec<usize> = Vec::new();
312        all.extend(shard0.layers());
313        all.extend(shard1.layers());
314        all.extend(shard2.layers());
315        all.sort();
316        assert_eq!(all, (0..10).collect::<Vec<_>>());
317    }
318
319    #[test]
320    fn owns_layer() {
321        let shard = CacheShardInfo::new(1, 2, 8);
322        assert!(!shard.owns_layer(0)); // layer 0 belongs to rank 0
323        assert!(shard.owns_layer(4)); // layer 4 belongs to rank 1
324    }
325
326    #[test]
327    fn protocol_track_updates() {
328        let mut proto = CacheSyncProtocol::new(2, 4);
329        proto.record_event(SyncEvent::ShardUpdated {
330            rank: 0,
331            layer: 0,
332            seq_pos: 10,
333        });
334        proto.record_event(SyncEvent::ShardUpdated {
335            rank: 1,
336            layer: 0,
337            seq_pos: 8,
338        });
339        assert_eq!(proto.latest_position(0, 0), Some(10));
340        assert_eq!(proto.latest_position(1, 0), Some(8));
341        assert_eq!(proto.min_synced_position(0), Some(8));
342    }
343
344    #[test]
345    fn protocol_min_synced_none_if_missing() {
346        let mut proto = CacheSyncProtocol::new(3, 2);
347        proto.record_event(SyncEvent::ShardUpdated {
348            rank: 0,
349            layer: 0,
350            seq_pos: 5,
351        });
352        // rank 1 and 2 haven't reported
353        assert_eq!(proto.min_synced_position(0), None);
354    }
355
356    #[test]
357    fn protocol_prefix_confirmation() {
358        let mut proto = CacheSyncProtocol::new(2, 4);
359        assert!(!proto.is_prefix_confirmed(0xABCD));
360        proto.record_event(SyncEvent::PrefixConfirmed {
361            prefix_id: 0xABCD,
362            num_tokens: 64,
363        });
364        assert!(proto.is_prefix_confirmed(0xABCD));
365        assert_eq!(proto.prefix_tokens(0xABCD), Some(64));
366    }
367
368    #[test]
369    fn protocol_flush_all() {
370        let mut proto = CacheSyncProtocol::new(2, 4);
371        proto.record_event(SyncEvent::ShardUpdated {
372            rank: 0,
373            layer: 0,
374            seq_pos: 10,
375        });
376        proto.record_event(SyncEvent::PrefixConfirmed {
377            prefix_id: 1,
378            num_tokens: 5,
379        });
380        proto.record_event(SyncEvent::FlushAll);
381        assert_eq!(proto.latest_position(0, 0), None);
382        assert!(!proto.is_prefix_confirmed(1));
383    }
384
385    #[test]
386    fn protocol_log_bounded() {
387        let mut proto = CacheSyncProtocol::new(1, 1).with_max_log_size(3);
388        for i in 0..10 {
389            proto.record_event(SyncEvent::ShardUpdated {
390                rank: 0,
391                layer: 0,
392                seq_pos: i,
393            });
394        }
395        assert_eq!(proto.event_count(), 3);
396    }
397
398    #[test]
399    fn protocol_recent_events() {
400        let mut proto = CacheSyncProtocol::new(1, 1);
401        proto.record_event(SyncEvent::ShardUpdated {
402            rank: 0,
403            layer: 0,
404            seq_pos: 1,
405        });
406        proto.record_event(SyncEvent::ShardUpdated {
407            rank: 0,
408            layer: 0,
409            seq_pos: 2,
410        });
411        proto.record_event(SyncEvent::FlushAll);
412        let recent = proto.recent_events(2);
413        assert_eq!(recent.len(), 2);
414        assert_eq!(recent[1], SyncEvent::FlushAll);
415    }
416
417    #[test]
418    fn cache_routing_hint() {
419        let hint = CacheRoutingHint::new(42, vec![0, 2], 128);
420        assert!(hint.has_targets());
421        assert_eq!(hint.reusable_tokens, 128);
422    }
423
424    #[test]
425    fn cache_routing_hint_empty() {
426        let hint = CacheRoutingHint::new(0, vec![], 0);
427        assert!(!hint.has_targets());
428    }
429}