Skip to main content

ts_pd/
decode.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//! A Decode rank's peer: it finds a request's Prefill ranks through the
22//! bootstrap server, registers with them once per route, sends each
23//! admitted request's page manifest, and collects their completions.
24//!
25//! Threads: one connects requests to their routes (the bootstrap server's
26//! HTTP lookups), one handles the status socket, one watches the Prefill
27//! nodes' health. The scheduling loop only registers requests and polls;
28//! the forward thread hands over each plan's remote prefill
29//! ([`DecodeOutbox`]). Routes and manifests are planned here, over the
30//! rank's cache transfer contract ([`crate::layout`]).
31
32use std::{
33    collections::{BTreeSet, HashMap, HashSet},
34    sync::{
35        atomic::{AtomicBool, Ordering},
36        mpsc, Arc, Mutex, MutexGuard,
37    },
38    thread,
39    time::{Duration, Instant},
40};
41
42use ts_collective::{Group, Mesh, Reduce};
43
44use crate::{
45    batch::{BatchRows, Staged},
46    control::{BootstrapInfo, PdError, PdEvent, RemotePrefill, TransferPeer},
47    http,
48    layout::{
49        contract::Contract,
50        manifest::{build_manifest, validate_manifest},
51        registration::{plan_route, PrefillRoute},
52    },
53    sleep_or_stop,
54    status::{RoomTable, TransferPoll},
55    wire::{self, StatusMessage},
56    PeerConfig, StartError, Transport,
57};
58
59/// How often an idle socket thread checks for shutdown.
60const SOCKET_POLL: Duration = Duration::from_millis(100);
61
62/// How long a bootstrap-server lookup may take.
63const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
64
65/// A Decode rank's settings.
66#[derive(Debug, Clone)]
67pub struct DecodeConfig {
68    /// What both roles share.
69    pub peer: PeerConfig,
70    /// What this rank registers with a Prefill rank: its cache arena base,
71    /// its cache transfer contract's wire bytes (with its landing
72    /// addresses), and its attention TP size and rank.
73    pub arena_base: u64,
74    /// See `arena_base`.
75    pub layout: Vec<u8>,
76    /// See `arena_base`.
77    pub tp_size: i64,
78    /// See `arena_base`.
79    pub tp_rank: i64,
80    /// A request whose manifest went out this long ago without its KV
81    /// landing fails.
82    pub waiting_timeout: Duration,
83    /// How often the Prefill nodes' health is checked.
84    pub heartbeat_interval: Duration,
85    /// Failed checks in a row after which a Prefill node counts as lost.
86    pub heartbeat_max_failures: u32,
87}
88
89/// A Prefill rank a route reaches.
90#[derive(Debug, Clone)]
91struct Target {
92    host: String,
93    port: u16,
94    is_dummy: bool,
95}
96
97/// The first valid value each Prefill rank reported for a room.
98#[derive(Debug, Clone, Default)]
99struct Landed {
100    bootstrap_token: Option<i32>,
101    spec_candidate_ids: Option<Vec<i32>>,
102    bootstrap_logprob: Option<f64>,
103}
104
105/// What a route's registered targets are kept by: the bootstrap address,
106/// the room's Prefill DP group and the target ranks.
107type ConnectionKey = (String, u64, Vec<i64>);
108
109#[derive(Default)]
110struct State {
111    rooms: RoomTable,
112    /// The rooms of the requests the scheduling loop tracks. Only these
113    /// keep state: a late report, connection or admission of a room the
114    /// loop forgot must not bring it back.
115    tracked: HashSet<u64>,
116    /// The tracked requests' rooms, by request.
117    rooms_by_rid: HashMap<String, u64>,
118    expected: HashMap<u64, BTreeSet<i64>>,
119    responses: HashMap<u64, BTreeSet<i64>>,
120    pending: HashMap<u64, Landed>,
121    landed: HashMap<u64, Landed>,
122    cached_tokens: HashMap<u64, i64>,
123    targets: HashMap<u64, Arc<Vec<Target>>>,
124    admissions: HashMap<u64, i32>,
125    waiting_since: HashMap<u64, Instant>,
126    /// Bootstrap address -> the rooms reading from it.
127    addr_rooms: HashMap<String, HashSet<u64>>,
128    /// Bootstrap address -> its route; the addresses the heartbeat watches.
129    routes: HashMap<String, Arc<PrefillRoute>>,
130    connections: HashMap<ConnectionKey, Arc<Vec<Target>>>,
131}
132
133impl State {
134    /// Fail a tracked room; an untracked one has nothing to fail.
135    fn fail(&mut self, room: u64, reason: impl Into<String>) {
136        if !self.tracked.contains(&room) {
137            return;
138        }
139        self.rooms.record_failure(room, reason);
140        self.rooms.update(room, TransferPoll::Failed);
141    }
142
143    /// Apply one Prefill rank's report.
144    fn apply(&mut self, message: StatusMessage) {
145        let room = message.room;
146        if !self.tracked.contains(&room) {
147            return;
148        }
149        match message.status {
150            TransferPoll::Success => {
151                let Some(expected) = self.expected.get(&room).cloned() else {
152                    self.fail(
153                        room,
154                        "Received KV completion before its transfer route was registered",
155                    );
156                    return;
157                };
158                if !expected.contains(&message.prefill_rank) {
159                    self.fail(
160                        room,
161                        "Received KV completion from an unexpected Prefill TP rank",
162                    );
163                    return;
164                }
165                let responses = self.responses.entry(room).or_default();
166                responses.insert(message.prefill_rank);
167                let arrived = responses.clone();
168                // TP ranks describe overlapping prefixes, not disjoint ranges.
169                let cached = self.cached_tokens.entry(room).or_insert(0);
170                *cached = (*cached).max(message.cached_tokens);
171                // The rank carrying the first token need not complete last;
172                // -1 is a rank without one.
173                let token = match message.bootstrap_token {
174                    -1 => None,
175                    token => {
176                        match i32::try_from(token).ok().filter(|token| *token >= 0) {
177                            Some(token) => Some(token),
178                            None => {
179                                self.fail(
180                                room,
181                                format!("Received KV completion with an invalid first token {token}"),
182                            );
183                                return;
184                            }
185                        }
186                    }
187                };
188                let pending = self.pending.entry(room).or_default();
189                if let Some(token) = token {
190                    pending.bootstrap_token.get_or_insert(token);
191                }
192                if let Some(ids) = message.spec_candidate_ids {
193                    pending.spec_candidate_ids.get_or_insert(ids);
194                }
195                if let Some(logprob) = message.bootstrap_logprob {
196                    pending.bootstrap_logprob.get_or_insert(logprob);
197                }
198                if arrived.len() < expected.len() {
199                    return;
200                }
201                if arrived != expected {
202                    self.fail(
203                        room,
204                        "Prefill TP completion set disagrees with the transfer route",
205                    );
206                    return;
207                }
208                let landed = self.pending.remove(&room).unwrap_or_default();
209                if landed.bootstrap_token.is_none() {
210                    // The first decode feeds on that token: without it the
211                    // request cannot run here.
212                    self.fail(
213                        room,
214                        "Every Prefill rank reported the KV landed without the request's first token",
215                    );
216                    return;
217                }
218                self.landed.insert(room, landed);
219                self.rooms.update(room, TransferPoll::Success);
220            }
221            TransferPoll::Failed => {
222                self.fail(
223                    room,
224                    "Failed to get kvcache from prefill instance, it might be dead",
225                );
226            }
227            status => self.rooms.update(room, status),
228        }
229    }
230
231    /// A room is connected to `targets`, the ranks `route` names at `addr`:
232    /// it is bootstrapped. A room whose request ended while the lookups ran
233    /// stays forgotten. Returns whether the room was still tracked.
234    fn attach(
235        &mut self,
236        room: u64,
237        addr: &str,
238        route: &PrefillRoute,
239        targets: Arc<Vec<Target>>,
240    ) -> bool {
241        if !self.tracked.contains(&room) {
242            return false;
243        }
244        self.expected
245            .insert(room, route.expected_prefill_ranks.iter().copied().collect());
246        self.targets.insert(room, targets);
247        self.addr_rooms
248            .entry(addr.to_owned())
249            .or_default()
250            .insert(room);
251        self.rooms.update(room, TransferPoll::Bootstrapped);
252        true
253    }
254
255    /// The Prefill node at bootstrap address `addr` is lost: forget its
256    /// route and its connections, and fail its unfinished rooms. Returns how
257    /// many failed.
258    fn lose_node(&mut self, addr: &str) -> usize {
259        self.connections
260            .retain(|(connected, _, _), _| connected != addr);
261        self.routes.remove(addr);
262        let rooms = self.addr_rooms.remove(addr).unwrap_or_default();
263        let mut affected = 0;
264        for room in rooms {
265            if matches!(
266                self.rooms.get(room),
267                None | Some(TransferPoll::Success | TransferPoll::Failed)
268            ) || self.rooms.has_failure(room)
269            {
270                continue;
271            }
272            self.fail(
273                room,
274                format!("Losing connection with prefill instance (bootstrap_addr: {addr})"),
275            );
276            affected += 1;
277        }
278        affected
279    }
280
281    fn clear(&mut self, room: u64, addr: &str) {
282        self.rooms.remove(room);
283        self.tracked.remove(&room);
284        self.expected.remove(&room);
285        self.responses.remove(&room);
286        self.pending.remove(&room);
287        self.landed.remove(&room);
288        self.cached_tokens.remove(&room);
289        self.targets.remove(&room);
290        self.admissions.remove(&room);
291        self.waiting_since.remove(&room);
292        if let Some(rooms) = self.addr_rooms.get_mut(addr) {
293            rooms.remove(&room);
294        }
295    }
296}
297
298struct Shared {
299    transport: Arc<Transport>,
300    state: Mutex<State>,
301    local_ip: String,
302    engine_rank: i64,
303    arena_base: u64,
304    /// The contract's wire bytes, as registered.
305    layout_wire: Vec<u8>,
306    layout: Contract,
307    tp_size: i64,
308    tp_rank: i64,
309    metrics: Option<Arc<ts_metrics::KvTransferMetrics>>,
310}
311
312impl Shared {
313    fn state(&self) -> MutexGuard<'_, State> {
314        self.state.lock().expect("the PD state lock is poisoned")
315    }
316
317    /// The route for `addr`, fetched and planned once.
318    fn route(&self, addr: &str) -> Result<Arc<PrefillRoute>, String> {
319        if let Some(route) = self.state().routes.get(addr).cloned() {
320            return Ok(route);
321        }
322        let info = http::get_text(
323            &format!("http://{addr}/route?engine_rank=-1&target_dp_group=-1"),
324            LOOKUP_TIMEOUT,
325        )
326        .map_err(|error| {
327            tracing::error!(addr, error, "cannot fetch the prefill parallel info");
328            format!("Could not fetch prefill parallel info from bootstrap_addr: {addr}")
329        })?;
330        let route = Arc::new(
331            plan_route(&info, &self.layout, self.tp_size, self.tp_rank).map_err(|error| error.0)?,
332        );
333        self.state().routes.insert(addr.to_owned(), route.clone());
334        Ok(route)
335    }
336
337    /// Connect a room to its Prefill ranks:
338    /// look up the route, register with its ranks the first time the route
339    /// is used, and mark the room bootstrapped.
340    fn connect(&self, room: u64, addr: &str) -> Result<(), String> {
341        let route = self.route(addr)?;
342        let dp_group = room % route.prefill_dp_size.max(1) as u64;
343        let key: ConnectionKey = (addr.to_owned(), dp_group, route.target_tp_ranks.clone());
344        let known = self.state().connections.get(&key).cloned();
345        let targets = match known {
346            Some(targets) => targets,
347            None => {
348                let mut targets = Vec::with_capacity(route.target_tp_ranks.len());
349                for &rank in &route.target_tp_ranks {
350                    let lookup_failed = || {
351                        format!(
352                            "Could not fetch bootstrap info for engine rank: {} and \
353                             target_dp_group: {dp_group}",
354                            self.engine_rank
355                        )
356                    };
357                    let body = http::get_text(
358                        &format!(
359                            "http://{addr}/route?engine_rank={rank}&target_dp_group={dp_group}"
360                        ),
361                        LOOKUP_TIMEOUT,
362                    )
363                    .map_err(|error| {
364                        tracing::error!(addr, rank, error, "cannot fetch a prefill rank");
365                        lookup_failed()
366                    })?;
367                    let info: serde_json::Value =
368                        serde_json::from_str(&body).map_err(|_| lookup_failed())?;
369                    let host = info["rank_ip"].as_str().ok_or_else(lookup_failed)?;
370                    let port = info["rank_port"]
371                        .as_u64()
372                        .and_then(|port| u16::try_from(port).ok())
373                        .ok_or_else(lookup_failed)?;
374                    targets.push(Target {
375                        host: host.to_owned(),
376                        port,
377                        is_dummy: route.dummy_tp_ranks.contains(&rank),
378                    });
379                }
380                let targets = Arc::new(targets);
381                self.state().connections.insert(key, targets.clone());
382                // Registered once per route; a room's pre-allocation follows
383                // on the same socket, so it arrives after the registration.
384                let session_id = self.transport.session_id();
385                for target in targets.iter() {
386                    self.transport.sockets.send(
387                        &target.host,
388                        target.port,
389                        wire::registration_frames(
390                            &self.local_ip,
391                            self.transport.sockets.port(),
392                            &session_id,
393                            self.arena_base,
394                            &self.layout_wire,
395                            self.tp_size,
396                            self.tp_rank,
397                        ),
398                    );
399                }
400                targets
401            }
402        };
403        if self.state().attach(room, addr, &route, targets) {
404            tracing::debug!(room, "connected");
405        }
406        Ok(())
407    }
408
409    /// Fail every unfinished room reading from a lost Prefill node.
410    fn lose_node(&self, addr: &str) {
411        let affected = self.state().lose_node(addr);
412        if let Some(metrics) = &self.metrics {
413            for _ in 0..affected {
414                metrics.record_failure();
415            }
416        }
417        tracing::error!(
418            addr,
419            affected,
420            "lost the connection with a prefill instance"
421        );
422    }
423
424    fn watch(&self, interval: Duration, max_failures: u32, stop: &mpsc::Receiver<()>) {
425        let mut failures: HashMap<String, u32> = HashMap::new();
426        while !sleep_or_stop(stop, interval) {
427            let addrs: Vec<String> = self.state().routes.keys().cloned().collect();
428            for addr in addrs {
429                let count = failures.entry(addr.clone()).or_insert(0);
430                if http::healthy(
431                    &format!("http://{addr}/health"),
432                    Duration::from_secs(2),
433                    Duration::from_secs(3),
434                ) {
435                    *count = 0;
436                    // Drop the rooms that ended.
437                    let mut state = self.state();
438                    let State {
439                        rooms, addr_rooms, ..
440                    } = &mut *state;
441                    if let Some(tracked) = addr_rooms.get_mut(&addr) {
442                        tracked.retain(|&room| rooms.get(room).is_some());
443                    }
444                } else {
445                    tracing::info!(addr, "attempting to reconnect to a prefill instance");
446                    *count += 1;
447                }
448                if *count >= max_failures {
449                    self.lose_node(&addr);
450                }
451            }
452        }
453    }
454}
455
456/// Where the forward thread hands a Decode rank's peer its work; never
457/// waits for a transfer.
458///
459/// A plan's remote prefill is staged when the plan goes to the data plane
460/// ([`DecodeOutbox::stage_remote_prefill`]) and handed over by the data plane
461/// once the plan's page zeroing finished
462/// ([`DecodeOutbox::hand_over_remote_prefill`]): the Prefill ranks' writes
463/// are not ordered with the device's, so the pages they land in must be
464/// sanitized first.
465#[derive(Clone)]
466pub struct DecodeOutbox {
467    shared: Arc<Shared>,
468    staged: Staged,
469}
470
471impl DecodeOutbox {
472    /// A sleep releases the cache: deregister it from the transfer engine,
473    /// refused while a tracked request's KV may still land.
474    pub fn release_registration(&self) -> Result<(), String> {
475        let mut receiving: Vec<u64> = self.shared.state().tracked.iter().copied().collect();
476        if !receiving.is_empty() {
477            receiving.sort_unstable();
478            return Err(format!(
479                "cannot release the cache while KV receives are in flight: rooms {receiving:?}"
480            ));
481        }
482        self.shared.transport.release_regions()
483    }
484
485    /// A wake re-mapped the cache: register it under a fresh transfer
486    /// session, as the Prefill ranks write into this memory with the keys of
487    /// its registration, and forget the registrations made under the old
488    /// one, so the next request registers the new session again.
489    pub fn restore_registration(&self) -> Result<(), String> {
490        self.shared.transport.restore_regions(true)?;
491        self.shared.state().connections.clear();
492        Ok(())
493    }
494
495    /// Stage a plan's remote prefill: the prompts whose pages it admitted.
496    pub fn stage_remote_prefill(&self, rows: BatchRows) -> Result<(), String> {
497        self.staged.stage(rows)
498    }
499
500    /// Whether a staged remote prefill still waits to be handed over.
501    pub fn remote_prefill_pending(&self) -> bool {
502        self.staged.is_pending()
503    }
504
505    /// The staged remote prefill's pages are sanitized: send each admitted
506    /// prompt's manifest to its Prefill ranks (`HostDecodePeer.execute`).
507    /// Every row is planned before any goes out.
508    pub fn hand_over_remote_prefill(&self) -> Result<(), String> {
509        let rows = self.staged.take()?;
510        if rows.num_extends != rows.request_ids.len() {
511            return Err("CachePD Decode admission does not support mixed batches".to_owned());
512        }
513        let layout = &self.shared.layout;
514        let mut pending = Vec::new();
515        for (index, rid) in rows.request_ids.iter().enumerate() {
516            let Some(room) = self.shared.state().rooms_by_rid.get(rid).copied() else {
517                continue;
518            };
519            let manifest = build_manifest(
520                &rows.tables,
521                layout,
522                index,
523                i64::from(rows.extend_prefix_lens[index]),
524                i64::from(rows.prefill_lengths[index]),
525                rows.state_prefix_cached[index],
526            )
527            .and_then(|manifest| {
528                validate_manifest(&manifest, layout, "destination")?;
529                manifest.to_wire()
530            })
531            .map_err(|error| error.0)?;
532            pending.push((rid, room, rows.request_pool_indices[index], manifest));
533        }
534        for (rid, room, request_pool_index, manifest) in pending {
535            self.receive(rid, room, request_pool_index, &manifest);
536        }
537        Ok(())
538    }
539
540    /// A request's pages are admitted: send its manifest to its Prefill
541    /// ranks; its KV lands at `request_pool_index`.
542    fn receive(&self, rid: &str, room: u64, request_pool_index: i32, manifest: &[u8]) {
543        let targets = {
544            let mut state = self.shared.state();
545            if !state.tracked.contains(&room) {
546                // The loop ended the request (its transfer failed) after it
547                // planned this admission: its pages go back, so no Prefill
548                // rank may write them.
549                tracing::debug!(rid, room, "not pre-allocating an ended request");
550                return;
551            }
552            state.admissions.insert(room, request_pool_index);
553            state.waiting_since.insert(room, Instant::now());
554            state.rooms.update(room, TransferPoll::WaitingForInput);
555            state.targets.get(&room).cloned()
556        };
557        let Some(targets) = targets else {
558            tracing::warn!(
559                rid,
560                room,
561                "a request without a route reached its remote prefill"
562            );
563            return;
564        };
565        let session_id = self.shared.transport.session_id();
566        for target in targets.iter() {
567            self.shared.transport.sockets.send(
568                &target.host,
569                target.port,
570                wire::preallocation_frames(
571                    room,
572                    &session_id,
573                    (!target.is_dummy).then_some(manifest),
574                ),
575            );
576        }
577        tracing::debug!(rid, room, "pre-allocation sent");
578    }
579}
580
581/// One request's transfer as the scheduling loop tracks it.
582struct Receiver {
583    room: u64,
584    addr: String,
585    local: TransferPoll,
586    concluded: Option<TransferPoll>,
587}
588
589/// A Decode rank's PD transfer peer.
590pub struct DecodePeer {
591    shared: Arc<Shared>,
592    connect: mpsc::Sender<(u64, String)>,
593    stop: Arc<AtomicBool>,
594    _heartbeat_stop: mpsc::Sender<()>,
595    waiting_timeout: Duration,
596    /// Tracked requests, in registration order (every replica member
597    /// registers the same requests in the same order).
598    order: Vec<String>,
599    receivers: HashMap<String, Receiver>,
600    timeout_logged: bool,
601}
602
603impl DecodePeer {
604    /// Start the transfer engine, register the cache, open the status
605    /// socket and start the threads.
606    pub fn start(config: DecodeConfig) -> Result<(Self, DecodeOutbox), StartError> {
607        let layout = Contract::from_wire(&config.layout)?;
608        let (incoming, messages) = mpsc::channel::<Vec<bytes::Bytes>>();
609        let transport = Transport::start(&config.peer, incoming)?;
610        let shared = Arc::new(Shared {
611            transport,
612            state: Mutex::new(State::default()),
613            local_ip: config.peer.local_ip.clone(),
614            engine_rank: config.peer.engine_rank,
615            arena_base: config.arena_base,
616            layout_wire: config.layout,
617            layout,
618            tp_size: config.tp_size,
619            tp_rank: config.tp_rank,
620            metrics: config.peer.metrics.clone(),
621        });
622        let stop = Arc::new(AtomicBool::new(false));
623        {
624            let shared = shared.clone();
625            let stop = stop.clone();
626            thread::Builder::new()
627                .name("ts-pd-status".to_owned())
628                .spawn(move || loop {
629                    match messages.recv_timeout(SOCKET_POLL) {
630                        Ok(frames) => match StatusMessage::from_frames(&frames) {
631                            Ok(message) => {
632                                tracing::debug!(room = message.room, status = ?message.status, "status");
633                                shared.state().apply(message);
634                            }
635                            Err(error) => {
636                                tracing::error!(%error, "rejecting a malformed Prefill status message");
637                            }
638                        },
639                        Err(mpsc::RecvTimeoutError::Timeout) => {
640                            if stop.load(Ordering::Relaxed) {
641                                return;
642                            }
643                        }
644                        Err(mpsc::RecvTimeoutError::Disconnected) => return,
645                    }
646                })?;
647        }
648        let (connect, rooms) = mpsc::channel::<(u64, String)>();
649        {
650            let shared = shared.clone();
651            thread::Builder::new()
652                .name("ts-pd-connect".to_owned())
653                .spawn(move || {
654                    while let Ok((room, addr)) = rooms.recv() {
655                        if let Err(reason) = shared.connect(room, &addr) {
656                            shared.state().fail(room, reason);
657                        }
658                    }
659                })?;
660        }
661        let (heartbeat_stop, stopped) = mpsc::channel();
662        {
663            let shared = shared.clone();
664            let interval = config.heartbeat_interval;
665            let max_failures = config.heartbeat_max_failures.max(1);
666            thread::Builder::new()
667                .name("ts-pd-heartbeat".to_owned())
668                .spawn(move || shared.watch(interval, max_failures, &stopped))?;
669        }
670        let outbox = DecodeOutbox {
671            shared: shared.clone(),
672            staged: Staged::default(),
673        };
674        Ok((
675            Self {
676                shared,
677                connect,
678                stop,
679                _heartbeat_stop: heartbeat_stop,
680                waiting_timeout: config.waiting_timeout,
681                order: Vec::new(),
682                receivers: HashMap::new(),
683                timeout_logged: false,
684            },
685            outbox,
686        ))
687    }
688
689    /// The status socket's port.
690    pub fn port(&self) -> u16 {
691        self.shared.transport.sockets.port()
692    }
693
694    /// The transfer engine's session id.
695    pub fn session_id(&self) -> String {
696        self.shared.transport.session_id()
697    }
698
699    fn forget(&mut self, rid: &str) {
700        let Some(receiver) = self.receivers.remove(rid) else {
701            return;
702        };
703        self.order.retain(|tracked| tracked != rid);
704        let mut state = self.shared.state();
705        state.clear(receiver.room, &receiver.addr);
706        state.rooms_by_rid.remove(rid);
707    }
708}
709
710impl Drop for DecodePeer {
711    fn drop(&mut self) {
712        self.stop.store(true, Ordering::Relaxed);
713    }
714}
715
716/// `host:port`, with an IPv6 host in brackets (`join_host_port`).
717fn join_host_port(host: &str, port: u16) -> String {
718    if host.contains(':') && !host.starts_with('[') {
719        format!("[{host}]:{port}")
720    } else {
721        format!("{host}:{port}")
722    }
723}
724
725impl TransferPeer for DecodePeer {
726    fn register(&mut self, rid: &str, bootstrap: &BootstrapInfo) -> Result<(), PdError> {
727        let room = bootstrap.room;
728        let addr = join_host_port(&bootstrap.host, bootstrap.port);
729        {
730            let mut state = self.shared.state();
731            state.tracked.insert(room);
732            state.rooms_by_rid.insert(rid.to_owned(), room);
733            state.rooms.update(room, TransferPoll::Bootstrapping);
734        }
735        if self
736            .receivers
737            .insert(
738                rid.to_owned(),
739                Receiver {
740                    room,
741                    addr: addr.clone(),
742                    local: TransferPoll::Bootstrapping,
743                    concluded: None,
744                },
745            )
746            .is_none()
747        {
748            self.order.push(rid.to_owned());
749        }
750        self.connect
751            .send((room, addr))
752            .map_err(|_| PdError::Peer("the PD connect thread stopped".to_owned()))
753    }
754
755    fn poll(&mut self, mesh: &mut Mesh, replica: &Group) -> Result<Vec<PdEvent>, PdError> {
756        if self.order.is_empty() {
757            return Ok(Vec::new());
758        }
759        let now = Instant::now();
760        let mut polls = Vec::with_capacity(self.order.len());
761        {
762            let mut state = self.shared.state();
763            for rid in &self.order {
764                let receiver = self.receivers.get_mut(rid).expect("tracked");
765                let poll = match receiver.concluded {
766                    Some(poll) => poll,
767                    None => match state
768                        .rooms
769                        .get(receiver.room)
770                        .unwrap_or(TransferPoll::Failed)
771                    {
772                        status @ (TransferPoll::Success | TransferPoll::Failed) => {
773                            receiver.concluded = Some(status);
774                            status
775                        }
776                        TransferPoll::WaitingForInput => {
777                            let waited = state
778                                .waiting_since
779                                .get(&receiver.room)
780                                .map(|since| now - *since);
781                            match waited {
782                                Some(waited) if waited >= self.waiting_timeout => {
783                                    if !self.timeout_logged {
784                                        self.timeout_logged = true;
785                                        tracing::warn!(
786                                            "Some requests never received their KV after \
787                                             bootstrapping. Raise \
788                                             TOKENSPEED_DISAGGREGATION_WAITING_TIMEOUT if a \
789                                             longer TTFT is acceptable."
790                                        );
791                                    }
792                                    state.rooms.record_failure(
793                                        receiver.room,
794                                        format!(
795                                            "Request {} timed out after {:.1}s in \
796                                             TransferPoll.WaitingForInput",
797                                            receiver.room,
798                                            waited.as_secs_f64()
799                                        ),
800                                    );
801                                    if let Some(metrics) = &self.shared.metrics {
802                                        metrics.record_timeout("waiting_for_input");
803                                    }
804                                    receiver.concluded = Some(TransferPoll::Failed);
805                                    TransferPoll::Failed
806                                }
807                                _ => TransferPoll::WaitingForInput,
808                            }
809                        }
810                        status => status,
811                    },
812                };
813                polls.push(poll as i64);
814            }
815        }
816        let agreed = if replica.size() > 1 {
817            mesh.all_reduce(replica, &polls, Reduce::Min)?
818        } else {
819            polls
820        };
821
822        let mut events = Vec::new();
823        let mut ended = Vec::new();
824        for (rid, &poll) in self.order.iter().zip(&agreed) {
825            let receiver = self.receivers.get_mut(rid).expect("tracked");
826            let poll = TransferPoll::from_wire(poll).unwrap_or(TransferPoll::Failed);
827            if receiver.local == TransferPoll::Bootstrapping && poll == TransferPoll::Bootstrapped {
828                receiver.local = TransferPoll::Bootstrapped;
829                events.push(PdEvent::Bootstrapped(rid.clone()));
830            } else if poll == TransferPoll::Failed {
831                let reason = self
832                    .shared
833                    .state()
834                    .rooms
835                    .failure(receiver.room)
836                    .map(str::to_owned);
837                tracing::warn!(rid, room = receiver.room, reason, "KV transfer failed");
838                events.push(PdEvent::Failed(rid.clone()));
839                ended.push(rid.clone());
840            } else if receiver.local == TransferPoll::Bootstrapped && poll == TransferPoll::Success
841            {
842                receiver.local = TransferPoll::Success;
843                let mut state = self.shared.state();
844                let landed = state.landed.remove(&receiver.room).unwrap_or_default();
845                let cached_tokens = state.cached_tokens.remove(&receiver.room).unwrap_or(0);
846                let request_pool_index =
847                    *state.admissions.get(&receiver.room).ok_or_else(|| {
848                        PdError::Peer(format!("{rid}'s KV landed before its pages were admitted"))
849                    })?;
850                // A room lands only with its first token (`State::apply`).
851                let bootstrap_token = landed.bootstrap_token.ok_or_else(|| {
852                    PdError::Peer(format!("{rid}'s KV landed without its first token"))
853                })?;
854                events.push(PdEvent::RemotePrefillDone(RemotePrefill {
855                    rid: rid.clone(),
856                    bootstrap_token,
857                    cached_tokens,
858                    bootstrap_logprob: landed.bootstrap_logprob,
859                    spec_candidate_ids: landed.spec_candidate_ids,
860                    request_pool_index,
861                }));
862                ended.push(rid.clone());
863            }
864        }
865        for rid in ended {
866            self.forget(&rid);
867        }
868        Ok(events)
869    }
870
871    fn record_usage(&mut self, _rid: &str, _cached_tokens: i64, _bootstrap_logprob: Option<f64>) {}
872
873    fn has_in_flight(&self) -> bool {
874        !self.order.is_empty()
875    }
876
877    /// The decode node has no encoder to wait on.
878    fn abort_room(&mut self, _: &str, _: u64) {}
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884
885    fn success(room: u64, prefill_rank: i64, bootstrap_token: i64) -> StatusMessage {
886        StatusMessage {
887            room,
888            status: TransferPoll::Success,
889            prefill_rank,
890            bootstrap_token,
891            spec_candidate_ids: None,
892            cached_tokens: 0,
893            bootstrap_logprob: None,
894        }
895    }
896
897    fn route() -> PrefillRoute {
898        PrefillRoute {
899            target_tp_ranks: vec![0, 1],
900            dummy_tp_ranks: Vec::new(),
901            expected_prefill_ranks: vec![0, 1],
902            prefill_dp_size: 1,
903        }
904    }
905
906    /// Track `room` and connect it to Prefill ranks 0 and 1 at `addr`.
907    fn connected(state: &mut State, room: u64, addr: &str) {
908        state.tracked.insert(room);
909        state.rooms.update(room, TransferPoll::Bootstrapping);
910        assert!(state.attach(room, addr, &route(), Arc::new(Vec::new())));
911    }
912
913    #[test]
914    fn a_room_lands_with_the_first_token_any_rank_carried() {
915        let mut state = State::default();
916        connected(&mut state, 1, "prefill:80");
917        state.apply(success(1, 1, -1));
918        assert_eq!(state.rooms.get(1), Some(TransferPoll::Bootstrapped));
919        state.apply(success(1, 0, 7));
920        assert_eq!(state.rooms.get(1), Some(TransferPoll::Success));
921        assert_eq!(state.landed[&1].bootstrap_token, Some(7));
922    }
923
924    #[test]
925    fn a_landing_without_a_valid_first_token_fails_the_room() {
926        let mut state = State::default();
927        connected(&mut state, 1, "prefill:80");
928        state.apply(success(1, 0, -1));
929        state.apply(success(1, 1, -1));
930        assert_eq!(state.rooms.get(1), Some(TransferPoll::Failed));
931        assert!(state.rooms.failure(1).unwrap().contains("first token"));
932        connected(&mut state, 2, "prefill:80");
933        state.apply(success(2, 0, i64::from(i32::MAX) + 1));
934        assert_eq!(state.rooms.get(2), Some(TransferPoll::Failed));
935    }
936
937    #[test]
938    fn a_forgotten_room_is_not_brought_back() {
939        let mut state = State::default();
940        connected(&mut state, 1, "prefill:80");
941        state.clear(1, "prefill:80");
942        // A late report, failure or connection of the room changes nothing.
943        state.apply(success(1, 0, 7));
944        state.fail(1, "late");
945        assert!(!state.attach(1, "prefill:80", &route(), Arc::new(Vec::new())));
946        assert_eq!(state.rooms.get(1), None);
947        assert!(state.expected.is_empty());
948        assert!(state.targets.is_empty());
949        assert!(state.pending.is_empty() && state.landed.is_empty());
950    }
951
952    #[test]
953    fn losing_a_node_keeps_another_whose_address_extends_its_own() {
954        let mut state = State::default();
955        for (room, addr) in [(1, "host:80"), (2, "host:8000")] {
956            connected(&mut state, room, addr);
957            state
958                .connections
959                .insert((addr.to_owned(), 0, vec![0, 1]), Arc::new(Vec::new()));
960        }
961        assert_eq!(state.lose_node("host:80"), 1);
962        assert_eq!(state.rooms.get(1), Some(TransferPoll::Failed));
963        assert_eq!(state.rooms.get(2), Some(TransferPoll::Bootstrapped));
964        let kept: Vec<&str> = state
965            .connections
966            .keys()
967            .map(|(addr, _, _)| addr.as_str())
968            .collect();
969        assert_eq!(kept, ["host:8000"]);
970    }
971}