Skip to main content

ts_pd/
lib.rs

1// Copyright (c) 2026 LightSeek Foundation
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in
11// all copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19// SOFTWARE.
20
21//! TokenSpeed's PD transfer peer (RFC 0002): one rank's half of moving a
22//! request's KV from the prefill node to the decode node.
23//!
24//! The peer owns the Mooncake transfer engine, the P/D handshake over ZMQ,
25//! the bootstrap-server HTTP calls, the room states, the transfer workers
26//! and the per-round agreement on transfer progress. It also plans
27//! everything that needs the ranks' cache layouts ([`layout`]): the routes
28//! between unequal Prefill and Decode rank sets, the block manifests read
29//! off the plans' block tables, and the copies one transfer makes. The data
30//! plane only builds the rank's cache transfer contract at startup and
31//! tells the forward thread when a plan's remote stream may go out
32//! ([`PrefillOutbox`], [`DecodeOutbox`]). The bootstrap server the nodes
33//! rendezvous through is here too ([`bootstrap`]). The wire formats are
34//! unchanged from the Python peers this crate replaced, so nodes of either
35//! version interoperate.
36
37mod batch;
38pub mod bootstrap;
39mod control;
40mod decode;
41pub mod epd;
42mod http;
43pub mod layout;
44mod net;
45mod prefill;
46mod status;
47mod wire;
48
49use std::{
50    path::PathBuf,
51    sync::{Arc, RwLock},
52    time::Duration,
53};
54
55pub use batch::BatchRows;
56pub use bootstrap::{BootstrapServer, Registry};
57pub use control::{
58    BootstrapInfo, EmbeddingChunk, EmbeddingItem, EmbeddingReceiver, EmbeddingSender, LandedItem,
59    LandedRequest, PdError, PdEvent, RemotePrefill, TransferPeer,
60};
61pub use decode::{DecodeConfig, DecodeOutbox, DecodePeer};
62pub use layout::{
63    registration::{PrefillLayout, PrefillRoute},
64    LayoutError,
65};
66pub use net::NetError;
67pub use prefill::{Layerwise, PrefillConfig, PrefillOutbox, PrefillPeer};
68pub use status::TransferPoll;
69use ts_mooncake::{Engine, PageGrid};
70pub use wire::StatusMessage;
71
72/// What one transfer copies, in order.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Copy {
75    /// A pages x fields grid.
76    Pages(PageGrid),
77    /// Contiguous spans: source, destination, length each.
78    Spans {
79        /// Local addresses.
80        sources: Vec<u64>,
81        /// The peer's addresses.
82        destinations: Vec<u64>,
83        /// Byte counts.
84        lengths: Vec<u64>,
85    },
86}
87
88/// The most descriptors one WRITE batch carries.
89const DESCRIPTOR_BATCH: usize = 4096;
90
91/// A peer that failed to start.
92#[derive(Debug, thiserror::Error)]
93pub enum StartError {
94    /// The transfer engine failed.
95    #[error(transparent)]
96    Engine(#[from] ts_mooncake::Error),
97    /// The control sockets failed.
98    #[error(transparent)]
99    Net(#[from] NetError),
100    /// A thread did not start.
101    #[error("starting a PD thread: {0}")]
102    Thread(#[from] std::io::Error),
103    /// The rank's cache transfer contract is malformed.
104    #[error(transparent)]
105    Layout(#[from] LayoutError),
106}
107
108/// What both roles start from.
109#[derive(Debug, Clone)]
110pub struct PeerConfig {
111    /// The Mooncake library (`libtokenspeed_transfer_engine.so`).
112    pub library: PathBuf,
113    /// This rank's address peers reach it at (`get_local_ip_by_remote`).
114    pub local_ip: String,
115    /// The RDMA devices to use, comma-separated; empty for all.
116    pub ib_device: String,
117    /// The device ranges to register: the cache arena or its regions.
118    pub regions: Vec<(usize, usize)>,
119    /// This rank's global rank, for messages.
120    pub engine_rank: i64,
121    /// The transfer metrics, when the engine exports metrics.
122    pub metrics: Option<Arc<ts_metrics::KvTransferMetrics>>,
123}
124
125/// The engine and sockets both roles share.
126struct Transport {
127    library: Arc<ts_mooncake::Library>,
128    local_ip: String,
129    ib_device: String,
130    /// The device ranges registered with the engine.
131    regions: Vec<(usize, usize)>,
132    /// Replaced when a sleeping Decode rank wakes under a fresh session.
133    engine: RwLock<Arc<Engine>>,
134    sockets: net::Sockets,
135}
136
137impl Transport {
138    fn start(
139        config: &PeerConfig,
140        incoming: std::sync::mpsc::Sender<Vec<bytes::Bytes>>,
141    ) -> Result<Arc<Self>, StartError> {
142        let library = ts_mooncake::Library::load(&config.library)?;
143        let engine = Engine::start(library.clone(), &config.local_ip, &config.ib_device)?;
144        for &(addr, length) in &config.regions {
145            engine.register(addr, length)?;
146        }
147        tracing::info!(
148            session = engine.session_id(),
149            ib_device = config.ib_device,
150            "Mooncake transfer engine ready"
151        );
152        let sockets = net::Sockets::start(&config.local_ip, incoming)?;
153        Ok(Arc::new(Self {
154            library,
155            local_ip: config.local_ip.clone(),
156            ib_device: config.ib_device.clone(),
157            regions: config.regions.clone(),
158            engine: RwLock::new(Arc::new(engine)),
159            sockets,
160        }))
161    }
162
163    /// The engine transfers run on.
164    fn engine(&self) -> Arc<Engine> {
165        self.engine
166            .read()
167            .expect("the engine lock is poisoned")
168            .clone()
169    }
170
171    /// The id peers address this rank's memory by.
172    fn session_id(&self) -> String {
173        self.engine().session_id().to_owned()
174    }
175
176    /// Deregister the cache before a sleep unmaps its memory.
177    fn release_regions(&self) -> Result<(), String> {
178        let engine = self.engine();
179        let failed = self
180            .regions
181            .iter()
182            .filter(|&&(addr, _)| engine.unregister(addr).is_err())
183            .count();
184        if failed > 0 {
185            return Err(format!(
186                "Mooncake failed to deregister {failed} cache region(s) before the memory release"
187            ));
188        }
189        Ok(())
190    }
191
192    /// Register the re-mapped cache after a wake; with `fresh_session`
193    /// under a new engine, so no peer keeps writing with the keys of the
194    /// pages the sleep freed.
195    fn restore_regions(&self, fresh_session: bool) -> Result<(), String> {
196        let engine = if fresh_session {
197            Arc::new(
198                Engine::start(self.library.clone(), &self.local_ip, &self.ib_device)
199                    .map_err(|error| error.to_string())?,
200            )
201        } else {
202            self.engine()
203        };
204        let failed = self
205            .regions
206            .iter()
207            .filter(|&&(addr, length)| engine.register(addr, length).is_err())
208            .count();
209        if failed > 0 {
210            return Err(format!(
211                "Mooncake failed to register {failed} cache region(s) after the memory resume"
212            ));
213        }
214        if fresh_session {
215            tracing::info!(session = engine.session_id(), "a fresh transfer session");
216            *self.engine.write().expect("the engine lock is poisoned") = engine;
217        }
218        Ok(())
219    }
220
221    /// Run one transfer's copies to `session`.
222    fn write(&self, session: &str, copies: &[Copy]) -> Result<(), ts_mooncake::Error> {
223        let engine = self.engine();
224        for copy in copies {
225            match copy {
226                Copy::Pages(grid) => engine.write_pages(session, grid, DESCRIPTOR_BATCH)?,
227                Copy::Spans {
228                    sources,
229                    destinations,
230                    lengths,
231                } => {
232                    for start in (0..sources.len()).step_by(DESCRIPTOR_BATCH) {
233                        let end = (start + DESCRIPTOR_BATCH).min(sources.len());
234                        engine.write(
235                            session,
236                            &sources[start..end],
237                            &destinations[start..end],
238                            &lengths[start..end],
239                        )?;
240                    }
241                }
242            }
243        }
244        Ok(())
245    }
246}
247
248/// Sleep `duration` unless `stop` fires first; returns whether it fired.
249fn sleep_or_stop(stop: &std::sync::mpsc::Receiver<()>, duration: Duration) -> bool {
250    !matches!(
251        stop.recv_timeout(duration),
252        Err(std::sync::mpsc::RecvTimeoutError::Timeout)
253    )
254}