rlx_distributed/partition.rs
1// RLX models — distributed inference.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Pipeline-parallel layer partitioning.
17//!
18//! Layers are split into `world_size` contiguous blocks and assigned to
19//! ranks in **reverse**: rank 0 owns the *last* block (so it produces the
20//! logits and samples), rank `world-1` owns the *first* block (so it does
21//! the token embedding). This matches mlx-lm's `PipelineMixin` and means a
22//! token's activations flow rank `world-1 → … → 1 → 0`.
23//!
24//! The split is exact (every layer assigned exactly once) and as even as
25//! possible: the first `num_layers % world` forward-blocks get one extra
26//! layer.
27
28use std::ops::Range;
29
30/// Where a rank sits in the pipeline. Drives whether it embeds tokens,
31/// just transforms hidden states, or produces logits.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum BlockRole {
34 /// `world == 1`: this rank runs the whole model (embed → layers → head).
35 Single,
36 /// Highest rank: owns the first layers, embeds the input tokens, and
37 /// sends hidden states toward rank 0.
38 First,
39 /// Interior rank: receives hidden states, runs its layers, forwards.
40 Middle,
41 /// Rank 0: owns the last layers, runs the final norm + LM head, and
42 /// produces logits to sample from.
43 Last,
44}
45
46/// Role of `rank` in a `world`-sized pipeline.
47pub fn block_role(rank: u32, world: u32) -> BlockRole {
48 if world <= 1 {
49 BlockRole::Single
50 } else if rank == world - 1 {
51 BlockRole::First
52 } else if rank == 0 {
53 BlockRole::Last
54 } else {
55 BlockRole::Middle
56 }
57}
58
59/// The contiguous, half-open range of layer indices `[start, end)` that
60/// `rank` owns in a `world`-sized pipeline.
61///
62/// rank 0 → last block, rank `world-1` → first block. Panics if
63/// `rank >= world`.
64pub fn pipeline_layer_range(num_layers: usize, rank: u32, world: u32) -> Range<usize> {
65 let world = world.max(1) as usize;
66 let rank = rank as usize;
67 assert!(rank < world, "rank {rank} out of range for world {world}");
68
69 let per = num_layers / world;
70 let extra = num_layers % world;
71 // This rank owns forward-block `j` (reverse assignment).
72 let j = world - 1 - rank;
73 // Forward block i has `per + (i < extra)` layers; the first `extra`
74 // blocks carry the remainder.
75 let size_of = |i: usize| per + if i < extra { 1 } else { 0 };
76 let start: usize = (0..j).map(size_of).sum();
77 let len = size_of(j);
78 start..(start + len)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn roles_map_correctly() {
87 assert_eq!(block_role(0, 1), BlockRole::Single);
88 assert_eq!(block_role(0, 4), BlockRole::Last);
89 assert_eq!(block_role(3, 4), BlockRole::First);
90 assert_eq!(block_role(1, 4), BlockRole::Middle);
91 assert_eq!(block_role(2, 4), BlockRole::Middle);
92 }
93
94 #[test]
95 fn even_split_is_reversed() {
96 // 16 layers / 4 ranks: rank 0 = last 4, rank 3 = first 4.
97 assert_eq!(pipeline_layer_range(16, 0, 4), 12..16);
98 assert_eq!(pipeline_layer_range(16, 1, 4), 8..12);
99 assert_eq!(pipeline_layer_range(16, 2, 4), 4..8);
100 assert_eq!(pipeline_layer_range(16, 3, 4), 0..4);
101 }
102
103 #[test]
104 fn uneven_split_tiles_exactly() {
105 // 10 layers / 4 ranks: forward block sizes 3,3,2,2.
106 // reverse → rank0=[8,10), rank1=[6,8), rank2=[3,6), rank3=[0,3).
107 assert_eq!(pipeline_layer_range(10, 0, 4), 8..10);
108 assert_eq!(pipeline_layer_range(10, 1, 4), 6..8);
109 assert_eq!(pipeline_layer_range(10, 2, 4), 3..6);
110 assert_eq!(pipeline_layer_range(10, 3, 4), 0..3);
111
112 // Reassembled in rank order (world-1 down to 0) covers [0,10) once.
113 let mut covered = [0u8; 10];
114 for r in 0..4u32 {
115 for i in pipeline_layer_range(10, r, 4) {
116 covered[i] += 1;
117 }
118 }
119 assert!(covered.iter().all(|&c| c == 1), "every layer covered once");
120 }
121
122 #[test]
123 fn single_rank_owns_all() {
124 assert_eq!(pipeline_layer_range(28, 0, 1), 0..28);
125 }
126
127 #[test]
128 fn more_ranks_than_layers_is_safe() {
129 // 2 layers, 4 ranks: two ranks get a layer, two get empty ranges.
130 let total: usize = (0..4u32).map(|r| pipeline_layer_range(2, r, 4).len()).sum();
131 assert_eq!(total, 2);
132 }
133}