Skip to main content

rlx_distributed/
pipeline.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Pipeline-parallel forward orchestration.
6//!
7//! Each rank owns a contiguous block of layers (see [`crate::partition`])
8//! and implements [`BlockRunner`] for it. [`PipelineCoordinator`] drives
9//! one forward pass as a relay over a [`ProcessGroup`]:
10//!
11//! ```text
12//!   rank world-1 (First):  tokens ─embed→ layers ─hidden→  send → world-2
13//!   rank r       (Middle):           recv ← layers ─hidden→ send → r-1
14//!   rank 0       (Last):             recv ← layers → norm+head → logits → sample
15//!                                                    └─ broadcast token → all ranks
16//! ```
17//!
18//! The sampled token is broadcast from rank 0 so every rank can append it
19//! and stay in lockstep for the next step. The coordinator is model-
20//! agnostic — only [`BlockRunner`] knows how to run a block.
21//!
22//! Teardown contract: after the last step, call [`PipelineCoordinator::barrier`]
23//! before dropping the group, so no rank tears down its transport while a
24//! peer is still mid-transfer.
25
26use crate::partition::{BlockRole, block_role};
27use anyhow::{Result, bail};
28use rlx_driver::ProcessGroup;
29
30/// Tag for pipeline hidden-state messages (below `TAG_RESERVED_BASE`, so
31/// it never collides with collective/barrier traffic).
32const TAG_PIPE_HIDDEN: u32 = 100;
33
34/// Input handed to a [`BlockRunner`].
35pub enum BlockInput<'a> {
36    /// Token ids — only the First/Single block receives these (it embeds).
37    Tokens(&'a [u32]),
38    /// Hidden states `[batch * seq * hidden]` from the previous block.
39    Hidden(&'a [f32]),
40}
41
42/// Output produced by a [`BlockRunner`].
43pub enum BlockOutput {
44    /// Hidden states to forward to the next block.
45    Hidden(Vec<f32>),
46    /// Final logits (last token), produced only by the Last/Single block.
47    Logits(Vec<f32>),
48}
49
50impl BlockOutput {
51    fn into_hidden(self) -> Result<Vec<f32>> {
52        match self {
53            BlockOutput::Hidden(v) => Ok(v),
54            BlockOutput::Logits(_) => bail!("block produced Logits where Hidden was expected"),
55        }
56    }
57    fn into_logits(self) -> Result<Vec<f32>> {
58        match self {
59            BlockOutput::Logits(v) => Ok(v),
60            BlockOutput::Hidden(_) => bail!("block produced Hidden where Logits was expected"),
61        }
62    }
63}
64
65/// A model's per-rank layer block. One implementation per model family
66/// (e.g. `Qwen3PipelineStage`); the coordinator drives it.
67pub trait BlockRunner {
68    /// This rank's role, which dictates the input/output kinds it handles.
69    fn role(&self) -> BlockRole;
70
71    /// Run this block. The First/Single block receives [`BlockInput::Tokens`]
72    /// and embeds; others receive [`BlockInput::Hidden`]. The Last/Single
73    /// block returns [`BlockOutput::Logits`]; others return
74    /// [`BlockOutput::Hidden`].
75    fn run(&mut self, input: BlockInput<'_>) -> Result<BlockOutput>;
76}
77
78/// Drives pipeline-parallel forward passes over a process group.
79pub struct PipelineCoordinator {
80    group: ProcessGroup,
81}
82
83impl PipelineCoordinator {
84    pub fn new(group: ProcessGroup) -> Self {
85        Self { group }
86    }
87
88    pub fn rank(&self) -> u32 {
89        self.group.rank()
90    }
91    pub fn world_size(&self) -> u32 {
92        self.group.world_size()
93    }
94    pub fn is_leader(&self) -> bool {
95        self.group.is_leader()
96    }
97    pub fn group(&self) -> &ProcessGroup {
98        &self.group
99    }
100
101    /// Synchronize all ranks. Call once after the final step before
102    /// dropping the group (teardown contract above).
103    pub fn barrier(&self) -> Result<()> {
104        Ok(self.group.barrier()?)
105    }
106
107    /// Run one pipeline forward pass over `token_ids` (the full sequence so
108    /// far, prefill-style) and return the next token.
109    ///
110    /// `sample` is invoked only on the Last/Single rank, with the final
111    /// logits; its result is broadcast so every rank returns the same
112    /// token. `token_ids` is only read by the First/Single rank, but all
113    /// ranks must call this in lockstep.
114    pub fn forward_step(
115        &self,
116        runner: &mut dyn BlockRunner,
117        token_ids: &[u32],
118        sample: impl FnOnce(&[f32]) -> u32,
119    ) -> Result<u32> {
120        let world = self.group.world_size();
121        let rank = self.group.rank();
122
123        // Defensive: the runner's role must agree with its rank.
124        let role = block_role(rank, world);
125        if runner.role() != role {
126            bail!(
127                "BlockRunner role {:?} disagrees with rank {rank}/{world} role {role:?}",
128                runner.role()
129            );
130        }
131
132        let mut sampled: Option<u32> = None;
133        match role {
134            BlockRole::Single => {
135                let logits = runner.run(BlockInput::Tokens(token_ids))?.into_logits()?;
136                sampled = Some(sample(&logits));
137            }
138            BlockRole::First => {
139                let hidden = runner.run(BlockInput::Tokens(token_ids))?.into_hidden()?;
140                self.group.send_f32(rank - 1, TAG_PIPE_HIDDEN, &hidden)?;
141            }
142            BlockRole::Middle => {
143                let hidden_in = self.group.recv_f32(rank + 1, TAG_PIPE_HIDDEN)?;
144                let hidden = runner.run(BlockInput::Hidden(&hidden_in))?.into_hidden()?;
145                self.group.send_f32(rank - 1, TAG_PIPE_HIDDEN, &hidden)?;
146            }
147            BlockRole::Last => {
148                let hidden_in = self.group.recv_f32(rank + 1, TAG_PIPE_HIDDEN)?;
149                let logits = runner.run(BlockInput::Hidden(&hidden_in))?.into_logits()?;
150                sampled = Some(sample(&logits));
151            }
152        }
153
154        // Broadcast the token from rank 0 (the Last/Single block) to all.
155        let mut tok = [sampled.unwrap_or(0) as f32];
156        self.group.broadcast(0, &mut tok)?;
157        Ok(tok[0] as u32)
158    }
159
160    /// Generate up to `max_tokens`, appending each sampled token to
161    /// `tokens` (seeded with the prompt). `sample` runs on the leader;
162    /// `should_stop` ends early (e.g. on EOS) and — because the token is
163    /// broadcast — fires identically on every rank, so all stay in
164    /// lockstep. Returns the newly generated ids and barriers at the end.
165    ///
166    /// This is the prefill-recompute loop (each step re-runs the whole
167    /// sequence); a KV-cached decode loop is the follow-up.
168    pub fn generate(
169        &self,
170        runner: &mut dyn BlockRunner,
171        tokens: &mut Vec<u32>,
172        max_tokens: usize,
173        mut sample: impl FnMut(&[f32]) -> u32,
174        mut should_stop: impl FnMut(u32) -> bool,
175    ) -> Result<Vec<u32>> {
176        let start = tokens.len();
177        for _ in 0..max_tokens {
178            let tok = self.forward_step(runner, tokens, |l| sample(l))?;
179            tokens.push(tok);
180            if should_stop(tok) {
181                break;
182            }
183        }
184        self.barrier()?;
185        Ok(tokens[start..].to_vec())
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::partition::pipeline_layer_range;
193    use rlx_driver::NetTransport;
194    use std::net::{Ipv4Addr, SocketAddr, TcpListener};
195    use std::ops::Range;
196    use std::sync::Arc;
197    use std::thread;
198
199    /// Toy model used to verify the relay end-to-end without real weights.
200    ///
201    ///   embed(t):  h[i] = t + i
202    ///   layer g:   h[i] += g + 1
203    ///   last:      logits = h ; token = round(logits[0])
204    ///
205    /// After all `L` layers, `h[0] = t + 0 + sum_{g=0..L}(g+1) = t + L(L+1)/2`.
206    /// So the token a correct pipeline yields is `t + L(L+1)/2` — and it only
207    /// comes out right if every layer is applied exactly once, in order.
208    struct MockStage {
209        role: BlockRole,
210        layers: Range<usize>,
211        hidden: usize,
212    }
213
214    impl BlockRunner for MockStage {
215        fn role(&self) -> BlockRole {
216            self.role
217        }
218        fn run(&mut self, input: BlockInput<'_>) -> Result<BlockOutput> {
219            let mut h: Vec<f32> = match input {
220                BlockInput::Tokens(ids) => {
221                    let t = *ids.last().unwrap() as f32;
222                    (0..self.hidden).map(|i| t + i as f32).collect()
223                }
224                BlockInput::Hidden(hv) => hv.to_vec(),
225            };
226            for g in self.layers.clone() {
227                for x in h.iter_mut() {
228                    *x += (g + 1) as f32;
229                }
230            }
231            Ok(match self.role {
232                BlockRole::Last | BlockRole::Single => BlockOutput::Logits(h),
233                _ => BlockOutput::Hidden(h),
234            })
235        }
236    }
237
238    /// Spin up `world` loopback ranks (ephemeral ports, no collisions) and
239    /// run `body(ProcessGroup)` on a thread each.
240    fn run_group<F>(world: u32, body: F)
241    where
242        F: Fn(ProcessGroup) + Send + Sync + 'static,
243    {
244        let listeners: Vec<TcpListener> = (0..world)
245            .map(|_| TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap())
246            .collect();
247        let addrs: Vec<SocketAddr> = listeners.iter().map(|l| l.local_addr().unwrap()).collect();
248        let body = Arc::new(body);
249        let handles: Vec<_> = listeners
250            .into_iter()
251            .enumerate()
252            .map(|(rank, listener)| {
253                let addrs = addrs.clone();
254                let body = body.clone();
255                thread::spawn(move || {
256                    let t =
257                        NetTransport::from_listener(rank as u32, world, listener, addrs, 1 << 20)
258                            .expect("build transport");
259                    body(ProcessGroup::new(Arc::new(t)));
260                })
261            })
262            .collect();
263        for h in handles {
264            h.join().unwrap();
265        }
266    }
267
268    fn run_pipeline_case(world: u32, num_layers: usize, hidden: usize, token: u32) {
269        let expected = token as usize + num_layers * (num_layers + 1) / 2;
270        run_group(world, move |group| {
271            let rank = group.rank();
272            let mut stage = MockStage {
273                role: block_role(rank, world),
274                layers: pipeline_layer_range(num_layers, rank, world),
275                hidden,
276            };
277            let coord = PipelineCoordinator::new(group);
278            let tok = coord
279                .forward_step(&mut stage, &[token], |logits| logits[0].round() as u32)
280                .unwrap();
281            assert_eq!(tok as usize, expected, "rank {rank}");
282            // Two steps to exercise repeated relays staying in lockstep.
283            let tok2 = coord
284                .forward_step(&mut stage, &[token], |logits| logits[0].round() as u32)
285                .unwrap();
286            assert_eq!(tok2 as usize, expected, "rank {rank} step 2");
287            coord.barrier().unwrap();
288        });
289    }
290
291    #[test]
292    fn pipeline_even_split_matches_serial() {
293        run_pipeline_case(4, 16, 8, 5);
294    }
295
296    #[test]
297    fn pipeline_uneven_split_matches_serial() {
298        run_pipeline_case(3, 10, 6, 7);
299    }
300
301    #[test]
302    fn pipeline_two_ranks() {
303        run_pipeline_case(2, 5, 4, 11);
304    }
305
306    #[test]
307    fn single_rank_runs_whole_model() {
308        run_group(1, |group| {
309            let mut stage = MockStage {
310                role: BlockRole::Single,
311                layers: 0..6,
312                hidden: 4,
313            };
314            let coord = PipelineCoordinator::new(group);
315            let tok = coord
316                .forward_step(&mut stage, &[9], |logits| logits[0].round() as u32)
317                .unwrap();
318            assert_eq!(tok as usize, 9 + 6 * 7 / 2);
319        });
320    }
321
322    #[test]
323    fn generate_loops_and_appends() {
324        // Mock token = last_token + S where S = L(L+1)/2. Generating from
325        // [T] gives [T+S, T+2S, T+3S] (each step feeds the previous token).
326        let world = 2u32;
327        let num_layers = 5usize;
328        let hidden = 4usize;
329        let prompt0 = 11u32;
330        let s = (num_layers * (num_layers + 1) / 2) as u32;
331        run_group(world, move |group| {
332            let rank = group.rank();
333            let mut stage = MockStage {
334                role: block_role(rank, world),
335                layers: pipeline_layer_range(num_layers, rank, world),
336                hidden,
337            };
338            let coord = PipelineCoordinator::new(group);
339            let mut tokens = vec![prompt0];
340            let produced = coord
341                .generate(
342                    &mut stage,
343                    &mut tokens,
344                    3,
345                    |l| l[0].round() as u32,
346                    |_| false,
347                )
348                .unwrap();
349            assert_eq!(
350                produced,
351                vec![prompt0 + s, prompt0 + 2 * s, prompt0 + 3 * s],
352                "rank {rank}"
353            );
354            assert_eq!(tokens.len(), 4, "prompt + 3 generated");
355        });
356    }
357
358    #[test]
359    fn generate_stops_early_on_should_stop() {
360        run_group(2, |group| {
361            let rank = group.rank();
362            let mut stage = MockStage {
363                role: block_role(rank, 2),
364                layers: pipeline_layer_range(4, rank, 2),
365                hidden: 4,
366            };
367            let coord = PipelineCoordinator::new(group);
368            let mut tokens = vec![1u32];
369            // Stop after the first generated token regardless of value.
370            let produced = coord
371                .generate(
372                    &mut stage,
373                    &mut tokens,
374                    10,
375                    |l| l[0].round() as u32,
376                    |_| true,
377                )
378                .unwrap();
379            assert_eq!(produced.len(), 1, "rank {rank}: stopped after one token");
380        });
381    }
382}