Skip to main content

dynamo_runtime/routing_policy/
picker.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use super::{
7    AdmissionKind, CandidateView, RouteCandidate, RouteContext, RouteDecision, RouteDevice,
8    RoutePolicy, RouteTarget,
9};
10
11#[derive(Debug)]
12pub(crate) struct RoutePicker {
13    policy: RoutePolicy,
14    round_robin_cursor: AtomicU64,
15}
16
17/// Concurrent picker for first-party routing policies.
18///
19/// This exposes the same first-party policy implementations used by
20/// [`PushRouter`](crate::pipeline::PushRouter) without coupling policy hosts to
21/// transport dispatch.
22#[derive(Debug)]
23pub struct BuiltinRoutePicker {
24    inner: RoutePicker,
25}
26
27impl BuiltinRoutePicker {
28    pub const fn round_robin() -> Self {
29        Self {
30            inner: RoutePicker::new(RoutePolicy::RoundRobin),
31        }
32    }
33
34    pub const fn random() -> Self {
35        Self {
36            inner: RoutePicker::new(RoutePolicy::Random),
37        }
38    }
39
40    pub const fn power_of_two_choices() -> Self {
41        Self {
42            inner: RoutePicker::new(RoutePolicy::PowerOfTwoChoices),
43        }
44    }
45
46    pub const fn least_loaded() -> Self {
47        Self {
48            inner: RoutePicker::new(RoutePolicy::LeastLoaded),
49        }
50    }
51
52    /// Select one worker and advance any policy state.
53    pub fn select_worker(&self, worker_ids: &[u64], load: impl Fn(u64) -> u64) -> Option<u64> {
54        self.inner
55            .select(
56                CandidateView::Workers(worker_ids),
57                RouteContext::default(),
58                load,
59            )
60            .map(|decision| decision.target.worker_id)
61    }
62
63    /// Inspect the next worker without advancing round-robin state.
64    pub fn peek_worker(&self, worker_ids: &[u64], load: impl Fn(u64) -> u64) -> Option<u64> {
65        self.inner
66            .peek(
67                CandidateView::Workers(worker_ids),
68                RouteContext::default(),
69                load,
70            )
71            .map(|decision| decision.target.worker_id)
72    }
73}
74
75impl RoutePicker {
76    pub(crate) const fn new(policy: RoutePolicy) -> Self {
77        Self {
78            policy,
79            round_robin_cursor: AtomicU64::new(0),
80        }
81    }
82
83    pub(crate) const fn policy(&self) -> RoutePolicy {
84        self.policy
85    }
86
87    #[inline(always)]
88    pub(crate) fn peek(
89        &self,
90        candidates: CandidateView<'_>,
91        context: RouteContext,
92        load: impl Fn(u64) -> u64,
93    ) -> Option<RouteDecision> {
94        let mut samples = RandomSamples;
95        self.choose_with_samples(candidates, context, &load, false, &mut samples)
96    }
97
98    #[inline(always)]
99    pub(crate) fn select(
100        &self,
101        candidates: CandidateView<'_>,
102        context: RouteContext,
103        load: impl Fn(u64) -> u64,
104    ) -> Option<RouteDecision> {
105        let mut samples = RandomSamples;
106        self.choose_with_samples(candidates, context, &load, true, &mut samples)
107    }
108
109    #[inline(always)]
110    fn choose_stateless_index(
111        &self,
112        candidate_count: usize,
113        commit: bool,
114        samples: &mut impl SampleSource,
115    ) -> Option<usize> {
116        if candidate_count == 0 {
117            return None;
118        }
119
120        match self.policy {
121            RoutePolicy::RoundRobin => {
122                let cursor = if commit {
123                    self.round_robin_cursor.fetch_add(1, Ordering::Relaxed)
124                } else {
125                    self.round_robin_cursor.load(Ordering::Relaxed)
126                };
127                Some(cursor as usize % candidate_count)
128            }
129            RoutePolicy::Random => Some(samples.index(candidate_count)),
130            RoutePolicy::PowerOfTwoChoices
131            | RoutePolicy::LeastLoaded
132            | RoutePolicy::DeviceAwareWeighted => None,
133        }
134    }
135
136    #[inline(always)]
137    fn choose_with_samples(
138        &self,
139        candidates: CandidateView<'_>,
140        context: RouteContext,
141        load: &impl Fn(u64) -> u64,
142        commit: bool,
143        samples: &mut impl SampleSource,
144    ) -> Option<RouteDecision> {
145        if candidates.is_empty() {
146            return None;
147        }
148
149        match self.policy {
150            RoutePolicy::RoundRobin | RoutePolicy::Random => Some(RouteDecision {
151                target: candidates.target(self.choose_stateless_index(
152                    candidates.len(),
153                    commit,
154                    samples,
155                )?),
156                admission: AdmissionKind::None,
157            }),
158            RoutePolicy::PowerOfTwoChoices => {
159                let first = samples.index(candidates.len());
160                if candidates.len() == 1 {
161                    return Some(RouteDecision {
162                        target: candidates.target(first),
163                        admission: AdmissionKind::Occupancy,
164                    });
165                }
166                let second_offset = 1 + samples.index(candidates.len() - 1);
167                let second = (first + second_offset) % candidates.len();
168                let first_target = candidates.target(first);
169                let second_target = candidates.target(second);
170                let target = if load(first_target.worker_id) <= load(second_target.worker_id) {
171                    first_target
172                } else {
173                    second_target
174                };
175                Some(RouteDecision {
176                    target,
177                    admission: AdmissionKind::Occupancy,
178                })
179            }
180            RoutePolicy::LeastLoaded => {
181                lowest_load(candidates, load, samples).map(|target| RouteDecision {
182                    target,
183                    admission: AdmissionKind::Occupancy,
184                })
185            }
186            RoutePolicy::DeviceAwareWeighted => {
187                let CandidateView::DeviceAware(candidates) = candidates else {
188                    return None;
189                };
190                device_aware(candidates, context, load, samples)
191            }
192        }
193    }
194}
195
196trait SampleSource {
197    fn index(&mut self, upper: usize) -> usize;
198}
199
200struct RandomSamples;
201
202impl SampleSource for RandomSamples {
203    #[inline(always)]
204    fn index(&mut self, upper: usize) -> usize {
205        fastrand::usize(..upper)
206    }
207}
208
209#[inline(always)]
210fn lowest_load(
211    candidates: CandidateView<'_>,
212    load: &impl Fn(u64) -> u64,
213    samples: &mut impl SampleSource,
214) -> Option<RouteTarget> {
215    match candidates {
216        CandidateView::Workers(workers) => {
217            let mut best = None;
218            let mut best_load = u64::MAX;
219            let mut ties = 0usize;
220            for &worker_id in workers {
221                let target_load = load(worker_id);
222                if target_load < best_load {
223                    best = Some(RouteTarget::worker(worker_id));
224                    best_load = target_load;
225                    ties = 1;
226                } else if target_load == best_load {
227                    ties += 1;
228                    if samples.index(ties) == 0 {
229                        best = Some(RouteTarget::worker(worker_id));
230                    }
231                }
232            }
233            best
234        }
235        CandidateView::DeviceAware(candidates) => {
236            let mut best = None;
237            let mut best_load = u64::MAX;
238            let mut ties = 0usize;
239            for candidate in candidates {
240                let target_load = load(candidate.target.worker_id);
241                if target_load < best_load {
242                    best = Some(candidate.target);
243                    best_load = target_load;
244                    ties = 1;
245                } else if target_load == best_load {
246                    ties += 1;
247                    if samples.index(ties) == 0 {
248                        best = Some(candidate.target);
249                    }
250                }
251            }
252            best
253        }
254    }
255}
256
257#[derive(Default)]
258struct DeviceGroup {
259    count: u64,
260    total_load: u64,
261    best: Option<RouteTarget>,
262    best_load: u64,
263    best_ties: usize,
264}
265
266impl DeviceGroup {
267    #[inline(always)]
268    fn consider(&mut self, target: RouteTarget, target_load: u64, samples: &mut impl SampleSource) {
269        self.count += 1;
270        self.total_load = self.total_load.saturating_add(target_load);
271        if self.best.is_none() || target_load < self.best_load {
272            self.best = Some(target);
273            self.best_load = target_load;
274            self.best_ties = 1;
275        } else if target_load == self.best_load {
276            self.best_ties += 1;
277            if samples.index(self.best_ties) == 0 {
278                self.best = Some(target);
279            }
280        }
281    }
282}
283
284#[inline(always)]
285fn device_aware(
286    candidates: &[RouteCandidate],
287    context: RouteContext,
288    load: &impl Fn(u64) -> u64,
289    samples: &mut impl SampleSource,
290) -> Option<RouteDecision> {
291    let mut cpu = DeviceGroup {
292        best_load: u64::MAX,
293        ..Default::default()
294    };
295    let mut accelerator = DeviceGroup {
296        best_load: u64::MAX,
297        ..Default::default()
298    };
299    let mut full_cache = DeviceGroup {
300        best_load: u64::MAX,
301        ..Default::default()
302    };
303
304    for candidate in candidates {
305        let target_load = load(candidate.target.worker_id);
306        match candidate.device {
307            RouteDevice::Cpu => cpu.consider(candidate.target, target_load, samples),
308            RouteDevice::Accelerator => {
309                accelerator.consider(candidate.target, target_load, samples)
310            }
311        }
312        if context.required_cache_hits > 0 && candidate.cache_hits >= context.required_cache_hits {
313            full_cache.consider(candidate.target, target_load, samples);
314        }
315    }
316
317    if let Some(target) = full_cache.best {
318        return Some(RouteDecision {
319            target,
320            admission: AdmissionKind::None,
321        });
322    }
323
324    let target = match (cpu.best, accelerator.best) {
325        (None, None) => return None,
326        (Some(target), None) => target,
327        (None, Some(target)) => target,
328        (Some(cpu_target), Some(accelerator_target)) => {
329            let ratio = context.non_cpu_to_cpu_ratio.max(1) as u64;
330            let allowed_cpu = accelerator.total_load.saturating_mul(cpu.count)
331                / ratio.saturating_mul(accelerator.count);
332            if cpu.total_load < allowed_cpu {
333                cpu_target
334            } else {
335                accelerator_target
336            }
337        }
338    };
339
340    Some(RouteDecision {
341        target,
342        admission: AdmissionKind::Occupancy,
343    })
344}
345
346#[cfg(test)]
347mod tests {
348    use std::cell::Cell;
349
350    use super::*;
351
352    struct ScriptedSamples {
353        values: Vec<usize>,
354        consumed: usize,
355    }
356
357    impl ScriptedSamples {
358        fn new(values: impl Into<Vec<usize>>) -> Self {
359            Self {
360                values: values.into(),
361                consumed: 0,
362            }
363        }
364    }
365
366    impl SampleSource for ScriptedSamples {
367        fn index(&mut self, upper: usize) -> usize {
368            let value = self.values[self.consumed];
369            self.consumed += 1;
370            value % upper
371        }
372    }
373
374    #[test]
375    fn round_robin_peek_does_not_advance_selection() {
376        let picker = RoutePicker::new(RoutePolicy::RoundRobin);
377        let candidates = CandidateView::Workers(&[10, 20, 30, 40]);
378        for _ in 0..16 {
379            assert_eq!(
380                picker
381                    .peek(candidates, RouteContext::default(), |_| 0)
382                    .unwrap()
383                    .target
384                    .worker_id,
385                10
386            );
387        }
388        let selected = (0..4)
389            .map(|_| {
390                picker
391                    .select(candidates, RouteContext::default(), |_| 0)
392                    .unwrap()
393                    .target
394                    .worker_id
395            })
396            .collect::<Vec<_>>();
397        assert_eq!(selected, [10, 20, 30, 40]);
398    }
399
400    #[test]
401    fn random_peek_uses_one_independent_sample() {
402        let picker = RoutePicker::new(RoutePolicy::Random);
403        let candidates = CandidateView::Workers(&[10, 20, 30, 40]);
404        let mut samples = ScriptedSamples::new(vec![2]);
405        let decision = picker
406            .choose_with_samples(
407                candidates,
408                RouteContext::default(),
409                &|_| 0,
410                false,
411                &mut samples,
412            )
413            .unwrap();
414        assert_eq!(decision.target.worker_id, 30);
415        assert_eq!(samples.consumed, 1);
416    }
417
418    #[test]
419    fn p2c_uses_exactly_two_samples_and_two_load_reads() {
420        let picker = RoutePicker::new(RoutePolicy::PowerOfTwoChoices);
421        let candidates = CandidateView::Workers(&[10, 20, 30, 40]);
422        let mut samples = ScriptedSamples::new(vec![1, 1]);
423        let reads = Cell::new(0);
424        let decision = picker
425            .choose_with_samples(
426                candidates,
427                RouteContext::default(),
428                &|worker| {
429                    reads.set(reads.get() + 1);
430                    if worker == 20 { 5 } else { 1 }
431                },
432                true,
433                &mut samples,
434            )
435            .unwrap();
436        assert_eq!(decision.target.worker_id, 40);
437        assert_eq!(samples.consumed, 2);
438        assert_eq!(reads.get(), 2);
439    }
440
441    #[test]
442    fn least_loaded_ties_follow_scripted_reservoir_samples() {
443        let picker = RoutePicker::new(RoutePolicy::LeastLoaded);
444        let candidates = CandidateView::Workers(&[10, 20, 30]);
445        let mut samples = ScriptedSamples::new(vec![1, 0]);
446        let decision = picker
447            .choose_with_samples(
448                candidates,
449                RouteContext::default(),
450                &|_| 3,
451                true,
452                &mut samples,
453            )
454            .unwrap();
455        assert_eq!(decision.target.worker_id, 30);
456        assert_eq!(samples.consumed, 2);
457    }
458
459    #[test]
460    fn device_aware_scans_each_candidate_once_and_full_hits_skip_admission() {
461        let picker = RoutePicker::new(RoutePolicy::DeviceAwareWeighted);
462        let candidates = [
463            RouteCandidate {
464                target: RouteTarget::worker(10),
465                device: RouteDevice::Cpu,
466                cache_hits: 0,
467            },
468            RouteCandidate {
469                target: RouteTarget::worker(20),
470                device: RouteDevice::Accelerator,
471                cache_hits: 2,
472            },
473            RouteCandidate {
474                target: RouteTarget::worker(30),
475                device: RouteDevice::Accelerator,
476                cache_hits: 1,
477            },
478        ];
479        let reads = Cell::new(0);
480        let decision = picker
481            .peek(
482                CandidateView::DeviceAware(&candidates),
483                RouteContext {
484                    required_cache_hits: 2,
485                    non_cpu_to_cpu_ratio: 8,
486                },
487                |worker| {
488                    reads.set(reads.get() + 1);
489                    worker
490                },
491            )
492            .unwrap();
493        assert_eq!(decision.target.worker_id, 20);
494        assert_eq!(decision.admission, AdmissionKind::None);
495        assert_eq!(reads.get(), candidates.len());
496    }
497}