[Router] Add load-aware prefill admission and bounded policy proposals (#37843)
Signed-off-by: Vincent Gao <vincentbo@linux.alibaba.com> Co-authored-by: Kangyan Zhou <zky314343421@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Shangming Cai <csmthu@gmail.com>
This commit is contained in:
co-authored by
Kangyan Zhou
Claude Opus 4.8
Shangming Cai
parent
d50e9a9756
commit
ecd97de1fc
@@ -11,10 +11,11 @@ use std::num::NonZeroU32;
|
||||
|
||||
use crate::config::{
|
||||
default_cb_cool_down, default_proxy_request_timeout_secs, default_stale_request_timeout_secs,
|
||||
resolve_mode, ActiveLoadConfig, CacheAwareConfig, CircuitBreakerConfig, Config,
|
||||
DiscoveryBackend, EligibilityConfig, FilterKind, FusedTerm, K8sDiscoveryConfig,
|
||||
KvIndexerEndpointConfig, LogFormat, ModelConfig, ObservabilityConfig, PolicyKind, ProxyConfig,
|
||||
ServerConfig, StaticUrlsDiscoveryConfig, StickyConfig, StickyFallbackKind, DEFAULT_FUSE,
|
||||
resolve_mode, ActiveLoadConfig, AffinityConfig, AffinityMode, CacheAwareConfig,
|
||||
CircuitBreakerConfig, Config, DiscoveryBackend, EligibilityConfig, FilterKind, FusedTerm,
|
||||
K8sDiscoveryConfig, KvIndexerEndpointConfig, LogFormat, ModelConfig, ObservabilityConfig,
|
||||
PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode, StaticUrlsDiscoveryConfig,
|
||||
StickyConfig, StickyFallbackKind, DEFAULT_FUSE,
|
||||
};
|
||||
|
||||
const DEFAULT_KV_INDEXER_QUERY_TIMEOUT_MS: u64 = 100;
|
||||
@@ -63,7 +64,7 @@ pub struct Cli {
|
||||
#[arg(long)]
|
||||
pub cb_cool_down_secs: Option<u64>,
|
||||
|
||||
// ---- cache-aware-zmq tuning (only used by that policy) ----
|
||||
// ---- legacy cache-aware-zmq tuning ----
|
||||
/// Min `matched_blocks / total_blocks` for a cache match to win.
|
||||
#[arg(long)]
|
||||
pub cache_threshold: Option<f32>,
|
||||
@@ -73,19 +74,63 @@ pub struct Cli {
|
||||
/// Multiplicative load spread gating the absolute balance check.
|
||||
#[arg(long)]
|
||||
pub balance_rel_threshold: Option<f32>,
|
||||
/// External KV indexer gRPC endpoint used as the cache signal.
|
||||
/// External KV indexer gRPC endpoint used as the authoritative cache signal.
|
||||
/// Needs an explicit scheme, e.g. `http://10.0.0.1:50051`.
|
||||
#[arg(long)]
|
||||
pub kv_indexer_endpoint: Option<String>,
|
||||
/// KV Indexer query timeout in milliseconds. Requires
|
||||
/// `--kv-indexer-endpoint`; defaults to 100.
|
||||
#[arg(long)]
|
||||
pub kv_indexer_query_timeout_ms: Option<u64>,
|
||||
/// Maximum concurrent KV Indexer queries. Requires
|
||||
/// Maximum concurrent KV Indexer queries issued by this Router. Requires
|
||||
/// `--kv-indexer-endpoint`; defaults to 32.
|
||||
#[arg(long)]
|
||||
pub kv_indexer_query_max_inflight: Option<usize>,
|
||||
|
||||
/// Weighted terms for `--policy fused_score`.
|
||||
// ---- session-affinity tuning ----
|
||||
/// Header carrying the session ID for `--policy session_aware`.
|
||||
#[arg(long)]
|
||||
pub session_id_header: Option<String>,
|
||||
/// Idle timeout for a session assignment, in seconds.
|
||||
#[arg(long)]
|
||||
pub session_idle_secs: Option<u64>,
|
||||
/// Session-assignment eviction cadence, in seconds.
|
||||
#[arg(long)]
|
||||
pub session_eviction_interval_secs: Option<u64>,
|
||||
/// Use a deterministic backup for the affinity key and candidate range.
|
||||
#[arg(long)]
|
||||
pub stable_pair: bool,
|
||||
/// Session-affinity admission mode.
|
||||
#[arg(long, value_enum)]
|
||||
pub affinity_mode: Option<AffinityMode>,
|
||||
/// Session-affinity primary lookup and fallback behavior.
|
||||
#[arg(long, value_enum)]
|
||||
pub session_affinity_mode: Option<SessionAffinityMode>,
|
||||
/// Minimum cache-hit tokens for a cache-aware candidate.
|
||||
#[arg(long)]
|
||||
pub cache_affinity_min_matched_tokens: Option<u64>,
|
||||
/// Minimum cache-hit ratio for a cache-aware candidate.
|
||||
#[arg(long)]
|
||||
pub cache_affinity_min_match_ratio: Option<f64>,
|
||||
/// Minimum number of cache-aware candidates to try.
|
||||
#[arg(long)]
|
||||
pub cache_candidate_min_workers: Option<usize>,
|
||||
/// Fraction of healthy prefill workers considered as cache-aware candidates.
|
||||
#[arg(long)]
|
||||
pub cache_candidate_ratio: Option<f64>,
|
||||
/// Maximum number of cache-aware candidates to try.
|
||||
#[arg(long)]
|
||||
pub cache_candidate_max_workers: Option<usize>,
|
||||
/// Maximum uncached-work difference that pressure may override.
|
||||
#[arg(long)]
|
||||
pub cache_switch_margin_tokens: Option<u64>,
|
||||
|
||||
// ---- score composition ----
|
||||
/// Policies to sum, spelled exactly as `--policy` spells them and each
|
||||
/// optionally weighted: `--fuse prefix_cache=2.0,load_based=0.3`. An
|
||||
/// omitted weight keeps that policy's own default. Requires `--policy
|
||||
/// score_policy` or `fused_score`; when either policy is set and this flag
|
||||
/// is omitted, the terms default to `prefix_cache,load_based`.
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
pub fuse: Vec<FusedTerm>,
|
||||
|
||||
@@ -184,16 +229,12 @@ impl Cli {
|
||||
enabled by --cb-threshold)"
|
||||
));
|
||||
}
|
||||
let tuned_cache_aware = self.cache_threshold.is_some()
|
||||
let tuned_legacy_cache_aware = self.cache_threshold.is_some()
|
||||
|| self.balance_abs_threshold.is_some()
|
||||
|| self.balance_rel_threshold.is_some()
|
||||
|| self.kv_indexer_endpoint.is_some()
|
||||
|| self.kv_indexer_query_timeout_ms.is_some()
|
||||
|| self.kv_indexer_query_max_inflight.is_some();
|
||||
if tuned_cache_aware && self.policy != PolicyKind::CacheAwareZmq {
|
||||
|| self.balance_rel_threshold.is_some();
|
||||
if tuned_legacy_cache_aware && self.policy != PolicyKind::CacheAwareZmq {
|
||||
return Err(anyhow!(
|
||||
"--cache-threshold / --balance-abs-threshold / --balance-rel-threshold \
|
||||
require --policy cache_aware_zmq"
|
||||
"cache-aware tuning flags require --policy cache_aware_zmq"
|
||||
));
|
||||
}
|
||||
if self.kv_indexer_query_timeout_ms == Some(0) {
|
||||
@@ -216,11 +257,59 @@ impl Cli {
|
||||
"--kv-indexer-query-max-inflight requires --kv-indexer-endpoint"
|
||||
));
|
||||
}
|
||||
|
||||
if !self.fuse.is_empty() && self.policy != PolicyKind::FusedScore {
|
||||
return Err(anyhow!("--fuse requires --policy fused_score"));
|
||||
if self.kv_indexer_endpoint.is_some()
|
||||
&& !matches!(
|
||||
self.policy,
|
||||
PolicyKind::CacheAware | PolicyKind::CacheAwareZmq
|
||||
)
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"--kv-indexer-endpoint requires --policy cache_aware or cache_aware_zmq"
|
||||
));
|
||||
}
|
||||
let fused = if self.policy == PolicyKind::FusedScore {
|
||||
if self.policy == PolicyKind::CacheAware && self.kv_indexer_endpoint.is_none() {
|
||||
return Err(anyhow!(
|
||||
"--policy cache_aware requires --kv-indexer-endpoint"
|
||||
));
|
||||
}
|
||||
let tuned_cache_aware = tuned_legacy_cache_aware || self.kv_indexer_endpoint.is_some();
|
||||
let affinity_policy = matches!(
|
||||
self.policy,
|
||||
PolicyKind::SessionAware | PolicyKind::CacheAware
|
||||
);
|
||||
let tuned_session_affinity = self.session_id_header.is_some()
|
||||
|| self.session_idle_secs.is_some()
|
||||
|| self.session_eviction_interval_secs.is_some()
|
||||
|| self.stable_pair
|
||||
|| self.affinity_mode.is_some()
|
||||
|| self.session_affinity_mode.is_some();
|
||||
if tuned_session_affinity && self.policy != PolicyKind::SessionAware {
|
||||
return Err(anyhow!(
|
||||
"--session-id-header, --session-*-secs, --stable-pair, --affinity-mode, and \
|
||||
--session-affinity-mode require --policy session_aware"
|
||||
));
|
||||
}
|
||||
let tuned_cache_candidates = self.cache_affinity_min_matched_tokens.is_some()
|
||||
|| self.cache_affinity_min_match_ratio.is_some()
|
||||
|| self.cache_candidate_min_workers.is_some()
|
||||
|| self.cache_candidate_ratio.is_some()
|
||||
|| self.cache_candidate_max_workers.is_some()
|
||||
|| self.cache_switch_margin_tokens.is_some();
|
||||
if tuned_cache_candidates && self.policy != PolicyKind::CacheAware {
|
||||
return Err(anyhow!(
|
||||
"cache candidate tuning flags require --policy cache_aware"
|
||||
));
|
||||
}
|
||||
let is_score_composition = matches!(
|
||||
self.policy,
|
||||
PolicyKind::FusedScore | PolicyKind::ScorePolicy
|
||||
);
|
||||
if !self.fuse.is_empty() && !is_score_composition {
|
||||
return Err(anyhow!(
|
||||
"--fuse requires --policy score_policy or fused_score"
|
||||
));
|
||||
}
|
||||
let fused = if is_score_composition {
|
||||
let terms = if self.fuse.is_empty() {
|
||||
DEFAULT_FUSE
|
||||
.iter()
|
||||
@@ -250,6 +339,9 @@ impl Cli {
|
||||
"--max-in-flight and `--filter overloaded` require each other"
|
||||
));
|
||||
}
|
||||
if self.max_in_flight == Some(0) {
|
||||
return Err(anyhow!("--max-in-flight must be greater than 0"));
|
||||
}
|
||||
if self.prefix_cache_min_share.is_some() != has(FilterKind::PrefixCache) {
|
||||
return Err(anyhow!(
|
||||
"--prefix-cache-min-share and `--filter prefix_cache` require each other"
|
||||
@@ -261,6 +353,9 @@ impl Cli {
|
||||
{
|
||||
return Err(anyhow!("--prefix-cache-min-share must be in (0, 1]"));
|
||||
}
|
||||
if self.policy == PolicyKind::Sticky && !self.filter.is_empty() {
|
||||
return Err(anyhow!("--filter cannot be combined with --policy sticky"));
|
||||
}
|
||||
let eligibility = (!self.filter.is_empty()).then(|| EligibilityConfig {
|
||||
filters: self.filter.clone(),
|
||||
max_in_flight: self.max_in_flight,
|
||||
@@ -318,6 +413,81 @@ impl Cli {
|
||||
None
|
||||
};
|
||||
|
||||
let affinity = if affinity_policy {
|
||||
let d = AffinityConfig::default();
|
||||
let session_id_header = self.session_id_header.unwrap_or(d.session_id_header);
|
||||
axum::http::HeaderName::try_from(session_id_header.as_str()).map_err(|e| {
|
||||
anyhow!("--session-id-header {session_id_header:?} is not a valid HTTP header name: {e}")
|
||||
})?;
|
||||
let cache_affinity_min_match_ratio = self
|
||||
.cache_affinity_min_match_ratio
|
||||
.or(d.cache_affinity_min_match_ratio);
|
||||
if cache_affinity_min_match_ratio
|
||||
.is_some_and(|ratio| !ratio.is_finite() || !(0.0..=1.0).contains(&ratio))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"--cache-affinity-min-match-ratio must be finite and in [0, 1]"
|
||||
));
|
||||
}
|
||||
let cache_candidate_ratio = self
|
||||
.cache_candidate_ratio
|
||||
.unwrap_or(d.cache_candidate_ratio);
|
||||
if !cache_candidate_ratio.is_finite() || !(0.0..=1.0).contains(&cache_candidate_ratio) {
|
||||
return Err(anyhow!(
|
||||
"--cache-candidate-ratio must be finite and in [0, 1]"
|
||||
));
|
||||
}
|
||||
let cache_candidate_min_workers = self
|
||||
.cache_candidate_min_workers
|
||||
.unwrap_or(d.cache_candidate_min_workers);
|
||||
let cache_candidate_max_workers = self
|
||||
.cache_candidate_max_workers
|
||||
.unwrap_or(d.cache_candidate_max_workers);
|
||||
if cache_candidate_min_workers == 0
|
||||
|| cache_candidate_max_workers == 0
|
||||
|| cache_candidate_min_workers > cache_candidate_max_workers
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"--cache-candidate-min-workers and --cache-candidate-max-workers must be \
|
||||
positive and min must not exceed max"
|
||||
));
|
||||
}
|
||||
let session_idle_secs = self.session_idle_secs.unwrap_or(d.session_idle_secs);
|
||||
let session_eviction_interval_secs = self
|
||||
.session_eviction_interval_secs
|
||||
.unwrap_or(d.session_eviction_interval_secs);
|
||||
if session_idle_secs == 0 {
|
||||
return Err(anyhow!("--session-idle-secs must be greater than 0"));
|
||||
}
|
||||
if session_eviction_interval_secs == 0 {
|
||||
return Err(anyhow!(
|
||||
"--session-eviction-interval-secs must be greater than 0"
|
||||
));
|
||||
}
|
||||
Some(AffinityConfig {
|
||||
session_id_header,
|
||||
session_idle_secs,
|
||||
session_eviction_interval_secs,
|
||||
stable_pair: self.stable_pair,
|
||||
mode: self.affinity_mode.unwrap_or(d.mode),
|
||||
session_affinity_mode: self
|
||||
.session_affinity_mode
|
||||
.unwrap_or(d.session_affinity_mode),
|
||||
cache_affinity_min_matched_tokens: self
|
||||
.cache_affinity_min_matched_tokens
|
||||
.or(d.cache_affinity_min_matched_tokens),
|
||||
cache_affinity_min_match_ratio,
|
||||
cache_candidate_min_workers,
|
||||
cache_candidate_ratio,
|
||||
cache_candidate_max_workers,
|
||||
cache_switch_margin_tokens: self
|
||||
.cache_switch_margin_tokens
|
||||
.unwrap_or(d.cache_switch_margin_tokens),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let circuit_breaker = self.cb_threshold.map(|threshold| CircuitBreakerConfig {
|
||||
threshold,
|
||||
cool_down_secs: self.cb_cool_down_secs.unwrap_or_else(default_cb_cool_down),
|
||||
@@ -326,14 +496,17 @@ impl Cli {
|
||||
// Only build a CacheAwareConfig when the operator tuned at least
|
||||
// one knob; otherwise leave it None so the policy uses its own
|
||||
// defaults. Unset knobs fall back to the per-field defaults.
|
||||
let kv_indexer_query_timeout_ms = self
|
||||
.kv_indexer_query_timeout_ms
|
||||
.unwrap_or(DEFAULT_KV_INDEXER_QUERY_TIMEOUT_MS);
|
||||
let kv_indexer_query_max_inflight = self
|
||||
.kv_indexer_query_max_inflight
|
||||
.unwrap_or(DEFAULT_KV_INDEXER_QUERY_MAX_INFLIGHT);
|
||||
let cache_aware = if tuned_cache_aware {
|
||||
let d = CacheAwareConfig::default();
|
||||
let kv_indexer_endpoint = self.kv_indexer_endpoint.map(|url| KvIndexerEndpointConfig {
|
||||
url,
|
||||
query_timeout_ms: self
|
||||
.kv_indexer_query_timeout_ms
|
||||
.unwrap_or(DEFAULT_KV_INDEXER_QUERY_TIMEOUT_MS),
|
||||
query_max_inflight: self
|
||||
.kv_indexer_query_max_inflight
|
||||
.unwrap_or(DEFAULT_KV_INDEXER_QUERY_MAX_INFLIGHT),
|
||||
});
|
||||
Some(CacheAwareConfig {
|
||||
cache_threshold: self.cache_threshold.unwrap_or(d.cache_threshold),
|
||||
balance_abs_threshold: self
|
||||
@@ -342,11 +515,7 @@ impl Cli {
|
||||
balance_rel_threshold: self
|
||||
.balance_rel_threshold
|
||||
.unwrap_or(d.balance_rel_threshold),
|
||||
kv_indexer_endpoint: self.kv_indexer_endpoint.map(|url| KvIndexerEndpointConfig {
|
||||
url,
|
||||
query_timeout_ms: kv_indexer_query_timeout_ms,
|
||||
query_max_inflight: kv_indexer_query_max_inflight,
|
||||
}),
|
||||
kv_indexer_endpoint,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -370,6 +539,7 @@ impl Cli {
|
||||
circuit_breaker,
|
||||
cache_aware,
|
||||
sticky,
|
||||
affinity,
|
||||
fused,
|
||||
eligibility,
|
||||
},
|
||||
@@ -889,7 +1059,7 @@ mod tests {
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--policy",
|
||||
"cache_aware_zmq",
|
||||
"cache_aware",
|
||||
"--kv-indexer-endpoint",
|
||||
"http://indexer:50051",
|
||||
"--kv-indexer-query-timeout-ms",
|
||||
@@ -907,6 +1077,30 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn kv_indexer_uses_safe_query_defaults() {
|
||||
let c = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--policy",
|
||||
"cache_aware",
|
||||
"--kv-indexer-endpoint",
|
||||
"http://indexer:50051",
|
||||
]))
|
||||
.unwrap();
|
||||
let indexer = c
|
||||
.model
|
||||
.cache_aware
|
||||
.expect("cache-aware config")
|
||||
.kv_indexer_endpoint
|
||||
.expect("Indexer config");
|
||||
assert_eq!(
|
||||
indexer.query_timeout_ms,
|
||||
DEFAULT_KV_INDEXER_QUERY_TIMEOUT_MS
|
||||
);
|
||||
assert_eq!(indexer.query_max_inflight, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_indexer_is_accepted_by_cache_aware_zmq() {
|
||||
let c = into_config_owned(with_model(&[
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
@@ -922,8 +1116,7 @@ mod tests {
|
||||
.expect("cache-aware config")
|
||||
.kv_indexer_endpoint
|
||||
.expect("Indexer config");
|
||||
assert_eq!(indexer.query_timeout_ms, 100);
|
||||
assert_eq!(indexer.query_max_inflight, 32);
|
||||
assert_eq!(indexer.url, "http://indexer:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -936,7 +1129,7 @@ mod tests {
|
||||
]))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("require --policy cache_aware_zmq"));
|
||||
assert!(err.contains("requires --policy cache_aware"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -945,7 +1138,7 @@ mod tests {
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--policy",
|
||||
"cache_aware_zmq",
|
||||
"cache_aware",
|
||||
"--kv-indexer-query-timeout-ms",
|
||||
"75",
|
||||
]))
|
||||
@@ -960,7 +1153,7 @@ mod tests {
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--policy",
|
||||
"cache_aware_zmq",
|
||||
"cache_aware",
|
||||
"--kv-indexer-query-max-inflight",
|
||||
"17",
|
||||
]))
|
||||
@@ -975,7 +1168,7 @@ mod tests {
|
||||
"--worker-urls",
|
||||
"http://x:30000",
|
||||
"--policy",
|
||||
"cache_aware_zmq",
|
||||
"cache_aware",
|
||||
"--kv-indexer-endpoint",
|
||||
"http://indexer:50051",
|
||||
"--kv-indexer-query-max-inflight",
|
||||
@@ -1145,7 +1338,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn filter_misconfigurations_fail_at_startup() {
|
||||
let cases: [(&[&str], &str); 6] = [
|
||||
let cases: [(&[&str], &str); 8] = [
|
||||
(&["--filter", "overloaded"], "require each other"),
|
||||
(&["--max-in-flight", "64"], "require each other"),
|
||||
(&["--filter", "prefix_cache"], "require each other"),
|
||||
@@ -1163,6 +1356,21 @@ mod tests {
|
||||
],
|
||||
"must be in (0, 1]",
|
||||
),
|
||||
(
|
||||
&["--filter", "overloaded", "--max-in-flight", "0"],
|
||||
"must be greater than 0",
|
||||
),
|
||||
(
|
||||
&[
|
||||
"--policy",
|
||||
"sticky",
|
||||
"--filter",
|
||||
"overloaded",
|
||||
"--max-in-flight",
|
||||
"1",
|
||||
],
|
||||
"cannot be combined with --policy sticky",
|
||||
),
|
||||
];
|
||||
for (extra, want) in cases {
|
||||
let mut args = vec!["--worker-urls", "http://x:30000"];
|
||||
@@ -1307,8 +1515,31 @@ mod tests {
|
||||
fused_of(argv).expect("fused_score builds a term list")
|
||||
}
|
||||
|
||||
/// `--policy fused_score` alone composes the useful pair, and `--fuse`
|
||||
/// overrides it with names + optional per-term weights.
|
||||
/// `score_policy` is an independent top-level policy.
|
||||
#[test]
|
||||
fn score_policy_is_a_top_level_policy_with_its_own_cli_spelling() {
|
||||
use PolicyKind::ScorePolicy;
|
||||
use ScoreTermKind::{LoadBased, PrefixCache};
|
||||
let pair = [(PrefixCache, None), (LoadBased, None)];
|
||||
let config = cfg_of("--policy score_policy").unwrap();
|
||||
assert_eq!(config.model.policy, ScorePolicy);
|
||||
assert_eq!(
|
||||
config
|
||||
.model
|
||||
.fused
|
||||
.expect("score_policy must resolve its score terms")
|
||||
.iter()
|
||||
.map(|t| (t.kind, t.weight))
|
||||
.collect::<Vec<_>>(),
|
||||
pair,
|
||||
);
|
||||
assert_eq!(
|
||||
fuse_ok("--policy score_policy --fuse prefix_cache=2.0,load_based=0.3"),
|
||||
[(PrefixCache, Some(2.0)), (LoadBased, Some(0.3))],
|
||||
);
|
||||
}
|
||||
|
||||
/// `fused_score` keeps the compatibility entry point.
|
||||
#[test]
|
||||
fn fuse_defaults_to_the_useful_pair_and_parses_weights() {
|
||||
use ScoreTermKind::{LoadBased, PrefixCache, Random};
|
||||
@@ -1345,7 +1576,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fuse_rejects_malformed_compositions() {
|
||||
let cases: [(&str, &[&str]); 5] = [
|
||||
let cases: [(&str, &[&str]); 6] = [
|
||||
("--fuse load_based", &["--fuse requires", "fused_score"]),
|
||||
(
|
||||
"--policy fused_score --fuse fused_score,load_based",
|
||||
@@ -1355,6 +1586,10 @@ mod tests {
|
||||
"--policy fused_score --fuse load_based,load_based",
|
||||
&["load_based", "listed more than once"],
|
||||
),
|
||||
(
|
||||
"--policy score_policy --fuse score_policy,load_based",
|
||||
&["score_policy", "not a score term"],
|
||||
),
|
||||
(
|
||||
"--policy fused_score --fuse not_a_policy",
|
||||
&["not_a_policy", "is not a score term"],
|
||||
@@ -1371,4 +1606,199 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_aware_builds_affinity_config_from_its_cli_knobs() {
|
||||
let config = cfg_of(
|
||||
"--policy session_aware --session-id-header x-agent-session --stable-pair \
|
||||
--affinity-mode strict --session-affinity-mode global-rebind",
|
||||
)
|
||||
.unwrap();
|
||||
let affinity = config
|
||||
.model
|
||||
.affinity
|
||||
.expect("session policy needs affinity config");
|
||||
|
||||
assert_eq!(config.model.policy, PolicyKind::SessionAware);
|
||||
assert_eq!(affinity.session_id_header, "x-agent-session");
|
||||
assert!(affinity.stable_pair);
|
||||
assert_eq!(affinity.mode, AffinityMode::Strict);
|
||||
assert_eq!(
|
||||
affinity.session_affinity_mode,
|
||||
SessionAffinityMode::GlobalRebind
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_removed_token_pressure_flags() {
|
||||
for flag in [
|
||||
"--disable-pressure-guard",
|
||||
"--pressure-abs-threshold-tokens 2048",
|
||||
"--pressure-rel-threshold 2.0",
|
||||
] {
|
||||
let error = cfg_of(&format!("--policy session_aware {flag}"))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unexpected argument"), "{flag}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_removed_affinity_aware_range_flag() {
|
||||
let error = cfg_of("--policy session_aware --affinity-aware-range global-first")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unexpected argument '--affinity-aware-range'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_affinity_mode_accepts_all_new_values() {
|
||||
for (value, expected) in [
|
||||
("bucket", SessionAffinityMode::Bucket),
|
||||
("global-rebind", SessionAffinityMode::GlobalRebind),
|
||||
("global-preserve", SessionAffinityMode::GlobalPreserve),
|
||||
] {
|
||||
let config = cfg_of(&format!(
|
||||
"--policy session_aware --session-affinity-mode {value}"
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.model.affinity.unwrap().session_affinity_mode,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_aware_configures_bounded_assignment_lifetime() {
|
||||
let config = cfg_of(
|
||||
"--policy session_aware --session-idle-secs 120 \
|
||||
--session-eviction-interval-secs 15",
|
||||
)
|
||||
.unwrap();
|
||||
let affinity = config.model.affinity.expect("session affinity config");
|
||||
assert_eq!(affinity.session_idle_secs, 120);
|
||||
assert_eq!(affinity.session_eviction_interval_secs, 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_aware_accepts_indexer_endpoint_and_rejects_affinity_knobs_elsewhere() {
|
||||
let config = cfg_of(
|
||||
"--policy cache_aware --kv-indexer-endpoint http://indexer:50051 \
|
||||
--kv-indexer-query-timeout-ms 40 \
|
||||
--cache-affinity-min-matched-tokens 512 --cache-affinity-min-match-ratio 0.25 \
|
||||
--cache-candidate-min-workers 4 --cache-candidate-ratio 0.1 \
|
||||
--cache-candidate-max-workers 16 --cache-switch-margin-tokens 128",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.model.policy, PolicyKind::CacheAware);
|
||||
assert_eq!(
|
||||
config
|
||||
.model
|
||||
.cache_aware
|
||||
.as_ref()
|
||||
.expect("cache-aware needs indexer config")
|
||||
.kv_indexer_endpoint
|
||||
.as_ref()
|
||||
.map(|indexer| indexer.url.as_str()),
|
||||
Some("http://indexer:50051"),
|
||||
);
|
||||
let indexer_timeout_ms = config
|
||||
.model
|
||||
.cache_aware
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.kv_indexer_endpoint.as_ref())
|
||||
.expect("cache-aware needs indexer config")
|
||||
.query_timeout_ms;
|
||||
let affinity = config
|
||||
.model
|
||||
.affinity
|
||||
.expect("cache-aware needs candidate config");
|
||||
assert_eq!(affinity.cache_affinity_min_matched_tokens, Some(512));
|
||||
assert_eq!(affinity.cache_affinity_min_match_ratio, Some(0.25));
|
||||
assert_eq!(affinity.cache_candidate_min_workers, 4);
|
||||
assert_eq!(affinity.cache_candidate_ratio, 0.1);
|
||||
assert_eq!(affinity.cache_candidate_max_workers, 16);
|
||||
assert_eq!(affinity.cache_switch_margin_tokens, 128);
|
||||
assert_eq!(indexer_timeout_ms, 40);
|
||||
|
||||
let defaults =
|
||||
cfg_of("--policy cache_aware --kv-indexer-endpoint http://indexer:50051").unwrap();
|
||||
let defaults_indexer_timeout_ms = defaults
|
||||
.model
|
||||
.cache_aware
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.kv_indexer_endpoint.as_ref())
|
||||
.expect("default indexer config")
|
||||
.query_timeout_ms;
|
||||
let defaults_affinity = defaults
|
||||
.model
|
||||
.affinity
|
||||
.expect("default cache candidate config");
|
||||
assert_eq!(
|
||||
defaults_affinity.cache_affinity_min_matched_tokens,
|
||||
Some(1_024)
|
||||
);
|
||||
assert_eq!(defaults_affinity.cache_affinity_min_match_ratio, None);
|
||||
assert_eq!(
|
||||
defaults_indexer_timeout_ms,
|
||||
DEFAULT_KV_INDEXER_QUERY_TIMEOUT_MS
|
||||
);
|
||||
|
||||
let err = cfg_of("--policy power_of_two --stable-pair")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("--stable-pair") && err.contains("session_aware"),
|
||||
"got: {err}"
|
||||
);
|
||||
|
||||
let err =
|
||||
cfg_of("--policy cache_aware --kv-indexer-endpoint http://indexer:50051 --stable-pair")
|
||||
.expect_err("Cache-Aware has no stable backup")
|
||||
.to_string();
|
||||
assert!(err.contains("--stable-pair"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_candidate_cli_rejects_invalid_bounds() {
|
||||
for (args, expected) in [
|
||||
(
|
||||
"--policy cache_aware --kv-indexer-endpoint http://indexer:50051 \
|
||||
--cache-affinity-min-match-ratio 1.1",
|
||||
"--cache-affinity-min-match-ratio",
|
||||
),
|
||||
(
|
||||
"--policy cache_aware --kv-indexer-endpoint http://indexer:50051 \
|
||||
--cache-candidate-min-workers 9 --cache-candidate-max-workers 8",
|
||||
"--cache-candidate-min-workers",
|
||||
),
|
||||
(
|
||||
"--policy cache_aware --kv-indexer-endpoint http://indexer:50051 \
|
||||
--cache-candidate-ratio=-0.1",
|
||||
"--cache-candidate-ratio",
|
||||
),
|
||||
(
|
||||
"--policy cache_aware --kv-indexer-endpoint http://indexer:50051 \
|
||||
--kv-indexer-query-timeout-ms 0",
|
||||
"--kv-indexer-query-timeout-ms",
|
||||
),
|
||||
] {
|
||||
let err = cfg_of(args)
|
||||
.expect_err("invalid candidate bound")
|
||||
.to_string();
|
||||
assert!(err.contains(expected), "got: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_affinity_options_that_cannot_affect_the_selected_policy() {
|
||||
let missing_indexer = cfg_of("--policy cache_aware")
|
||||
.expect_err("cache_aware without an indexer can only behave like P2")
|
||||
.to_string();
|
||||
assert!(
|
||||
missing_indexer.contains("--kv-indexer-endpoint"),
|
||||
"got: {missing_indexer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ mod tests {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -70,7 +70,8 @@ impl Default for ActiveLoadConfig {
|
||||
/// policy factory.
|
||||
///
|
||||
/// Accepted on the CLI (`--policy`) as `round_robin` / `random` /
|
||||
/// `power_of_two` / `load_based` / `fused_score` / `cache_aware_zmq` /
|
||||
/// `power_of_two` / `load_based` / `fused_score` / `score_policy` /
|
||||
/// `session_aware` / `cache_aware` / `cache_aware_zmq` /
|
||||
/// `sticky`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
||||
pub enum PolicyKind {
|
||||
@@ -87,6 +88,15 @@ pub enum PolicyKind {
|
||||
/// Weighted sum of `--fuse` terms.
|
||||
#[value(name = "fused_score")]
|
||||
FusedScore,
|
||||
/// Composes compatible scoring terms into a single routing policy.
|
||||
#[value(name = "score_policy")]
|
||||
ScorePolicy,
|
||||
/// Selects a worker from session affinity.
|
||||
#[value(name = "session_aware")]
|
||||
SessionAware,
|
||||
/// Selects cache-affine prefill candidates from external indexer data.
|
||||
#[value(name = "cache_aware")]
|
||||
CacheAware,
|
||||
/// Cache-aware routing fed by SGLang's ZMQ KV-cache event publisher.
|
||||
/// Requires the model to have a tokenizer loaded; cache_aware tuning
|
||||
/// lives on `ModelConfig::cache_aware`.
|
||||
@@ -222,16 +232,19 @@ pub struct ModelConfig {
|
||||
pub tokenizer_path: String,
|
||||
pub policy: PolicyKind,
|
||||
pub circuit_breaker: Option<CircuitBreakerConfig>,
|
||||
/// Tuning for the cache-aware ZMQ policy. Ignored unless
|
||||
/// `policy = "cache_aware_zmq"`. `None` falls back to defaults at
|
||||
/// policy construction time.
|
||||
/// Cache-Aware ZMQ tuning and optional external Indexer endpoint.
|
||||
pub cache_aware: Option<CacheAwareConfig>,
|
||||
/// Tuning for the sticky-session policy. `Some` exactly when
|
||||
/// `policy = "sticky"` (built by [`crate::config::cli::Cli::into_config`]).
|
||||
/// The chat handler reads `sticky.header_name` to populate
|
||||
/// [`crate::policies::SelectionContext::routing_key`].
|
||||
pub sticky: Option<StickyConfig>,
|
||||
/// Terms for `policy = "fused_score"`.
|
||||
/// Session and cache-affinity tuning.
|
||||
pub affinity: Option<AffinityConfig>,
|
||||
/// Terms the score-composition policy sums. `Some` exactly when
|
||||
/// `policy = "fused_score"` or `policy = "score_policy"` (built by
|
||||
/// [`crate::config::cli::Cli::into_config`]), defaulting to
|
||||
/// [`DEFAULT_FUSE`] when `--fuse` is omitted.
|
||||
pub fused: Option<Vec<FusedTerm>>,
|
||||
/// Hard constraints applied before policy selection.
|
||||
pub eligibility: Option<EligibilityConfig>,
|
||||
@@ -340,6 +353,76 @@ fn default_balance_rel() -> f32 {
|
||||
/// (`x-sgl-decode-url`, `x-sgl-router-error-code`).
|
||||
pub const DEFAULT_STICKY_HEADER: &str = "x-sgl-routing-key";
|
||||
|
||||
/// Default request header for session-aware routing.
|
||||
pub const DEFAULT_SESSION_ID_HEADER: &str = "x-session-id";
|
||||
|
||||
/// Default external-indexer request limits.
|
||||
pub const DEFAULT_KV_INDEXER_QUERY_MAX_INFLIGHT: usize = 32;
|
||||
|
||||
/// Controls whether admission may select a session-affinity backup.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum AffinityMode {
|
||||
/// Keep the primary after it passes admission.
|
||||
#[value(name = "strict")]
|
||||
Strict,
|
||||
/// Allow the admitted backup to relieve pressure.
|
||||
#[default]
|
||||
#[value(name = "soft")]
|
||||
Soft,
|
||||
}
|
||||
|
||||
/// Controls the session-affinity lookup and fallback behavior.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
|
||||
pub enum SessionAffinityMode {
|
||||
/// Search only within the target bucket.
|
||||
#[default]
|
||||
#[value(name = "bucket")]
|
||||
Bucket,
|
||||
/// Rebind to a target-bucket fallback when the global primary is unavailable.
|
||||
#[value(name = "global-rebind")]
|
||||
GlobalRebind,
|
||||
/// Keep a valid global assignment when a bucket fallback is used.
|
||||
#[value(name = "global-preserve")]
|
||||
GlobalPreserve,
|
||||
}
|
||||
|
||||
/// Shared session-aware and cache-aware settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AffinityConfig {
|
||||
pub session_id_header: String,
|
||||
pub session_idle_secs: u64,
|
||||
pub session_eviction_interval_secs: u64,
|
||||
pub stable_pair: bool,
|
||||
pub mode: AffinityMode,
|
||||
pub session_affinity_mode: SessionAffinityMode,
|
||||
pub cache_affinity_min_matched_tokens: Option<u64>,
|
||||
pub cache_affinity_min_match_ratio: Option<f64>,
|
||||
pub cache_candidate_min_workers: usize,
|
||||
pub cache_candidate_ratio: f64,
|
||||
pub cache_candidate_max_workers: usize,
|
||||
pub cache_switch_margin_tokens: u64,
|
||||
}
|
||||
|
||||
impl Default for AffinityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_id_header: DEFAULT_SESSION_ID_HEADER.to_string(),
|
||||
session_idle_secs: default_sticky_idle_secs(),
|
||||
session_eviction_interval_secs: default_sticky_eviction_interval_secs(),
|
||||
stable_pair: false,
|
||||
mode: AffinityMode::Soft,
|
||||
session_affinity_mode: SessionAffinityMode::Bucket,
|
||||
// Indexer prefix scans are truncated, so use an absolute token floor.
|
||||
cache_affinity_min_matched_tokens: Some(1_024),
|
||||
cache_affinity_min_match_ratio: None,
|
||||
cache_candidate_min_workers: 8,
|
||||
cache_candidate_ratio: 0.05,
|
||||
cache_candidate_max_workers: 32,
|
||||
cache_switch_margin_tokens: 1_024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-model sticky-session tuning. Built from the `--routing-key-header`
|
||||
/// / `--sticky-*` flags by [`crate::config::cli::Cli::into_config`], which
|
||||
/// also validates that `header_name` parses as an HTTP header name.
|
||||
|
||||
@@ -141,6 +141,7 @@ async fn main() -> Result<()> {
|
||||
kv_index.tree(),
|
||||
Arc::clone(&tokenizers),
|
||||
Arc::clone(&block_size_oracle),
|
||||
kv_index.engine_load(),
|
||||
)
|
||||
.context("build policy registry")?,
|
||||
);
|
||||
@@ -197,6 +198,7 @@ async fn main() -> Result<()> {
|
||||
);
|
||||
app_ctx.prefix_index = prefix_index;
|
||||
app_ctx.block_size_oracle = block_size_oracle;
|
||||
app_ctx.engine_load = kv_index.engine_load();
|
||||
let ctx = Arc::new(app_ctx);
|
||||
ctx.mark_ready();
|
||||
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Shared admission and candidate comparison for Prefill and Decode.
|
||||
//!
|
||||
//! Decisions use only fields published in `LoadStat`.
|
||||
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
|
||||
use crate::policies::power_of_two::select_with_snapshot;
|
||||
use crate::policies::{CacheCandidate, CacheCandidateProposal, SelectionProposal};
|
||||
use crate::workers::Worker;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A Prefill candidate range and its optional pending-token budget.
|
||||
pub struct CandidateRange<'a> {
|
||||
pub id: &'a str,
|
||||
pub workers: &'a [Arc<Worker>],
|
||||
pub max_pending_prefill_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl<'a> CandidateRange<'a> {
|
||||
pub fn global(workers: &'a [Arc<Worker>]) -> Self {
|
||||
Self {
|
||||
id: "global",
|
||||
workers,
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A role-specific candidate domain resolved before policy evaluation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RoutingStage {
|
||||
Prefill,
|
||||
Decode,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CandidateDomain {
|
||||
pub id: String,
|
||||
pub stage: RoutingStage,
|
||||
pub workers: Vec<Arc<Worker>>,
|
||||
pub max_pending_prefill_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
impl CandidateDomain {
|
||||
pub fn global_prefill(workers: &[Arc<Worker>]) -> Self {
|
||||
Self {
|
||||
id: "global".to_string(),
|
||||
stage: RoutingStage::Prefill,
|
||||
workers: workers.to_vec(),
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn global_decode(workers: &[Arc<Worker>]) -> Self {
|
||||
Self {
|
||||
id: "global".to_string(),
|
||||
stage: RoutingStage::Decode,
|
||||
workers: workers.to_vec(),
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bucket_prefill(
|
||||
id: impl Into<String>,
|
||||
workers: Vec<Arc<Worker>>,
|
||||
max_pending_prefill_tokens: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
stage: RoutingStage::Prefill,
|
||||
workers,
|
||||
max_pending_prefill_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bucket_decode(id: impl Into<String>, workers: Vec<Arc<Worker>>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
stage: RoutingStage::Decode,
|
||||
workers,
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefill_range(&self) -> Option<CandidateRange<'_>> {
|
||||
(self.stage == RoutingStage::Prefill).then(|| CandidateRange {
|
||||
id: self.id.as_str(),
|
||||
workers: &self.workers,
|
||||
max_pending_prefill_tokens: self.max_pending_prefill_tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DecisionReason {
|
||||
Primary,
|
||||
CacheCandidate,
|
||||
BackupPrimaryAdmission,
|
||||
/// Both Decode candidates were admitted; the lower-pressure backup won.
|
||||
BackupLoadComparison,
|
||||
RangeFallback,
|
||||
CapacityFallbackPowerOfTwo,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FinalDecision {
|
||||
pub selected: Arc<Worker>,
|
||||
pub primary: Arc<Worker>,
|
||||
pub backup: Option<Arc<Worker>>,
|
||||
pub reason: DecisionReason,
|
||||
pub candidate_range_id: String,
|
||||
pub load_snapshot_version: u64,
|
||||
}
|
||||
|
||||
/// Resolves a bounded cache-candidate set, using pressure to break near ties.
|
||||
pub fn resolve_cache_candidates(
|
||||
proposal: &CacheCandidateProposal,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> Option<FinalDecision> {
|
||||
let loads = FreshLoadLookup::new(
|
||||
Some(snapshot),
|
||||
proposal
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|candidate| &candidate.worker),
|
||||
);
|
||||
let admitted: Vec<&CacheCandidate> = proposal
|
||||
.candidates
|
||||
.iter()
|
||||
.filter(|candidate| is_cache_candidate_admitted(candidate, request_input_tokens, &loads))
|
||||
.collect();
|
||||
let work_floor = admitted
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by_key(|candidate| candidate.uncached_tokens)?;
|
||||
let near_tie_ceiling = work_floor
|
||||
.uncached_tokens
|
||||
.saturating_add(proposal.cache_switch_margin_tokens);
|
||||
let mut winner = work_floor;
|
||||
for candidate in admitted {
|
||||
if candidate.uncached_tokens <= near_tie_ceiling
|
||||
&& compare_cache_candidates(winner, candidate, &loads).is_gt()
|
||||
{
|
||||
winner = candidate;
|
||||
}
|
||||
}
|
||||
Some(FinalDecision {
|
||||
selected: Arc::clone(&winner.worker),
|
||||
primary: Arc::clone(&winner.worker),
|
||||
backup: None,
|
||||
reason: DecisionReason::CacheCandidate,
|
||||
candidate_range_id: winner.candidate_range_id.clone(),
|
||||
load_snapshot_version: snapshot.version,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_prefill(
|
||||
range: &CandidateRange<'_>,
|
||||
proposal: &SelectionProposal,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> Option<FinalDecision> {
|
||||
if !contains_worker(range, &proposal.primary) {
|
||||
return None;
|
||||
}
|
||||
let backup = proposal
|
||||
.backup
|
||||
.as_ref()
|
||||
.filter(|worker| contains_worker(range, worker))
|
||||
.cloned();
|
||||
let primary_admitted = is_proposal_worker_eligible(proposal, &proposal.primary)
|
||||
&& is_prefill_admitted(&proposal.primary, request_input_tokens, snapshot);
|
||||
let backup_admitted = backup.as_ref().is_some_and(|worker| {
|
||||
is_proposal_worker_eligible(proposal, worker)
|
||||
&& is_prefill_admitted(worker, request_input_tokens, snapshot)
|
||||
});
|
||||
|
||||
let (selected, reason) = match (primary_admitted, backup.as_ref(), backup_admitted) {
|
||||
(true, _, _) => (Arc::clone(&proposal.primary), DecisionReason::Primary),
|
||||
(false, Some(backup), true) => (Arc::clone(backup), DecisionReason::BackupPrimaryAdmission),
|
||||
_ => {
|
||||
let legal = legal_prefill_candidates(range, proposal);
|
||||
range_fallback(&legal, request_input_tokens, snapshot).or_else(|| {
|
||||
select_with_snapshot(&legal, Some(snapshot))
|
||||
.map(|worker| (worker, DecisionReason::CapacityFallbackPowerOfTwo))
|
||||
})?
|
||||
}
|
||||
};
|
||||
Some(FinalDecision {
|
||||
selected,
|
||||
primary: Arc::clone(&proposal.primary),
|
||||
backup,
|
||||
reason,
|
||||
candidate_range_id: range.id.to_string(),
|
||||
load_snapshot_version: snapshot.version,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_decode(
|
||||
domain: &CandidateDomain,
|
||||
proposal: &SelectionProposal,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> Option<FinalDecision> {
|
||||
if domain.stage != RoutingStage::Decode || !contains_domain_worker(domain, &proposal.primary) {
|
||||
return None;
|
||||
}
|
||||
let backup = proposal
|
||||
.backup
|
||||
.as_ref()
|
||||
.filter(|worker| contains_domain_worker(domain, worker))
|
||||
.cloned();
|
||||
let primary_admitted = is_decode_admitted(&proposal.primary, request_kv_tokens, snapshot);
|
||||
let backup_admitted = backup
|
||||
.as_ref()
|
||||
.is_some_and(|worker| is_decode_admitted(worker, request_kv_tokens, snapshot));
|
||||
let (selected, reason) = match (primary_admitted, backup.as_ref(), backup_admitted) {
|
||||
(true, Some(backup), true) => {
|
||||
if compare_decode_pressure(&proposal.primary, backup, Some(snapshot)).is_gt() {
|
||||
(Arc::clone(backup), DecisionReason::BackupLoadComparison)
|
||||
} else {
|
||||
(Arc::clone(&proposal.primary), DecisionReason::Primary)
|
||||
}
|
||||
}
|
||||
(true, _, _) => (Arc::clone(&proposal.primary), DecisionReason::Primary),
|
||||
(false, Some(backup), true) => (Arc::clone(backup), DecisionReason::BackupPrimaryAdmission),
|
||||
_ => decode_domain_fallback(domain, request_kv_tokens, snapshot)?,
|
||||
};
|
||||
Some(FinalDecision {
|
||||
selected,
|
||||
primary: Arc::clone(&proposal.primary),
|
||||
backup,
|
||||
reason,
|
||||
candidate_range_id: domain.id.clone(),
|
||||
load_snapshot_version: snapshot.version,
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_worker(range: &CandidateRange<'_>, candidate: &Arc<Worker>) -> bool {
|
||||
range.workers.iter().any(|worker| worker.id == candidate.id)
|
||||
}
|
||||
|
||||
fn contains_domain_worker(domain: &CandidateDomain, candidate: &Arc<Worker>) -> bool {
|
||||
domain
|
||||
.workers
|
||||
.iter()
|
||||
.any(|worker| worker.id == candidate.id)
|
||||
}
|
||||
|
||||
fn is_proposal_worker_eligible(proposal: &SelectionProposal, candidate: &Arc<Worker>) -> bool {
|
||||
proposal
|
||||
.eligible_workers
|
||||
.as_ref()
|
||||
.is_none_or(|workers| workers.iter().any(|worker| worker.id == candidate.id))
|
||||
}
|
||||
|
||||
/// A zero LoadStat capacity is unknown and does not reject a candidate.
|
||||
fn has_kv_capacity(load: Option<&EngineWorkerLoad>, requested_tokens: u64) -> bool {
|
||||
let Some(load) = load else {
|
||||
return true;
|
||||
};
|
||||
load.max_total_num_tokens == 0
|
||||
|| load.num_tokens.saturating_add(requested_tokens) <= load.max_total_num_tokens
|
||||
}
|
||||
|
||||
fn is_prefill_admitted(
|
||||
worker: &Arc<Worker>,
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> bool {
|
||||
has_kv_capacity(
|
||||
snapshot.fresh_load_for_url(&worker.url),
|
||||
request_input_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
fn is_decode_admitted(
|
||||
worker: &Arc<Worker>,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> bool {
|
||||
has_kv_capacity(snapshot.fresh_load_for_url(&worker.url), request_kv_tokens)
|
||||
}
|
||||
|
||||
fn is_cache_candidate_admitted(
|
||||
candidate: &CacheCandidate,
|
||||
request_input_tokens: u64,
|
||||
loads: &FreshLoadLookup<'_>,
|
||||
) -> bool {
|
||||
has_kv_capacity(loads.get(&candidate.worker.id), request_input_tokens)
|
||||
}
|
||||
|
||||
fn compare_cache_candidates(
|
||||
left: &CacheCandidate,
|
||||
right: &CacheCandidate,
|
||||
loads: &FreshLoadLookup<'_>,
|
||||
) -> Ordering {
|
||||
left.uncached_tokens
|
||||
.cmp(&right.uncached_tokens)
|
||||
.then_with(|| loads.compare_prefill_pressure(&left.worker, &right.worker))
|
||||
.then_with(|| left.worker.id.0.cmp(&right.worker.id.0))
|
||||
}
|
||||
|
||||
/// Per-request lookup that uses engine pressure only for a complete fresh set.
|
||||
pub(crate) struct FreshLoadLookup<'a> {
|
||||
by_worker_id: HashMap<String, &'a EngineWorkerLoad>,
|
||||
local_active_by_worker_id: HashMap<String, usize>,
|
||||
compare_engine: bool,
|
||||
}
|
||||
|
||||
impl<'a> FreshLoadLookup<'a> {
|
||||
pub(crate) fn new<'w>(
|
||||
snapshot: Option<&'a EngineLoadSnapshot>,
|
||||
workers: impl IntoIterator<Item = &'w Arc<Worker>>,
|
||||
) -> Self {
|
||||
let workers: Vec<&Arc<Worker>> = workers.into_iter().collect();
|
||||
let local_active_by_worker_id: HashMap<String, usize> = workers
|
||||
.iter()
|
||||
.map(|worker| (worker.id.0.clone(), worker.active_load()))
|
||||
.collect();
|
||||
let by_worker_id = snapshot
|
||||
.into_iter()
|
||||
.flat_map(|snapshot| {
|
||||
workers.iter().filter_map(move |worker| {
|
||||
snapshot
|
||||
.fresh_load_for_url(&worker.url)
|
||||
.map(|load| (worker.id.0.clone(), load))
|
||||
})
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let compare_engine = !local_active_by_worker_id.is_empty()
|
||||
&& by_worker_id.len() == local_active_by_worker_id.len();
|
||||
Self {
|
||||
by_worker_id,
|
||||
local_active_by_worker_id,
|
||||
compare_engine,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(
|
||||
&self,
|
||||
worker_id: &crate::discovery::WorkerId,
|
||||
) -> Option<&'a EngineWorkerLoad> {
|
||||
self.by_worker_id.get(worker_id.0.as_str()).copied()
|
||||
}
|
||||
|
||||
fn comparable_get(
|
||||
&self,
|
||||
worker_id: &crate::discovery::WorkerId,
|
||||
) -> Option<&'a EngineWorkerLoad> {
|
||||
self.compare_engine.then(|| self.get(worker_id)).flatten()
|
||||
}
|
||||
|
||||
fn pressure_key(&self, worker: &Arc<Worker>) -> PressureKey<'a> {
|
||||
PressureKey {
|
||||
load: self.comparable_get(&worker.id),
|
||||
local_active: self
|
||||
.local_active_by_worker_id
|
||||
.get(worker.id.0.as_str())
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX),
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_prefill_keys(&self, left: &PressureKey<'a>, right: &PressureKey<'a>) -> Ordering {
|
||||
match (left.load, right.load) {
|
||||
(Some(left_load), Some(right_load)) => prefill_pressure_key(left_load)
|
||||
.cmp(&prefill_pressure_key(right_load))
|
||||
.then_with(|| left.local_active.cmp(&right.local_active)),
|
||||
_ => left.local_active.cmp(&right.local_active),
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_decode_keys(&self, left: &PressureKey<'a>, right: &PressureKey<'a>) -> Ordering {
|
||||
match (left.load, right.load) {
|
||||
(Some(left_load), Some(right_load)) => compare_decode_load(left_load, right_load)
|
||||
.then_with(|| left.local_active.cmp(&right.local_active)),
|
||||
_ => left.local_active.cmp(&right.local_active),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compare_prefill_pressure(
|
||||
&self,
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
) -> Ordering {
|
||||
self.compare_prefill_keys(&self.pressure_key(left), &self.pressure_key(right))
|
||||
}
|
||||
|
||||
/// Returns corrected engine queue depth for a complete fresh set, otherwise
|
||||
/// local load.
|
||||
pub(crate) fn score_load(&self, worker: &Arc<Worker>) -> usize {
|
||||
self.comparable_get(&worker.id)
|
||||
.map(|load| {
|
||||
let recent_dispatches = worker
|
||||
.slots_acquired_since(load.captured_at)
|
||||
.try_into()
|
||||
.unwrap_or(u64::MAX);
|
||||
load.num_waiting_reqs
|
||||
.saturating_add(load.num_running_reqs)
|
||||
.saturating_add(recent_dispatches)
|
||||
.try_into()
|
||||
.unwrap_or(usize::MAX)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.local_active_by_worker_id
|
||||
.get(worker.id.0.as_str())
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX)
|
||||
})
|
||||
}
|
||||
|
||||
fn min_by_pressure_key(
|
||||
&self,
|
||||
candidates: Vec<Arc<Worker>>,
|
||||
compare: impl Fn(&Self, &PressureKey<'a>, &PressureKey<'a>) -> Ordering,
|
||||
) -> Option<Arc<Worker>> {
|
||||
let mut candidates = candidates.into_iter();
|
||||
let mut best = candidates.next()?;
|
||||
let mut best_key = self.pressure_key(&best);
|
||||
for candidate in candidates {
|
||||
let key = self.pressure_key(&candidate);
|
||||
if compare(self, &key, &best_key).is_lt() {
|
||||
best = candidate;
|
||||
best_key = key;
|
||||
}
|
||||
}
|
||||
Some(best)
|
||||
}
|
||||
}
|
||||
|
||||
struct PressureKey<'a> {
|
||||
load: Option<&'a EngineWorkerLoad>,
|
||||
local_active: usize,
|
||||
}
|
||||
|
||||
fn range_fallback(
|
||||
legal: &[Arc<Worker>],
|
||||
request_input_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> Option<(Arc<Worker>, DecisionReason)> {
|
||||
let admitted = legal
|
||||
.iter()
|
||||
.filter(|worker| is_prefill_admitted(worker, request_input_tokens, snapshot))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let loads = FreshLoadLookup::new(Some(snapshot), admitted.iter());
|
||||
loads
|
||||
.min_by_pressure_key(admitted, FreshLoadLookup::compare_prefill_keys)
|
||||
.map(|worker| (worker, DecisionReason::RangeFallback))
|
||||
}
|
||||
|
||||
fn legal_prefill_candidates(
|
||||
range: &CandidateRange<'_>,
|
||||
proposal: &SelectionProposal,
|
||||
) -> Vec<Arc<Worker>> {
|
||||
proposal
|
||||
.eligible_workers
|
||||
.as_deref()
|
||||
.unwrap_or(range.workers)
|
||||
.iter()
|
||||
.filter(|worker| contains_worker(range, worker))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_domain_fallback(
|
||||
domain: &CandidateDomain,
|
||||
request_kv_tokens: u64,
|
||||
snapshot: &EngineLoadSnapshot,
|
||||
) -> Option<(Arc<Worker>, DecisionReason)> {
|
||||
let admitted = domain
|
||||
.workers
|
||||
.iter()
|
||||
.filter(|worker| is_decode_admitted(worker, request_kv_tokens, snapshot))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let loads = FreshLoadLookup::new(Some(snapshot), admitted.iter());
|
||||
loads
|
||||
.min_by_pressure_key(admitted, FreshLoadLookup::compare_decode_keys)
|
||||
.map(|worker| (worker, DecisionReason::RangeFallback))
|
||||
}
|
||||
|
||||
/// Compares Prefill pressure by waiting requests, running requests, and KV use.
|
||||
pub(crate) fn compare_prefill_pressure(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
) -> Ordering {
|
||||
match snapshot.and_then(|snapshot| {
|
||||
Some((
|
||||
snapshot.fresh_load_for_url(&left.url)?,
|
||||
snapshot.fresh_load_for_url(&right.url)?,
|
||||
))
|
||||
}) {
|
||||
Some((left_load, right_load)) => prefill_pressure_key(left_load)
|
||||
.cmp(&prefill_pressure_key(right_load))
|
||||
.then_with(|| left.active_load().cmp(&right.active_load())),
|
||||
None => left.active_load().cmp(&right.active_load()),
|
||||
}
|
||||
}
|
||||
|
||||
fn prefill_pressure_key(load: &EngineWorkerLoad) -> (u64, u64, u64, u64) {
|
||||
(
|
||||
load.num_waiting_reqs,
|
||||
load.num_running_reqs,
|
||||
load.num_tokens,
|
||||
load.max_total_num_tokens,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compares Decode pressure using only LoadStat values.
|
||||
pub(crate) fn compare_decode_pressure(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
) -> Ordering {
|
||||
match snapshot.and_then(|snapshot| {
|
||||
Some((
|
||||
snapshot.fresh_load_for_url(&left.url)?,
|
||||
snapshot.fresh_load_for_url(&right.url)?,
|
||||
))
|
||||
}) {
|
||||
Some((left_load, right_load)) => compare_decode_load(left_load, right_load)
|
||||
.then_with(|| left.active_load().cmp(&right.active_load())),
|
||||
None => left.active_load().cmp(&right.active_load()),
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_decode_load(left: &EngineWorkerLoad, right: &EngineWorkerLoad) -> Ordering {
|
||||
let kv_usage = match (left.max_total_num_tokens, right.max_total_num_tokens) {
|
||||
(left_cap, right_cap) if left_cap > 0 && right_cap > 0 => u128::from(left.num_tokens)
|
||||
.saturating_mul(u128::from(right_cap))
|
||||
.cmp(&u128::from(right.num_tokens).saturating_mul(u128::from(left_cap))),
|
||||
_ => Ordering::Equal,
|
||||
};
|
||||
left.num_waiting_reqs
|
||||
.cmp(&right.num_waiting_reqs)
|
||||
.then_with(|| left.num_running_reqs.cmp(&right.num_running_reqs))
|
||||
.then(kv_usage)
|
||||
.then_with(|| left.num_tokens.cmp(&right.num_tokens))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use std::time::Instant;
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
id: WorkerId(id.into()),
|
||||
url: format!("http://{id}:30000"),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId("model".into())],
|
||||
bootstrap_port: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_tokens: *used,
|
||||
max_total_num_tokens: *capacity,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_rejects_only_when_the_published_capacity_is_exceeded() {
|
||||
let full = worker("full");
|
||||
let unknown = worker("unknown");
|
||||
let workers = vec![Arc::clone(&full), Arc::clone(&unknown)];
|
||||
let range = CandidateRange::global(&workers);
|
||||
let loads = snapshot(&[(&full, 0, 0, 90, 100), (&unknown, 0, 0, 90, 0)]);
|
||||
|
||||
assert!(resolve_prefill(
|
||||
&range,
|
||||
&SelectionProposal::primary(Arc::clone(&full)),
|
||||
20,
|
||||
&loads
|
||||
)
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
resolve_prefill(&range, &SelectionProposal::primary(full), 20, &loads)
|
||||
.expect("fallback selects unknown-capacity worker")
|
||||
.selected
|
||||
.id,
|
||||
unknown.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_capacity_rejected_falls_back_to_power_of_two_within_eligible_domain() {
|
||||
let primary = worker("primary");
|
||||
let backup = worker("backup");
|
||||
let filtered = worker("filtered");
|
||||
let workers = vec![
|
||||
Arc::clone(&primary),
|
||||
Arc::clone(&backup),
|
||||
Arc::clone(&filtered),
|
||||
];
|
||||
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup))
|
||||
.with_eligible_workers(vec![Arc::clone(&primary), Arc::clone(&backup)]);
|
||||
let loads = snapshot(&[
|
||||
(&primary, 0, 0, 100, 100),
|
||||
(&backup, 0, 0, 100, 100),
|
||||
(&filtered, 0, 0, 0, 100),
|
||||
]);
|
||||
|
||||
let decision = resolve_prefill(&CandidateRange::global(&workers), &proposal, 32, &loads)
|
||||
.expect("capacity exhaustion must degrade within the legal domain");
|
||||
|
||||
assert!(matches!(
|
||||
decision.selected.id.0.as_str(),
|
||||
"primary" | "backup"
|
||||
));
|
||||
assert_eq!(decision.reason, DecisionReason::CapacityFallbackPowerOfTwo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_fallback_uses_the_explicit_snapshot_for_power_of_two() {
|
||||
let primary = worker("primary");
|
||||
let backup = worker("backup");
|
||||
let workers = vec![Arc::clone(&primary), Arc::clone(&backup)];
|
||||
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
|
||||
let explicit = snapshot(&[(&primary, 0, 0, 100, 100), (&backup, 0, 10, 100, 100)]);
|
||||
let opposite = snapshot(&[(&primary, 0, 10, 100, 100), (&backup, 0, 0, 100, 100)]);
|
||||
let opposite_decision = select_with_snapshot(&workers, Some(&opposite))
|
||||
.expect("the opposite snapshot has the same legal workers");
|
||||
assert_eq!(opposite_decision.id, backup.id);
|
||||
|
||||
let decision = resolve_prefill(&CandidateRange::global(&workers), &proposal, 32, &explicit)
|
||||
.expect("capacity exhaustion must degrade to Power-of-Two");
|
||||
|
||||
assert_eq!(decision.selected.id, primary.id);
|
||||
assert_eq!(decision.load_snapshot_version, explicit.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_pressure_uses_waiting_then_running_requests() {
|
||||
let busy = worker("busy");
|
||||
let idle = worker("idle");
|
||||
let loads = snapshot(&[(&busy, 1, 8, 10, 100), (&idle, 9, 2, 90, 100)]);
|
||||
assert!(compare_prefill_pressure(&busy, &idle, Some(&loads)).is_gt());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_snapshot_uses_local_active_load() {
|
||||
let left = worker("left");
|
||||
let right = worker("right");
|
||||
let _guard = left.load_guard();
|
||||
assert!(compare_prefill_pressure(&left, &right, None).is_gt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Builds bounded cache-aware candidates from ingress Indexer results.
|
||||
|
||||
use crate::config::AffinityConfig;
|
||||
use crate::policies::admission::FreshLoadLookup;
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::{
|
||||
CacheCandidate, CacheCandidateProposal, Policy, PrefillProposal, ProposalKind,
|
||||
SelectionContext, SelectionProposal,
|
||||
};
|
||||
use crate::workers::Worker;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CacheAwarePolicy {
|
||||
config: AffinityConfig,
|
||||
}
|
||||
|
||||
impl CacheAwarePolicy {
|
||||
pub fn new(config: AffinityConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn cache_candidate_proposal(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<CacheCandidateProposal> {
|
||||
let input_tokens = ctx.input_tokens()?;
|
||||
let signal = ctx.external_prefix()?;
|
||||
let sgl_kv_indexer::PrefixOutcome::Matched { matches, .. } = &signal.outcome else {
|
||||
return None;
|
||||
};
|
||||
if signal.query_blocks == 0 || workers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The #33370 indexer contract routes on the worker address (matched
|
||||
// byte-for-byte against registered worker URLs); worker_id is for
|
||||
// logs only.
|
||||
let by_url: HashMap<&str, &Arc<Worker>> = workers
|
||||
.iter()
|
||||
.map(|worker| (worker.url.as_str(), worker))
|
||||
.collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut candidates = Vec::new();
|
||||
for entry in matches {
|
||||
let Some(worker) = by_url.get(entry.address.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if entry.matched_prefix_blocks == 0 || !seen.insert(worker.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let matched_prefix_tokens = estimate_matched_prefix_tokens(
|
||||
input_tokens,
|
||||
signal.query_blocks,
|
||||
entry.matched_prefix_blocks,
|
||||
);
|
||||
if !self.passes_cache_gate(input_tokens, matched_prefix_tokens) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(CacheCandidate {
|
||||
worker: Arc::clone(worker),
|
||||
matched_prefix_tokens,
|
||||
uncached_tokens: input_tokens.saturating_sub(matched_prefix_tokens),
|
||||
candidate_range_id: ctx.candidate_range_id().to_string(),
|
||||
max_pending_prefill_tokens: None,
|
||||
});
|
||||
}
|
||||
|
||||
let limit = self.candidate_limit(workers.len());
|
||||
if limit == 0 {
|
||||
return None;
|
||||
}
|
||||
let loads = FreshLoadLookup::new(
|
||||
ctx.load_snapshot(),
|
||||
candidates.iter().map(|candidate| &candidate.worker),
|
||||
);
|
||||
if candidates.len() > limit {
|
||||
candidates.select_nth_unstable_by(limit, |left, right| {
|
||||
compare_candidate_seed(left, right, &loads)
|
||||
});
|
||||
candidates.truncate(limit);
|
||||
}
|
||||
candidates.sort_by(|left, right| compare_candidate_seed(left, right, &loads));
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(CacheCandidateProposal {
|
||||
candidates,
|
||||
cache_switch_margin_tokens: self.config.cache_switch_margin_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
fn passes_cache_gate(&self, input_tokens: u64, matched_prefix_tokens: u64) -> bool {
|
||||
self.config
|
||||
.cache_affinity_min_matched_tokens
|
||||
.is_none_or(|minimum| matched_prefix_tokens >= minimum)
|
||||
&& self
|
||||
.config
|
||||
.cache_affinity_min_match_ratio
|
||||
.is_none_or(|minimum| {
|
||||
input_tokens > 0
|
||||
&& matched_prefix_tokens as f64 / input_tokens as f64 >= minimum
|
||||
})
|
||||
}
|
||||
|
||||
fn candidate_limit(&self, worker_count: usize) -> usize {
|
||||
let proportional = (self.config.cache_candidate_ratio.clamp(0.0, 1.0) * worker_count as f64)
|
||||
.ceil() as usize;
|
||||
worker_count
|
||||
.min(self.config.cache_candidate_max_workers)
|
||||
.min(self.config.cache_candidate_min_workers.max(proportional))
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_candidate_seed(
|
||||
left: &CacheCandidate,
|
||||
right: &CacheCandidate,
|
||||
loads: &FreshLoadLookup<'_>,
|
||||
) -> Ordering {
|
||||
right
|
||||
.matched_prefix_tokens
|
||||
.cmp(&left.matched_prefix_tokens)
|
||||
.then_with(|| loads.compare_prefill_pressure(&left.worker, &right.worker))
|
||||
.then_with(|| left.worker.id.0.cmp(&right.worker.id.0))
|
||||
}
|
||||
|
||||
impl Policy for CacheAwarePolicy {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
self.propose(workers, ctx).map(|proposal| proposal.primary)
|
||||
}
|
||||
|
||||
fn propose(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
match self.propose_prefill(workers, ctx)? {
|
||||
PrefillProposal::Pair(proposal) => Some(proposal),
|
||||
PrefillProposal::CacheCandidates(proposal) => {
|
||||
let candidate = proposal.candidates.into_iter().next()?;
|
||||
Some(
|
||||
SelectionProposal::primary(candidate.worker)
|
||||
.with_kind(ProposalKind::CacheAffinity),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn propose_prefill(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<PrefillProposal> {
|
||||
if ctx.affinity_lookup_enabled() {
|
||||
if let Some(proposal) = self.cache_candidate_proposal(workers, ctx) {
|
||||
return Some(PrefillProposal::CacheCandidates(proposal));
|
||||
}
|
||||
}
|
||||
PowerOfTwoChoicesPolicy::new()
|
||||
.propose(workers, ctx)
|
||||
.map(PrefillProposal::Pair)
|
||||
}
|
||||
|
||||
fn needs_request_tokens(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_matched_prefix_tokens(
|
||||
input_tokens: u64,
|
||||
query_blocks: usize,
|
||||
matched_prefix_blocks: u32,
|
||||
) -> u64 {
|
||||
let query_blocks = u64::try_from(query_blocks).unwrap_or(u64::MAX).max(1);
|
||||
let matched_prefix_blocks = u64::from(matched_prefix_blocks).min(query_blocks);
|
||||
input_tokens.saturating_mul(matched_prefix_blocks) / query_blocks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matched_token_estimate_caps_untrusted_block_count() {
|
||||
assert_eq!(estimate_matched_prefix_tokens(80, 8, 99), 80);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Engine-reported runtime load, fed by the load subscriber.
|
||||
//!
|
||||
//! Workers publish a [`LoadStat`] gauge on their dedicated load socket (see
|
||||
//! `python/sglang/srt/managers/scheduler_components/load_publisher.py`). The
|
||||
//! load subscriber routes those into this table, keyed per
|
||||
//! `(worker_url, dp_rank)`; the
|
||||
//! cache-aware-zmq policy reads the freshest aggregate per worker as a
|
||||
//! truthful load signal, falling back to the router-side in-flight counter
|
||||
//! when no fresh snapshot exists (cold start, stale publisher, or a worker
|
||||
//! that predates load publishing).
|
||||
//!
|
||||
//! Load is a *gauge*, not a delta: last value wins, no sequence/replay
|
||||
//! semantics. Entries older than [`EngineLoadTable::freshness`] are ignored.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use serde::de::{self, Deserializer, IgnoredAny, SeqAccess, Visitor};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Per-scheduler runtime load snapshot. Mirrors the Python `LoadStat` in
|
||||
/// `managers/scheduler_components/load_publisher.py`, published on the
|
||||
/// worker's dedicated load socket (separate from KV-cache events).
|
||||
///
|
||||
/// Wire shape (msgspec `tag=True` + `array_like`):
|
||||
/// `["LoadStat", num_running_reqs, num_waiting_reqs, num_tokens,
|
||||
/// max_total_num_tokens, attn_dp_rank?]`. We read the four counts and ignore
|
||||
/// any trailing fields (`attn_dp_rank` — the router keys load by the
|
||||
/// subscriber's socket rank, not the payload).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadStat {
|
||||
/// Requests currently running on the engine.
|
||||
pub num_running_reqs: u64,
|
||||
/// Requests queued waiting to run.
|
||||
pub num_waiting_reqs: u64,
|
||||
/// KV tokens currently in use.
|
||||
pub num_tokens: u64,
|
||||
/// KV-cache token capacity; 0 when unknown.
|
||||
pub max_total_num_tokens: u64,
|
||||
}
|
||||
|
||||
/// Aggregated, usable Engine load for one Worker at a fixed instant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EngineWorkerLoad {
|
||||
pub num_running_reqs: u64,
|
||||
pub num_waiting_reqs: u64,
|
||||
pub num_tokens: u64,
|
||||
pub max_total_num_tokens: u64,
|
||||
pub captured_at: Instant,
|
||||
}
|
||||
|
||||
/// Immutable Engine-load view captured once at request ingress.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EngineLoadSnapshot {
|
||||
pub version: u64,
|
||||
workers: HashMap<String, EngineWorkerLoad>,
|
||||
}
|
||||
|
||||
impl EngineLoadSnapshot {
|
||||
pub fn fresh_load_for_url(&self, worker_url: &str) -> Option<&EngineWorkerLoad> {
|
||||
self.workers.get(worker_url)
|
||||
}
|
||||
|
||||
/// Builds a view from already validated Worker data for tests and offline checks.
|
||||
pub fn from_workers(version: u64, workers: HashMap<String, EngineWorkerLoad>) -> Self {
|
||||
Self { version, workers }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for LoadStat {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
struct LoadStatVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for LoadStatVisitor {
|
||||
type Value = LoadStat;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a tagged msgpack array [\"LoadStat\", ...fields]")
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> Result<LoadStat, A::Error>
|
||||
where
|
||||
A: SeqAccess<'de>,
|
||||
{
|
||||
let tag: String = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field("event tag"))?;
|
||||
if tag != "LoadStat" {
|
||||
return Err(de::Error::custom(format!(
|
||||
"expected \"LoadStat\" tag, got {tag:?}"
|
||||
)));
|
||||
}
|
||||
// The Python publisher always emits all four counts. Treat a
|
||||
// shortened frame as malformed rather than inventing zeros:
|
||||
// a partial gauge must fall back to router-local load, never
|
||||
// make a worker appear artificially idle.
|
||||
let num_running_reqs: u64 = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field("num_running_reqs"))?;
|
||||
let num_waiting_reqs: u64 = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field("num_waiting_reqs"))?;
|
||||
let num_tokens: u64 = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field("num_tokens"))?;
|
||||
let max_total_num_tokens: u64 = seq
|
||||
.next_element()?
|
||||
.ok_or_else(|| de::Error::missing_field("max_total_num_tokens"))?;
|
||||
while seq.next_element::<IgnoredAny>()?.is_some() {}
|
||||
Ok(LoadStat {
|
||||
num_running_reqs,
|
||||
num_waiting_reqs,
|
||||
num_tokens,
|
||||
max_total_num_tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_seq(LoadStatVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a single load frame's msgpack payload into a [`LoadStat`].
|
||||
pub fn decode_load_stat(payload: &[u8]) -> Result<LoadStat, rmp_serde::decode::Error> {
|
||||
rmp_serde::from_slice(payload)
|
||||
}
|
||||
|
||||
/// A per-rank load snapshot older than this is treated as stale, so a silent
|
||||
/// or slow publisher degrades to the router-side load signal rather than
|
||||
/// pinning a worker at its last reported value.
|
||||
const DEFAULT_FRESHNESS: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LoadEntry {
|
||||
load: LoadStat,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
/// Per-`(worker_url, dp_rank)` engine-reported load. Written by the load
|
||||
/// subscriber pump, read by the cache-aware-zmq policy. Shared out of
|
||||
/// [`super::kv_events::index::KvEventIndex`] the same way the hash tree is.
|
||||
#[derive(Debug)]
|
||||
pub struct EngineLoadTable {
|
||||
by_rank: DashMap<(String, u32), LoadEntry>,
|
||||
/// Per-rank publishers the worker advertised. A worker is usable only
|
||||
/// when every advertised rank has a fresh value; accepting a partial
|
||||
/// aggregate would make a silent rank look idle and attract traffic.
|
||||
expected: DashSet<(String, u32)>,
|
||||
freshness: Duration,
|
||||
version: AtomicU64,
|
||||
}
|
||||
|
||||
impl EngineLoadTable {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
by_rank: DashMap::new(),
|
||||
expected: DashSet::new(),
|
||||
freshness: DEFAULT_FRESHNESS,
|
||||
version: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn with_freshness(freshness: Duration) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
by_rank: DashMap::new(),
|
||||
expected: DashSet::new(),
|
||||
freshness,
|
||||
version: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Record the latest load for one `(worker_url, dp_rank)`.
|
||||
pub fn set(&self, url: &str, dp_rank: u32, load: LoadStat, at: Instant) {
|
||||
self.by_rank
|
||||
.insert((url.to_string(), dp_rank), LoadEntry { load, at });
|
||||
self.version.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Mark one advertised scheduler rank as expected to publish load.
|
||||
pub fn mark_expected_rank(&self, url: &str, dp_rank: u32) {
|
||||
if self.expected.insert((url.to_string(), dp_rank)) {
|
||||
self.version.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of workers expected to publish load. Compared against the size
|
||||
/// of [`Self::snapshot_fresh`] to surface a dead/misconfigured publisher
|
||||
/// (expected > 0 but no fresh snapshots) in logs.
|
||||
pub fn expected_count(&self) -> usize {
|
||||
self.expected
|
||||
.iter()
|
||||
.map(|entry| entry.key().0.clone())
|
||||
.collect::<HashSet<_>>()
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Shared accumulation pass behind [`Self::snapshot_fresh`] and
|
||||
/// [`Self::capture_snapshot`]. It produces the #34608 fields summed across
|
||||
/// ranks and the OLDEST snapshot timestamp — **but only for workers whose
|
||||
/// every advertised rank is present and fresh**. A missing or stale rank is
|
||||
/// omitted, so the caller falls back to its own load signal. (Summing
|
||||
/// only the fresh ranks would make a worker whose other ranks went silent
|
||||
/// look misleadingly idle and draw *more* traffic.) Callers that never
|
||||
/// registered expected ranks retain the legacy all-known-ranks rule.
|
||||
/// `snapshot_fresh` and any other consumer walking this same pass can
|
||||
/// never disagree with each other about which workers count as fresh.
|
||||
///
|
||||
/// The oldest (not newest) rank's timestamp is deliberately what's kept
|
||||
/// alongside the depth: a caller using it as a "dispatches not yet
|
||||
/// reflected in this number" cutoff (see
|
||||
/// `crate::policies::cache_aware_zmq::WorkerLoads::load_of`) needs a
|
||||
/// bound that never treats an unreported dispatch as already-covered —
|
||||
/// the freshest rank's timestamp could do exactly that for whichever
|
||||
/// rank published less recently. This conservatism is one-sided, not
|
||||
/// free: for a multi-rank worker with skewed publish times, a dispatch
|
||||
/// that landed on (and was already reported by) the FRESHER rank can
|
||||
/// get re-added by the caller's cutoff-based correction anyway, since
|
||||
/// that correction has no way to attribute a dispatch to a specific
|
||||
/// rank. That's an accepted, bounded over-count (it biases the wrong
|
||||
/// direction relative to the under-count this method exists to avoid,
|
||||
/// not a correctness hole) rather than something this method can close
|
||||
/// on its own — closing it would require per-rank dispatch attribution,
|
||||
/// which the router-side slot tracking below doesn't have.
|
||||
fn fresh_worker_loads(&self, now: Instant) -> HashMap<String, EngineWorkerLoad> {
|
||||
// url -> rank -> (reported load, fresh, timestamp).
|
||||
let mut observed: HashMap<String, HashMap<u32, (LoadStat, bool, Instant)>> = HashMap::new();
|
||||
for entry in self.by_rank.iter() {
|
||||
let at = entry.value().at;
|
||||
let fresh = now.saturating_duration_since(at) <= self.freshness;
|
||||
observed
|
||||
.entry(entry.key().0.clone())
|
||||
.or_default()
|
||||
.insert(entry.key().1, (entry.value().load.clone(), fresh, at));
|
||||
}
|
||||
let mut expected: HashMap<String, HashSet<u32>> = HashMap::new();
|
||||
for entry in self.expected.iter() {
|
||||
expected
|
||||
.entry(entry.key().0.clone())
|
||||
.or_default()
|
||||
.insert(entry.key().1);
|
||||
}
|
||||
|
||||
let workers: HashSet<String> = observed.keys().chain(expected.keys()).cloned().collect();
|
||||
workers
|
||||
.into_iter()
|
||||
.filter_map(|url| {
|
||||
let ranks = observed.get(&url)?;
|
||||
let required: Vec<u32> = match expected.get(&url) {
|
||||
Some(expected_ranks) => expected_ranks.iter().copied().collect(),
|
||||
None => ranks.keys().copied().collect(),
|
||||
};
|
||||
let mut num_running_reqs = 0u64;
|
||||
let mut num_waiting_reqs = 0u64;
|
||||
let mut num_tokens = 0u64;
|
||||
let mut max_total_num_tokens = 0u64;
|
||||
let mut oldest_at = None;
|
||||
for rank in required {
|
||||
let (load, fresh, at) = ranks.get(&rank)?;
|
||||
if !fresh {
|
||||
return None;
|
||||
}
|
||||
num_running_reqs = num_running_reqs.saturating_add(load.num_running_reqs);
|
||||
num_waiting_reqs = num_waiting_reqs.saturating_add(load.num_waiting_reqs);
|
||||
num_tokens = num_tokens.saturating_add(load.num_tokens);
|
||||
max_total_num_tokens =
|
||||
max_total_num_tokens.saturating_add(load.max_total_num_tokens);
|
||||
oldest_at = Some(oldest_at.map_or(*at, |oldest: Instant| oldest.min(*at)));
|
||||
}
|
||||
oldest_at.map(|captured_at| {
|
||||
(
|
||||
url,
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs,
|
||||
num_waiting_reqs,
|
||||
num_tokens,
|
||||
max_total_num_tokens,
|
||||
captured_at,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Captures the immutable view consumed by all routing decisions in one request.
|
||||
pub fn capture_snapshot(&self, now: Instant) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot {
|
||||
version: self.version.load(Ordering::Acquire),
|
||||
workers: self.fresh_worker_loads(now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fresh_worker_state(&self, now: Instant) -> HashMap<String, (usize, Instant)> {
|
||||
self.fresh_worker_loads(now)
|
||||
.into_iter()
|
||||
.map(|(url, load)| {
|
||||
(
|
||||
url,
|
||||
(
|
||||
load.num_running_reqs
|
||||
.saturating_add(load.num_waiting_reqs)
|
||||
.try_into()
|
||||
.unwrap_or(usize::MAX),
|
||||
load.captured_at,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Per worker URL, the summed queue depth (`num_running_reqs +
|
||||
/// num_waiting_reqs`) across that worker's ranks, for workers whose
|
||||
/// every advertised rank is fresh. Computed once per selection so per-worker
|
||||
/// lookups are O(1). See [`Self::fresh_worker_state`] for the freshness
|
||||
/// gate behind this.
|
||||
pub fn snapshot_fresh(&self, now: Instant) -> HashMap<String, usize> {
|
||||
self.fresh_worker_state(now)
|
||||
.into_iter()
|
||||
.map(|(url, (depth, _))| (url, depth))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drop every rank entry (and the expected mark) for a worker. Called on
|
||||
/// worker removal so a re-added worker does not leave stale load behind.
|
||||
pub fn forget_worker(&self, url: &str) {
|
||||
self.by_rank.retain(|k, _| k.0 != url);
|
||||
self.expected.retain(|key| key.0 != url);
|
||||
self.version.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.by_rank.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn load(running: u64, waiting: u64) -> LoadStat {
|
||||
LoadStat {
|
||||
num_running_reqs: running,
|
||||
num_waiting_reqs: waiting,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_wire_rejects_wrong_tag_and_missing_counts() {
|
||||
let mut wrong_tag = Vec::new();
|
||||
rmp::encode::write_array_len(&mut wrong_tag, 5).unwrap();
|
||||
rmp::encode::write_str(&mut wrong_tag, "OtherStat").unwrap();
|
||||
for value in [1, 2, 3, 4] {
|
||||
rmp::encode::write_u64(&mut wrong_tag, value).unwrap();
|
||||
}
|
||||
assert!(decode_load_stat(&wrong_tag).is_err());
|
||||
|
||||
let mut missing_count = Vec::new();
|
||||
rmp::encode::write_array_len(&mut missing_count, 4).unwrap();
|
||||
rmp::encode::write_str(&mut missing_count, "LoadStat").unwrap();
|
||||
for value in [1, 2, 3] {
|
||||
rmp::encode::write_u64(&mut missing_count, value).unwrap();
|
||||
}
|
||||
assert!(decode_load_stat(&missing_count).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_queue_depth_across_ranks() {
|
||||
let t = EngineLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.set("http://w:30000", 0, load(5, 1), now);
|
||||
t.set("http://w:30000", 1, load(3, 2), now);
|
||||
let fresh = t.snapshot_fresh(now);
|
||||
// (5+1) + (3+2) = 11
|
||||
assert_eq!(fresh.get("http://w:30000").copied(), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_entries_are_dropped_from_snapshot() {
|
||||
let t = EngineLoadTable::with_freshness(Duration::from_millis(10));
|
||||
let old = Instant::now();
|
||||
t.set("http://w:30000", 0, load(9, 9), old);
|
||||
// A read far in the future sees the entry as stale -> worker absent.
|
||||
let later = old + Duration::from_secs(60);
|
||||
assert!(!t.snapshot_fresh(later).contains_key("http://w:30000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_worker_clears_all_ranks() {
|
||||
let t = EngineLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.set("http://w:30000", 0, load(1, 0), now);
|
||||
t.set("http://w:30000", 1, load(1, 0), now);
|
||||
t.set("http://other:30000", 0, load(1, 0), now);
|
||||
t.forget_worker("http://w:30000");
|
||||
assert_eq!(t.entry_count(), 1);
|
||||
assert!(!t.snapshot_fresh(now).contains_key("http://w:30000"));
|
||||
assert!(t.snapshot_fresh(now).contains_key("http://other:30000"));
|
||||
}
|
||||
|
||||
/// A worker with any stale rank is omitted entirely (not summed over only
|
||||
/// its fresh ranks), so a partially-silent worker falls back to the
|
||||
/// router-side counter instead of looking misleadingly idle.
|
||||
#[test]
|
||||
fn partial_freshness_excludes_worker() {
|
||||
let t = EngineLoadTable::with_freshness(Duration::from_secs(5));
|
||||
let now = Instant::now();
|
||||
let stale = now - Duration::from_secs(3600);
|
||||
t.set("http://w:30000", 0, load(5, 1), now); // fresh
|
||||
t.set("http://w:30000", 1, load(9, 9), stale); // stale
|
||||
assert!(
|
||||
!t.snapshot_fresh(now).contains_key("http://w:30000"),
|
||||
"any stale rank must drop the whole worker from the snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_expected_rank_excludes_worker() {
|
||||
let t = EngineLoadTable::new();
|
||||
let now = Instant::now();
|
||||
t.mark_expected_rank("http://w:30000", 0);
|
||||
t.mark_expected_rank("http://w:30000", 1);
|
||||
t.set("http://w:30000", 0, load(5, 1), now);
|
||||
assert!(
|
||||
!t.snapshot_fresh(now).contains_key("http://w:30000"),
|
||||
"an advertised rank without a reading must not produce a partial aggregate"
|
||||
);
|
||||
|
||||
t.set("http://w:30000", 1, load(3, 2), now);
|
||||
assert_eq!(t.snapshot_fresh(now).get("http://w:30000"), Some(&11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_worker_state_picks_the_earliest_rank_timestamp() {
|
||||
let t = EngineLoadTable::new();
|
||||
let earlier = Instant::now() - Duration::from_secs(2);
|
||||
let later = earlier + Duration::from_secs(1);
|
||||
t.set("http://w:30000", 0, load(5, 1), later);
|
||||
t.set("http://w:30000", 1, load(3, 2), earlier);
|
||||
let now = later + Duration::from_millis(1);
|
||||
assert_eq!(
|
||||
t.fresh_worker_state(now).get("http://w:30000").copied(),
|
||||
Some((11, earlier)),
|
||||
"must expose the OLDEST rank's timestamp, not the newest"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_worker_state_agrees_with_snapshot_fresh_on_which_workers_are_present() {
|
||||
let t = EngineLoadTable::with_freshness(Duration::from_secs(5));
|
||||
let now = Instant::now();
|
||||
let stale = now - Duration::from_secs(3600);
|
||||
t.set("http://fresh:30000", 0, load(1, 0), now);
|
||||
t.set("http://mixed:30000", 0, load(1, 0), now);
|
||||
t.set("http://mixed:30000", 1, load(1, 0), stale);
|
||||
|
||||
let depths = t.snapshot_fresh(now);
|
||||
let state = t.fresh_worker_state(now);
|
||||
assert!(depths.contains_key("http://fresh:30000"));
|
||||
assert!(state.contains_key("http://fresh:30000"));
|
||||
assert!(!depths.contains_key("http://mixed:30000"));
|
||||
assert!(!state.contains_key("http://mixed:30000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expected_count_tracks_marked_workers_and_forget() {
|
||||
let t = EngineLoadTable::new();
|
||||
assert_eq!(t.expected_count(), 0);
|
||||
t.mark_expected_rank("http://w:30000", 0);
|
||||
t.mark_expected_rank("http://w:30000", 1); // same worker
|
||||
t.mark_expected_rank("http://other:30000", 0);
|
||||
assert_eq!(t.expected_count(), 2);
|
||||
t.forget_worker("http://w:30000");
|
||||
assert_eq!(t.expected_count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ use crate::config::{
|
||||
};
|
||||
use crate::discovery::ModelId;
|
||||
use crate::policies::{
|
||||
cache_aware::CacheAwarePolicy,
|
||||
cache_aware_zmq::CacheAwareZmqPolicy,
|
||||
engine_load::EngineLoadTable,
|
||||
kv_events::{BlockSizeOracle, HashTree},
|
||||
load_based::LoadBasedPolicy,
|
||||
power_of_two::PowerOfTwoChoicesPolicy,
|
||||
@@ -14,8 +16,9 @@ use crate::policies::{
|
||||
round_robin::RoundRobinPolicy,
|
||||
scoring::{
|
||||
admission::Overloaded, prefix_cache, prefix_cache::PrefixCachePolicy, FusedScorePolicy,
|
||||
Pipeline,
|
||||
Pipeline, ScorePolicy,
|
||||
},
|
||||
session_aware::SessionAwarePolicy,
|
||||
sticky::StickyPolicy,
|
||||
Policy, PolicyRegistry,
|
||||
};
|
||||
@@ -52,8 +55,17 @@ pub fn build_policy(
|
||||
tree: Arc<HashTree>,
|
||||
tokenizers: Arc<TokenizerRegistry>,
|
||||
block_size_oracle: Arc<BlockSizeOracle>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
) -> Result<Arc<dyn Policy>> {
|
||||
let inner = build_kind(model.policy, model, &tree, &tokenizers, &block_size_oracle)?;
|
||||
validate_eligibility(model)?;
|
||||
let inner = build_kind(
|
||||
model.policy,
|
||||
model,
|
||||
&tree,
|
||||
&tokenizers,
|
||||
&block_size_oracle,
|
||||
&engine_load,
|
||||
)?;
|
||||
let Some(elig) = model.eligibility.as_ref().filter(|e| !e.filters.is_empty()) else {
|
||||
return Ok(inner);
|
||||
};
|
||||
@@ -64,6 +76,26 @@ pub fn build_policy(
|
||||
Ok(Arc::new(Pipeline::new(filters, inner)?))
|
||||
}
|
||||
|
||||
/// Reject configurations that can bypass CLI validation when constructed in code.
|
||||
fn validate_eligibility(model: &ModelConfig) -> Result<()> {
|
||||
let Some(eligibility) = model.eligibility.as_ref().filter(|e| !e.filters.is_empty()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if model.policy == PolicyKind::Sticky {
|
||||
return Err(anyhow!(
|
||||
"eligibility filters cannot be combined with sticky policy"
|
||||
));
|
||||
}
|
||||
if eligibility.filters.contains(&FilterKind::Overloaded)
|
||||
&& eligibility.max_in_flight.unwrap_or(0) == 0
|
||||
{
|
||||
return Err(anyhow!("max_in_flight must be greater than 0"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build one policy kind with the shared model dependencies.
|
||||
fn build_kind(
|
||||
kind: PolicyKind,
|
||||
@@ -71,6 +103,7 @@ fn build_kind(
|
||||
tree: &Arc<HashTree>,
|
||||
tokenizers: &Arc<TokenizerRegistry>,
|
||||
block_size_oracle: &Arc<BlockSizeOracle>,
|
||||
engine_load: &Arc<EngineLoadTable>,
|
||||
) -> Result<Arc<dyn Policy>> {
|
||||
let (tree, tokenizers, block_size_oracle) = (
|
||||
Arc::clone(tree),
|
||||
@@ -89,10 +122,18 @@ fn build_kind(
|
||||
tree,
|
||||
tokenizers,
|
||||
block_size_oracle,
|
||||
Arc::clone(engine_load),
|
||||
))
|
||||
}
|
||||
PolicyKind::SessionAware => Arc::new(SessionAwarePolicy::new(
|
||||
model.affinity.clone().unwrap_or_default(),
|
||||
)),
|
||||
PolicyKind::CacheAware => Arc::new(CacheAwarePolicy::new(
|
||||
model.affinity.clone().unwrap_or_default(),
|
||||
)),
|
||||
PolicyKind::Sticky => build_sticky(model),
|
||||
PolicyKind::FusedScore => build_fused(model, &tree, &block_size_oracle)?,
|
||||
PolicyKind::ScorePolicy => build_score_policy(model, &tree, &block_size_oracle)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,6 +203,17 @@ fn build_fused(
|
||||
Ok(Arc::new(FusedScorePolicy::new(terms)?))
|
||||
}
|
||||
|
||||
/// Builds the top-level `score_policy`.
|
||||
fn build_score_policy(
|
||||
model: &ModelConfig,
|
||||
tree: &Arc<HashTree>,
|
||||
oracle: &Arc<BlockSizeOracle>,
|
||||
) -> Result<Arc<dyn Policy>> {
|
||||
Ok(Arc::new(ScorePolicy::new(build_fused(
|
||||
model, tree, oracle,
|
||||
)?)))
|
||||
}
|
||||
|
||||
/// Builds a policy with test defaults.
|
||||
#[cfg(test)]
|
||||
pub fn build_policy_kind_only(kind: PolicyKind) -> Result<Arc<dyn Policy>> {
|
||||
@@ -175,6 +227,13 @@ pub fn build_policy_kind_only(kind: PolicyKind) -> Result<Arc<dyn Policy>> {
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::new(TokenizerRegistry::default()),
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)),
|
||||
PolicyKind::SessionAware => Arc::new(SessionAwarePolicy::new(
|
||||
crate::config::AffinityConfig::default(),
|
||||
)),
|
||||
PolicyKind::CacheAware => Arc::new(CacheAwarePolicy::new(
|
||||
crate::config::AffinityConfig::default(),
|
||||
)),
|
||||
PolicyKind::Sticky => {
|
||||
let s = crate::config::StickyConfig::default();
|
||||
@@ -184,7 +243,7 @@ pub fn build_policy_kind_only(kind: PolicyKind) -> Result<Arc<dyn Policy>> {
|
||||
build_sticky_fallback(s.fallback_policy),
|
||||
))
|
||||
}
|
||||
PolicyKind::FusedScore => {
|
||||
PolicyKind::FusedScore | PolicyKind::ScorePolicy => {
|
||||
return Err(anyhow!("--policy {kind} needs --fuse terms from the model"))
|
||||
}
|
||||
})
|
||||
@@ -195,6 +254,7 @@ pub fn build_registry(
|
||||
tree: Arc<HashTree>,
|
||||
tokenizers: Arc<TokenizerRegistry>,
|
||||
block_size_oracle: Arc<BlockSizeOracle>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
) -> Result<PolicyRegistry> {
|
||||
let reg = PolicyRegistry::default();
|
||||
let m = &cfg.model;
|
||||
@@ -205,6 +265,7 @@ pub fn build_registry(
|
||||
Arc::clone(&tree),
|
||||
Arc::clone(&tokenizers),
|
||||
Arc::clone(&block_size_oracle),
|
||||
Arc::clone(&engine_load),
|
||||
)?,
|
||||
);
|
||||
Ok(reg)
|
||||
@@ -217,6 +278,7 @@ pub fn build_registry_with_defaults(cfg: &Config) -> Result<PolicyRegistry> {
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::new(TokenizerRegistry::default()),
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -327,6 +389,7 @@ mod tests {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
@@ -340,17 +403,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_policy_kind_only_covers_all_variants() {
|
||||
for kind in [
|
||||
PolicyKind::RoundRobin,
|
||||
PolicyKind::Random,
|
||||
PolicyKind::PowerOfTwo,
|
||||
PolicyKind::LoadBased,
|
||||
PolicyKind::CacheAwareZmq,
|
||||
PolicyKind::Sticky,
|
||||
for (kind, needs_load_snapshot) in [
|
||||
(PolicyKind::RoundRobin, false),
|
||||
(PolicyKind::Random, false),
|
||||
(PolicyKind::PowerOfTwo, true),
|
||||
(PolicyKind::LoadBased, true),
|
||||
(PolicyKind::CacheAwareZmq, true),
|
||||
(PolicyKind::SessionAware, true),
|
||||
(PolicyKind::CacheAware, true),
|
||||
(PolicyKind::Sticky, false),
|
||||
] {
|
||||
assert!(build_policy_kind_only(kind).is_ok(), "{kind:?}");
|
||||
let policy = build_policy_kind_only(kind).unwrap();
|
||||
assert_eq!(
|
||||
policy.needs_load_snapshot(),
|
||||
needs_load_snapshot,
|
||||
"{kind:?}"
|
||||
);
|
||||
}
|
||||
assert!(build_policy_kind_only(PolicyKind::FusedScore).is_err());
|
||||
assert!(build_policy_kind_only(PolicyKind::ScorePolicy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -361,6 +432,7 @@ mod tests {
|
||||
&BlockSizeOracle::new(),
|
||||
);
|
||||
assert!(p.can_fuse(), "prefix_cache must be usable as a --fuse term");
|
||||
assert!(!p.needs_load_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -380,28 +452,84 @@ mod tests {
|
||||
.contains("at least one --fuse term"));
|
||||
}
|
||||
|
||||
/// `score_policy` uses its own top-level factory branch.
|
||||
#[test]
|
||||
fn fused_score_accepts_an_outer_eligibility_pipeline() {
|
||||
let mut cfg = cfg_with_model("modelA", PolicyKind::FusedScore);
|
||||
fn score_policy_builds_via_its_own_factory_branch() {
|
||||
let mut cfg = cfg_with_model("modelA", PolicyKind::ScorePolicy);
|
||||
cfg.model.fused = Some(vec![crate::config::FusedTerm {
|
||||
kind: ScoreTermKind::PrefixCache,
|
||||
weight: Some(2.0),
|
||||
}]);
|
||||
|
||||
let registry = build_registry_with_defaults(&cfg).expect("score policy builds");
|
||||
let policy = registry
|
||||
.get(&ModelId("modelA".into()))
|
||||
.expect("configured model has a policy");
|
||||
assert!(
|
||||
policy.can_fuse(),
|
||||
"the score policy exposes score semantics"
|
||||
);
|
||||
assert!(
|
||||
policy.uses_shared_prefill_admission(),
|
||||
"top-level score_policy participates in the shared hard admission layer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_policy_with_filter_builds_one_outer_pipeline() {
|
||||
let mut cfg = cfg_with_model("modelA", PolicyKind::ScorePolicy);
|
||||
cfg.model.fused = Some(vec![crate::config::FusedTerm {
|
||||
kind: ScoreTermKind::LoadBased,
|
||||
weight: None,
|
||||
weight: Some(1.0),
|
||||
}]);
|
||||
cfg.model.eligibility = Some(EligibilityConfig {
|
||||
filters: vec![FilterKind::Overloaded],
|
||||
max_in_flight: Some(0),
|
||||
max_in_flight: Some(2),
|
||||
min_prefix_share: None,
|
||||
});
|
||||
|
||||
let policy = build_registry_with_defaults(&cfg)
|
||||
.expect("an outer filter must not make fused terms non-fusable")
|
||||
let registry = build_registry_with_defaults(&cfg)
|
||||
.expect("a score policy keeps eligibility outside its scoring terms");
|
||||
let policy = registry
|
||||
.get(&ModelId("modelA".into()))
|
||||
.unwrap();
|
||||
let workers = vec![worker("w0"), worker("w1")];
|
||||
let model = ModelId("modelA".into());
|
||||
assert!(policy
|
||||
.select(&workers, &SelectionContext::new(&model, None))
|
||||
.is_none());
|
||||
.expect("configured model has a policy");
|
||||
assert!(policy.uses_shared_prefill_admission());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_rejects_missing_or_zero_overloaded_capacity() {
|
||||
for max_in_flight in [None, Some(0)] {
|
||||
let mut cfg = cfg_with_model("modelA", PolicyKind::FusedScore);
|
||||
cfg.model.fused = Some(vec![crate::config::FusedTerm {
|
||||
kind: ScoreTermKind::LoadBased,
|
||||
weight: None,
|
||||
}]);
|
||||
cfg.model.eligibility = Some(EligibilityConfig {
|
||||
filters: vec![FilterKind::Overloaded],
|
||||
max_in_flight,
|
||||
min_prefix_share: None,
|
||||
});
|
||||
|
||||
let error = build_registry_with_defaults(&cfg)
|
||||
.expect_err("overloaded capacity must be positive at construction");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("max_in_flight must be greater than 0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_rejects_sticky_eligibility_pipeline() {
|
||||
let mut cfg = cfg_with_model("modelA", PolicyKind::Sticky);
|
||||
cfg.model.eligibility = Some(EligibilityConfig {
|
||||
filters: vec![FilterKind::Overloaded],
|
||||
max_in_flight: Some(2),
|
||||
min_prefix_share: None,
|
||||
});
|
||||
|
||||
let error = build_registry_with_defaults(&cfg)
|
||||
.expect_err("sticky assignments cannot be wrapped by eligibility filters");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("eligibility filters cannot be combined with sticky"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,7 +537,14 @@ mod tests {
|
||||
let cfg = cfg_with_model("qwen", PolicyKind::RoundRobin);
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let tokenizers = Arc::new(TokenizerRegistry::default());
|
||||
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
|
||||
let reg = build_registry(
|
||||
&cfg,
|
||||
tree,
|
||||
tokenizers,
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reg.get(&ModelId("qwen".into())).is_some());
|
||||
assert!(reg.get(&ModelId("missing".into())).is_none());
|
||||
}
|
||||
@@ -419,13 +554,21 @@ mod tests {
|
||||
let cfg = cfg_with_model("modelA", PolicyKind::CacheAwareZmq);
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let tokenizers = Arc::new(TokenizerRegistry::default());
|
||||
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
|
||||
let reg = build_registry(
|
||||
&cfg,
|
||||
tree,
|
||||
tokenizers,
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = reg.get(&ModelId("modelA".into())).unwrap();
|
||||
let dbg = format!("{p:?}");
|
||||
assert!(
|
||||
dbg.contains("CacheAwareZmqPolicy"),
|
||||
"expected CacheAwareZmqPolicy debug repr, got: {dbg}",
|
||||
);
|
||||
assert!(p.needs_load_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -433,13 +576,21 @@ mod tests {
|
||||
let cfg = cfg_with_model("modelA", PolicyKind::LoadBased);
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let tokenizers = Arc::new(TokenizerRegistry::default());
|
||||
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
|
||||
let reg = build_registry(
|
||||
&cfg,
|
||||
tree,
|
||||
tokenizers,
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = reg.get(&ModelId("modelA".into())).unwrap();
|
||||
let dbg = format!("{p:?}");
|
||||
assert!(
|
||||
dbg.contains("LoadBasedPolicy"),
|
||||
"expected LoadBasedPolicy debug repr, got: {dbg}",
|
||||
);
|
||||
assert!(p.needs_load_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -447,7 +598,14 @@ mod tests {
|
||||
let cfg = cfg_with_model("modelA", PolicyKind::Sticky);
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let tokenizers = Arc::new(TokenizerRegistry::default());
|
||||
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
|
||||
let reg = build_registry(
|
||||
&cfg,
|
||||
tree,
|
||||
tokenizers,
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = reg.get(&ModelId("modelA".into())).unwrap();
|
||||
let dbg = format!("{p:?}");
|
||||
assert!(
|
||||
|
||||
@@ -39,6 +39,14 @@ pub struct EventConfig {
|
||||
pub port_base: u16,
|
||||
/// ZMQ topic prefix the gateway should SUBSCRIBE to.
|
||||
pub topic: String,
|
||||
/// Base port of the worker's dedicated load-snapshot socket range
|
||||
/// (per-rank load port = `load_port_base + dp_rank`). `None` when the
|
||||
/// worker predates load publishing — the load subscriber is then skipped
|
||||
/// and selection falls back to the router-side in-flight counter.
|
||||
pub load_port_base: Option<u16>,
|
||||
/// Topic prefix for the dedicated load socket. The publisher advertises
|
||||
/// it with `load_port_base`; both fields are required before subscribing.
|
||||
pub load_topic: Option<String>,
|
||||
/// Worker-reported `page_size`. Callers MUST compare against their
|
||||
/// own configured `block_size`; a mismatch produces silent
|
||||
/// miscompute since [`super::hash::compute_block_hashes`] is keyed
|
||||
@@ -127,6 +135,8 @@ pub async fn fetch_event_config(
|
||||
host,
|
||||
port_base: block.endpoint_port_base,
|
||||
topic: block.topic,
|
||||
load_port_base: block.load_endpoint_port_base,
|
||||
load_topic: block.load_topic,
|
||||
block_size: block.block_size,
|
||||
dp_size: block.dp_size,
|
||||
is_bigram,
|
||||
@@ -252,6 +262,12 @@ struct KvEventsBlock {
|
||||
endpoint_port_base: u16,
|
||||
#[serde(default)]
|
||||
topic: String,
|
||||
/// Base port of the dedicated load-snapshot socket range. Absent on
|
||||
/// workers that predate load publishing (`None` ⇒ no load subscriber).
|
||||
#[serde(default)]
|
||||
load_endpoint_port_base: Option<u16>,
|
||||
#[serde(default)]
|
||||
load_topic: Option<String>,
|
||||
block_size: u32,
|
||||
dp_size: u32,
|
||||
}
|
||||
@@ -306,6 +322,8 @@ mod tests {
|
||||
"endpoint_host": "*",
|
||||
"endpoint_port_base": 5557,
|
||||
"topic": "kv",
|
||||
"load_endpoint_port_base": 5559,
|
||||
"load_topic": "load",
|
||||
"block_size": 64,
|
||||
"dp_size": 2,
|
||||
}
|
||||
@@ -318,6 +336,8 @@ mod tests {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port_base: 5557,
|
||||
topic: "kv".to_string(),
|
||||
load_port_base: Some(5559),
|
||||
load_topic: Some("load".to_string()),
|
||||
block_size: 64,
|
||||
dp_size: 2,
|
||||
is_bigram: false,
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
//! always operate together in production:
|
||||
//!
|
||||
//! - [`HashTree`] — the cache-aware routing index keyed by SGLang block hash.
|
||||
//! - [`KvEventSubscriberRegistry`] — one ZMQ SUB connection per `(worker_url,
|
||||
//! dp_rank)`.
|
||||
//! - A pump task that drains [`WorkerEvent`]s from the subscriber and applies
|
||||
//! them to the tree.
|
||||
//! - [`EngineLoadTable`] — engine-reported per-worker load.
|
||||
//! - Two [`KvEventSubscriberRegistry`]s — one per `(worker_url, dp_rank)` on
|
||||
//! the cache topic, one on the load topic.
|
||||
//! - A pump task that drains [`WorkerEvent`]s and applies KV batches to the
|
||||
//! tree and `Load` snapshots to the engine-load table.
|
||||
//!
|
||||
//! `add_worker` / `remove_worker` are driven from the worker manager on every
|
||||
//! `DiscoveryEvent::Added` / `DiscoveryEvent::Removed`.
|
||||
@@ -26,7 +27,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -36,9 +37,10 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use super::block_size_oracle::BlockSizeOracle;
|
||||
use super::discovery::{fetch_event_config, EventConfig};
|
||||
use super::subscriber::{KvEventSubscriberRegistry, WorkerEvent};
|
||||
use super::subscriber::{KvEventSubscriberRegistry, SubKind, WorkerEvent};
|
||||
use super::tree::{HashTree, KvWorkerId};
|
||||
use super::wire::KvCacheEvent;
|
||||
use crate::policies::engine_load::EngineLoadTable;
|
||||
|
||||
/// Channel buffer between the subscriber registry and the pump task.
|
||||
///
|
||||
@@ -58,6 +60,16 @@ struct WorkerEntry {
|
||||
dp_ranks: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Ranks whose socket port is representable for a publisher range. This is
|
||||
/// shared by lifecycle bookkeeping and the subscriber registry contract so an
|
||||
/// expected load rank always has a corresponding SUB socket.
|
||||
fn subscribable_ranks(port_base: u16, dp_size: u32) -> Vec<u32> {
|
||||
let port_base = u32::from(port_base);
|
||||
(0..dp_size)
|
||||
.filter(|rank| port_base.saturating_add(*rank) <= u32::from(u16::MAX))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bundle of `HashTree` + `KvEventSubscriberRegistry` + pump task.
|
||||
///
|
||||
/// Construct one instance per router process and hand it to the worker
|
||||
@@ -67,6 +79,14 @@ pub struct KvEventIndex {
|
||||
tree: Arc<HashTree>,
|
||||
maintain_tree: bool,
|
||||
subscribers: Arc<KvEventSubscriberRegistry>,
|
||||
/// Second registry subscribing to the load topic (one per worker rank),
|
||||
/// feeding `LoadStat` snapshots into `engine_load`. Shares the pump
|
||||
/// channel with `subscribers`; keyed independently so KV and load
|
||||
/// subscribers for the same worker don't collide.
|
||||
load_subscribers: Arc<KvEventSubscriberRegistry>,
|
||||
/// Engine-reported per-worker load, written by the pump from
|
||||
/// `WorkerEvent::Load` and read by the cache-aware-zmq policy.
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
pump: Mutex<Option<JoinHandle<()>>>,
|
||||
pump_cancel: CancellationToken,
|
||||
workers: Mutex<HashMap<String, WorkerEntry>>,
|
||||
@@ -136,12 +156,15 @@ impl KvEventIndex {
|
||||
) -> Arc<Self> {
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let (tx, rx) = mpsc::channel::<WorkerEvent>(EVENT_CHANNEL_BUFFER);
|
||||
let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx));
|
||||
let subscribers = Arc::new(KvEventSubscriberRegistry::new(tx.clone()));
|
||||
let load_subscribers = Arc::new(KvEventSubscriberRegistry::with_kind(tx, SubKind::Load));
|
||||
let engine_load = EngineLoadTable::new();
|
||||
let cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
let live_workers: Arc<Mutex<HashSet<KvWorkerId>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
let pump_cancel = CancellationToken::new();
|
||||
let pump = tokio::spawn(pump_loop(
|
||||
tree.clone(),
|
||||
engine_load.clone(),
|
||||
cursors.clone(),
|
||||
live_workers.clone(),
|
||||
pump_cancel.clone(),
|
||||
@@ -151,6 +174,8 @@ impl KvEventIndex {
|
||||
tree,
|
||||
maintain_tree,
|
||||
subscribers,
|
||||
load_subscribers,
|
||||
engine_load,
|
||||
pump: Mutex::new(Some(pump)),
|
||||
pump_cancel,
|
||||
workers: Mutex::new(HashMap::new()),
|
||||
@@ -176,15 +201,26 @@ impl KvEventIndex {
|
||||
self.tree.clone()
|
||||
}
|
||||
|
||||
/// Shared accessor for the engine-load table. The `CacheAwareZmqPolicy`
|
||||
/// (via [`crate::policies::factory`]) holds the same `Arc` and only reads
|
||||
/// it at selection time. Load *values* are written solely by the pump
|
||||
/// (from `LoadStat` events); `add_worker` / `remove_worker` here manage
|
||||
/// the expected set and per-worker eviction.
|
||||
pub fn engine_load(&self) -> Arc<EngineLoadTable> {
|
||||
Arc::clone(&self.engine_load)
|
||||
}
|
||||
|
||||
/// Register a worker. If `preresolved` is `Some`, the caller has
|
||||
/// already fetched `/server_info` (worker manager path) and we skip
|
||||
/// the internal HTTP round-trip; otherwise (standalone callers,
|
||||
/// e.g. integration tests) we fall back to `fetch_event_config`.
|
||||
///
|
||||
/// Opens one ZMQ SUB per advertised DP rank. If the worker is not
|
||||
/// publishing KV events (older SGLang, opt-out config), this is a
|
||||
/// logged no-op — the worker still routes via the non-cache-aware
|
||||
/// policies.
|
||||
/// Opens one ZMQ SUB per advertised DP rank for each usable stream. In
|
||||
/// metadata-only mode KV subscriptions remain disabled, but the separate
|
||||
/// #34608 load stream is still attached when its full descriptor exists.
|
||||
/// If the worker is not publishing KV events (older SGLang, opt-out
|
||||
/// config), this is a logged no-op — the worker still routes via the
|
||||
/// non-cache-aware policies.
|
||||
pub async fn add_worker(&self, worker_url: &str, preresolved: Option<EventConfig>) {
|
||||
let cfg: EventConfig = match preresolved {
|
||||
Some(c) => c,
|
||||
@@ -228,39 +264,58 @@ impl KvEventIndex {
|
||||
// hash KV blocks over token bigrams, so the policy must use the bigram
|
||||
// hasher for its query hashes to match the worker's stored hashes.
|
||||
self.block_size_oracle.set_bigram(cfg.is_bigram);
|
||||
if !self.maintain_tree {
|
||||
info!(
|
||||
let kv_dp_ranks = if self.maintain_tree {
|
||||
subscribable_ranks(cfg.port_base, cfg.dp_size)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let load_descriptor_complete = cfg.load_port_base.is_some() && cfg.load_topic.is_some();
|
||||
if cfg.load_port_base.is_some() != cfg.load_topic.is_some() {
|
||||
warn!(
|
||||
worker_url = %worker_url,
|
||||
block_size = cfg.block_size,
|
||||
is_bigram = cfg.is_bigram,
|
||||
"kv-events: external Indexer configured; discovered hash metadata without subscribing"
|
||||
load_port_base = ?cfg.load_port_base,
|
||||
load_topic = ?cfg.load_topic,
|
||||
"kv-events: incomplete load descriptor; refusing load subscription"
|
||||
);
|
||||
return;
|
||||
}
|
||||
info!(
|
||||
worker_url = %worker_url,
|
||||
dp_size = cfg.dp_size,
|
||||
port_base = cfg.port_base,
|
||||
block_size = cfg.block_size,
|
||||
is_bigram = cfg.is_bigram,
|
||||
"kv-events: subscribing",
|
||||
);
|
||||
// Compute the DP ranks that will actually be subscribed (skip
|
||||
// ranks whose port overflows u16; the subscriber will warn on
|
||||
// each skipped rank).
|
||||
let port_base_u32 = u32::from(cfg.port_base);
|
||||
let dp_ranks: Vec<u32> = (0..cfg.dp_size)
|
||||
.filter(|rank| (port_base_u32 + rank) <= u32::from(u16::MAX))
|
||||
.collect();
|
||||
let load_dp_ranks = cfg
|
||||
.load_port_base
|
||||
.filter(|_| load_descriptor_complete)
|
||||
.map(|port_base| subscribable_ranks(port_base, cfg.dp_size))
|
||||
.unwrap_or_default();
|
||||
let mut dp_ranks = kv_dp_ranks.clone();
|
||||
dp_ranks.extend(load_dp_ranks.iter().copied());
|
||||
dp_ranks.sort_unstable();
|
||||
dp_ranks.dedup();
|
||||
if dp_ranks.is_empty() {
|
||||
warn!(
|
||||
worker_url = %worker_url,
|
||||
port_base = cfg.port_base,
|
||||
dp_size = cfg.dp_size,
|
||||
"kv-events: every advertised rank's port overflows u16; skipping worker",
|
||||
"kv-events: no usable KV or load publisher ranks; skipping worker",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.maintain_tree {
|
||||
info!(
|
||||
worker_url = %worker_url,
|
||||
dp_size = cfg.dp_size,
|
||||
port_base = cfg.port_base,
|
||||
load_port_base = ?cfg.load_port_base,
|
||||
block_size = cfg.block_size,
|
||||
is_bigram = cfg.is_bigram,
|
||||
"kv-events: subscribing",
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
worker_url = %worker_url,
|
||||
dp_size = cfg.dp_size,
|
||||
load_port_base = ?cfg.load_port_base,
|
||||
block_size = cfg.block_size,
|
||||
is_bigram = cfg.is_bigram,
|
||||
"kv-events: external Indexer configured; subscribing only to engine load",
|
||||
);
|
||||
}
|
||||
// Mark every rank live BEFORE the subscriber starts so any event
|
||||
// it queues is accepted by the pump.
|
||||
{
|
||||
@@ -278,7 +333,17 @@ impl KvEventIndex {
|
||||
dp_ranks: dp_ranks.clone(),
|
||||
},
|
||||
);
|
||||
self.subscribers.add_worker(worker_url, &cfg).await;
|
||||
if self.maintain_tree && !kv_dp_ranks.is_empty() {
|
||||
self.subscribers.add_worker(worker_url, &cfg).await;
|
||||
}
|
||||
// Mark only the ranks that have an actual SUB socket. `EngineLoadTable`
|
||||
// then rejects missing or stale advertised ranks as a whole worker.
|
||||
if !load_dp_ranks.is_empty() {
|
||||
for rank in &load_dp_ranks {
|
||||
self.engine_load.mark_expected_rank(worker_url, *rank);
|
||||
}
|
||||
self.load_subscribers.add_worker(worker_url, &cfg).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a worker's subscribers and clear it from the tree.
|
||||
@@ -307,12 +372,14 @@ impl KvEventIndex {
|
||||
live.remove(id);
|
||||
}
|
||||
}
|
||||
// 2. Cancel and join the per-rank subscriber tasks. No further
|
||||
// events for these ranks will be queued after this returns.
|
||||
// 2. Cancel and join the per-rank subscriber tasks (KV + load). No
|
||||
// further events for these ranks will be queued after this returns.
|
||||
self.subscribers.remove_worker(worker_url).await;
|
||||
// 3. Drop each rank's tree state and cursor. Any event already in
|
||||
// the mpsc buffer at this point will be filtered by the
|
||||
// live-set check inside the pump.
|
||||
self.load_subscribers.remove_worker(worker_url).await;
|
||||
// 3. Drop each rank's tree state and cursor, and the worker's engine
|
||||
// load. Any event already in the mpsc buffer at this point will be
|
||||
// filtered by the live-set check inside the pump.
|
||||
self.engine_load.forget_worker(worker_url);
|
||||
let mut cursors = self.cursors.lock();
|
||||
for id in &ids {
|
||||
self.tree.clear_worker(id);
|
||||
@@ -334,6 +401,7 @@ impl KvEventIndex {
|
||||
/// events are discarded and the task exits promptly.
|
||||
pub async fn shutdown(&self) {
|
||||
self.subscribers.shutdown().await;
|
||||
self.load_subscribers.shutdown().await;
|
||||
self.pump_cancel.cancel();
|
||||
let handle = self.pump.lock().take();
|
||||
if let Some(h) = handle {
|
||||
@@ -349,12 +417,14 @@ impl KvEventIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain `WorkerEvent`s and apply each batch to the tree. Out-of-order
|
||||
/// (seq ≤ last_applied) and stale (worker not in `live_workers`) batches
|
||||
/// are skipped. `PublisherReset` events clear the cursor so a publisher
|
||||
/// Drain `WorkerEvent`s: apply KV `Batch`es to the tree and `Load` snapshots
|
||||
/// to the engine-load table. Out-of-order (seq ≤ last_applied) and stale
|
||||
/// (worker not in `live_workers`) KV batches are skipped; `Load` is a gauge
|
||||
/// with no seq. `PublisherReset` events clear the cursor so a publisher
|
||||
/// restarting from seq=1 (after sending END_SEQ) is not filtered.
|
||||
async fn pump_loop(
|
||||
tree: Arc<HashTree>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
|
||||
live_workers: Arc<Mutex<HashSet<KvWorkerId>>>,
|
||||
cancel: CancellationToken,
|
||||
@@ -390,6 +460,11 @@ async fn pump_loop(
|
||||
}
|
||||
|
||||
match ev {
|
||||
WorkerEvent::Load { worker, load } => {
|
||||
// Gauge: last value wins, no sequence/dedup. The live-worker
|
||||
// filter above already dropped load from detached workers.
|
||||
engine_load.set(&worker.url, worker.dp_rank, load, Instant::now());
|
||||
}
|
||||
WorkerEvent::PublisherReset { worker } => {
|
||||
if cursors.lock().remove(&worker).is_some() {
|
||||
info!(
|
||||
@@ -433,6 +508,7 @@ async fn pump_loop(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::policies::engine_load::LoadStat;
|
||||
use crate::policies::kv_events::wire::{BlockRemoved, BlockStored, KvEventBatch};
|
||||
|
||||
fn worker_id(url: &str, rank: u32) -> KvWorkerId {
|
||||
@@ -454,6 +530,7 @@ mod tests {
|
||||
/// can destructure just the bits they need.
|
||||
struct PumpHarness {
|
||||
tree: Arc<HashTree>,
|
||||
engine_load: Arc<EngineLoadTable>,
|
||||
cursors: Arc<Mutex<HashMap<KvWorkerId, i64>>>,
|
||||
#[allow(dead_code)]
|
||||
live_set: Arc<Mutex<HashSet<KvWorkerId>>>,
|
||||
@@ -467,6 +544,7 @@ mod tests {
|
||||
/// the given workers pre-marked live.
|
||||
fn spawn_pump(live: &[KvWorkerId]) -> PumpHarness {
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let engine_load = EngineLoadTable::new();
|
||||
let cursors = Arc::new(Mutex::new(HashMap::new()));
|
||||
let live_set: Arc<Mutex<HashSet<KvWorkerId>>> =
|
||||
Arc::new(Mutex::new(live.iter().cloned().collect()));
|
||||
@@ -474,6 +552,7 @@ mod tests {
|
||||
let (tx, rx) = mpsc::channel(4);
|
||||
let pump = tokio::spawn(pump_loop(
|
||||
tree.clone(),
|
||||
engine_load.clone(),
|
||||
cursors.clone(),
|
||||
live_set.clone(),
|
||||
cancel.clone(),
|
||||
@@ -481,6 +560,7 @@ mod tests {
|
||||
));
|
||||
PumpHarness {
|
||||
tree,
|
||||
engine_load,
|
||||
cursors,
|
||||
live_set,
|
||||
cancel,
|
||||
@@ -521,6 +601,34 @@ mod tests {
|
||||
assert!(m.workers.contains(&id), "tree must hold the worker");
|
||||
}
|
||||
|
||||
/// A `WorkerEvent::Load` lands in the engine-load table (gauge, no
|
||||
/// cursor) keyed by the worker URL, and does not touch the tree.
|
||||
#[tokio::test]
|
||||
async fn pump_applies_load_to_engine_load_table() {
|
||||
let id = worker_id("http://w1", 0);
|
||||
let h = spawn_pump(std::slice::from_ref(&id));
|
||||
let (tree, engine_load, tx, pump) = (h.tree, h.engine_load, h.tx, h.pump);
|
||||
|
||||
tx.send(WorkerEvent::Load {
|
||||
worker: id.clone(),
|
||||
load: LoadStat {
|
||||
num_running_reqs: 8,
|
||||
num_waiting_reqs: 4,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
pump.await.unwrap();
|
||||
|
||||
let fresh = engine_load.snapshot_fresh(Instant::now());
|
||||
assert_eq!(fresh.get("http://w1").copied(), Some(12)); // 8 + 4
|
||||
// Load events must not pollute the cache tree.
|
||||
assert_eq!(tree.node_count(), 0);
|
||||
}
|
||||
|
||||
/// Out-of-order seq is filtered: a batch with seq <= last_applied is
|
||||
/// dropped silently and does not mutate the tree.
|
||||
#[tokio::test]
|
||||
@@ -678,6 +786,8 @@ mod tests {
|
||||
host: "127.0.0.1".into(),
|
||||
port_base: 30100,
|
||||
topic: String::new(),
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
block_size: 128,
|
||||
dp_size: 1,
|
||||
is_bigram: false,
|
||||
@@ -707,6 +817,8 @@ mod tests {
|
||||
host: "127.0.0.1".into(),
|
||||
port_base: 30200,
|
||||
topic: String::new(),
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
block_size: 64,
|
||||
dp_size: 0,
|
||||
is_bigram: false,
|
||||
@@ -728,6 +840,8 @@ mod tests {
|
||||
host: "127.0.0.1".into(),
|
||||
port_base: 30300,
|
||||
topic: String::new(),
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
block_size: 64,
|
||||
dp_size: 0,
|
||||
is_bigram: true,
|
||||
@@ -741,7 +855,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_only_mode_seeds_oracle_without_registering_subscribers() {
|
||||
async fn metadata_only_mode_keeps_the_load_subscriber() {
|
||||
let oracle = BlockSizeOracle::new();
|
||||
let index = KvEventIndex::new_metadata_only_with_http_and_oracle(
|
||||
reqwest::Client::new(),
|
||||
@@ -751,6 +865,8 @@ mod tests {
|
||||
host: "127.0.0.1".into(),
|
||||
port_base: 30400,
|
||||
topic: "kv-events".into(),
|
||||
load_port_base: Some(30410),
|
||||
load_topic: Some("load".into()),
|
||||
block_size: 64,
|
||||
dp_size: 2,
|
||||
is_bigram: true,
|
||||
@@ -760,7 +876,55 @@ mod tests {
|
||||
|
||||
assert_eq!(oracle.get(), Some(64));
|
||||
assert!(oracle.is_bigram());
|
||||
assert_eq!(index.known_worker_count(), 0);
|
||||
assert_eq!(index.known_worker_count(), 1);
|
||||
assert_eq!(index.engine_load().expected_count(), 1);
|
||||
index.shutdown().await;
|
||||
}
|
||||
|
||||
/// `remove_worker` clears the worker's engine load and its expected mark,
|
||||
/// so a re-added worker does not inherit stale load. The worker advertises
|
||||
/// a load port (no publisher there; the subscriber just retries in the
|
||||
/// background and is cancelled on remove).
|
||||
#[tokio::test]
|
||||
async fn remove_worker_clears_engine_load() {
|
||||
let index = KvEventIndex::new();
|
||||
let url = "http://127.0.0.1:59123";
|
||||
let cfg = EventConfig {
|
||||
host: "127.0.0.1".into(),
|
||||
port_base: 59123,
|
||||
topic: String::new(),
|
||||
load_port_base: Some(59223),
|
||||
load_topic: Some("load".into()),
|
||||
block_size: 64,
|
||||
dp_size: 1,
|
||||
is_bigram: false,
|
||||
};
|
||||
index.add_worker(url, Some(cfg)).await;
|
||||
assert_eq!(index.engine_load().expected_count(), 1);
|
||||
|
||||
let now = Instant::now();
|
||||
index.engine_load().set(
|
||||
url,
|
||||
0,
|
||||
LoadStat {
|
||||
num_running_reqs: 3,
|
||||
num_waiting_reqs: 1,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert!(index.engine_load().snapshot_fresh(now).contains_key(url));
|
||||
|
||||
index.remove_worker(url).await;
|
||||
assert!(
|
||||
!index
|
||||
.engine_load()
|
||||
.snapshot_fresh(Instant::now())
|
||||
.contains_key(url),
|
||||
"remove_worker must clear engine load"
|
||||
);
|
||||
assert_eq!(index.engine_load().expected_count(), 0);
|
||||
index.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! ZMQ-based KV-cache event indexer for cache-aware routing.
|
||||
//!
|
||||
//! Decodes the msgpack wire format emitted by SGLang's `ZmqEventPublisher`
|
||||
//! (see `python/sglang/srt/disaggregation/kv_events.py`) and maintains the
|
||||
//! (`python/sglang/srt/utils/event_publisher.py`; KV event types in
|
||||
//! `python/sglang/srt/disaggregation/kv_events.py`) and maintains the
|
||||
//! router-side index used for cache-aware request routing.
|
||||
//!
|
||||
//! # Submodules
|
||||
@@ -27,7 +28,7 @@ pub(crate) use discovery::classify_bigram;
|
||||
pub use discovery::{fetch_event_config, EventConfig};
|
||||
pub use hash::{compute_block_hashes, compute_block_hashes_bigram, sha256_to_i64};
|
||||
pub use index::KvEventIndex;
|
||||
pub use subscriber::{KvEventSubscriberRegistry, WorkerEvent};
|
||||
pub use subscriber::{KvEventSubscriberRegistry, SubKind, WorkerEvent};
|
||||
pub use tree::{HashTree, KvWorkerId, MatchResult};
|
||||
pub use wire::{
|
||||
decode_event_batch, BlockRemoved, BlockStored, DecodeError, KvCacheEvent, KvEventBatch,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
//! Per-worker, per-DP-rank ZMQ subscriber for SGLang's `ZmqEventPublisher`.
|
||||
//!
|
||||
//! This module owns the I/O plumbing between SGLang workers (which publish
|
||||
//! KV-cache events on a PUB socket — see
|
||||
//! `python/sglang/srt/disaggregation/kv_events.py`) and the in-memory hash
|
||||
//! tree consumed by [`super::index::KvEventIndex`]. Each `(worker_url,
|
||||
//! dp_rank)` pair gets its own SUB socket on its own tokio task, decodes
|
||||
//! msgpack batches via [`super::wire`], and forwards [`WorkerEvent`]s to
|
||||
//! a shared mpsc channel.
|
||||
//! This module owns the I/O plumbing between SGLang workers (which publish on
|
||||
//! a PUB socket via `python/sglang/srt/utils/event_publisher.py` —
|
||||
//! KV-cache events from `disaggregation/kv_events.py`, load gauges from
|
||||
//! `managers/scheduler_components/load_publisher.py`) and the in-memory state
|
||||
//! consumed by [`super::index::KvEventIndex`]. Each `(worker_url, dp_rank)`
|
||||
//! pair gets its own SUB socket on its own tokio task, decodes msgpack frames
|
||||
//! by [`SubKind`] (KV batches via [`super::wire`], load via
|
||||
//! [`crate::policies::engine_load`]), and forwards [`WorkerEvent`]s to a
|
||||
//! shared mpsc channel.
|
||||
//!
|
||||
//! # Wire format (3-frame multipart)
|
||||
//!
|
||||
@@ -70,6 +72,7 @@ use zeromq::{Socket, SocketRecv, SubSocket, ZmqMessage};
|
||||
use super::discovery::EventConfig;
|
||||
use super::tree::KvWorkerId;
|
||||
use super::wire::{decode_event_batch, KvEventBatch};
|
||||
use crate::policies::engine_load::{decode_load_stat, LoadStat};
|
||||
|
||||
/// Maximum number of consecutive `recv()` errors before the subscriber
|
||||
/// gives up and exits its task. ZMQ's internal reconnect handles transient
|
||||
@@ -92,7 +95,25 @@ const CONNECT_BACKOFF_CAP: Duration = Duration::from_secs(2);
|
||||
/// signal is handled correctly.
|
||||
const END_SEQ_SENTINEL: i64 = -1;
|
||||
|
||||
/// Which topic a subscriber task listens on, and therefore what kind of
|
||||
/// [`WorkerEvent`] it produces. KV-cache events feed the hash tree (with
|
||||
/// sequence-ordered dedup); load snapshots feed the engine-load table (a
|
||||
/// gauge — no sequence semantics).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SubKind {
|
||||
/// Cache-delta topic (`BlockStored` / `BlockRemoved` / `AllBlocksCleared`).
|
||||
Kv,
|
||||
/// Load-snapshot topic (`LoadStat`).
|
||||
Load,
|
||||
}
|
||||
|
||||
/// Message forwarded from a per-worker subscriber task to the pump.
|
||||
///
|
||||
/// The variants partition by subscriber [`SubKind`]: `Batch` and
|
||||
/// `PublisherReset` come only from a `SubKind::Kv` subscriber and carry the
|
||||
/// cache stream's sequence/replay semantics; `Load` comes only from a
|
||||
/// `SubKind::Load` subscriber and is a seqless gauge. A given subscriber
|
||||
/// never emits both families.
|
||||
#[derive(Debug)]
|
||||
pub enum WorkerEvent {
|
||||
/// A normal decoded event batch.
|
||||
@@ -105,6 +126,14 @@ pub enum WorkerEvent {
|
||||
/// Decoded batch payload.
|
||||
batch: KvEventBatch,
|
||||
},
|
||||
/// A runtime load snapshot from the load topic. Carries no sequence
|
||||
/// number: load is a gauge, applied last-value-wins with no dedup.
|
||||
Load {
|
||||
/// Identity of the SGLang worker (DP rank) that produced this load.
|
||||
worker: KvWorkerId,
|
||||
/// Latest load snapshot for this `(worker, dp_rank)`.
|
||||
load: LoadStat,
|
||||
},
|
||||
/// The publisher emitted its `END_SEQ` (-1) sentinel, signalling
|
||||
/// shutdown. A re-connecting publisher will restart its sequence
|
||||
/// counter from 1; the pump uses this to reset the cursor so those
|
||||
@@ -117,6 +146,7 @@ impl WorkerEvent {
|
||||
pub fn worker(&self) -> &KvWorkerId {
|
||||
match self {
|
||||
Self::Batch { worker, .. } => worker,
|
||||
Self::Load { worker, .. } => worker,
|
||||
Self::PublisherReset { worker } => worker,
|
||||
}
|
||||
}
|
||||
@@ -142,19 +172,32 @@ struct Inner {
|
||||
|
||||
/// Owns one ZMQ SUB connection per `(worker_url, dp_rank)`. Forwards
|
||||
/// decoded batches to a tokio mpsc channel supplied at construction time.
|
||||
///
|
||||
/// A registry is single-kind: a [`SubKind::Kv`] registry subscribes to the
|
||||
/// cache topic and emits [`WorkerEvent::Batch`]; a [`SubKind::Load`] registry
|
||||
/// subscribes to the load topic and emits [`WorkerEvent::Load`]. The index
|
||||
/// runs one of each, both feeding the same pump channel, so KV and load
|
||||
/// subscribers for the same worker never collide in the handle map.
|
||||
pub struct KvEventSubscriberRegistry {
|
||||
inner: Arc<Inner>,
|
||||
kind: SubKind,
|
||||
}
|
||||
|
||||
impl KvEventSubscriberRegistry {
|
||||
/// Build an empty registry. `tx` is where decoded events flow out;
|
||||
/// the channel buffer capacity is the caller's choice.
|
||||
/// Build an empty KV-cache registry. `tx` is where decoded events flow
|
||||
/// out; the channel buffer capacity is the caller's choice.
|
||||
pub fn new(tx: mpsc::Sender<WorkerEvent>) -> Self {
|
||||
Self::with_kind(tx, SubKind::Kv)
|
||||
}
|
||||
|
||||
/// Build an empty registry of the given kind.
|
||||
pub fn with_kind(tx: mpsc::Sender<WorkerEvent>, kind: SubKind) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
tx,
|
||||
handles: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +217,24 @@ impl KvEventSubscriberRegistry {
|
||||
/// If `cfg.port_base + dp_rank` overflows `u16`, that rank is skipped
|
||||
/// with a `warn!` log and the remaining ranks proceed.
|
||||
pub async fn add_worker(&self, worker_url: &str, cfg: &EventConfig) {
|
||||
// (port_base, topic) depend on this registry's kind. KV uses the cache
|
||||
// socket + configured topic; Load uses its own advertised socket +
|
||||
// topic. Refuse an incomplete load descriptor rather than subscribe-all:
|
||||
// the load wire is a distinct contract and a future mixed-use socket
|
||||
// must not feed unrelated payloads into the load decoder.
|
||||
let (port_base, topic) = match self.kind {
|
||||
SubKind::Kv => (cfg.port_base, cfg.topic.clone()),
|
||||
SubKind::Load => match (&cfg.load_port_base, &cfg.load_topic) {
|
||||
(Some(port), Some(topic)) => (*port, topic.clone()),
|
||||
_ => {
|
||||
debug!(
|
||||
worker_url = %worker_url,
|
||||
"kv-events: worker lacks a complete load descriptor; skipping load subscribers"
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut handles = self.inner.handles.lock().await;
|
||||
for dp_rank in 0..cfg.dp_size {
|
||||
let id = KvWorkerId {
|
||||
@@ -188,13 +249,14 @@ impl KvEventSubscriberRegistry {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let port = match u16::try_from(cfg.port_base as u32 + dp_rank) {
|
||||
let port = match u16::try_from(port_base as u32 + dp_rank) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
worker_url = %worker_url,
|
||||
dp_rank,
|
||||
port_base = cfg.port_base,
|
||||
port_base,
|
||||
kind = ?self.kind,
|
||||
"ZMQ event port overflows u16; skipping this rank"
|
||||
);
|
||||
continue;
|
||||
@@ -205,7 +267,8 @@ impl KvEventSubscriberRegistry {
|
||||
let join = spawn_subscriber_task(
|
||||
id.clone(),
|
||||
endpoint,
|
||||
cfg.topic.clone(),
|
||||
topic.clone(),
|
||||
self.kind,
|
||||
self.inner.tx.clone(),
|
||||
cancel.clone(),
|
||||
);
|
||||
@@ -286,11 +349,12 @@ fn spawn_subscriber_task(
|
||||
id: KvWorkerId,
|
||||
endpoint: String,
|
||||
topic: String,
|
||||
kind: SubKind,
|
||||
tx: mpsc::Sender<WorkerEvent>,
|
||||
cancel: CancellationToken,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
run_subscriber(id, endpoint, topic, tx, cancel).await;
|
||||
run_subscriber(id, endpoint, topic, kind, tx, cancel).await;
|
||||
})
|
||||
}
|
||||
|
||||
@@ -305,6 +369,7 @@ async fn run_subscriber(
|
||||
id: KvWorkerId,
|
||||
endpoint: String,
|
||||
topic: String,
|
||||
kind: SubKind,
|
||||
tx: mpsc::Sender<WorkerEvent>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
@@ -313,6 +378,7 @@ async fn run_subscriber(
|
||||
dp_rank = id.dp_rank,
|
||||
endpoint = %endpoint,
|
||||
topic = %topic,
|
||||
kind = ?kind,
|
||||
"starting kv-event subscriber"
|
||||
);
|
||||
|
||||
@@ -337,7 +403,7 @@ async fn run_subscriber(
|
||||
match res {
|
||||
Ok(msg) => {
|
||||
errors_in_a_row = 0;
|
||||
if let Some(event) = decode_message(&id, msg) {
|
||||
if let Some(event) = decode_message(&id, msg, kind) {
|
||||
if tx.send(event).await.is_err() {
|
||||
// The pump (or the entire index) is gone.
|
||||
// This is unexpected mid-stream; warn so
|
||||
@@ -462,8 +528,9 @@ async fn connect_with_backoff(
|
||||
|
||||
/// Validate, parse, and decode a single 3-frame multipart ZMQ message.
|
||||
/// Returns `None` (with logging) for any non-event input (bad frame
|
||||
/// count, sentinel sequence, or msgpack decode error).
|
||||
fn decode_message(id: &KvWorkerId, msg: ZmqMessage) -> Option<WorkerEvent> {
|
||||
/// count, sentinel sequence, or msgpack decode error). `kind` selects
|
||||
/// whether to emit a KV [`WorkerEvent::Batch`] or a [`WorkerEvent::Load`].
|
||||
fn decode_message(id: &KvWorkerId, msg: ZmqMessage, kind: SubKind) -> Option<WorkerEvent> {
|
||||
if msg.len() != 3 {
|
||||
warn!(
|
||||
worker_url = %id.url,
|
||||
@@ -498,41 +565,77 @@ fn decode_message(id: &KvWorkerId, msg: ZmqMessage) -> Option<WorkerEvent> {
|
||||
let seq = i64::from_be_bytes(seq_bytes);
|
||||
|
||||
if seq == END_SEQ_SENTINEL {
|
||||
info!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
"publisher signalled shutdown (END_SEQ); forwarding cursor reset"
|
||||
);
|
||||
return Some(WorkerEvent::PublisherReset { worker: id.clone() });
|
||||
match kind {
|
||||
SubKind::Kv => {
|
||||
info!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
"publisher signalled shutdown (END_SEQ); forwarding cursor reset"
|
||||
);
|
||||
return Some(WorkerEvent::PublisherReset { worker: id.clone() });
|
||||
}
|
||||
// Load has no cursor / replay state to reset — just drop.
|
||||
SubKind::Load => return None,
|
||||
}
|
||||
}
|
||||
|
||||
let batch = match decode_event_batch(payload.as_ref()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
// Decode by kind: the cache topic carries `KvEventBatch`es, the load
|
||||
// topic carries bare `LoadStat` snapshots — two independent wire formats
|
||||
// on two independent sockets.
|
||||
match kind {
|
||||
SubKind::Kv => {
|
||||
let batch = match decode_event_batch(payload.as_ref()) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
seq,
|
||||
error = %e,
|
||||
"failed to decode KV event batch payload; dropping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
trace!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
seq,
|
||||
error = %e,
|
||||
"failed to decode KV event batch payload; dropping"
|
||||
n_events = batch.events.len(),
|
||||
"decoded KV event batch"
|
||||
);
|
||||
return None;
|
||||
Some(WorkerEvent::Batch {
|
||||
worker: id.clone(),
|
||||
seq,
|
||||
batch,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
trace!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
seq,
|
||||
n_events = batch.events.len(),
|
||||
"decoded KV event batch"
|
||||
);
|
||||
|
||||
Some(WorkerEvent::Batch {
|
||||
worker: id.clone(),
|
||||
seq,
|
||||
batch,
|
||||
})
|
||||
SubKind::Load => {
|
||||
let load = match decode_load_stat(payload.as_ref()) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
seq,
|
||||
error = %e,
|
||||
"failed to decode load snapshot payload; dropping"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
trace!(
|
||||
worker_url = %id.url,
|
||||
dp_rank = id.dp_rank,
|
||||
seq,
|
||||
"decoded load snapshot"
|
||||
);
|
||||
Some(WorkerEvent::Load {
|
||||
worker: id.clone(),
|
||||
load,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the host out of a routing URL like `http://10.0.0.1:30000` or
|
||||
@@ -591,6 +694,8 @@ mod tests {
|
||||
host: extract_host(worker_url).unwrap_or_else(|| "127.0.0.1".to_string()),
|
||||
port_base,
|
||||
topic: String::new(),
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
block_size: 64,
|
||||
dp_size,
|
||||
is_bigram: false,
|
||||
@@ -618,6 +723,29 @@ mod tests {
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a LoadStat batch `[ts, [["LoadStat", running, waiting,
|
||||
/// num_tokens, max_total]], dp_rank?]` in msgspec's array layout.
|
||||
/// Encode a bare LoadStat msgpack array `["LoadStat", running, waiting,
|
||||
/// num_tokens, max_total, attn_dp_rank]` — the payload on the load
|
||||
/// socket (no EventBatch envelope).
|
||||
pub fn encode_load_stat(
|
||||
running: u64,
|
||||
waiting: u64,
|
||||
num_tokens: u64,
|
||||
max_total: u64,
|
||||
attn_dp_rank: u32,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
mp::write_array_len(&mut buf, 6).unwrap();
|
||||
mp::write_str(&mut buf, "LoadStat").unwrap();
|
||||
mp::write_uint(&mut buf, running).unwrap();
|
||||
mp::write_uint(&mut buf, waiting).unwrap();
|
||||
mp::write_uint(&mut buf, num_tokens).unwrap();
|
||||
mp::write_uint(&mut buf, max_total).unwrap();
|
||||
mp::write_uint(&mut buf, attn_dp_rank as u64).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build a 3-frame multipart with topic="", the given seq (BE i64),
|
||||
/// and the given payload bytes.
|
||||
pub fn build_multipart(seq: i64, payload: Vec<u8>) -> ZmqMessage {
|
||||
@@ -644,6 +772,9 @@ mod tests {
|
||||
pub fn expect_batch(ev: WorkerEvent) -> (KvWorkerId, i64, KvEventBatch) {
|
||||
match ev {
|
||||
WorkerEvent::Batch { worker, seq, batch } => (worker, seq, batch),
|
||||
WorkerEvent::Load { worker, .. } => {
|
||||
panic!("expected Batch, got Load for {worker:?}")
|
||||
}
|
||||
WorkerEvent::PublisherReset { worker } => {
|
||||
panic!("expected Batch, got PublisherReset for {worker:?}")
|
||||
}
|
||||
@@ -761,6 +892,56 @@ mod tests {
|
||||
registry.shutdown().await;
|
||||
}
|
||||
|
||||
/// The #34608 load stream has its own advertised topic. It must use that
|
||||
/// filter too: accepting every frame on the socket would make a future
|
||||
/// colocated publisher influence routing through a coincidentally
|
||||
/// decodable payload.
|
||||
#[tokio::test]
|
||||
async fn load_subscriber_filters_by_advertised_topic() {
|
||||
let (mut pub_sock, port) = helpers::make_pub_bound().await;
|
||||
let (tx, mut rx) = mpsc::channel::<WorkerEvent>(8);
|
||||
let registry = KvEventSubscriberRegistry::with_kind(tx, SubKind::Load);
|
||||
|
||||
let worker_url = "http://127.0.0.1:30101";
|
||||
let mut cfg = helpers::cfg_for(worker_url, port, 1);
|
||||
cfg.load_port_base = Some(port);
|
||||
cfg.load_topic = Some("load".into());
|
||||
registry.add_worker(worker_url, &cfg).await;
|
||||
helpers::settle().await;
|
||||
|
||||
let payload = helpers::encode_load_stat(5, 2, 100, 1000, 0);
|
||||
pub_sock
|
||||
.send(helpers::build_multipart_with_topic(b"load", 3, payload))
|
||||
.await
|
||||
.unwrap();
|
||||
let other_payload = helpers::encode_load_stat(99, 0, 0, 0, 0);
|
||||
pub_sock
|
||||
.send(helpers::build_multipart_with_topic(
|
||||
b"other",
|
||||
4,
|
||||
other_payload,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let event = timeout(Duration::from_millis(500), rx.recv())
|
||||
.await
|
||||
.expect("timed out waiting for load event")
|
||||
.expect("channel closed");
|
||||
match event {
|
||||
WorkerEvent::Load { load, .. } => assert_eq!(load.num_running_reqs, 5),
|
||||
other => panic!("expected Load, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
timeout(Duration::from_millis(200), rx.recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"unmatched load topic must not reach the subscriber"
|
||||
);
|
||||
|
||||
registry.shutdown().await;
|
||||
}
|
||||
|
||||
/// DP rank fan-out: 3 PUB sockets, 3 distinct events, all delivered.
|
||||
#[tokio::test]
|
||||
async fn dp_rank_fan_out() {
|
||||
@@ -1252,34 +1433,63 @@ mod tests {
|
||||
|
||||
// Wrong frame count.
|
||||
let one_frame = ZmqMessage::from(Bytes::from_static(b"only"));
|
||||
assert!(decode_message(&id, one_frame).is_none());
|
||||
assert!(decode_message(&id, one_frame, SubKind::Kv).is_none());
|
||||
|
||||
// Sentinel seq = -1 now surfaces as PublisherReset (not None) so
|
||||
// the downstream pump can clear its cursor before a reconnecting
|
||||
// publisher restarts from seq=1.
|
||||
let sentinel = helpers::build_multipart(-1, b"ignored".to_vec());
|
||||
let reset = decode_message(&id, sentinel).expect("END_SEQ forwards");
|
||||
let reset = decode_message(&id, sentinel, SubKind::Kv).expect("END_SEQ forwards");
|
||||
assert!(matches!(reset, WorkerEvent::PublisherReset { .. }));
|
||||
|
||||
// Bad seq frame length.
|
||||
let mut bad_seq = ZmqMessage::from(Bytes::new());
|
||||
bad_seq.push_back(Bytes::from_static(b"abc")); // 3 bytes, not 8
|
||||
bad_seq.push_back(Bytes::from_static(b""));
|
||||
assert!(decode_message(&id, bad_seq).is_none());
|
||||
assert!(decode_message(&id, bad_seq, SubKind::Kv).is_none());
|
||||
|
||||
// Bad payload.
|
||||
let bad_payload = helpers::build_multipart(1, vec![0xff, 0xfe]);
|
||||
assert!(decode_message(&id, bad_payload).is_none());
|
||||
assert!(decode_message(&id, bad_payload, SubKind::Kv).is_none());
|
||||
|
||||
// Happy path.
|
||||
let payload = helpers::encode_all_blocks_cleared_batch(0.0, None);
|
||||
let good = helpers::build_multipart(7, payload);
|
||||
let event = decode_message(&id, good).expect("should decode");
|
||||
let event = decode_message(&id, good, SubKind::Kv).expect("should decode");
|
||||
let (worker, seq, _batch) = helpers::expect_batch(event);
|
||||
assert_eq!(seq, 7);
|
||||
assert_eq!(worker, id);
|
||||
}
|
||||
|
||||
/// A `SubKind::Load` subscriber decodes a bare LoadStat frame into
|
||||
/// `WorkerEvent::Load`, and drops the END_SEQ sentinel (no cursor state).
|
||||
#[test]
|
||||
fn decode_message_load_kind() {
|
||||
let id = KvWorkerId {
|
||||
url: "http://x".to_string(),
|
||||
dp_rank: 1,
|
||||
};
|
||||
|
||||
// END_SEQ is dropped for the load topic.
|
||||
let sentinel = helpers::build_multipart(-1, b"ignored".to_vec());
|
||||
assert!(decode_message(&id, sentinel, SubKind::Load).is_none());
|
||||
|
||||
// A bare LoadStat frame becomes WorkerEvent::Load.
|
||||
let payload = helpers::encode_load_stat(5, 2, 100, 1000, 1);
|
||||
let msg = helpers::build_multipart(3, payload);
|
||||
let event = decode_message(&id, msg, SubKind::Load).expect("should decode load");
|
||||
match event {
|
||||
WorkerEvent::Load { worker, load } => {
|
||||
assert_eq!(worker, id);
|
||||
assert_eq!(load.num_running_reqs, 5);
|
||||
assert_eq!(load.num_waiting_reqs, 2);
|
||||
assert_eq!(load.num_tokens, 100);
|
||||
assert_eq!(load.max_total_num_tokens, 1000);
|
||||
}
|
||||
other => panic!("expected Load, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart-resume contract: after a worker is removed and then re-added
|
||||
/// to the same endpoint, the new subscriber must connect and forward
|
||||
/// fresh events. Confirms that `remove_worker` releases the SUB socket
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::policies::admission::FreshLoadLookup;
|
||||
use crate::policies::scoring::ScoringPolicy;
|
||||
use crate::policies::SelectionContext;
|
||||
use crate::workers::Worker;
|
||||
@@ -17,6 +18,10 @@ impl LoadBasedPolicy {
|
||||
}
|
||||
|
||||
impl ScoringPolicy for LoadBasedPolicy {
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// `1.0` for the least loaded down to `0.0` for the most, min-max scaled to
|
||||
/// the CURRENT fleet -- relative, not absolute, so it cannot saturate:
|
||||
/// `1 - load/256` reads a busy fleet as all-`0.0`, tied inside
|
||||
@@ -24,8 +29,9 @@ impl ScoringPolicy for LoadBasedPolicy {
|
||||
///
|
||||
/// Purely a preference: "everybody is busy" is not a reason to refuse to
|
||||
/// route, so this term never constrains. Capacity is `--filter`'s job.
|
||||
fn scores(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Vec<f32> {
|
||||
let loads: Vec<usize> = workers.iter().map(|w| w.active_load()).collect();
|
||||
fn scores(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Vec<f32> {
|
||||
let lookup = FreshLoadLookup::new(ctx.load_snapshot(), workers.iter());
|
||||
let loads: Vec<usize> = workers.iter().map(|w| lookup.score_load(w)).collect();
|
||||
let lo = loads.iter().min().copied().unwrap_or(0);
|
||||
let span = (loads.iter().max().copied().unwrap_or(0) - lo) as f32;
|
||||
// `max(1.0)` is exact: a zero span means every `l - lo` is zero too.
|
||||
@@ -38,8 +44,11 @@ impl ScoringPolicy for LoadBasedPolicy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
|
||||
use crate::policies::scoring::argmax::TIE_EPSILON;
|
||||
use crate::policies::Policy;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
@@ -85,4 +94,161 @@ mod tests {
|
||||
assert_eq!(got, *loads.iter().min().expect("non-empty"), "{spec}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_snapshot_overrides_later_router_active_load() {
|
||||
let model = ModelId("tiny".into());
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
// After the request snapshot, local counters say w0 is lighter.
|
||||
// The policy must still preserve the frozen Engine Load ordering.
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| w1.load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
23,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
|
||||
|
||||
assert_eq!(
|
||||
LoadBasedPolicy::new().select(&workers, &ctx).unwrap().id,
|
||||
w1.id,
|
||||
"load-based scoring must use the request snapshot before local active-load"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_dispatches_after_snapshot_change_load_based_choice() {
|
||||
let model = ModelId("tiny".into());
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
let captured_at = Instant::now();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
37,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at,
|
||||
},
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at,
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
let _after_snapshot = [w0.timestamped_load_guard(), w0.timestamped_load_guard()];
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
|
||||
|
||||
assert_eq!(
|
||||
LoadBasedPolicy::new().select(&workers, &ctx).unwrap().id,
|
||||
w1.id,
|
||||
"dispatches newer than Engine Load must correct its queue depth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatches_before_snapshot_are_not_double_counted() {
|
||||
let model = ModelId("tiny".into());
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
let _before_snapshot = [w0.timestamped_load_guard(), w0.timestamped_load_guard()];
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let captured_at = Instant::now();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
41,
|
||||
HashMap::from([
|
||||
(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at,
|
||||
},
|
||||
),
|
||||
(
|
||||
w1.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at,
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
|
||||
|
||||
assert_eq!(
|
||||
LoadBasedPolicy::new().select(&workers, &ctx).unwrap().id,
|
||||
w0.id,
|
||||
"slots already covered by the snapshot must not be added again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_snapshot_uses_frozen_local_active_fallback() {
|
||||
let model = ModelId("tiny".into());
|
||||
let w0 = worker("w0");
|
||||
let w1 = worker("w1");
|
||||
let _local_load = [w0.load_guard(), w0.load_guard()];
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
43,
|
||||
HashMap::from([(
|
||||
w0.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
)]),
|
||||
);
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let workers = vec![Arc::clone(&w0), Arc::clone(&w1)];
|
||||
|
||||
assert_eq!(
|
||||
LoadBasedPolicy::new().select(&workers, &ctx).unwrap().id,
|
||||
w1.id,
|
||||
"a partial Engine Load set must not mix engine and local gauges"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::policies::{Policy, SelectionContext};
|
||||
use crate::policies::admission::compare_prefill_pressure;
|
||||
use crate::policies::engine_load::EngineLoadSnapshot;
|
||||
use crate::policies::{Policy, ProposalKind, SelectionContext, SelectionProposal};
|
||||
use crate::workers::Worker;
|
||||
use rand::Rng;
|
||||
use std::sync::Arc;
|
||||
@@ -16,10 +18,21 @@ impl PowerOfTwoChoicesPolicy {
|
||||
}
|
||||
|
||||
impl Policy for PowerOfTwoChoicesPolicy {
|
||||
fn select(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
select_with_snapshot(workers, ctx.load_snapshot())
|
||||
}
|
||||
|
||||
/// Returns the primary and backup from one sample.
|
||||
fn propose(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
match workers.len() {
|
||||
0 => None,
|
||||
1 => Some(workers[0].clone()),
|
||||
1 => Some(
|
||||
SelectionProposal::primary(workers[0].clone()).with_kind(ProposalKind::PowerOfTwo),
|
||||
),
|
||||
len => {
|
||||
let mut rng = rand::thread_rng();
|
||||
let i = rng.gen_range(0..len);
|
||||
@@ -27,8 +40,60 @@ impl Policy for PowerOfTwoChoicesPolicy {
|
||||
if j >= i {
|
||||
j += 1;
|
||||
}
|
||||
Some(std::cmp::min_by_key(&workers[i], &workers[j], |w| w.active_load()).clone())
|
||||
let (primary, backup) = ordered_pair(&workers[i], &workers[j], ctx);
|
||||
Some(SelectionProposal::with_backup(primary, backup))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_with_snapshot(
|
||||
workers: &[Arc<Worker>],
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
) -> Option<Arc<Worker>> {
|
||||
match workers.len() {
|
||||
0 => None,
|
||||
1 => Some(workers[0].clone()),
|
||||
len => {
|
||||
let mut rng = rand::thread_rng();
|
||||
let i = rng.gen_range(0..len);
|
||||
let mut j = rng.gen_range(0..len - 1);
|
||||
if j >= i {
|
||||
j += 1;
|
||||
}
|
||||
Some(select_lower_pressure(&workers[i], &workers[j], snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_lower_pressure(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
) -> Arc<Worker> {
|
||||
ordered_pair_with_snapshot(left, right, snapshot).0
|
||||
}
|
||||
|
||||
fn ordered_pair(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> (Arc<Worker>, Arc<Worker>) {
|
||||
ordered_pair_with_snapshot(left, right, ctx.load_snapshot())
|
||||
}
|
||||
|
||||
fn ordered_pair_with_snapshot(
|
||||
left: &Arc<Worker>,
|
||||
right: &Arc<Worker>,
|
||||
snapshot: Option<&EngineLoadSnapshot>,
|
||||
) -> (Arc<Worker>, Arc<Worker>) {
|
||||
if compare_prefill_pressure(left, right, snapshot).is_gt() {
|
||||
(Arc::clone(right), Arc::clone(left))
|
||||
} else {
|
||||
(Arc::clone(left), Arc::clone(right))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn the_cap_is_a_strict_ceiling() {
|
||||
assert!(!Overloaded::new(3).needs_load_snapshot());
|
||||
let ws = vec![worker("idle"), worker("under"), worker("at")];
|
||||
let _under: Vec<_> = (0..2).map(|_| ws[1].load_guard()).collect();
|
||||
let _at: Vec<_> = (0..3).map(|_| ws[2].load_guard()).collect();
|
||||
|
||||
@@ -7,7 +7,7 @@ pub mod admission;
|
||||
pub mod argmax;
|
||||
pub mod prefix_cache;
|
||||
|
||||
use crate::policies::{Policy, SelectionContext};
|
||||
use crate::policies::{Policy, PrefillProposal, SelectionContext, SelectionProposal};
|
||||
use crate::workers::Worker;
|
||||
use argmax::{Selector, ARGMAX};
|
||||
use std::sync::Arc;
|
||||
@@ -52,6 +52,11 @@ pub trait ScoringPolicy: Send + Sync + std::fmt::Debug {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether scoring reads the request-scoped Engine Load snapshot.
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Optional eligibility view for policies that provide both signals.
|
||||
fn as_filter(&self) -> Option<&dyn EligibilityFilter> {
|
||||
None
|
||||
@@ -99,11 +104,10 @@ pub fn admit<'f>(
|
||||
match on_empty {
|
||||
OnEmpty::Hold => return None,
|
||||
OnEmpty::Abstain if untouched => {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
filter = ?filter,
|
||||
n_workers = workers.len(),
|
||||
"eligibility filter rejected every worker on its own; a filter \
|
||||
with no signal should abstain (all true), not veto the fleet",
|
||||
"eligibility filter has no eligible workers; falling back to the full candidate set",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -135,6 +139,10 @@ impl<T: ScoringPolicy> Policy for T {
|
||||
ScoringPolicy::needs_tokens(self) || self.as_filter().is_some_and(|f| f.needs_tokens())
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
ScoringPolicy::needs_load_snapshot(self)
|
||||
}
|
||||
|
||||
fn as_scoring(&self) -> Option<&dyn ScoringPolicy> {
|
||||
Some(self)
|
||||
}
|
||||
@@ -185,12 +193,107 @@ impl Pipeline {
|
||||
fn views(&self) -> impl Iterator<Item = &dyn EligibilityFilter> {
|
||||
(self.filters.iter()).map(|p| p.as_filter().expect("checked by Pipeline::new"))
|
||||
}
|
||||
|
||||
/// Apply eligibility without rewriting an existing Session assignment.
|
||||
fn propose_prefill_filtered(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<PrefillProposal> {
|
||||
let eligible = admit(self.views(), workers, ctx)?;
|
||||
if self.inner.is_bucket_affinity_policy() && ctx.affinity_lookup_enabled() {
|
||||
let probe_ctx = (*ctx).clone().without_affinity_assignment();
|
||||
if let Some(
|
||||
proposal @ PrefillProposal::Pair(SelectionProposal {
|
||||
kind: crate::policies::ProposalKind::SessionAffinity,
|
||||
..
|
||||
}),
|
||||
) = self.inner.propose_prefill(workers, &probe_ctx)
|
||||
{
|
||||
return Some(proposal.with_eligible_workers(eligible));
|
||||
}
|
||||
}
|
||||
self.inner
|
||||
.propose_prefill(&eligible, ctx)
|
||||
.map(|proposal| proposal.with_eligible_workers(eligible))
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy for Pipeline {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
let eligible = admit(self.views(), workers, ctx)?;
|
||||
self.inner.select(&eligible, ctx)
|
||||
let (proposal_kind, selected) = match self.propose_prefill_filtered(workers, ctx)? {
|
||||
PrefillProposal::Pair(proposal) => {
|
||||
let eligible = proposal.eligible_workers.as_deref().unwrap_or(workers);
|
||||
if eligible
|
||||
.iter()
|
||||
.any(|worker| worker.id == proposal.primary.id)
|
||||
{
|
||||
(proposal.kind, proposal.primary)
|
||||
} else {
|
||||
let selected = proposal
|
||||
.backup
|
||||
.filter(|backup| eligible.iter().any(|worker| worker.id == backup.id))
|
||||
.or_else(|| eligible.first().cloned())?;
|
||||
(proposal.kind, selected)
|
||||
}
|
||||
}
|
||||
PrefillProposal::CacheCandidates(proposal) => {
|
||||
let selected = proposal.candidates.into_iter().next()?.worker;
|
||||
(crate::policies::ProposalKind::CacheAffinity, selected)
|
||||
}
|
||||
};
|
||||
self.inner
|
||||
.commit_prefill_selection(ctx, proposal_kind, &selected);
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
/// Preserves the inner policy's complete proposal.
|
||||
fn propose(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
match self.propose_prefill_filtered(workers, ctx)? {
|
||||
PrefillProposal::Pair(proposal) => Some(proposal),
|
||||
PrefillProposal::CacheCandidates(proposal) => {
|
||||
let candidate = proposal.candidates.into_iter().next()?;
|
||||
Some(
|
||||
SelectionProposal::primary(candidate.worker)
|
||||
.with_kind(crate::policies::ProposalKind::CacheAffinity),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn propose_prefill(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<PrefillProposal> {
|
||||
self.propose_prefill_filtered(workers, ctx)
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
self.inner.uses_shared_prefill_admission()
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
self.inner.needs_load_snapshot() || self.filters.iter().any(|p| p.needs_load_snapshot())
|
||||
}
|
||||
|
||||
fn commit_prefill_selection(
|
||||
&self,
|
||||
ctx: &SelectionContext<'_>,
|
||||
proposal_kind: crate::policies::ProposalKind,
|
||||
selected: &Arc<Worker>,
|
||||
) {
|
||||
self.inner
|
||||
.commit_prefill_selection(ctx, proposal_kind, selected);
|
||||
}
|
||||
|
||||
/// Preserves the inner policy's Bucket-affinity semantics.
|
||||
fn is_bucket_affinity_policy(&self) -> bool {
|
||||
self.inner.is_bucket_affinity_policy()
|
||||
}
|
||||
|
||||
fn needs_request_tokens(&self) -> bool {
|
||||
@@ -202,6 +305,54 @@ impl Policy for Pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level score policy that enters shared Prefill admission.
|
||||
#[derive(Debug)]
|
||||
pub struct ScorePolicy {
|
||||
inner: Arc<dyn Policy>,
|
||||
}
|
||||
|
||||
impl ScorePolicy {
|
||||
pub fn new(inner: Arc<dyn Policy>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy for ScorePolicy {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
self.inner.select(workers, ctx)
|
||||
}
|
||||
|
||||
fn propose(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
self.inner
|
||||
.propose(workers, ctx)
|
||||
.map(|proposal| proposal.with_kind(crate::policies::ProposalKind::Score))
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn needs_request_tokens(&self) -> bool {
|
||||
self.inner.needs_request_tokens()
|
||||
}
|
||||
|
||||
fn attach_metrics(&self, metrics: Arc<crate::server::metrics::MetricsRegistry>) {
|
||||
self.inner.attach_metrics(metrics);
|
||||
}
|
||||
|
||||
fn as_scoring(&self) -> Option<&dyn ScoringPolicy> {
|
||||
self.inner.as_scoring()
|
||||
}
|
||||
|
||||
fn as_filter(&self) -> Option<&dyn EligibilityFilter> {
|
||||
self.inner.as_filter()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScoringPolicy for FusedScorePolicy {
|
||||
fn scores(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Vec<f32> {
|
||||
let mut total = vec![0.0f32; workers.len()];
|
||||
@@ -216,6 +367,12 @@ impl ScoringPolicy for FusedScorePolicy {
|
||||
fn needs_tokens(&self) -> bool {
|
||||
self.terms.iter().map(view).any(|(t, _)| t.needs_tokens())
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
self.terms
|
||||
.iter()
|
||||
.any(|(policy, _)| policy.needs_load_snapshot())
|
||||
}
|
||||
}
|
||||
|
||||
/// Owned boxes as the borrowed views [`admit`] consumes. Shared by the tests
|
||||
@@ -230,8 +387,15 @@ pub(crate) fn refs(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::AffinityConfig;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::admission::{resolve_prefill, CandidateRange};
|
||||
use crate::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::round_robin::RoundRobinPolicy;
|
||||
use crate::policies::session_aware::SessionAwarePolicy;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
@@ -247,6 +411,27 @@ mod tests {
|
||||
vec![worker("a"), worker("b"), worker("c")]
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_workers(
|
||||
1,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_tokens: *used,
|
||||
max_total_num_tokens: *capacity,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
}
|
||||
|
||||
fn urls(ws: &[Arc<Worker>]) -> Vec<String> {
|
||||
ws.iter().map(|w| w.url.clone()).collect()
|
||||
}
|
||||
@@ -406,6 +591,39 @@ mod tests {
|
||||
assert!(filtered.needs_request_tokens(), "the filter is hungry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_propagates_load_snapshot_capability() {
|
||||
#[derive(Debug)]
|
||||
struct LoadHungry;
|
||||
impl ScoringPolicy for LoadHungry {
|
||||
fn scores(&self, workers: &[Arc<Worker>], _: &SelectionContext<'_>) -> Vec<f32> {
|
||||
vec![0.0; workers.len()]
|
||||
}
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
let plain = FusedScorePolicy::new(vec![term(by(1.0), None)]).unwrap();
|
||||
assert!(!Policy::needs_load_snapshot(&plain));
|
||||
let fused =
|
||||
FusedScorePolicy::new(vec![term(by(1.0), None), term(LoadHungry, None)]).unwrap();
|
||||
assert!(Policy::needs_load_snapshot(&fused));
|
||||
|
||||
let pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a"], OnEmpty::Abstain))],
|
||||
Arc::new(fused),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(pipeline.needs_load_snapshot());
|
||||
|
||||
let score = ScorePolicy::new(Arc::new(by(1.0)));
|
||||
assert!(
|
||||
score.needs_load_snapshot(),
|
||||
"shared admission requires a snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_worker_cannot_be_out_weighed() {
|
||||
let ws = fleet();
|
||||
@@ -431,6 +649,137 @@ mod tests {
|
||||
assert_eq!(open.select(&ws, &ctx).unwrap().url, ws[2].url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_preserves_the_inner_step_one_proposal_and_admission_opt_in() {
|
||||
let ws = fleet();
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None);
|
||||
let pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a", "b", "c"], OnEmpty::Abstain))],
|
||||
Arc::new(PowerOfTwoChoicesPolicy::new()),
|
||||
)
|
||||
.expect("valid filter and inner policy");
|
||||
|
||||
let proposal = pipeline
|
||||
.propose(&ws, &ctx)
|
||||
.expect("eligible P2 must retain a pair");
|
||||
|
||||
assert!(
|
||||
proposal.backup.is_some(),
|
||||
"Pipeline must not collapse P2 to one primary"
|
||||
);
|
||||
assert!(pipeline.uses_shared_prefill_admission());
|
||||
|
||||
let session_pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a", "b", "c"], OnEmpty::Abstain))],
|
||||
Arc::new(SessionAwarePolicy::new(AffinityConfig::default())),
|
||||
)
|
||||
.expect("valid filter and inner session policy");
|
||||
assert!(
|
||||
session_pipeline.is_bucket_affinity_policy(),
|
||||
"Pipeline must forward the inner Session affinity range capability"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_admission_fallback_cannot_reintroduce_a_filtered_worker() {
|
||||
let ws = fleet();
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None);
|
||||
let pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a", "b"], OnEmpty::Abstain))],
|
||||
Arc::new(PowerOfTwoChoicesPolicy::new()),
|
||||
)
|
||||
.expect("valid filter and inner policy");
|
||||
let proposal = pipeline
|
||||
.propose(&ws, &ctx)
|
||||
.expect("the two eligible workers produce a P2 proposal");
|
||||
let snapshot = snapshot(&[
|
||||
(&ws[0], 0, 0, 4_090, 4_096),
|
||||
(&ws[1], 0, 0, 4_090, 4_096),
|
||||
(&ws[2], 0, 0, 0, 4_096),
|
||||
]);
|
||||
|
||||
let decision = resolve_prefill(&CandidateRange::global(&ws), &proposal, 32, &snapshot)
|
||||
.expect("capacity exhaustion must degrade inside the filtered domain");
|
||||
assert!(matches!(decision.selected.id.0.as_str(), "a" | "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligibility_escape_does_not_rewrite_an_existing_session_assignment() {
|
||||
let ws = fleet();
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None).with_session_id(Some("session-a"));
|
||||
let session = Arc::new(SessionAwarePolicy::new(AffinityConfig::default()));
|
||||
|
||||
let initial = session
|
||||
.propose(&ws[2..], &ctx)
|
||||
.expect("one-worker domain establishes c");
|
||||
assert_eq!(initial.primary.id, ws[2].id);
|
||||
session.commit_prefill_selection(&ctx, initial.kind, &initial.primary);
|
||||
|
||||
let pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a", "b"], OnEmpty::Abstain))],
|
||||
session.clone(),
|
||||
)
|
||||
.expect("valid filter and session policy");
|
||||
let PrefillProposal::Pair(proposal) = pipeline
|
||||
.propose_prefill(&ws, &ctx)
|
||||
.expect("filtered session proposal")
|
||||
else {
|
||||
panic!("Session-Aware must retain pair semantics");
|
||||
};
|
||||
assert_eq!(
|
||||
proposal.kind,
|
||||
crate::policies::ProposalKind::SessionAffinity
|
||||
);
|
||||
assert_eq!(proposal.primary.id, ws[2].id);
|
||||
|
||||
let snapshot = EngineLoadSnapshot::default();
|
||||
let decision = resolve_prefill(&CandidateRange::global(&ws), &proposal, 32, &snapshot)
|
||||
.expect("an eligible escape worker exists");
|
||||
assert_ne!(decision.selected.id, ws[2].id);
|
||||
assert!(matches!(decision.selected.id.0.as_str(), "a" | "b"));
|
||||
|
||||
let after = session
|
||||
.propose(&ws, &ctx)
|
||||
.expect("the original assignment remains readable");
|
||||
assert_eq!(after.kind, crate::policies::ProposalKind::SessionAffinity);
|
||||
assert_eq!(after.primary.id, ws[2].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_assignment_is_created_inside_the_eligible_set() {
|
||||
let ws = fleet();
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None).with_session_id(Some("session-new"));
|
||||
let session = Arc::new(SessionAwarePolicy::new(AffinityConfig::default()));
|
||||
let pipeline = Pipeline::new(
|
||||
vec![Arc::new(Keep(vec!["a", "b"], OnEmpty::Abstain))],
|
||||
session.clone(),
|
||||
)
|
||||
.expect("valid filter and session policy");
|
||||
|
||||
let PrefillProposal::Pair(proposal) = pipeline
|
||||
.propose_prefill(&ws, &ctx)
|
||||
.expect("eligible workers establish the session")
|
||||
else {
|
||||
panic!("Session-Aware must retain pair semantics");
|
||||
};
|
||||
assert!(matches!(proposal.primary.id.0.as_str(), "a" | "b"));
|
||||
pipeline.commit_prefill_selection(&ctx, proposal.kind, &proposal.primary);
|
||||
|
||||
let mapped = session
|
||||
.propose(&ws, &ctx)
|
||||
.expect("the assignment is stored by the inner policy");
|
||||
assert_eq!(mapped.kind, crate::policies::ProposalKind::SessionAffinity);
|
||||
assert_eq!(mapped.primary.id, proposal.primary.id);
|
||||
}
|
||||
|
||||
/// Order is priority: the LOWER-priority filter yields, and what the
|
||||
/// higher-priority one narrowed to is kept. Asserted on the surviving set
|
||||
/// rather than on the winner, because with three workers a wrong rule can
|
||||
/// still land on the right one by luck.
|
||||
#[test]
|
||||
fn a_conflict_yields_the_later_filter_and_keeps_the_earlier_narrowing() {
|
||||
let ws = fleet();
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Session-aware prefill policy. Admission does not rewrite session assignments.
|
||||
|
||||
use crate::config::{AffinityConfig, SessionAffinityMode};
|
||||
use crate::discovery::WorkerId;
|
||||
use crate::policies::active_load::{spawn_sweeper, Clock, JanitorHandle, SystemTimeClock};
|
||||
use crate::policies::admission::compare_prefill_pressure;
|
||||
use crate::policies::power_of_two::PowerOfTwoChoicesPolicy;
|
||||
use crate::policies::{Policy, ProposalKind, SelectionContext, SelectionProposal};
|
||||
use crate::workers::Worker;
|
||||
use dashmap::DashMap;
|
||||
use rand::Rng;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Assignment {
|
||||
worker_id: WorkerId,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionState {
|
||||
assignments: DashMap<String, Assignment>,
|
||||
clock: Arc<dyn Clock>,
|
||||
idle: Duration,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
fn sweep_expired(&self) -> usize {
|
||||
let now = self.clock.now();
|
||||
let mut removed = 0;
|
||||
self.assignments.retain(|_, assignment| {
|
||||
let keep = now.saturating_duration_since(assignment.last_seen) <= self.idle;
|
||||
if !keep {
|
||||
removed += 1;
|
||||
}
|
||||
keep
|
||||
});
|
||||
removed
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionAwarePolicy {
|
||||
state: Arc<SessionState>,
|
||||
config: AffinityConfig,
|
||||
_janitor: Option<JanitorHandle>,
|
||||
}
|
||||
|
||||
impl SessionAwarePolicy {
|
||||
pub fn new(config: AffinityConfig) -> Self {
|
||||
let state = Arc::new(SessionState {
|
||||
assignments: DashMap::new(),
|
||||
clock: Arc::new(SystemTimeClock),
|
||||
idle: Duration::from_secs(config.session_idle_secs),
|
||||
});
|
||||
let _janitor = if tokio::runtime::Handle::try_current().is_ok() {
|
||||
let swept = Arc::clone(&state);
|
||||
Some(spawn_sweeper(
|
||||
move || swept.sweep_expired(),
|
||||
Duration::from_secs(config.session_eviction_interval_secs),
|
||||
"session-affinity-eviction",
|
||||
))
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"SessionAwarePolicy constructed outside a Tokio runtime; idle eviction is disabled"
|
||||
);
|
||||
None
|
||||
};
|
||||
Self {
|
||||
state,
|
||||
config,
|
||||
_janitor,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_clock(config: AffinityConfig, clock: Arc<dyn Clock>) -> Self {
|
||||
Self {
|
||||
state: Arc::new(SessionState {
|
||||
assignments: DashMap::new(),
|
||||
clock,
|
||||
idle: Duration::from_secs(config.session_idle_secs),
|
||||
}),
|
||||
config,
|
||||
_janitor: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn sweep_expired(&self) -> usize {
|
||||
self.state.sweep_expired()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn assignment_count(&self) -> usize {
|
||||
self.state.assignments.len()
|
||||
}
|
||||
|
||||
fn assignment_key(&self, session_id: &str, ctx: &SelectionContext<'_>) -> String {
|
||||
match self.config.session_affinity_mode {
|
||||
SessionAffinityMode::Bucket => {
|
||||
format!("{}\0{}", ctx.candidate_range_id(), session_id)
|
||||
}
|
||||
SessionAffinityMode::GlobalRebind | SessionAffinityMode::GlobalPreserve => {
|
||||
session_id.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initial_proposal(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
PowerOfTwoChoicesPolicy::new().propose(workers, ctx)
|
||||
}
|
||||
|
||||
fn affinity_proposal(
|
||||
&self,
|
||||
primary: Arc<Worker>,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
session_id: &str,
|
||||
) -> SelectionProposal {
|
||||
let backup = affinity_backup(
|
||||
workers,
|
||||
&primary,
|
||||
session_id,
|
||||
ctx.candidate_range_id(),
|
||||
self.config.stable_pair,
|
||||
ctx,
|
||||
);
|
||||
let proposal = match backup {
|
||||
Some(backup) => SelectionProposal::with_backup(primary, backup),
|
||||
None => SelectionProposal::primary(primary),
|
||||
};
|
||||
proposal.with_kind(ProposalKind::SessionAffinity)
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy for SessionAwarePolicy {
|
||||
fn select(&self, workers: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
let proposal = self.propose(workers, ctx)?;
|
||||
let proposal_kind = proposal.kind;
|
||||
let selected = proposal.primary;
|
||||
self.commit_prefill_selection(ctx, proposal_kind, &selected);
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn propose(
|
||||
&self,
|
||||
workers: &[Arc<Worker>],
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<SelectionProposal> {
|
||||
if !ctx.affinity_lookup_enabled() {
|
||||
return PowerOfTwoChoicesPolicy::new().propose(workers, ctx);
|
||||
}
|
||||
let Some(session_id) = ctx.session_id().filter(|id| !id.is_empty()) else {
|
||||
return self.initial_proposal(workers, ctx);
|
||||
};
|
||||
|
||||
let assignment_key = self.assignment_key(session_id, ctx);
|
||||
let assigned = self
|
||||
.state
|
||||
.assignments
|
||||
.get_mut(&assignment_key)
|
||||
.map(|mut assignment| {
|
||||
assignment.last_seen = self.state.clock.now();
|
||||
assignment.worker_id.clone()
|
||||
});
|
||||
if let Some(assigned) = assigned {
|
||||
if let Some(primary) = workers.iter().find(|worker| worker.id == assigned).cloned() {
|
||||
return Some(self.affinity_proposal(primary, workers, ctx, session_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Persist new assignments only after selecting the final prefill worker.
|
||||
self.initial_proposal(workers, ctx)
|
||||
}
|
||||
|
||||
fn commit_prefill_selection(
|
||||
&self,
|
||||
ctx: &SelectionContext<'_>,
|
||||
proposal_kind: ProposalKind,
|
||||
selected: &Arc<Worker>,
|
||||
) {
|
||||
if proposal_kind != ProposalKind::PowerOfTwo || !ctx.affinity_assignment_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(session_id) = ctx.session_id().filter(|id| !id.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
self.state.assignments.insert(
|
||||
self.assignment_key(session_id, ctx),
|
||||
Assignment {
|
||||
worker_id: selected.id.clone(),
|
||||
last_seen: self.state.clock.now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn is_bucket_affinity_policy(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SessionAwarePolicy {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SessionAwarePolicy")
|
||||
.field("config", &self.config)
|
||||
.field("assignments", &self.state.assignments.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn affinity_backup(
|
||||
workers: &[Arc<Worker>],
|
||||
primary: &Arc<Worker>,
|
||||
affinity_key: &str,
|
||||
candidate_range_id: &str,
|
||||
stable_pair: bool,
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<Arc<Worker>> {
|
||||
if stable_pair {
|
||||
return stable_backup(workers, primary, affinity_key, candidate_range_id);
|
||||
}
|
||||
sampled_backup_excluding(workers, primary, ctx)
|
||||
}
|
||||
|
||||
fn sampled_backup_excluding(
|
||||
workers: &[Arc<Worker>],
|
||||
primary: &Arc<Worker>,
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> Option<Arc<Worker>> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let first = sample_index_excluding(workers, &primary.id, None, &mut rng)?;
|
||||
let Some(second) = sample_index_excluding(workers, &primary.id, Some(first), &mut rng) else {
|
||||
return Some(Arc::clone(&workers[first]));
|
||||
};
|
||||
let left = &workers[first];
|
||||
let right = &workers[second];
|
||||
if compare_prefill_pressure(left, right, ctx.load_snapshot()).is_gt() {
|
||||
Some(Arc::clone(right))
|
||||
} else {
|
||||
Some(Arc::clone(left))
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_index_excluding(
|
||||
workers: &[Arc<Worker>],
|
||||
primary_id: &WorkerId,
|
||||
other_index: Option<usize>,
|
||||
rng: &mut impl Rng,
|
||||
) -> Option<usize> {
|
||||
if workers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for _ in 0..32 {
|
||||
let index = rng.gen_range(0..workers.len());
|
||||
if Some(index) != other_index && workers[index].id != *primary_id {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
workers.iter().enumerate().find_map(|(index, worker)| {
|
||||
(Some(index) != other_index && worker.id != *primary_id).then_some(index)
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_backup(
|
||||
workers: &[Arc<Worker>],
|
||||
primary: &Arc<Worker>,
|
||||
session_id: &str,
|
||||
candidate_range_id: &str,
|
||||
) -> Option<Arc<Worker>> {
|
||||
let mut others: Vec<Arc<Worker>> = workers
|
||||
.iter()
|
||||
.filter(|worker| worker.id != primary.id)
|
||||
.cloned()
|
||||
.collect();
|
||||
others.sort_by(|left, right| left.id.0.cmp(&right.id.0));
|
||||
if others.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut hasher = DefaultHasher::new();
|
||||
session_id.hash(&mut hasher);
|
||||
candidate_range_id.hash(&mut hasher);
|
||||
Some(others[(hasher.finish() as usize) % others.len()].clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerMode, WorkerSpec};
|
||||
use crate::policies::active_load::MockClock;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
id: WorkerId(id.into()),
|
||||
url: format!("http://{id}:30000"),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId("model".into())],
|
||||
bootstrap_port: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_session_assignments_are_evicted() {
|
||||
let clock = Arc::new(MockClock::new(Instant::now()));
|
||||
let policy = SessionAwarePolicy::with_clock(
|
||||
AffinityConfig {
|
||||
session_idle_secs: 10,
|
||||
..Default::default()
|
||||
},
|
||||
clock.clone(),
|
||||
);
|
||||
let model = ModelId("model".into());
|
||||
let ctx = SelectionContext::new(&model, None).with_session_id(Some("session-a"));
|
||||
let proposal = policy.propose(&[worker("w")], &ctx).unwrap();
|
||||
policy.commit_prefill_selection(&ctx, proposal.kind, &proposal.primary);
|
||||
assert_eq!(policy.assignment_count(), 1);
|
||||
|
||||
clock.advance(Duration::from_secs(11));
|
||||
assert_eq!(policy.sweep_expired(), 1);
|
||||
assert_eq!(policy.assignment_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_backup_excludes_primary_without_materializing_the_remaining_fleet() {
|
||||
let primary = worker("primary");
|
||||
let busy = worker("busy");
|
||||
let idle = worker("idle");
|
||||
busy.active_requests.store(8, Ordering::Relaxed);
|
||||
idle.active_requests.store(1, Ordering::Relaxed);
|
||||
let workers = vec![Arc::clone(&primary), busy, Arc::clone(&idle)];
|
||||
let model = ModelId("model".into());
|
||||
let ctx = SelectionContext::new(&model, None);
|
||||
|
||||
let backup = sampled_backup_excluding(&workers, &primary, &ctx)
|
||||
.expect("two non-primary workers are available");
|
||||
assert_eq!(backup.id, idle.id);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,10 @@ impl Policy for StickyPolicy {
|
||||
fn attach_metrics(&self, metrics: Arc<MetricsRegistry>) {
|
||||
let _ = self.state.metrics.set(metrics);
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
self.fallback.needs_load_snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for StickyPolicy {
|
||||
@@ -228,6 +232,16 @@ impl std::fmt::Debug for StickyPolicy {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
|
||||
#[test]
|
||||
fn sticky_propagates_fallback_load_snapshot_capability() {
|
||||
let policy = StickyPolicy::new(
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(10),
|
||||
Arc::new(crate::policies::power_of_two::PowerOfTwoChoicesPolicy::new()),
|
||||
);
|
||||
assert!(policy.needs_load_snapshot());
|
||||
}
|
||||
use crate::policies::round_robin::RoundRobinPolicy;
|
||||
|
||||
fn worker(id: &str) -> Arc<Worker> {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use crate::config::Config;
|
||||
|
||||
use crate::policies::active_load::ActiveLoadRegistry;
|
||||
use crate::policies::engine_load::EngineLoadTable;
|
||||
use crate::policies::kv_events::BlockSizeOracle;
|
||||
use crate::policies::PolicyRegistry;
|
||||
use crate::proxy::Proxy;
|
||||
@@ -19,17 +20,16 @@ pub struct AppContext {
|
||||
pub proxy: Arc<Proxy>,
|
||||
pub registry: Arc<WorkerRegistry>,
|
||||
pub policies: Arc<PolicyRegistry>,
|
||||
/// Per-worker active-load bookkeeping. Shared between the proxy
|
||||
/// (which mints guards on the request hot path), the cache-aware
|
||||
/// policy (which reads per-worker load when scoring candidates), and
|
||||
/// the stale-request janitor (which sweeps expired entries).
|
||||
/// Per-worker active-load bookkeeping shared by the proxy, policies,
|
||||
/// timeout janitor, and metrics.
|
||||
pub active_load: Arc<ActiveLoadRegistry>,
|
||||
/// Lightweight Prometheus-format metrics registry served via
|
||||
/// `/metrics`. Shared with the chat handler (requests_total),
|
||||
/// cache-aware-zmq policy (overlap_blocks), active-load registry
|
||||
/// (active_load gauge + stale_requests_total), and PD resolver
|
||||
/// (decode_affinity_total).
|
||||
/// (active_load gauge + stale_requests_total), and PD dispatch.
|
||||
pub metrics: Arc<MetricsRegistry>,
|
||||
/// Shared Engine LoadStat table; ingress captures one immutable snapshot per request.
|
||||
pub engine_load: Arc<EngineLoadTable>,
|
||||
pub prefix_index: Option<Arc<sgl_kv_indexer::GrpcPrefixIndex>>,
|
||||
pub block_size_oracle: Arc<BlockSizeOracle>,
|
||||
ready: AtomicBool,
|
||||
@@ -86,6 +86,7 @@ impl AppContext {
|
||||
metrics,
|
||||
prefix_index: None,
|
||||
block_size_oracle: BlockSizeOracle::new(),
|
||||
engine_load: EngineLoadTable::new(),
|
||||
ready: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
@@ -116,6 +117,7 @@ impl AppContext {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
@@ -135,6 +137,7 @@ impl AppContext {
|
||||
metrics: MetricsRegistry::new(),
|
||||
prefix_index: None,
|
||||
block_size_oracle: BlockSizeOracle::new(),
|
||||
engine_load: EngineLoadTable::new(),
|
||||
ready: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,10 +229,7 @@ impl IntoResponse for ApiError {
|
||||
);
|
||||
"request expired before completion".to_string()
|
||||
}
|
||||
ApiError::PolicySelectionFailed { model } => {
|
||||
tracing::warn!(model = %model, reason = "policy_selection_failed", "service unavailable");
|
||||
"service unavailable".to_string()
|
||||
}
|
||||
ApiError::PolicySelectionFailed { .. } => "service unavailable".to_string(),
|
||||
ApiError::BreakerOpen { worker } => {
|
||||
tracing::warn!(upstream = %worker, reason = "breaker_open", "service unavailable");
|
||||
"service unavailable".to_string()
|
||||
@@ -370,6 +367,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_selection_failed_envelope_is_unchanged() {
|
||||
let resp = ApiError::PolicySelectionFailed {
|
||||
model: "tiny".into(),
|
||||
}
|
||||
.into_response();
|
||||
let (status, code_header, env) = parse_envelope(resp);
|
||||
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(code_header.as_deref(), Some("policy_selection_failed"));
|
||||
assert_eq!(env.error.typ, "server_error");
|
||||
assert_eq!(env.error.code, "policy_selection_failed");
|
||||
assert_eq!(env.error.message, "service unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_envelope_has_expected_shape() {
|
||||
let msg = "invalid_request: body must be an object";
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
//! | `sgl_router_stale_requests_total` | Counter | `outcome` |
|
||||
//! | `sgl_router_decode_affinity_total` | Counter | `outcome` |
|
||||
//! | `sgl_router_sticky_total` | Counter | `outcome` |
|
||||
//! | `sgl_router_policy_decisions_total` | Counter | `policy`, `reason` |
|
||||
//! | `sgl_router_policy_selection_failures_total` | Counter | `policy`, `reason` |
|
||||
//! | `sgl_router_ingress_tokenize_errors_total` | Counter | `model_id` |
|
||||
//!
|
||||
//! The four `sgl_router_worker*` gauges and `sgl_router_workers` are sampled
|
||||
@@ -42,6 +44,7 @@
|
||||
//!
|
||||
//! The exposition is text/plain; version=0.0.4 per the Prometheus spec.
|
||||
|
||||
use crate::config::PolicyKind;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
|
||||
@@ -184,6 +187,23 @@ impl StaleRequestOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum PolicySelectionFailureReason {
|
||||
PrefillAdmissionExhausted,
|
||||
CacheCandidatesExhausted,
|
||||
ProposalEmpty,
|
||||
}
|
||||
|
||||
impl PolicySelectionFailureReason {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PrefillAdmissionExhausted => "prefill_admission_exhausted",
|
||||
Self::CacheCandidatesExhausted => "cache_candidates_exhausted",
|
||||
Self::ProposalEmpty => "proposal_empty",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active-load kind label — separates the two axes of per-worker load.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ActiveLoadKind {
|
||||
@@ -224,6 +244,8 @@ pub struct MetricsRegistry {
|
||||
stale_requests_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
|
||||
decode_affinity_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
|
||||
sticky_total: Mutex<HashMap<&'static str, Arc<AtomicU64>>>,
|
||||
policy_decisions_total: Mutex<HashMap<PolicyDecisionKey, Arc<AtomicU64>>>,
|
||||
policy_selection_failures_total: Mutex<HashMap<PolicyDecisionKey, Arc<AtomicU64>>>,
|
||||
ingress_tokenize_errors_total: Mutex<HashMap<String, Arc<AtomicU64>>>,
|
||||
}
|
||||
|
||||
@@ -274,6 +296,12 @@ struct ActiveLoadKey {
|
||||
kind: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
|
||||
struct PolicyDecisionKey {
|
||||
policy: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Histogram {
|
||||
/// Bucket upper bounds this histogram observes against (e.g.
|
||||
@@ -481,6 +509,39 @@ impl MetricsRegistry {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record the final Prefill policy decision.
|
||||
pub fn record_policy_decision(&self, policy: &str, reason: &str) {
|
||||
let key = PolicyDecisionKey {
|
||||
policy: policy.to_owned(),
|
||||
reason: reason.to_owned(),
|
||||
};
|
||||
let mut guard = self.policy_decisions_total.lock();
|
||||
let counter = guard
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
|
||||
.clone();
|
||||
drop(guard);
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn record_policy_selection_failure(
|
||||
&self,
|
||||
policy: PolicyKind,
|
||||
reason: PolicySelectionFailureReason,
|
||||
) {
|
||||
let key = PolicyDecisionKey {
|
||||
policy: policy.to_string(),
|
||||
reason: reason.as_str().to_owned(),
|
||||
};
|
||||
let mut guard = self.policy_selection_failures_total.lock();
|
||||
let counter = guard
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(AtomicU64::new(0)))
|
||||
.clone();
|
||||
drop(guard);
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Bump `sgl_router_ingress_tokenize_errors_total{model_id}`.
|
||||
///
|
||||
/// Recorded ONLY when the tokenization offload SHOULD have fired but the
|
||||
@@ -785,6 +846,48 @@ impl MetricsRegistry {
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
// policy_decisions_total
|
||||
out.push_str(
|
||||
"# HELP sgl_router_policy_decisions_total Final Prefill policy decisions by policy and bounded reason.\n",
|
||||
);
|
||||
out.push_str("# TYPE sgl_router_policy_decisions_total counter\n");
|
||||
let guard = self.policy_decisions_total.lock();
|
||||
let mut entries: Vec<(&PolicyDecisionKey, u64)> = guard
|
||||
.iter()
|
||||
.map(|(key, value)| (key, value.load(Ordering::Relaxed)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| (&a.0.policy, &a.0.reason).cmp(&(&b.0.policy, &b.0.reason)));
|
||||
for (key, value) in entries {
|
||||
out.push_str(&format!(
|
||||
"sgl_router_policy_decisions_total{{policy=\"{}\",reason=\"{}\"}} {}\n",
|
||||
escape_label(&key.policy),
|
||||
escape_label(&key.reason),
|
||||
value,
|
||||
));
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
// policy_selection_failures_total
|
||||
out.push_str(
|
||||
"# HELP sgl_router_policy_selection_failures_total Failed Prefill policy selections by policy and bounded reason.\n",
|
||||
);
|
||||
out.push_str("# TYPE sgl_router_policy_selection_failures_total counter\n");
|
||||
let guard = self.policy_selection_failures_total.lock();
|
||||
let mut entries: Vec<(&PolicyDecisionKey, u64)> = guard
|
||||
.iter()
|
||||
.map(|(key, value)| (key, value.load(Ordering::Relaxed)))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| (&a.0.policy, &a.0.reason).cmp(&(&b.0.policy, &b.0.reason)));
|
||||
for (key, value) in entries {
|
||||
out.push_str(&format!(
|
||||
"sgl_router_policy_selection_failures_total{{policy=\"{}\",reason=\"{}\"}} {}\n",
|
||||
escape_label(&key.policy),
|
||||
escape_label(&key.reason),
|
||||
value,
|
||||
));
|
||||
}
|
||||
drop(guard);
|
||||
|
||||
// ingress_tokenize_errors_total
|
||||
out.push_str(
|
||||
"# HELP sgl_router_ingress_tokenize_errors_total Chat requests on a chat-encoder model whose ingress tokenization failed, silently falling back to engine-side tokenization (the input_ids offload was defeated).\n",
|
||||
@@ -870,6 +973,7 @@ mod tests {
|
||||
assert!(out.contains("# TYPE sgl_router_stale_requests_total counter"));
|
||||
assert!(out.contains("# TYPE sgl_router_decode_affinity_total counter"));
|
||||
assert!(out.contains("# TYPE sgl_router_sticky_total counter"));
|
||||
assert!(out.contains("# TYPE sgl_router_policy_decisions_total counter"));
|
||||
assert!(out.contains("# TYPE sgl_router_ingress_tokenize_errors_total counter"));
|
||||
// Pool-size series exist (at 0) for all three modes even with no
|
||||
// workers, so dashboards have a stable series to graph.
|
||||
@@ -1172,6 +1276,50 @@ mod tests {
|
||||
assert!(out.contains(r#"sgl_router_sticky_total{outcome="no_routing_key"} 1"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_decisions_are_keyed_by_policy_and_reason() {
|
||||
let reg = MetricsRegistry::new();
|
||||
reg.record_policy_decision("session_aware", "session_primary");
|
||||
reg.record_policy_decision("session_aware", "session_primary");
|
||||
reg.record_policy_decision("cache_aware", "cache_candidate");
|
||||
|
||||
let out = reg.render();
|
||||
assert!(out.contains(
|
||||
r#"sgl_router_policy_decisions_total{policy="cache_aware",reason="cache_candidate"} 1"#
|
||||
));
|
||||
assert!(out.contains(
|
||||
r#"sgl_router_policy_decisions_total{policy="session_aware",reason="session_primary"} 2"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_selection_failures_are_keyed_by_policy_and_reason() {
|
||||
let reg = MetricsRegistry::new();
|
||||
reg.record_policy_selection_failure(
|
||||
PolicyKind::SessionAware,
|
||||
PolicySelectionFailureReason::PrefillAdmissionExhausted,
|
||||
);
|
||||
reg.record_policy_selection_failure(
|
||||
PolicyKind::CacheAware,
|
||||
PolicySelectionFailureReason::CacheCandidatesExhausted,
|
||||
);
|
||||
reg.record_policy_selection_failure(
|
||||
PolicyKind::RoundRobin,
|
||||
PolicySelectionFailureReason::ProposalEmpty,
|
||||
);
|
||||
|
||||
let out = reg.render();
|
||||
assert!(out.contains(
|
||||
r#"sgl_router_policy_selection_failures_total{policy="session_aware",reason="prefill_admission_exhausted"} 1"#
|
||||
));
|
||||
assert!(out.contains(
|
||||
r#"sgl_router_policy_selection_failures_total{policy="cache_aware",reason="cache_candidates_exhausted"} 1"#
|
||||
));
|
||||
assert!(out.contains(
|
||||
r#"sgl_router_policy_selection_failures_total{policy="round_robin",reason="proposal_empty"} 1"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ingress_tokenize_error_counter_increments_per_model() {
|
||||
let reg = MetricsRegistry::new();
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use crate::discovery::{ModelId, WorkerMode};
|
||||
use crate::policies::admission::{resolve_cache_candidates, resolve_prefill, CandidateRange};
|
||||
use crate::policies::kv_events::{compute_block_hashes, compute_block_hashes_bigram};
|
||||
use crate::policies::registry::{PdPoolResolver, PdResolveError};
|
||||
use crate::policies::{request_tokens_for, ExternalPrefixSignal, RequestTokens, SelectionContext};
|
||||
use crate::policies::{
|
||||
request_tokens_for, ExternalPrefixSignal, PrefillProposal, ProposalKind, RequestTokens,
|
||||
SelectionContext,
|
||||
};
|
||||
use crate::server::app_context::AppContext;
|
||||
use crate::server::error::ApiError;
|
||||
use crate::server::metrics::{
|
||||
MetricsRegistry, RequestOutcome, StaleRequestOutcome, WorkerModeLabel,
|
||||
MetricsRegistry, PolicySelectionFailureReason, RequestOutcome, StaleRequestOutcome,
|
||||
WorkerModeLabel,
|
||||
};
|
||||
use crate::workers::{LoadGuard, Worker};
|
||||
use axum::body::Body;
|
||||
@@ -91,6 +96,24 @@ impl Drop for RecordDurationOnDrop {
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_selection_failed(
|
||||
ctx: &AppContext,
|
||||
model: &str,
|
||||
reason: PolicySelectionFailureReason,
|
||||
) -> ApiError {
|
||||
ctx.metrics
|
||||
.record_policy_selection_failure(ctx.config.model.policy, reason);
|
||||
tracing::warn!(
|
||||
policy = %ctx.config.model.policy,
|
||||
reason = reason.as_str(),
|
||||
model,
|
||||
"prefill policy selection failed"
|
||||
);
|
||||
ApiError::PolicySelectionFailed {
|
||||
model: model.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /v1/chat/completions — parse model from body, select a healthy
|
||||
/// worker via the per-model policy, then proxy the request. If the
|
||||
/// request opts into streaming (`stream: true`), we pipe SSE bytes back;
|
||||
@@ -198,6 +221,15 @@ pub async fn chat_completions(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let prefill_load = request_tokens
|
||||
.as_ref()
|
||||
.map(|tokens| tokens.ids.len().max(1))
|
||||
.unwrap_or_else(|| estimate_prefill_tokens(&body));
|
||||
let request_input_tokens = prefill_load as u64;
|
||||
let needs_load_snapshot = policy.needs_load_snapshot();
|
||||
let load_snapshot =
|
||||
needs_load_snapshot.then(|| ctx.engine_load.capture_snapshot(std::time::Instant::now()));
|
||||
|
||||
// Sticky-session routing key. When the sticky policy is configured,
|
||||
// read the routing key from the operator-chosen header into the
|
||||
// selection context; the policy pins it to a worker. Other policies
|
||||
@@ -210,15 +242,69 @@ pub async fn chat_completions(
|
||||
.and_then(|s| headers.get(s.header_name.as_str()))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| !s.is_empty());
|
||||
let selection_ctx = SelectionContext::with_routing_key(&model_id, Some(&body), routing_key)
|
||||
.with_request_tokens(request_tokens.as_ref().map(|t| t.ids.as_slice()))
|
||||
let session_id = ctx
|
||||
.config
|
||||
.model
|
||||
.affinity
|
||||
.as_ref()
|
||||
.and_then(|config| headers.get(config.session_id_header.as_str()))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| !value.is_empty());
|
||||
let candidate_range = CandidateRange::global(&workers);
|
||||
let mut selection_ctx = SelectionContext::with_routing_key(&model_id, Some(&body), routing_key)
|
||||
.with_session_id(session_id)
|
||||
.with_candidate_range_id(candidate_range.id)
|
||||
.with_input_tokens(request_input_tokens)
|
||||
.with_request_tokens(request_tokens.as_ref().map(|tokens| tokens.ids.as_slice()))
|
||||
.with_external_prefix(external_prefix.as_ref());
|
||||
let worker =
|
||||
policy
|
||||
.select(&workers, &selection_ctx)
|
||||
.ok_or_else(|| ApiError::PolicySelectionFailed {
|
||||
model: model_str.clone(),
|
||||
})?;
|
||||
if let Some(snapshot) = load_snapshot.as_ref() {
|
||||
selection_ctx = selection_ctx.with_load_snapshot(snapshot);
|
||||
}
|
||||
let worker = match policy.propose_prefill(candidate_range.workers, &selection_ctx) {
|
||||
Some(PrefillProposal::Pair(proposal)) if policy.uses_shared_prefill_admission() => {
|
||||
let snapshot = load_snapshot
|
||||
.as_ref()
|
||||
.expect("shared prefill admission requires a load snapshot");
|
||||
let decision =
|
||||
resolve_prefill(&candidate_range, &proposal, request_input_tokens, snapshot)
|
||||
.ok_or_else(|| {
|
||||
policy_selection_failed(
|
||||
&ctx,
|
||||
&model_str,
|
||||
PolicySelectionFailureReason::PrefillAdmissionExhausted,
|
||||
)
|
||||
})?;
|
||||
policy.commit_prefill_selection(&selection_ctx, proposal.kind, &decision.selected);
|
||||
decision.selected
|
||||
}
|
||||
Some(PrefillProposal::Pair(proposal)) => proposal.primary,
|
||||
Some(PrefillProposal::CacheCandidates(proposal)) => {
|
||||
let snapshot = load_snapshot
|
||||
.as_ref()
|
||||
.expect("cache candidate resolution requires a load snapshot");
|
||||
let decision = resolve_cache_candidates(&proposal, request_input_tokens, snapshot)
|
||||
.ok_or_else(|| {
|
||||
policy_selection_failed(
|
||||
&ctx,
|
||||
&model_str,
|
||||
PolicySelectionFailureReason::CacheCandidatesExhausted,
|
||||
)
|
||||
})?;
|
||||
policy.commit_prefill_selection(
|
||||
&selection_ctx,
|
||||
ProposalKind::CacheAffinity,
|
||||
&decision.selected,
|
||||
);
|
||||
decision.selected
|
||||
}
|
||||
None => {
|
||||
return Err(policy_selection_failed(
|
||||
&ctx,
|
||||
&model_str,
|
||||
PolicySelectionFailureReason::ProposalEmpty,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// PD-mode decoder affinity. When the selected prefill worker is
|
||||
// part of a PD-disagg deployment, also resolve the matching decode
|
||||
@@ -291,15 +377,11 @@ pub async fn chat_completions(
|
||||
// 0 here: the active-load registry's decode axis is reserved for a
|
||||
// future decode-side scheduler — current decode selection is
|
||||
// host-affinity only.
|
||||
let guard = worker.load_guard();
|
||||
// Use the exact token count from the ingress tokenization when available;
|
||||
// fall back to the byte-count heuristic for load-only policies that don't
|
||||
// tokenize. The exact count makes the cache-aware load-imbalance fast-path
|
||||
// accurate rather than off by the char/token ratio.
|
||||
let prefill_load = request_tokens
|
||||
.as_ref()
|
||||
.map(|t| t.ids.len().max(1))
|
||||
.unwrap_or_else(|| estimate_prefill_tokens(&body));
|
||||
let guard = if needs_load_snapshot {
|
||||
worker.timestamped_load_guard()
|
||||
} else {
|
||||
worker.load_guard()
|
||||
};
|
||||
let active_guard =
|
||||
ctx.active_load
|
||||
.register(worker.id.clone(), worker.url.clone(), prefill_load, 0);
|
||||
@@ -469,8 +551,9 @@ pub async fn chat_completions(
|
||||
|
||||
// Synchronously await the decode worker. Its response is what
|
||||
// the client sees. The decode side gets its own LoadGuard so
|
||||
// per-worker `active_requests` reflects decode-pool load for
|
||||
// cache-aware-zmq decisions on the decode side.
|
||||
// per-worker `active_requests` reflects decode-pool load. Decode
|
||||
// selection reads that atomic counter directly, so it does not need
|
||||
// the prefill policy's timestamp registry.
|
||||
let decode_guard = decode_worker.load_guard();
|
||||
if streaming {
|
||||
let stream_guards: Box<dyn Send + 'static> =
|
||||
|
||||
@@ -54,6 +54,7 @@ mod tests {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ mod tests {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -235,6 +235,7 @@ mod tests {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -318,6 +318,8 @@ pub(crate) fn resolve_event_config(
|
||||
host,
|
||||
port_base: block.endpoint_port_base,
|
||||
topic: block.topic,
|
||||
load_port_base: block.load_endpoint_port_base,
|
||||
load_topic: block.load_topic,
|
||||
block_size: block.block_size,
|
||||
dp_size: block.dp_size,
|
||||
is_bigram,
|
||||
@@ -374,6 +376,12 @@ pub(crate) struct KvEventsBlock {
|
||||
pub endpoint_port_base: u16,
|
||||
#[serde(default)]
|
||||
pub topic: String,
|
||||
/// Base port of the dedicated load-snapshot socket range. Absent on
|
||||
/// workers that predate load publishing (`None` ⇒ no load subscriber).
|
||||
#[serde(default)]
|
||||
pub load_endpoint_port_base: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub load_topic: Option<String>,
|
||||
pub block_size: u32,
|
||||
pub dp_size: u32,
|
||||
}
|
||||
|
||||
@@ -481,6 +481,7 @@ mod tests {
|
||||
}),
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode};
|
||||
use crate::health::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
|
||||
use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, AtomicU8, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Parse a host from a worker URL. Matches SMG's `worker_builder.rs`
|
||||
/// fallback chain: parse as-is, retry with `http://` prefix if missing,
|
||||
@@ -31,27 +33,75 @@ fn parse_bootstrap_host(url: &str) -> String {
|
||||
"localhost".to_string()
|
||||
}
|
||||
|
||||
/// RAII guard that increments `active_requests` on construction and
|
||||
/// decrements on drop. Obtain via [`Worker::load_guard`].
|
||||
/// Tracks each in-flight slot with an acquisition timestamp so a routing
|
||||
/// policy can ask how many slots were claimed recently
|
||||
/// ([`count_acquired_since`](SlotRegistry::count_acquired_since)). The
|
||||
/// registry is separate from [`Worker::active_requests`]: ordinary load
|
||||
/// tracking stays lock-free, while policies that correct an engine snapshot
|
||||
/// explicitly opt into timestamp tracking.
|
||||
#[derive(Debug)]
|
||||
pub struct SlotRegistry {
|
||||
slots: Mutex<HashMap<u64, Instant>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl SlotRegistry {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
slots: Mutex::new(HashMap::new()),
|
||||
next_id: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Records one timestamped slot and returns its identity.
|
||||
fn claim(&self) -> u64 {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.slots.lock().unwrap().insert(id, Instant::now());
|
||||
id
|
||||
}
|
||||
|
||||
/// Releases timestamped slot `id`.
|
||||
fn release(&self, id: u64) {
|
||||
self.slots.lock().unwrap().remove(&id);
|
||||
}
|
||||
|
||||
/// Count of currently-claimed slots acquired at or after `since`. Used to
|
||||
/// bound how many of this worker's in-flight requests are dispatches the
|
||||
/// engine hasn't reported back on yet (see
|
||||
/// `crate::policies::cache_aware_zmq::WorkerLoads::load_of`), rather than
|
||||
/// adding the full in-flight count — which would also include long-held
|
||||
/// slots from slow-draining streaming responses (see
|
||||
/// `crate::proxy::Proxy::forward_streaming_to`'s `stream_guards` doc)
|
||||
/// that the engine's own last report likely already accounts for.
|
||||
pub fn count_acquired_since(&self, since: Instant) -> usize {
|
||||
self.slots
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|&&t| t >= since)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// RAII guard that increments `active_requests` on construction and decrements
|
||||
/// on drop. Obtain via [`Worker::load_guard`]. Policies that need to correct
|
||||
/// an engine snapshot use the crate-private timestamped variant.
|
||||
///
|
||||
/// `#[must_use]`: a statement-form call like `worker.load_guard();` would
|
||||
/// drop the guard on the same line, so the counter would never see the
|
||||
/// in-flight request. The compile-time warning catches that misuse.
|
||||
#[must_use = "LoadGuard must be held for the request's lifetime; dropping it immediately decrements active_requests"]
|
||||
pub struct LoadGuard {
|
||||
counter: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl LoadGuard {
|
||||
pub(crate) fn new(counter: Arc<AtomicUsize>) -> Self {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
Self { counter }
|
||||
}
|
||||
active_requests: Arc<AtomicUsize>,
|
||||
tracked_slot: Option<(Arc<SlotRegistry>, u64)>,
|
||||
}
|
||||
|
||||
impl Drop for LoadGuard {
|
||||
fn drop(&mut self) {
|
||||
self.counter.fetch_sub(1, Ordering::Relaxed);
|
||||
if let Some((registry, id)) = &self.tracked_slot {
|
||||
registry.release(*id);
|
||||
}
|
||||
self.active_requests.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +138,9 @@ pub struct Worker {
|
||||
pub model_ids: Vec<ModelId>,
|
||||
pub breaker: Arc<CircuitBreaker>,
|
||||
pub active_requests: Arc<AtomicUsize>,
|
||||
/// Timestamped ledger for requests whose policy reads Engine Load;
|
||||
/// answers [`Worker::slots_acquired_since`].
|
||||
slots: Arc<SlotRegistry>,
|
||||
/// Hostname parsed from `url` at construction time and cached.
|
||||
/// Used as the `bootstrap_host` field on PD-disagg requests so the
|
||||
/// prefill engine can match incoming KV-transfer requests from
|
||||
@@ -117,13 +170,16 @@ impl Worker {
|
||||
None => Arc::new(CircuitBreaker::new()),
|
||||
};
|
||||
let bootstrap_host = parse_bootstrap_host(&spec.url);
|
||||
let active_requests = Arc::new(AtomicUsize::new(0));
|
||||
let slots = SlotRegistry::new();
|
||||
Self {
|
||||
id: spec.id,
|
||||
url: spec.url,
|
||||
mode: AtomicU8::new(spec.mode.as_u8()),
|
||||
model_ids: spec.model_ids,
|
||||
breaker,
|
||||
active_requests: Arc::new(AtomicUsize::new(0)),
|
||||
active_requests,
|
||||
slots,
|
||||
bootstrap_host,
|
||||
bootstrap_port: spec.bootstrap_port,
|
||||
}
|
||||
@@ -159,10 +215,30 @@ impl Worker {
|
||||
self.active_requests.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Number of this worker's currently in-flight requests dispatched at or
|
||||
/// after `since`. See [`SlotRegistry::count_acquired_since`].
|
||||
pub fn slots_acquired_since(&self, since: Instant) -> usize {
|
||||
self.slots.count_acquired_since(since)
|
||||
}
|
||||
|
||||
/// Returns a RAII guard that increments `active_requests` now and
|
||||
/// decrements when the guard is dropped.
|
||||
pub fn load_guard(&self) -> LoadGuard {
|
||||
LoadGuard::new(self.active_requests.clone())
|
||||
self.active_requests.fetch_add(1, Ordering::Relaxed);
|
||||
LoadGuard {
|
||||
active_requests: Arc::clone(&self.active_requests),
|
||||
tracked_slot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a load guard that also records when the request was dispatched.
|
||||
pub(crate) fn timestamped_load_guard(&self) -> LoadGuard {
|
||||
let slot_id = self.slots.claim();
|
||||
self.active_requests.fetch_add(1, Ordering::Relaxed);
|
||||
LoadGuard {
|
||||
active_requests: Arc::clone(&self.active_requests),
|
||||
tracked_slot: Some((Arc::clone(&self.slots), slot_id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +257,7 @@ impl std::fmt::Debug for Worker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn load_guard_increments_and_decrements() {
|
||||
@@ -202,6 +279,33 @@ mod tests {
|
||||
assert_eq!(w.active_load(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_load_guard_does_not_track_a_timestamped_slot() {
|
||||
let w = test_worker();
|
||||
let cutoff = Instant::now() - Duration::from_secs(1);
|
||||
let guard = w.load_guard();
|
||||
|
||||
assert_eq!(w.active_load(), 1);
|
||||
assert_eq!(w.slots_acquired_since(cutoff), 0);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(w.active_load(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamped_load_guard_tracks_and_releases_its_slot() {
|
||||
let w = test_worker();
|
||||
let cutoff = Instant::now() - Duration::from_secs(1);
|
||||
let guard = w.timestamped_load_guard();
|
||||
|
||||
assert_eq!(w.active_load(), 1);
|
||||
assert_eq!(w.slots_acquired_since(cutoff), 1);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(w.active_load(), 0);
|
||||
assert_eq!(w.slots_acquired_since(cutoff), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_accessor_round_trips_all_variants() {
|
||||
for m in [WorkerMode::Plain, WorkerMode::Prefill, WorkerMode::Decode] {
|
||||
@@ -295,4 +399,53 @@ mod tests {
|
||||
});
|
||||
assert_eq!(w.bootstrap_host(), "localhost");
|
||||
}
|
||||
|
||||
fn test_worker() -> Worker {
|
||||
Worker::new(WorkerSpec {
|
||||
id: WorkerId("w".into()),
|
||||
url: "http://x".into(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId("m".into())],
|
||||
bootstrap_port: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_acquired_since_excludes_earlier_slots() {
|
||||
let w = test_worker();
|
||||
let _g_old = w.timestamped_load_guard();
|
||||
// A real (small) sleep, not a synthetic `Instant` offset: the slot's
|
||||
// acquisition time is captured internally by `claim()`, not
|
||||
// injectable, so the ordering guarantee has to come from wall-clock
|
||||
// separation wide enough to beat any platform's monotonic-clock
|
||||
// resolution.
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let cutoff = Instant::now();
|
||||
let _g_new1 = w.timestamped_load_guard();
|
||||
let _g_new2 = w.timestamped_load_guard();
|
||||
assert_eq!(w.active_load(), 3);
|
||||
assert_eq!(
|
||||
w.slots_acquired_since(cutoff),
|
||||
2,
|
||||
"only slots claimed at/after cutoff should count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_acquired_since_counts_all_slots_for_a_cutoff_before_every_claim() {
|
||||
let w = test_worker();
|
||||
let long_ago = Instant::now() - Duration::from_secs(3600);
|
||||
let _g1 = w.timestamped_load_guard();
|
||||
let _g2 = w.timestamped_load_guard();
|
||||
assert_eq!(w.slots_acquired_since(long_ago), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_acquired_since_is_zero_for_a_cutoff_after_every_claim() {
|
||||
let w = test_worker();
|
||||
let _g = w.timestamped_load_guard();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
let cutoff = Instant::now();
|
||||
assert_eq!(w.slots_acquired_since(cutoff), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ async fn static_urls_pd_role_resolved_end_to_end() {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ use sgl_router::config::{ActiveLoadConfig, ProxyConfig};
|
||||
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::cache_aware_zmq::CacheAwareZmqPolicy;
|
||||
use sgl_router::policies::engine_load::EngineLoadTable;
|
||||
use sgl_router::policies::kv_events::{compute_block_hashes, discovery::EventConfig, KvEventIndex};
|
||||
use sgl_router::policies::{Policy, SelectionContext};
|
||||
use sgl_router::tokenizer::TokenizerRegistry;
|
||||
@@ -72,6 +73,7 @@ async fn zmq_indexer_routes_to_publishing_worker_e2e() {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
@@ -116,6 +118,7 @@ async fn zmq_indexer_routes_to_publishing_worker_e2e() {
|
||||
kv_index.tree(),
|
||||
Arc::clone(&tokenizers),
|
||||
block_size_oracle,
|
||||
EngineLoadTable::new(),
|
||||
);
|
||||
|
||||
// 5. Register two workers. They share `127.0.0.1` so both
|
||||
@@ -129,6 +132,8 @@ async fn zmq_indexer_routes_to_publishing_worker_e2e() {
|
||||
topic: String::new(),
|
||||
block_size,
|
||||
dp_size: 1,
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
is_bigram: false,
|
||||
};
|
||||
kv_index.add_worker(url_a, Some(preresolved.clone())).await;
|
||||
|
||||
@@ -12,14 +12,17 @@
|
||||
//! and the idlest at 1.0, so every assertion below holds either way.
|
||||
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::{EngineLoadSnapshot, EngineWorkerLoad};
|
||||
use sgl_router::policies::kv_events::{
|
||||
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
|
||||
};
|
||||
use sgl_router::policies::load_based::LoadBasedPolicy;
|
||||
use sgl_router::policies::scoring::{prefix_cache::PrefixCachePolicy, FusedScorePolicy};
|
||||
use sgl_router::policies::scoring::{
|
||||
prefix_cache::PrefixCachePolicy, FusedScorePolicy, ScorePolicy,
|
||||
};
|
||||
use sgl_router::policies::{Policy, SelectionContext};
|
||||
use sgl_router::workers::Worker;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc, time::Instant};
|
||||
|
||||
const BLOCK: usize = 4;
|
||||
|
||||
@@ -73,3 +76,85 @@ fn the_weight_override_steers_a_two_term_fusion_past_either_term_alone() {
|
||||
assert_eq!(got.id, want.id, "--fuse load_based={load_weight}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fused_load_based_term_uses_the_request_snapshot() {
|
||||
let ws = vec![worker("w0"), worker("w1")];
|
||||
// Local counters changed after the request snapshot and prefer w0.
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
29,
|
||||
HashMap::from([
|
||||
(
|
||||
ws[0].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ws[1].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let fused = FusedScorePolicy::new(vec![(Arc::new(LoadBasedPolicy::new()), None)])
|
||||
.expect("load-based is fusable");
|
||||
|
||||
assert_eq!(
|
||||
fused.select(&ws, &ctx).expect("must route").id,
|
||||
ws[1].id,
|
||||
"fused score must pass the request snapshot to the load-based term"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_policy_forwards_the_request_snapshot_to_load_based() {
|
||||
let ws = vec![worker("w0"), worker("w1")];
|
||||
let _after_snapshot: Vec<_> = (0..10).map(|_| ws[1].load_guard()).collect();
|
||||
let snapshot = EngineLoadSnapshot::from_workers(
|
||||
31,
|
||||
HashMap::from([
|
||||
(
|
||||
ws[0].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 50,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ws[1].url.clone(),
|
||||
EngineWorkerLoad {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 0,
|
||||
max_total_num_tokens: 0,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
),
|
||||
]),
|
||||
);
|
||||
let model = ModelId("tiny".into());
|
||||
let ctx = SelectionContext::new(&model, None).with_load_snapshot(&snapshot);
|
||||
let score = ScorePolicy::new(Arc::new(LoadBasedPolicy::new()));
|
||||
|
||||
assert_eq!(
|
||||
score.select(&ws, &ctx).expect("must route").id,
|
||||
ws[1].id,
|
||||
"ScorePolicy must preserve the load-based snapshot contract"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
topic: String::new(),
|
||||
block_size,
|
||||
dp_size: 1,
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
is_bigram: false,
|
||||
};
|
||||
|
||||
@@ -53,12 +55,7 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
router_a.add_worker(worker_url, Some(cfg.clone())).await;
|
||||
router_b.add_worker(worker_url, Some(cfg.clone())).await;
|
||||
|
||||
// SUB-side handshake settle. Publishing before the subscribers
|
||||
// finish their initial connect loses messages in PUB/SUB semantics;
|
||||
// the polling loop below would then never converge.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// 3. Publish a deterministic, multi-block event chain.
|
||||
// 3. Build a deterministic, multi-block event chain.
|
||||
let tokens: Vec<u32> = (0..16).collect();
|
||||
let hashes = compute_block_hashes(&tokens, block_size as usize);
|
||||
assert!(
|
||||
@@ -68,21 +65,21 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
);
|
||||
let event_bytes = encode_block_stored_event(&hashes, None, &tokens, block_size);
|
||||
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
|
||||
publisher
|
||||
.send(build_multipart(1, payload))
|
||||
.await
|
||||
.expect("publish BlockStored");
|
||||
|
||||
// 4. Poll both trees until both report the FULL chain matched. The
|
||||
// SUB→mpsc→pump→tree pipeline is async; loopback delivery is
|
||||
// reliable but not instantaneous.
|
||||
// 4. Republish until both subscribers observe the event. PUB/SUB has
|
||||
// no readiness acknowledgement, so a one-shot send can race a new
|
||||
// subscriber's handshake under a parallel test load.
|
||||
let target = hashes.len();
|
||||
let key = KvWorkerId {
|
||||
url: worker_url.into(),
|
||||
dp_rank: 0,
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let mut sequence = 1i64;
|
||||
loop {
|
||||
publisher
|
||||
.send(build_multipart(sequence, payload.clone()))
|
||||
.await
|
||||
.expect("publish BlockStored");
|
||||
let ma = router_a.tree().match_prefix(None, &hashes);
|
||||
let mb = router_b.tree().match_prefix(None, &hashes);
|
||||
let converged = ma.matched_blocks == target
|
||||
@@ -112,6 +109,7 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
ma.matched_blocks, ma.workers, mb.matched_blocks, mb.workers,
|
||||
);
|
||||
}
|
||||
sequence += 1;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
@@ -174,6 +172,8 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
topic: String::new(),
|
||||
block_size,
|
||||
dp_size: 1,
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
is_bigram: false,
|
||||
};
|
||||
let cfg_y = EventConfig {
|
||||
@@ -182,6 +182,8 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
topic: String::new(),
|
||||
block_size,
|
||||
dp_size: 1,
|
||||
load_port_base: None,
|
||||
load_topic: None,
|
||||
is_bigram: false,
|
||||
};
|
||||
|
||||
@@ -193,10 +195,6 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
router_b.add_worker(worker_x, Some(cfg_x.clone())).await;
|
||||
router_b.add_worker(worker_y, Some(cfg_y.clone())).await;
|
||||
|
||||
// Four SUB→PUB handshakes need to settle before publishing; missed
|
||||
// SUBSCRIBE frames lose messages forever in PUB/SUB semantics.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Two non-overlapping token streams → two distinct hash chains. The
|
||||
// gap between them (0..16 vs 1000..1016) keeps `compute_block_hashes`
|
||||
// outputs disjoint so a cross-attribution bug can't be masked by
|
||||
@@ -221,15 +219,6 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
)],
|
||||
Some(0),
|
||||
);
|
||||
pub_x
|
||||
.send(build_multipart(1, payload_x))
|
||||
.await
|
||||
.expect("publish on pub_x");
|
||||
pub_y
|
||||
.send(build_multipart(1, payload_y))
|
||||
.await
|
||||
.expect("publish on pub_y");
|
||||
|
||||
let key_x = KvWorkerId {
|
||||
url: worker_x.into(),
|
||||
dp_rank: 0,
|
||||
@@ -242,7 +231,16 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
let target_y = hashes_y.len();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let mut sequence = 1i64;
|
||||
loop {
|
||||
pub_x
|
||||
.send(build_multipart(sequence, payload_x.clone()))
|
||||
.await
|
||||
.expect("publish on pub_x");
|
||||
pub_y
|
||||
.send(build_multipart(sequence, payload_y.clone()))
|
||||
.await
|
||||
.expect("publish on pub_y");
|
||||
let ax = router_a.tree().match_prefix(None, &hashes_x);
|
||||
let ay = router_a.tree().match_prefix(None, &hashes_y);
|
||||
let bx = router_b.tree().match_prefix(None, &hashes_x);
|
||||
@@ -298,6 +296,7 @@ async fn two_subscribers_merge_events_from_two_publishers() {
|
||||
by.workers,
|
||||
);
|
||||
}
|
||||
sequence += 1;
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ use sgl_router::discovery::{DiscoveryEvent, ModelId, WorkerId, WorkerMode, Worke
|
||||
use sgl_router::workers::{manager, WorkerRegistry};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::sync::{mpsc, oneshot, Barrier};
|
||||
|
||||
/// Spin up a tiny fake worker that returns `body` on `GET /server_info`.
|
||||
/// Returns the worker base URL and a shutdown channel.
|
||||
@@ -48,6 +48,19 @@ fn spec_for(id: &str, url: &str, mode: WorkerMode) -> WorkerSpec {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_until(condition: impl Fn() -> bool, description: &str) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if condition() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("timed out waiting for {description}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_processes_added_then_removed() {
|
||||
let (url_a, _s_a) = spawn_fake_worker(json!({"served_model_name": "m"})).await;
|
||||
@@ -72,8 +85,11 @@ async fn manager_processes_added_then_removed() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Give the manager time to drain.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
wait_until(
|
||||
|| registry.workers_for(&ModelId("m".into())).len() == 2,
|
||||
"both workers to register",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 2);
|
||||
|
||||
tx.send(DiscoveryEvent::Removed {
|
||||
@@ -81,7 +97,11 @@ async fn manager_processes_added_then_removed() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_until(
|
||||
|| registry.workers_for(&ModelId("m".into())).len() == 1,
|
||||
"removed worker to leave the registry",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(registry.workers_for(&ModelId("m".into())).len(), 1);
|
||||
|
||||
drop(tx);
|
||||
@@ -103,7 +123,16 @@ async fn manager_handles_mode_changed() {
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
wait_until(
|
||||
|| {
|
||||
registry
|
||||
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
|
||||
.len()
|
||||
== 1
|
||||
},
|
||||
"prefill worker to register",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
registry
|
||||
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
|
||||
@@ -117,7 +146,19 @@ async fn manager_handles_mode_changed() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
wait_until(
|
||||
|| {
|
||||
registry
|
||||
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
|
||||
.is_empty()
|
||||
&& registry
|
||||
.workers_for_mode(&ModelId("m".into()), WorkerMode::Decode)
|
||||
.len()
|
||||
== 1
|
||||
},
|
||||
"worker mode to change to decode",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
registry
|
||||
.workers_for_mode(&ModelId("m".into()), WorkerMode::Prefill)
|
||||
@@ -280,7 +321,15 @@ async fn manager_handles_duplicate_added_as_upsert() {
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
wait_until(
|
||||
|| {
|
||||
registry
|
||||
.get(&WorkerId("w1".into()))
|
||||
.is_some_and(|worker| worker.url == url_second)
|
||||
},
|
||||
"replacement Added event to update the worker",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
registry.workers_for(&ModelId("m1".into())).len(),
|
||||
@@ -319,6 +368,32 @@ async fn spawn_slow_worker(body: Value, delay: Duration) -> (String, oneshot::Se
|
||||
(format!("http://127.0.0.1:{port}"), tx)
|
||||
}
|
||||
|
||||
async fn spawn_gated_worker(body: Value, gate: Arc<Barrier>) -> (String, oneshot::Sender<()>) {
|
||||
let body = Arc::new(body);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let app = Router::new().route(
|
||||
"/server_info",
|
||||
get(move || {
|
||||
let body = body.clone();
|
||||
let gate = Arc::clone(&gate);
|
||||
async move {
|
||||
gate.wait().await;
|
||||
Json((*body).clone())
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (tx, rx) = oneshot::channel::<()>();
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = rx.await;
|
||||
})
|
||||
.await;
|
||||
});
|
||||
(format!("http://127.0.0.1:{port}"), tx)
|
||||
}
|
||||
|
||||
/// Spawn a fake worker that counts each `GET /server_info` hit in the
|
||||
/// returned `AtomicUsize`. Used to assert the manager makes exactly
|
||||
/// one round-trip per worker.
|
||||
@@ -350,25 +425,22 @@ async fn spawn_counting_worker(body: Value) -> (String, Arc<AtomicUsize>, onesho
|
||||
(format!("http://127.0.0.1:{port}"), counter, tx)
|
||||
}
|
||||
|
||||
/// Registration must run in parallel across multiple `Added` events.
|
||||
/// Each fake worker delays its `/server_info` by 200ms; with sequential
|
||||
/// processing the manager would take ≥1000ms for 5 workers. We allow
|
||||
/// up to 600ms (3x the per-fetch delay) as a generous bound that still
|
||||
/// rejects the sequential implementation.
|
||||
/// Registration must start every `/server_info` fetch before any response is
|
||||
/// released. A sequential manager stalls at the first worker's barrier.
|
||||
#[tokio::test]
|
||||
async fn added_events_run_in_parallel() {
|
||||
let delay = Duration::from_millis(200);
|
||||
let n = 5;
|
||||
let gate = Arc::new(Barrier::new(n + 1));
|
||||
let mut workers = Vec::new();
|
||||
for _ in 0..n {
|
||||
workers.push(spawn_slow_worker(json!({"served_model_name": "m"}), delay).await);
|
||||
workers
|
||||
.push(spawn_gated_worker(json!({"served_model_name": "m"}), Arc::clone(&gate)).await);
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
let h = tokio::spawn(manager::run(rx, registry.clone()));
|
||||
|
||||
let start = Instant::now();
|
||||
for (i, (url, _s)) in workers.iter().enumerate() {
|
||||
tx.send(DiscoveryEvent::Added(spec_for(
|
||||
&format!("w{i}"),
|
||||
@@ -378,22 +450,22 @@ async fn added_events_run_in_parallel() {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let registered = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_secs(2), gate.wait())
|
||||
.await
|
||||
.is_ok(),
|
||||
"manager did not start all {n} /server_info requests concurrently"
|
||||
);
|
||||
let registered = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if registry.workers_for(&ModelId("m".into())).len() == n {
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let elapsed = start.elapsed();
|
||||
assert!(registered.is_ok(), "manager failed to register {n} workers");
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(600),
|
||||
"registration of {n} workers took {elapsed:?}; sequential per-worker /server_info \
|
||||
fetches would take ≥1000ms — parallel spawn is required"
|
||||
);
|
||||
|
||||
drop(tx);
|
||||
h.await.unwrap();
|
||||
|
||||
@@ -34,6 +34,18 @@ from .model_specs import get_model_spec
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _wait_for_process_group_exit(pgid: int, timeout: float) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
return False
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _get_open_port() -> int:
|
||||
"""Allocate an ephemeral TCP port in the range [20000, 55535].
|
||||
|
||||
@@ -72,6 +84,7 @@ class ModelInstance:
|
||||
model_id: str
|
||||
gpu_ids: list[int] = field(default_factory=list)
|
||||
kv_events_endpoint: str | None = None
|
||||
_shutdown_started: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def __enter__(self) -> "ModelInstance":
|
||||
return self
|
||||
@@ -80,16 +93,31 @@ class ModelInstance:
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.process is not None and self.process.poll() is None:
|
||||
try:
|
||||
self.process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
self.process.wait(timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if self.process is None or self._shutdown_started:
|
||||
return
|
||||
self._shutdown_started = True
|
||||
pgid = self.process.pid
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
|
||||
try:
|
||||
self.process.wait(timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
|
||||
if _wait_for_process_group_exit(pgid, timeout=30):
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
self.process.wait()
|
||||
if not _wait_for_process_group_exit(pgid, timeout=5):
|
||||
raise RuntimeError(f"worker process group {pgid} did not exit")
|
||||
|
||||
|
||||
def spawn_worker(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import signal
|
||||
|
||||
from infra import model_pool
|
||||
|
||||
|
||||
class _Process:
|
||||
pid = 1234
|
||||
|
||||
def __init__(self):
|
||||
self.wait_timeouts = []
|
||||
|
||||
def poll(self):
|
||||
return None
|
||||
|
||||
def send_signal(self, sig):
|
||||
raise AssertionError(f"signaled only the parent process: {sig}")
|
||||
|
||||
def wait(self, timeout=None):
|
||||
self.wait_timeouts.append(timeout)
|
||||
return 0
|
||||
|
||||
|
||||
def test_shutdown_waits_for_the_worker_process_group(monkeypatch):
|
||||
process = _Process()
|
||||
signals = []
|
||||
probes = iter([True, True, False])
|
||||
|
||||
def killpg(pgid, sig):
|
||||
signals.append((pgid, sig))
|
||||
if sig == 0 and not next(probes):
|
||||
raise ProcessLookupError
|
||||
|
||||
monkeypatch.setattr(model_pool.os, "killpg", killpg)
|
||||
monkeypatch.setattr(model_pool.time, "sleep", lambda _: None)
|
||||
|
||||
instance = model_pool.ModelInstance(
|
||||
url="http://127.0.0.1:30000",
|
||||
port=30000,
|
||||
process=process,
|
||||
model_id="qwen3-0.6b",
|
||||
)
|
||||
instance.shutdown()
|
||||
|
||||
assert signals == [
|
||||
(process.pid, signal.SIGTERM),
|
||||
(process.pid, 0),
|
||||
(process.pid, 0),
|
||||
(process.pid, 0),
|
||||
]
|
||||
assert process.wait_timeouts == [60]
|
||||
@@ -11,6 +11,7 @@ Teardown: ./tests/e2e/k8s_integration/setup.sh teardown
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -97,6 +98,60 @@ def _wait_for_pod_ready(
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_replacement_pod_ready(
|
||||
old_pod: str,
|
||||
selector: str,
|
||||
namespace: str = NAMESPACE,
|
||||
timeout: int = 120,
|
||||
interval: float = 0.5,
|
||||
) -> str:
|
||||
deadline = time.time() + timeout
|
||||
last_observed = "no pods"
|
||||
|
||||
while time.time() < deadline:
|
||||
result = _kubectl(
|
||||
"get",
|
||||
"pods",
|
||||
"-n",
|
||||
namespace,
|
||||
"-l",
|
||||
selector,
|
||||
"-o",
|
||||
"json",
|
||||
check=False,
|
||||
)
|
||||
if getattr(result, "returncode", 0) == 0:
|
||||
pods = json.loads(result.stdout or "{}").get("items", [])
|
||||
names = [pod.get("metadata", {}).get("name", "") for pod in pods]
|
||||
last_observed = ", ".join(filter(None, names)) or "no pods"
|
||||
|
||||
if old_pod not in names:
|
||||
for pod in sorted(
|
||||
pods, key=lambda item: item.get("metadata", {}).get("name", "")
|
||||
):
|
||||
metadata = pod.get("metadata", {})
|
||||
status = pod.get("status", {})
|
||||
ready = any(
|
||||
condition.get("type") == "Ready"
|
||||
and condition.get("status") == "True"
|
||||
for condition in status.get("conditions", [])
|
||||
)
|
||||
if (
|
||||
metadata.get("name") != old_pod
|
||||
and not metadata.get("deletionTimestamp")
|
||||
and status.get("phase") == "Running"
|
||||
and ready
|
||||
):
|
||||
return metadata["name"]
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError(
|
||||
f"No ready replacement for pod {old_pod!r} with selector {selector!r} "
|
||||
f"after {timeout}s; last observed: {last_observed}"
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_port(port: int, proc: subprocess.Popen, timeout: int = 15) -> None:
|
||||
"""Poll until a TCP connection to localhost:port succeeds."""
|
||||
deadline = time.time() + timeout
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import conftest as k8s_conftest
|
||||
|
||||
|
||||
def _pod(name: str, phase: str, ready: bool) -> dict:
|
||||
return {
|
||||
"metadata": {"name": name},
|
||||
"status": {
|
||||
"phase": phase,
|
||||
"conditions": [
|
||||
{
|
||||
"type": "Ready",
|
||||
"status": "True" if ready else "False",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_wait_for_replacement_pod_ignores_old_and_pending_pods(monkeypatch):
|
||||
old_pod = "sgl-router-old"
|
||||
new_pod = "sgl-router-new"
|
||||
responses = iter(
|
||||
[
|
||||
[_pod(old_pod, "Running", True)],
|
||||
[
|
||||
_pod(old_pod, "Running", True),
|
||||
_pod(new_pod, "Running", True),
|
||||
],
|
||||
[_pod(new_pod, "Pending", False)],
|
||||
[_pod(new_pod, "Running", True)],
|
||||
]
|
||||
)
|
||||
calls = []
|
||||
|
||||
def fake_kubectl(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return SimpleNamespace(stdout=json.dumps({"items": next(responses)}))
|
||||
|
||||
monkeypatch.setattr(k8s_conftest, "_kubectl", fake_kubectl)
|
||||
monkeypatch.setattr(k8s_conftest.time, "sleep", lambda _: None)
|
||||
|
||||
replacement = k8s_conftest._wait_for_replacement_pod_ready(
|
||||
old_pod,
|
||||
"app=sgl-router",
|
||||
timeout=5,
|
||||
interval=0,
|
||||
)
|
||||
|
||||
assert replacement == new_pod
|
||||
assert len(calls) == 4
|
||||
assert all("-o" in args and "json" in args for args, _ in calls)
|
||||
@@ -13,10 +13,7 @@ by driving the deployment scale.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from conftest import (
|
||||
NAMESPACE,
|
||||
_cleanup_port_forward,
|
||||
@@ -24,7 +21,7 @@ from conftest import (
|
||||
_poll_until,
|
||||
_port_forward_start,
|
||||
_wait_for_deployment_ready,
|
||||
logger,
|
||||
_wait_for_replacement_pod_ready,
|
||||
)
|
||||
|
||||
ROUTER_RESTART_PORT = 8092
|
||||
@@ -132,7 +129,10 @@ class TestRouterRestart:
|
||||
_cleanup_port_forward("router-restart-pre-kill", pf_holder[0])
|
||||
pf_holder[0] = None
|
||||
|
||||
_wait_for_deployment_ready("sgl-router")
|
||||
if old_pod:
|
||||
_wait_for_replacement_pod_ready(old_pod, "app=sgl-router")
|
||||
else:
|
||||
_wait_for_deployment_ready("sgl-router")
|
||||
|
||||
pf_holder[0] = _port_forward_start(
|
||||
NAMESPACE, "sgl-router", ROUTER_RESTART_PORT, 8090
|
||||
|
||||
@@ -25,6 +25,7 @@ use sgl_router::config::{
|
||||
PolicyKind, ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::EngineLoadTable;
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{BlockSizeOracle, HashTree};
|
||||
use sgl_router::proxy::Proxy;
|
||||
@@ -54,6 +55,7 @@ fn config() -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
@@ -88,6 +90,7 @@ fn build_ctx(url: String) -> Arc<AppContext> {
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::clone(&tokenizers),
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ fn config_for(_worker_url: &str) -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn config() -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ use sgl_kv_indexer::{
|
||||
server_builder, GrpcPrefixIndex, InMemoryKvIndexerBackend, KvIndexerService, PrefixIndexConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::EngineLoadTable;
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{compute_block_hashes, BlockSizeOracle, HashTree};
|
||||
use sgl_router::policies::request_tokens_for;
|
||||
@@ -94,6 +95,7 @@ async fn external_indexer_routes_to_the_cached_worker() {
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::clone(&tokenizers),
|
||||
Arc::clone(&oracle),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
@@ -41,6 +41,7 @@ async fn failover_when_one_worker_dies() {
|
||||
}),
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -34,6 +34,7 @@ async fn forwards_whitelisted_headers_strips_others() {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ mod header_forwarding;
|
||||
mod pd_bootstrap_injection;
|
||||
mod pd_pool_isolation;
|
||||
mod roundrobin_input_ids;
|
||||
mod shared_prefill_admission;
|
||||
mod sticky_input_ids;
|
||||
mod sticky_routing;
|
||||
mod timeout;
|
||||
|
||||
@@ -49,6 +49,7 @@ fn config() -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ fn config() -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ fn config() -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::LoadStat;
|
||||
use sgl_router::policies::{
|
||||
CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind,
|
||||
SelectionContext, SelectionProposal,
|
||||
};
|
||||
use sgl_router::proxy::Proxy;
|
||||
use sgl_router::server::app::build_router;
|
||||
use sgl_router::server::app_context::AppContext;
|
||||
use sgl_router::tokenizer::TokenizerRegistry;
|
||||
use sgl_router::workers::{Worker, WorkerRegistry};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::mock_worker::MockWorker;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdmissionProbePolicy {
|
||||
primary: Arc<Worker>,
|
||||
backup: Arc<Worker>,
|
||||
committed: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Policy for AdmissionProbePolicy {
|
||||
fn select(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
panic!("chat routing must resolve the prefill proposal before selection")
|
||||
}
|
||||
|
||||
fn propose(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<SelectionProposal> {
|
||||
Some(
|
||||
SelectionProposal::with_backup(Arc::clone(&self.primary), Arc::clone(&self.backup))
|
||||
.with_kind(ProposalKind::SessionAffinity),
|
||||
)
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn commit_prefill_selection(
|
||||
&self,
|
||||
_: &SelectionContext<'_>,
|
||||
_: ProposalKind,
|
||||
selected: &Arc<Worker>,
|
||||
) {
|
||||
*self.committed.lock().unwrap() = Some(selected.id.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct EmptyPolicy;
|
||||
|
||||
impl Policy for EmptyPolicy {
|
||||
fn select(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InvalidPairPolicy {
|
||||
outsider: Arc<Worker>,
|
||||
}
|
||||
|
||||
impl Policy for InvalidPairPolicy {
|
||||
fn select(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
panic!("chat routing must use the invalid prefill proposal")
|
||||
}
|
||||
|
||||
fn propose(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<SelectionProposal> {
|
||||
Some(SelectionProposal::primary(Arc::clone(&self.outsider)))
|
||||
}
|
||||
|
||||
fn uses_shared_prefill_admission(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CacheCandidatesPolicy {
|
||||
worker: Arc<Worker>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SnapshotProbePolicy {
|
||||
worker: Arc<Worker>,
|
||||
needs_snapshot: bool,
|
||||
observed_snapshot: Arc<Mutex<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Policy for SnapshotProbePolicy {
|
||||
fn select(&self, _: &[Arc<Worker>], ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
*self.observed_snapshot.lock().unwrap() = Some(ctx.load_snapshot().is_some());
|
||||
Some(Arc::clone(&self.worker))
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
self.needs_snapshot
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy for CacheCandidatesPolicy {
|
||||
fn select(&self, _: &[Arc<Worker>], _: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||
panic!("chat routing must use the cache-candidate proposal")
|
||||
}
|
||||
|
||||
fn propose_prefill(
|
||||
&self,
|
||||
_: &[Arc<Worker>],
|
||||
_: &SelectionContext<'_>,
|
||||
) -> Option<PrefillProposal> {
|
||||
Some(PrefillProposal::CacheCandidates(CacheCandidateProposal {
|
||||
candidates: vec![CacheCandidate {
|
||||
worker: Arc::clone(&self.worker),
|
||||
matched_prefix_tokens: 1,
|
||||
uncached_tokens: 1,
|
||||
candidate_range_id: "global".into(),
|
||||
max_pending_prefill_tokens: None,
|
||||
}],
|
||||
cache_switch_margin_tokens: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
fn needs_load_snapshot(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn config(policy: PolicyKind) -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "0".into(),
|
||||
port: 0,
|
||||
},
|
||||
observability: ObservabilityConfig::default(),
|
||||
model: ModelConfig {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
struct TestFixture {
|
||||
ctx: Arc<AppContext>,
|
||||
backends: Vec<MockWorker>,
|
||||
workers: Vec<Arc<Worker>>,
|
||||
}
|
||||
|
||||
async fn fixture(
|
||||
policy_kind: PolicyKind,
|
||||
build_policy: impl FnOnce(&[Arc<Worker>]) -> Arc<dyn Policy>,
|
||||
) -> TestFixture {
|
||||
let backends = vec![
|
||||
MockWorker::start(vec![]).await,
|
||||
MockWorker::start(vec![]).await,
|
||||
];
|
||||
let cfg = config(policy_kind);
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
for (index, backend) in backends.iter().enumerate() {
|
||||
registry
|
||||
.add(WorkerSpec {
|
||||
id: WorkerId(if index == 0 { "primary" } else { "backup" }.into()),
|
||||
url: backend.url.clone(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId("tiny".into())],
|
||||
bootstrap_port: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let registered = registry.workers_for(&ModelId("tiny".into()));
|
||||
let workers = ["primary", "backup"]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
registered
|
||||
.iter()
|
||||
.find(|worker| worker.id.0 == id)
|
||||
.cloned()
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let policies = Arc::new(PolicyRegistry::default());
|
||||
policies.insert(ModelId("tiny".into()), build_policy(&workers));
|
||||
let ctx = Arc::new(AppContext::new(
|
||||
cfg,
|
||||
tokenizers,
|
||||
Arc::new(Proxy::new(Duration::from_secs(5)).unwrap()),
|
||||
registry,
|
||||
policies,
|
||||
));
|
||||
TestFixture {
|
||||
ctx,
|
||||
backends,
|
||||
workers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_chat(ctx: &Arc<AppContext>) -> StatusCode {
|
||||
build_router(Arc::clone(ctx))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"model": "tiny",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_attaches_load_snapshot_only_when_the_policy_needs_it() {
|
||||
for needs_snapshot in [false, true] {
|
||||
let observed_snapshot = Arc::new(Mutex::new(None));
|
||||
let fixture = fixture(PolicyKind::RoundRobin, |workers| {
|
||||
Arc::new(SnapshotProbePolicy {
|
||||
worker: Arc::clone(&workers[0]),
|
||||
needs_snapshot,
|
||||
observed_snapshot: Arc::clone(&observed_snapshot),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
|
||||
assert_eq!(*observed_snapshot.lock().unwrap(), Some(needs_snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_failure_metric(ctx: &AppContext, policy: &str, expected_reason: &str) {
|
||||
let metrics = ctx.metrics.render();
|
||||
assert!(metrics.contains(&format!(
|
||||
"sgl_router_policy_selection_failures_total{{policy=\"{policy}\",reason=\"{expected_reason}\"}} 1"
|
||||
)));
|
||||
for other in [
|
||||
"prefill_admission_exhausted",
|
||||
"cache_candidates_exhausted",
|
||||
"proposal_empty",
|
||||
] {
|
||||
if other != expected_reason {
|
||||
assert!(
|
||||
!metrics.contains(&format!("reason=\"{other}\"")),
|
||||
"{metrics}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_commits_the_admitted_prefill_backup() {
|
||||
let committed = Arc::new(Mutex::new(None));
|
||||
let fixture = fixture(PolicyKind::SessionAware, |workers| {
|
||||
Arc::new(AdmissionProbePolicy {
|
||||
primary: Arc::clone(&workers[0]),
|
||||
backup: Arc::clone(&workers[1]),
|
||||
committed: Arc::clone(&committed),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
fixture.ctx.engine_load.set(
|
||||
&fixture.workers[0].url,
|
||||
0,
|
||||
LoadStat {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
|
||||
assert!(fixture.backends[0]
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last_body
|
||||
.is_none());
|
||||
assert!(fixture.backends[1]
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap()
|
||||
.last_body
|
||||
.is_some());
|
||||
assert_eq!(committed.lock().unwrap().as_deref(), Some("backup"));
|
||||
assert!(!fixture
|
||||
.ctx
|
||||
.metrics
|
||||
.render()
|
||||
.contains("sgl_router_policy_selection_failures_total{"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capacity_exhaustion_does_not_return_503() {
|
||||
let committed = Arc::new(Mutex::new(None));
|
||||
let fixture = fixture(PolicyKind::SessionAware, |workers| {
|
||||
Arc::new(AdmissionProbePolicy {
|
||||
primary: Arc::clone(&workers[0]),
|
||||
backup: Arc::clone(&workers[1]),
|
||||
committed: Arc::clone(&committed),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
for worker in &fixture.workers {
|
||||
fixture.ctx.engine_load.set(
|
||||
&worker.url,
|
||||
0,
|
||||
LoadStat {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.backends
|
||||
.iter()
|
||||
.filter(|backend| backend.captured.lock().unwrap().last_body.is_some())
|
||||
.count(),
|
||||
1,
|
||||
"the request must be dispatched to exactly one legal backend"
|
||||
);
|
||||
assert!(matches!(
|
||||
committed.lock().unwrap().as_deref(),
|
||||
Some("primary" | "backup")
|
||||
));
|
||||
assert!(!fixture
|
||||
.ctx
|
||||
.metrics
|
||||
.render()
|
||||
.contains("sgl_router_policy_selection_failures_total{"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_records_proposal_empty() {
|
||||
let fixture = fixture(PolicyKind::SessionAware, |_| Arc::new(EmptyPolicy)).await;
|
||||
|
||||
assert_eq!(
|
||||
send_chat(&fixture.ctx).await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_failure_metric(&fixture.ctx, "session_aware", "proposal_empty");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_records_prefill_admission_exhausted_for_out_of_range_primary() {
|
||||
let outsider = Arc::new(Worker::new(WorkerSpec {
|
||||
id: WorkerId("outsider".into()),
|
||||
url: "http://outsider:30000".into(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId("tiny".into())],
|
||||
bootstrap_port: None,
|
||||
}));
|
||||
let fixture = fixture(PolicyKind::SessionAware, |_| {
|
||||
Arc::new(InvalidPairPolicy {
|
||||
outsider: Arc::clone(&outsider),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
send_chat(&fixture.ctx).await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_failure_metric(&fixture.ctx, "session_aware", "prefill_admission_exhausted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_records_cache_candidates_exhausted() {
|
||||
let fixture = fixture(PolicyKind::CacheAware, |workers| {
|
||||
Arc::new(CacheCandidatesPolicy {
|
||||
worker: Arc::clone(&workers[0]),
|
||||
})
|
||||
})
|
||||
.await;
|
||||
fixture.ctx.engine_load.set(
|
||||
&fixture.workers[0].url,
|
||||
0,
|
||||
LoadStat {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
send_chat(&fixture.ctx).await,
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_failure_metric(&fixture.ctx, "cache_aware", "cache_candidates_exhausted");
|
||||
}
|
||||
@@ -64,6 +64,7 @@ fn config() -> Config {
|
||||
idle_secs: 3600,
|
||||
eviction_interval_secs: 3600,
|
||||
}),
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -50,6 +50,7 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
|
||||
idle_secs: 3600,
|
||||
eviction_interval_secs: 3600,
|
||||
}),
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ fn config(_worker_url: &str) -> Config {
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user