Skip to main content

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