[sgl-router] refactor - SLO ordering for bucket selection (#40292)

This commit is contained in:
Kan Wu
2026-09-22 11:16:10 +08:00
committed by GitHub
parent 27f796ca6c
commit 59a723ef1e
8 changed files with 422 additions and 37 deletions
+38 -13
View File
@@ -7,8 +7,9 @@ listed at the end.
## Principles ## Principles
1. **Order compatible buckets by token length first.** `BucketResolver` returns 1. **Filter buckets by token length, then order preferences.** `BucketResolver`
all matching buckets, smallest capacity first, without inspecting workers or policies. returns all compatible buckets, applying optional SLO preferences before
capacity/rank/ID ordering, without inspecting workers or policies.
2. **The bucket owns plain versus PD engine selection.** `Bucket::pick_engines` 2. **The bucket owns plain versus PD engine selection.** `Bucket::pick_engines`
calls its one plain group or both prefill and decode groups, returning a calls its one plain group or both prefill and decode groups, returning a
complete selection. Both PD engines come from that same bucket. complete selection. Both PD engines come from that same bucket.
@@ -79,7 +80,7 @@ tier executor, or separate selection framework is required.
## 2. Responsibilities and request flow ## 2. Responsibilities and request flow
`BucketResolver::resolve(input_tokens, expected_peak_tokens)` returns an ordered `BucketResolver::resolve(input_tokens, expected_peak_tokens, ttft_ms, tokens_per_second)` returns an ordered
list of compatible bucket references (possibly empty), or an invalid-signal error. list of compatible bucket references (possibly empty), or an invalid-signal error.
It does not receive a stage or a load view, resolve live engines, or invoke policies. It does not receive a stage or a load view, resolve live engines, or invoke policies.
The handler iterates this list until a bucket supplies the complete engine selection. The handler iterates this list until a bucket supplies the complete engine selection.
@@ -153,9 +154,10 @@ the existing body-size estimate when tokenization is unavailable.
2. Keep buckets whose inclusive input-token range contains the input length. 2. Keep buckets whose inclusive input-token range contains the input length.
3. Check the bucket context capacity against input plus requested output when 3. Check the bucket context capacity against input plus requested output when
known, or against input length when the output budget is unknown. known, or against input length when the output budget is unknown.
4. Sort by ascending input capacity (the lesser of the input upper bound and 4. Apply enabled SLO preferences to complete buckets. Count unmet preferences
context capacity). Unbounded capacities sort last. Break ties by ascending equally, then sort by ascending input capacity (the lesser of the input upper
bucket rank, then ID, and return the entire ordered list. bound and context capacity), rank, and ID. Unbounded capacities sort last.
Return the entire ordered list, retaining nonpreferred buckets for fallback.
5. The handler calls each bucket's `pick_engines` until one supplies its complete 5. The handler calls each bucket's `pick_engines` until one supplies its complete
selection. A failed PD attempt never contributes an engine to a later pair. selection. A failed PD attempt never contributes an engine to a later pair.
@@ -172,7 +174,29 @@ bucket, both groups share this one request-length decision. Policy fallback on
a cache/affinity miss stays within that group's candidates. There is no second a cache/affinity miss stays within that group's candidates. There is no second
pass with relaxed admission and no post-policy substitution. pass with relaxed admission and no post-policy substitution.
SLO ordering, global session modes, and sticky policies are follow-ups. Their ### Optional SLO ordering
`Bucket` has optional `ttft_ms` and `tokens_per_second` estimates. The resolver
has independent `ttft_slo` and `tps_slo` preferences: `Disabled` (default),
`SloFirst` (matching first), and `BestEffort` (nonmatching first). The handler
parses `x-sgl-ttft-slo-ms` and `x-sgl-tps-slo` only when their preference is enabled;
invalid enabled headers return 400 before dispatch. Disabled headers are ignored.
SLO targets belong to bucket resolution, not the engine policy's `PickRequest`.
Absent targets are neutral. A bucket matches TTFT when its positive estimate is
at most the target, and throughput when its finite positive estimate is at least
the target. Missing or invalid estimates do not match a supplied target. Enabled
TTFT targets must be positive; throughput targets must be finite and positive.
Each unmet preference adds one ordering penalty. With both preferences set to
`SloFirst`, a bucket matching both comes before one matching either, followed by
buckets matching neither. Capacity/rank/ID breaks ties within these tiers. The
same logic applies to plain and PD buckets, and a PD bucket always supplies both
engines. TTFT and throughput preferences never independently resolve P/D groups.
Length constraints are applied first and admission rejection still advances to
the next complete bucket, including a bucket outside the preferred SLO tier.
Global session modes and sticky policies are follow-ups. Their
integration must preserve bucket-first selection and the same-bucket PD rule. integration must preserve bucket-first selection and the same-bucket PD rule.
Cross-bucket affinity probing is not part of this interface. Session/routing Cross-bucket affinity probing is not part of this interface. Session/routing
keys still pass through `PickRequest` for policies operating inside the selected keys still pass through `PickRequest` for policies operating inside the selected
@@ -490,8 +514,8 @@ do not accept and ignore them.
- Keep `load_based` as the CLI name for `LeastLoadPolicy`. - Keep `load_based` as the CLI name for `LeastLoadPolicy`.
- Preserve explicit engine membership and context constraints. Bucket-level - Preserve explicit engine membership and context constraints. Bucket-level
ranges and ordering replace independent per-stage selection. ranges and ordering replace independent per-stage selection.
Restore existing SLO behavior in the separate SLO PR before serving switchover; SLO preferences order whole buckets; deployment configuration remains explicit
legacy routing continues to support SLOs during this skeleton-only phase. until the production configuration factory and serving switchover are ready.
- Preserve cache-provider selection, endpoint validation, query timeout and - Preserve cache-provider selection, endpoint validation, query timeout and
concurrency limits, and unavailable-backend fallback. concurrency limits, and unavailable-backend fallback.
- Preserve cache thresholds and tuning: the 1,024-token default minimum hit, - Preserve cache thresholds and tuning: the 1,024-token default minimum hit,
@@ -558,7 +582,8 @@ This PR adds the side-by-side interfaces in `src/buckets_reorg.rs` and
Implemented here: Implemented here:
- `BucketResolver::resolve` returns all length-compatible buckets in capacity/rank/ID order. - `BucketResolver::resolve` returns all length-compatible buckets in optional
SLO-preference tiers, with capacity/rank/ID order within each tier.
- `Bucket::pick_engines` owns plain/PD orchestration and stage-specific policy - `Bucket::pick_engines` owns plain/PD orchestration and stage-specific policy
requests; `BucketRequest` carries prepared facts and `BucketPick` retains picks. requests; `BucketRequest` carries prepared facts and `BucketPick` retains picks.
- `Bucket` owns input limits, context capacity, rank, and plain-or-PD groups. - `Bucket` owns input limits, context capacity, rank, and plain-or-PD groups.
@@ -576,6 +601,8 @@ Implemented here:
dispatches only after one complete selection. Exhaustion retains admission reasons. dispatches only after one complete selection. Exhaustion retains admission reasons.
- `AppContext::chat_routing` configures legacy versus reorg routing on the same - `AppContext::chat_routing` configures legacy versus reorg routing on the same
endpoint and carries the reorg model-resolver map. endpoint and carries the reorg model-resolver map.
- Optional bucket TTFT/throughput estimates and preferences, with enabled-header
parsing and whole-bucket fallback in the shared chat route.
- `CacheAwarePolicy` reads local radix-tree or remote indexer prefixes, intersects - `CacheAwarePolicy` reads local radix-tree or remote indexer prefixes, intersects
exact worker URLs with the current group, applies hit thresholds and candidate exact worker URLs with the current group, applies hit thresholds and candidate
bounds, and preserves the soft queue gate, saturation pin and pressure guard. bounds, and preserves the soft queue gate, saturation pin and pressure guard.
@@ -594,13 +621,11 @@ Implemented here:
The caller owns expiry and sweeper lifecycle. A binding may remain after a The caller owns expiry and sweeper lifecycle. A binding may remain after a
later PD group fails, because it records placement rather than dispatch. later PD group fails, because it records placement rather than dispatch.
Follow-up work includes bucket SLO ordering, remaining selection policies, Follow-up work includes remaining selection policies and production configuration.
and production configuration.
Not yet implemented in the reorg path: Not yet implemented in the reorg path:
- Other concrete selection policies. - Other concrete selection policies.
- SLO estimates, targets, and bucket preference ordering.
- CLI/configuration parsing, validation, and model-specific construction. - CLI/configuration parsing, validation, and model-specific construction.
The YAML above is illustrative; reorg resolvers are installed in code. The YAML above is illustrative; reorg resolvers are installed in code.
- Global session modes and sticky routing-key affinity. - Global session modes and sticky routing-key affinity.
+63 -7
View File
@@ -1,11 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors // SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
//! Order buckets by request length; each bucket owns the groups used to pick engines. //! Order length-compatible buckets by optional SLO preferences, then capacity and rank.
//! //!
//! ```text //! ```text
//! BucketResolver (one model's buckets) //! BucketResolver (one model's buckets)
//! -> Bucket (token limits, context capacity, rank) //! -> Bucket (token limits, context capacity, rank, SLO estimates)
//! -> Plain: one EngineGroup //! -> Plain: one EngineGroup
//! -> PD: prefill + decode EngineGroups //! -> PD: prefill + decode EngineGroups
//! -> each EngineGroup: worker membership + its own Policy //! -> each EngineGroup: worker membership + its own Policy
@@ -18,7 +18,7 @@
//! The handler tries buckets in order, advancing on missing candidates or admission //! The handler tries buckets in order, advancing on missing candidates or admission
//! rejection. Both P/D picks must succeed in the same bucket before dispatch. //! rejection. Both P/D picks must succeed in the same bucket before dispatch.
//! [`WorkerRegistry`] owns live workers; groups reference their IDs. Policies own //! [`WorkerRegistry`] owns live workers; groups reference their IDs. Policies own
//! their load/KV/affinity dependencies and share observations within each attempt. //! their load/KV/affinity dependencies and pass selected observations to admission.
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@@ -120,12 +120,15 @@ pub struct BucketPick {
#[derive(Debug)] #[derive(Debug)]
pub struct Bucket { pub struct Bucket {
pub id: String, pub id: String,
/// Break ties between equally sized buckets; lower ranks win. /// Break ties within an SLO tier between equally sized buckets; lower ranks win.
pub rank: u32, pub rank: u32,
/// Inclusive input-token range used to choose the bucket. /// Inclusive input-token range used to choose the bucket.
pub limits: TokenLimits, pub limits: TokenLimits,
/// Full sequence capacity, checked against the expected peak when known. /// Full sequence capacity, checked against the expected peak when known.
pub max_context_tokens: Option<u64>, pub max_context_tokens: Option<u64>,
/// Optional service estimates used only for bucket ordering.
pub ttft_ms: Option<u64>,
pub tokens_per_second: Option<f64>,
pub groups: BucketGroups, pub groups: BucketGroups,
} }
@@ -136,6 +139,8 @@ impl Bucket {
rank: 0, rank: 0,
limits: TokenLimits::default(), limits: TokenLimits::default(),
max_context_tokens: None, max_context_tokens: None,
ttft_ms: None,
tokens_per_second: None,
groups, groups,
} }
} }
@@ -225,10 +230,30 @@ impl Bucket {
} }
} }
/// Soft preference; nonpreferred buckets remain available for fallback.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SloPreference {
#[default]
Disabled,
SloFirst,
BestEffort,
}
impl SloPreference {
fn penalty(self, matches: Option<bool>) -> u8 {
match (self, matches) {
(Self::SloFirst, Some(false)) | (Self::BestEffort, Some(true)) => 1,
_ => 0,
}
}
}
/// Model-specific bucket configuration. Selection does not inspect engine state. /// Model-specific bucket configuration. Selection does not inspect engine state.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct BucketResolver { pub struct BucketResolver {
pub buckets: Vec<Bucket>, pub buckets: Vec<Bucket>,
pub ttft_slo: SloPreference,
pub tps_slo: SloPreference,
} }
impl BucketResolver { impl BucketResolver {
@@ -237,27 +262,58 @@ impl BucketResolver {
for bucket in &buckets { for bucket in &buckets {
bucket.validate()?; bucket.validate()?;
} }
Ok(Self { buckets }) Ok(Self {
buckets,
..Self::default()
})
} }
/// Return all length-compatible buckets, ordered by input capacity, rank, and ID. /// Return all length-compatible buckets, ordered by unmet SLO preferences,
/// then input capacity, rank, and ID. Both preferences have equal weight.
/// The caller tries their groups in order until a complete engine selection succeeds. /// The caller tries their groups in order until a complete engine selection succeeds.
pub fn resolve( pub fn resolve(
&self, &self,
input_tokens: u64, input_tokens: u64,
expected_peak_tokens: Option<u64>, expected_peak_tokens: Option<u64>,
ttft_ms: Option<u64>,
tokens_per_second: Option<f64>,
) -> Result<Vec<&Bucket>, PickError> { ) -> Result<Vec<&Bucket>, PickError> {
if expected_peak_tokens.is_some_and(|tokens| tokens < input_tokens) { if expected_peak_tokens.is_some_and(|tokens| tokens < input_tokens) {
return Err(PickError::InvalidSignal( return Err(PickError::InvalidSignal(
"expected peak tokens are below input length".into(), "expected peak tokens are below input length".into(),
)); ));
} }
if self.ttft_slo != SloPreference::Disabled && ttft_ms == Some(0) {
return Err(PickError::InvalidSignal(
"requested TTFT must be positive".into(),
));
}
if self.tps_slo != SloPreference::Disabled
&& tokens_per_second.is_some_and(|tps| !tps.is_finite() || tps <= 0.0)
{
return Err(PickError::InvalidSignal(
"requested tokens per second must be finite and positive".into(),
));
}
let mut buckets: Vec<_> = self let mut buckets: Vec<_> = self
.buckets .buckets
.iter() .iter()
.filter(|bucket| bucket.fits(input_tokens, expected_peak_tokens)) .filter(|bucket| bucket.fits(input_tokens, expected_peak_tokens))
.collect(); .collect();
buckets.sort_by_key(|bucket| (bucket.input_capacity(), bucket.rank, &bucket.id)); buckets.sort_by_key(|bucket| {
let ttft_matches = ttft_ms.map(|target| {
bucket
.ttft_ms
.is_some_and(|estimate| estimate > 0 && estimate <= target)
});
let tps_matches = tokens_per_second.map(|target| {
bucket.tokens_per_second.is_some_and(|estimate| {
estimate.is_finite() && estimate > 0.0 && estimate >= target
})
});
let penalty = self.ttft_slo.penalty(ttft_matches) + self.tps_slo.penalty(tps_matches);
(penalty, bucket.input_capacity(), bucket.rank, &bucket.id)
});
Ok(buckets) Ok(buckets)
} }
} }
@@ -2,9 +2,12 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
use super::forward::{forward_chat_request, SelectedWorkers}; use super::forward::{forward_chat_request, SelectedWorkers};
use super::nonempty_header;
use super::preparation::{parse_routing_fields, PreparedChatRequest}; use super::preparation::{parse_routing_fields, PreparedChatRequest};
use crate::buckets_reorg::{BucketRequest, BucketResolver}; use super::{
nonempty_header, parse_optional_positive_f64_header, parse_optional_positive_u64_header,
X_SGL_TPS_SLO, X_SGL_TTFT_SLO_MS,
};
use crate::buckets_reorg::{BucketRequest, BucketResolver, SloPreference};
use crate::discovery::ModelId; use crate::discovery::ModelId;
use crate::policies_reorg::{PickError, Stage}; use crate::policies_reorg::{PickError, Stage};
use crate::server::app_context::AppContext; use crate::server::app_context::AppContext;
@@ -45,8 +48,23 @@ pub(super) async fn chat_completions(
}) })
.transpose()?; .transpose()?;
let ttft_ms = if resolver.ttft_slo != SloPreference::Disabled {
parse_optional_positive_u64_header(&headers, &X_SGL_TTFT_SLO_MS, "TTFT SLO")?
} else {
None
};
let tokens_per_second = if resolver.tps_slo != SloPreference::Disabled {
parse_optional_positive_f64_header(&headers, &X_SGL_TPS_SLO, "TPS SLO")?
} else {
None
};
let buckets = resolver let buckets = resolver
.resolve(input_tokens, expected_peak_tokens) .resolve(
input_tokens,
expected_peak_tokens,
ttft_ms,
tokens_per_second,
)
.map_err(|error| selection_error(error, &request.model, None))?; .map_err(|error| selection_error(error, &request.model, None))?;
if buckets.is_empty() { if buckets.is_empty() {
return Err(selection_error( return Err(selection_error(
@@ -16,5 +16,6 @@ mod policies_reorg_cache_aware;
mod policies_reorg_load; mod policies_reorg_load;
mod policies_reorg_power_of_two; mod policies_reorg_power_of_two;
mod policies_reorg_session_aware; mod policies_reorg_session_aware;
mod policies_reorg_slo;
mod tokenizer; mod tokenizer;
mod workers; mod workers;
@@ -186,17 +186,20 @@ fn resolve_orders_all_length_fits_by_capacity_rank_and_id() {
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
resolver resolver
.resolve(10, None) .resolve(10, None, None, None)
.unwrap() .unwrap()
.iter() .iter()
.map(|bucket| bucket.id.as_str()) .map(|bucket| bucket.id.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
["a", "z", "later", "catch-all"] ["a", "z", "later", "catch-all"]
); );
assert_eq!(resolver.resolve(11, None).unwrap()[0].id, "min"); assert_eq!(resolver.resolve(11, None, None, None).unwrap()[0].id, "min");
assert_eq!(resolver.resolve(15, None).unwrap()[0].id, "min"); assert_eq!(resolver.resolve(15, None, None, None).unwrap()[0].id, "min");
assert_eq!(resolver.resolve(20, None).unwrap()[0].id, "a"); assert_eq!(resolver.resolve(20, None, None, None).unwrap()[0].id, "a");
assert_eq!(resolver.resolve(21, None).unwrap()[0].id, "catch-all"); assert_eq!(
resolver.resolve(21, None, None, None).unwrap()[0].id,
"catch-all"
);
} }
#[test] #[test]
@@ -207,17 +210,29 @@ fn context_capacity_checks_peak_when_known_and_input_otherwise() {
let mut long = bucket("long", None, policy); let mut long = bucket("long", None, policy);
long.max_context_tokens = Some(30); long.max_context_tokens = Some(30);
let resolver = BucketResolver::new(vec![long, short]).unwrap(); let resolver = BucketResolver::new(vec![long, short]).unwrap();
assert_eq!(resolver.resolve(10, None).unwrap()[0].id, "short"); assert_eq!(
assert_eq!(resolver.resolve(10, Some(20)).unwrap()[0].id, "short"); resolver.resolve(10, None, None, None).unwrap()[0].id,
assert_eq!(resolver.resolve(10, Some(21)).unwrap()[0].id, "long"); "short"
assert!(resolver.resolve(10, Some(31)).unwrap().is_empty()); );
assert!(resolver.resolve(31, None).unwrap().is_empty()); assert_eq!(
resolver.resolve(10, Some(20), None, None).unwrap()[0].id,
"short"
);
assert_eq!(
resolver.resolve(10, Some(21), None, None).unwrap()[0].id,
"long"
);
assert!(resolver
.resolve(10, Some(31), None, None)
.unwrap()
.is_empty());
assert!(resolver.resolve(31, None, None, None).unwrap().is_empty());
assert!(matches!( assert!(matches!(
resolver.resolve(10, Some(9)), resolver.resolve(10, Some(9), None, None),
Err(PickError::InvalidSignal(_)) Err(PickError::InvalidSignal(_))
)); ));
assert!(BucketResolver::default() assert!(BucketResolver::default()
.resolve(1, None) .resolve(1, None, None, None)
.unwrap() .unwrap()
.is_empty()); .is_empty());
} }
@@ -238,7 +253,7 @@ async fn selected_pd_bucket_owns_both_memberships_and_policies() {
}, },
)]) )])
.unwrap(); .unwrap();
let bucket = resolver.resolve(10, Some(20)).unwrap()[0]; let bucket = resolver.resolve(10, Some(20), None, None).unwrap()[0];
let request = BucketRequest { let request = BucketRequest {
prefix: None, prefix: None,
model: &model, model: &model,
@@ -270,7 +285,7 @@ async fn resolver_includes_empty_groups_without_invoking_policies() {
}; };
let resolver = let resolver =
BucketResolver::new(vec![empty, bucket("available", Some(20), policy.clone())]).unwrap(); BucketResolver::new(vec![empty, bucket("available", Some(20), policy.clone())]).unwrap();
let buckets = resolver.resolve(10, None).unwrap(); let buckets = resolver.resolve(10, None, None, None).unwrap();
assert_eq!( assert_eq!(
buckets buckets
.iter() .iter()
@@ -0,0 +1,135 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use std::sync::Arc;
use sgl_router::buckets_reorg::{Bucket, BucketGroups, BucketResolver, EngineGroup, SloPreference};
use sgl_router::policies_reorg::power_of_two::PowerOfTwoPolicy;
use sgl_router::policies_reorg::PickError;
use sgl_router::state::load_monitor::engine_reported_load::EngineReportedLoadTable;
fn bucket(id: &str, max: u64, rank: u32, ttft: Option<u64>, tps: Option<f64>) -> Bucket {
let mut bucket = Bucket::new(
id,
BucketGroups::Plain(EngineGroup::new(Arc::new(PowerOfTwoPolicy::new(
EngineReportedLoadTable::new(),
)))),
);
bucket.limits.max = Some(max);
bucket.rank = rank;
bucket.ttft_ms = ttft;
bucket.tokens_per_second = tps;
bucket
}
fn resolver() -> BucketResolver {
BucketResolver::new(vec![
bucket("both", 100, 9, Some(50), Some(100.0)),
bucket("ttft", 10, 1, Some(50), Some(10.0)),
bucket("tps", 10, 2, Some(100), Some(100.0)),
bucket("neither", 8, 0, Some(100), Some(10.0)),
bucket("missing", 10, 3, None, None),
bucket("too-short", 1, 0, Some(1), Some(1000.0)),
])
.unwrap()
}
fn ids(resolver: &BucketResolver, ttft: Option<u64>, tps: Option<f64>) -> Vec<&str> {
resolver
.resolve(5, Some(5), ttft, tps)
.unwrap()
.into_iter()
.map(|b| b.id.as_str())
.collect()
}
#[test]
fn slo_tiers_keep_length_constraints_and_capacity_rank_order() {
let mut resolver = resolver();
assert_eq!(
ids(&resolver, Some(50), Some(100.0)),
["neither", "ttft", "tps", "missing", "both"]
);
resolver.ttft_slo = SloPreference::SloFirst;
resolver.tps_slo = SloPreference::SloFirst;
assert_eq!(
ids(&resolver, Some(50), Some(100.0)),
["both", "ttft", "tps", "neither", "missing"]
);
resolver.ttft_slo = SloPreference::BestEffort;
resolver.tps_slo = SloPreference::BestEffort;
assert_eq!(
ids(&resolver, Some(50), Some(100.0)),
["neither", "missing", "ttft", "tps", "both"]
);
resolver.ttft_slo = SloPreference::SloFirst;
assert_eq!(
ids(&resolver, Some(50), Some(100.0)),
["ttft", "neither", "missing", "both", "tps"]
);
}
#[test]
fn absent_targets_are_neutral_and_each_preference_can_be_disabled() {
let mut resolver = resolver();
resolver.ttft_slo = SloPreference::SloFirst;
resolver.tps_slo = SloPreference::BestEffort;
assert_eq!(
ids(&resolver, None, None),
["neither", "ttft", "tps", "missing", "both"]
);
assert_eq!(
ids(&resolver, Some(50), None),
["ttft", "both", "neither", "tps", "missing"]
);
resolver.ttft_slo = SloPreference::Disabled;
resolver.tps_slo = SloPreference::SloFirst;
assert_eq!(
ids(&resolver, Some(1), Some(100.0)),
["tps", "both", "neither", "ttft", "missing"]
);
}
#[test]
fn invalid_enabled_targets_fail_and_invalid_estimates_do_not_match() {
let mut resolver = resolver();
resolver.ttft_slo = SloPreference::SloFirst;
resolver.tps_slo = SloPreference::SloFirst;
assert!(matches!(
resolver.resolve(5, None, Some(0), None),
Err(PickError::InvalidSignal(_))
));
for tps in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert!(matches!(
resolver.resolve(5, None, None, Some(tps)),
Err(PickError::InvalidSignal(_))
));
}
resolver.buckets[0].ttft_ms = Some(0);
resolver.buckets[0].tokens_per_second = Some(f64::INFINITY);
assert_eq!(
ids(&resolver, Some(50), Some(100.0)),
["ttft", "tps", "neither", "missing", "both"]
);
resolver.ttft_slo = SloPreference::Disabled;
resolver.tps_slo = SloPreference::Disabled;
assert!(resolver.resolve(5, None, Some(0), Some(f64::NAN)).is_ok());
}
#[test]
fn slo_preferences_never_relax_peak_capacity_or_input_range() {
let mut resolver = resolver();
resolver.ttft_slo = SloPreference::SloFirst;
resolver.tps_slo = SloPreference::SloFirst;
resolver.buckets[0].max_context_tokens = Some(6);
resolver.buckets[1].limits.min = Some(6);
let found = resolver.resolve(5, Some(7), Some(50), Some(100.0)).unwrap();
assert_eq!(
found.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(),
["tps", "neither", "missing"]
);
assert!(matches!(
resolver.resolve(5, Some(4), None, None),
Err(PickError::InvalidSignal(_))
));
}
@@ -14,6 +14,7 @@ use sgl_router::server::app_context::ChatRouting;
use std::sync::Mutex; use std::sync::Mutex;
mod session_aware; mod session_aware;
mod slo;
type PickCall = (String, Stage, u64, Option<u64>); type PickCall = (String, Stage, u64, Option<u64>);
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
use super::*;
#[tokio::test]
async fn slo_headers_order_complete_pd_buckets_and_validate_before_dispatch() {
use sgl_router::buckets_reorg::SloPreference;
let fast_p = MockWorker::start(vec![]).await;
let slow_p = MockWorker::start(vec![]).await;
let fast_d = MockWorker::start(vec![]).await;
let slow_d = MockWorker::start(vec![]).await;
let mut ctx = Arc::try_unwrap(context(
&[
("fast-p", Stage::Prefill, &fast_p),
("slow-p", Stage::Prefill, &slow_p),
("fast-d", Stage::Decode, &fast_d),
("slow-d", Stage::Decode, &slow_d),
],
vec![],
))
.unwrap_or_else(|_| panic!("context is not shared yet"));
let mut prefill = Bucket::new(
"a-prefill",
BucketGroups::Pd {
prefill: group("fast-p", Arc::new(FirstPolicy::default())),
decode: group("slow-d", Arc::new(FirstPolicy::default())),
},
);
prefill.ttft_ms = Some(50);
prefill.tokens_per_second = Some(10.0);
let mut decode = Bucket::new(
"b-decode",
BucketGroups::Pd {
prefill: group("slow-p", Arc::new(FirstPolicy::default())),
decode: group("fast-d", Arc::new(FirstPolicy::default())),
},
);
decode.ttft_ms = Some(100);
decode.tokens_per_second = Some(100.0);
let mut resolver = BucketResolver::new(vec![decode, prefill]).unwrap();
resolver.ttft_slo = SloPreference::SloFirst;
resolver.tps_slo = SloPreference::SloFirst;
ctx.chat_routing = ChatRouting::Reorg([(ModelId("tiny".into()), resolver)].into());
let app = build_router(Arc::new(ctx));
for (ttft, tps) in [("0", "100"), ("50", "NaN"), ("50", "0"), ("abc", "100")] {
let mut req = request(body("hi"));
req.headers_mut()
.insert("x-sgl-ttft-slo-ms", ttft.parse().unwrap());
req.headers_mut()
.insert("x-sgl-tps-slo", tps.parse().unwrap());
assert_eq!(
app.clone().oneshot(req).await.unwrap().status(),
StatusCode::BAD_REQUEST
);
}
for worker in [&fast_p, &slow_p, &fast_d, &slow_d] {
assert!(worker.captured.lock().unwrap().last_body.is_none());
}
// With both targets unmet once, the existing bucket order breaks the tie.
// With TTFT unconstrained, throughput chooses the other complete bucket.
for (ttft, expected_d, expected_p, unused_d, unused_p) in [
("50", &slow_d, &fast_p, &fast_d, &slow_p),
("100", &fast_d, &slow_p, &slow_d, &fast_p),
] {
for worker in [&fast_p, &slow_p, &fast_d, &slow_d] {
worker.captured.lock().unwrap().last_body = None;
}
let mut req = request(body("hi"));
req.headers_mut()
.insert("x-sgl-ttft-slo-ms", ttft.parse().unwrap());
req.headers_mut()
.insert("x-sgl-tps-slo", "100".parse().unwrap());
let response = app.clone().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-sgl-decode-url"], expected_d.url);
response.into_body().collect().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), async {
while expected_p.captured.lock().unwrap().last_body.is_none() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
assert!(unused_p.captured.lock().unwrap().last_body.is_none());
assert!(unused_d.captured.lock().unwrap().last_body.is_none());
}
}
#[tokio::test]
async fn preferred_bucket_rejection_falls_back_and_disabled_headers_are_ignored() {
use sgl_router::buckets_reorg::SloPreference;
for enabled in [false, true] {
let rejected = MockWorker::start(vec![]).await;
let accepted = MockWorker::start(vec![]).await;
let mut ctx = Arc::try_unwrap(context(
&[
("rejected", Stage::Plain, &rejected),
("accepted", Stage::Plain, &accepted),
],
vec![],
))
.unwrap_or_else(|_| panic!("context is not shared yet"));
let mut fast = Bucket::new(
"a-fast",
BucketGroups::Plain(group("rejected", rejecting_policy())),
);
fast.ttft_ms = Some(10);
let mut slow = Bucket::new(
"b-slow",
BucketGroups::Plain(group("accepted", Arc::new(FirstPolicy::default()))),
);
slow.ttft_ms = Some(100);
let mut resolver = BucketResolver::new(vec![slow, fast]).unwrap();
if enabled {
resolver.ttft_slo = SloPreference::SloFirst;
}
ctx.chat_routing = ChatRouting::Reorg([(ModelId("tiny".into()), resolver)].into());
let app = build_router(Arc::new(ctx));
let mut req = request(body("hi"));
req.headers_mut().insert(
"x-sgl-ttft-slo-ms",
if enabled { "50" } else { "invalid" }.parse().unwrap(),
);
// TPS is disabled even when TTFT is enabled.
req.headers_mut()
.insert("x-sgl-tps-slo", "NaN".parse().unwrap());
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response.into_body().collect().await.unwrap();
assert!(rejected.captured.lock().unwrap().last_body.is_none());
assert!(accepted.captured.lock().unwrap().last_body.is_some());
}
}