[Router] Add bucket-aware policy domains and native cache indexing (#38108)
Signed-off-by: Vincent Gao <vincentbo@linux.alibaba.com> Co-authored-by: inkcherry <mingzhi.liu@amd.com> Co-authored-by: yangbodong22011 <13137470+yangbodong22011@users.noreply.github.com>
This commit is contained in:
co-authored by
inkcherry
yangbodong22011
parent
a176ba2f7b
commit
5bebe7a033
@@ -130,6 +130,8 @@ async fn static_urls_pd_role_resolved_end_to_end() {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: sgl_router::config::PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Static buckets define candidate domains and fallback order. Worker scoring,
|
||||
//! admission, and guards remain the responsibility of the P/D policies.
|
||||
|
||||
use sgl_router::config::{BucketConfig, BucketSpec, BucketStage, SloBucketPolicy};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::buckets::{BucketRequest, BucketSelector};
|
||||
use sgl_router::policies::CacheCandidate;
|
||||
use sgl_router::workers::Worker;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn worker(id: &str, mode: WorkerMode) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
id: WorkerId(id.into()),
|
||||
url: format!("http://{id}:30000"),
|
||||
mode,
|
||||
model_ids: vec![ModelId("m".into())],
|
||||
bootstrap_port: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn bucket(id: &str, stage: BucketStage, rank: u32, worker_ids: &[&str]) -> BucketSpec {
|
||||
BucketSpec {
|
||||
id: id.into(),
|
||||
stage,
|
||||
rank,
|
||||
worker_ids: worker_ids.iter().map(|id| (*id).into()).collect(),
|
||||
min_extend_tokens: None,
|
||||
max_extend_tokens: None,
|
||||
min_sequence_tokens: None,
|
||||
max_sequence_tokens: None,
|
||||
max_context_tokens: None,
|
||||
ttft_p95_at_capacity_ms: None,
|
||||
tps_p05_at_capacity: None,
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_slo_first_tries_eligible_buckets_by_rank_before_degrading() {
|
||||
let fast = worker("fast", WorkerMode::Prefill);
|
||||
let cheap = worker("cheap", WorkerMode::Prefill);
|
||||
let mut cheap_bucket = bucket("cheap", BucketStage::Prefill, 10, &["cheap"]);
|
||||
cheap_bucket.ttft_p95_at_capacity_ms = Some(300);
|
||||
let mut fast_bucket = bucket("fast", BucketStage::Prefill, 20, &["fast"]);
|
||||
fast_bucket.ttft_p95_at_capacity_ms = Some(80);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![cheap_bucket, fast_bucket],
|
||||
ttft_slo_policy: SloBucketPolicy::SloFirst,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
|
||||
let domains = selector.prefill_domains(
|
||||
&[cheap, fast],
|
||||
BucketRequest {
|
||||
input_tokens: 256,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: Some(100),
|
||||
tps_slo: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
domains
|
||||
.iter()
|
||||
.map(|domain| domain.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["fast", "cheap"],
|
||||
"eligible buckets come first; non-eligible buckets are the explicit SLO-degraded fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_best_effort_tries_non_slo_bucket_before_reserved_slo_capacity() {
|
||||
let fast = worker("fast", WorkerMode::Prefill);
|
||||
let cheap = worker("cheap", WorkerMode::Prefill);
|
||||
let mut fast_bucket = bucket("fast", BucketStage::Prefill, 10, &["fast"]);
|
||||
fast_bucket.ttft_p95_at_capacity_ms = Some(80);
|
||||
let mut cheap_bucket = bucket("cheap", BucketStage::Prefill, 20, &["cheap"]);
|
||||
cheap_bucket.ttft_p95_at_capacity_ms = Some(300);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![fast_bucket, cheap_bucket],
|
||||
ttft_slo_policy: SloBucketPolicy::BestEffort,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
|
||||
let domains = selector.prefill_domains(
|
||||
&[fast, cheap],
|
||||
BucketRequest {
|
||||
input_tokens: 256,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: Some(100),
|
||||
tps_slo: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
domains
|
||||
.iter()
|
||||
.map(|domain| domain.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["cheap", "fast"],
|
||||
"best-effort tries non-SLO capacity first and retains the SLO tier as fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_candidate_uses_uncached_work_range_but_full_context_and_own_ttft_profile() {
|
||||
let short = worker("short", WorkerMode::Prefill);
|
||||
let long = worker("long", WorkerMode::Prefill);
|
||||
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, &["short"]);
|
||||
short_bucket.max_extend_tokens = Some(64);
|
||||
short_bucket.max_context_tokens = Some(4_096);
|
||||
short_bucket.ttft_p95_at_capacity_ms = Some(80);
|
||||
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, &["long"]);
|
||||
long_bucket.min_extend_tokens = Some(65);
|
||||
long_bucket.max_context_tokens = Some(4_096);
|
||||
long_bucket.ttft_p95_at_capacity_ms = Some(300);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![short_bucket, long_bucket],
|
||||
ttft_slo_policy: SloBucketPolicy::SloFirst,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
let workers = vec![Arc::clone(&short), Arc::clone(&long)];
|
||||
let request = BucketRequest {
|
||||
input_tokens: 256,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: Some(100),
|
||||
tps_slo: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
selector
|
||||
.prefill_domains(&workers, request)
|
||||
.iter()
|
||||
.map(|domain| domain.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["p-long"],
|
||||
"no-hit target selection uses E=L for extend-work compatibility"
|
||||
);
|
||||
let short_hit = CacheCandidate {
|
||||
worker: Arc::clone(&short),
|
||||
matched_prefix_tokens: 224,
|
||||
uncached_tokens: 32,
|
||||
candidate_range_id: "global".into(),
|
||||
max_pending_prefill_tokens: None,
|
||||
};
|
||||
let bound = selector
|
||||
.bind_prefill_cache_candidate(short_hit, request)
|
||||
.expect("E=32 fits short work range and the full L=256 fits max context");
|
||||
assert_eq!(bound.candidate_range_id, "p-short");
|
||||
|
||||
let long_hit = CacheCandidate {
|
||||
worker: Arc::clone(&long),
|
||||
matched_prefix_tokens: 0,
|
||||
uncached_tokens: 256,
|
||||
candidate_range_id: "global".into(),
|
||||
max_pending_prefill_tokens: None,
|
||||
};
|
||||
assert!(
|
||||
selector
|
||||
.bind_prefill_cache_candidate(long_hit, request)
|
||||
.is_none(),
|
||||
"a cache candidate whose own Hard TTFT profile misses the request SLO is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_candidate_without_bucket_configuration_keeps_global_metadata() {
|
||||
let p = worker("p", WorkerMode::Prefill);
|
||||
let selector = BucketSelector::new(None);
|
||||
let candidate = CacheCandidate {
|
||||
worker: p,
|
||||
matched_prefix_tokens: 64,
|
||||
uncached_tokens: 64,
|
||||
candidate_range_id: "probe".into(),
|
||||
max_pending_prefill_tokens: Some(1),
|
||||
};
|
||||
let bound = selector
|
||||
.bind_prefill_cache_candidate(
|
||||
candidate,
|
||||
BucketRequest {
|
||||
input_tokens: 128,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: None,
|
||||
tps_slo: None,
|
||||
},
|
||||
)
|
||||
.expect("Step 1 always has a catch-all domain");
|
||||
|
||||
assert_eq!(bound.candidate_range_id, "global");
|
||||
assert_eq!(bound.max_pending_prefill_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_bucket_uses_peak_sequence_length_then_tps_profile_and_rank() {
|
||||
let short = worker("short", WorkerMode::Decode);
|
||||
let long = worker("long", WorkerMode::Decode);
|
||||
let mut short_bucket = bucket("short", BucketStage::Decode, 10, &["short"]);
|
||||
short_bucket.max_sequence_tokens = Some(1_024);
|
||||
short_bucket.tps_p05_at_capacity = Some(80.0);
|
||||
let mut long_bucket = bucket("long", BucketStage::Decode, 20, &["long"]);
|
||||
long_bucket.max_sequence_tokens = Some(8_192);
|
||||
long_bucket.tps_p05_at_capacity = Some(40.0);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![short_bucket, long_bucket],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::SloFirst,
|
||||
}));
|
||||
|
||||
let domains = selector.decode_domains(
|
||||
&[short, long],
|
||||
BucketRequest {
|
||||
input_tokens: 256,
|
||||
expected_peak_sequence_tokens: Some(900),
|
||||
ttft_slo_ms: None,
|
||||
tps_slo: Some(60.0),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(domains.len(), 2);
|
||||
assert_eq!(domains[0].id, "short");
|
||||
assert_eq!(domains[1].id, "long");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bucket_configuration_keeps_the_global_domain() {
|
||||
let p = worker("p", WorkerMode::Prefill);
|
||||
let d = worker("d", WorkerMode::Decode);
|
||||
let selector = BucketSelector::new(None);
|
||||
let facts = BucketRequest {
|
||||
input_tokens: 128,
|
||||
expected_peak_sequence_tokens: Some(512),
|
||||
ttft_slo_ms: Some(100),
|
||||
tps_slo: Some(20.0),
|
||||
};
|
||||
|
||||
let prefill = selector.prefill_domains(&[p], facts);
|
||||
let decode = selector.decode_domains(&[d], facts);
|
||||
|
||||
assert_eq!(prefill.len(), 1);
|
||||
assert_eq!(prefill[0].id, "global");
|
||||
assert_eq!(decode.len(), 1);
|
||||
assert_eq!(decode[0].id, "global");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefill_only_bucket_configuration_keeps_the_global_decode_domain() {
|
||||
let p = worker("p", WorkerMode::Prefill);
|
||||
let d = worker("d", WorkerMode::Decode);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![bucket("p", BucketStage::Prefill, 10, &["p"])],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
let facts = BucketRequest {
|
||||
input_tokens: 128,
|
||||
expected_peak_sequence_tokens: Some(512),
|
||||
ttft_slo_ms: None,
|
||||
tps_slo: None,
|
||||
};
|
||||
|
||||
assert_eq!(selector.prefill_domains(&[p], facts)[0].id, "p");
|
||||
let decode = selector.decode_domains(&[d], facts);
|
||||
assert_eq!(decode.len(), 1);
|
||||
assert_eq!(decode[0].id, "global");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_catch_all_still_rejects_input_beyond_runtime_context() {
|
||||
let d = worker("d", WorkerMode::Decode);
|
||||
let mut catch_all = bucket("d-catch-all", BucketStage::Decode, 10, &["d"]);
|
||||
catch_all.max_context_tokens = Some(1_024);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![catch_all],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
|
||||
let domains = selector.decode_domains(
|
||||
&[d],
|
||||
BucketRequest {
|
||||
input_tokens: 2_048,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: None,
|
||||
tps_slo: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
domains.is_empty(),
|
||||
"an unknown output budget does not erase the known input context requirement"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_index_preserves_exact_matching_and_fleet_order() {
|
||||
let workers: Vec<_> = (0..10)
|
||||
.map(|index| worker(&format!("w{index}"), WorkerMode::Prefill))
|
||||
.collect();
|
||||
let scan = bucket("scan", BucketStage::Prefill, 10, &["w3", "W3", "w1", "w1"]);
|
||||
let set = bucket(
|
||||
"set",
|
||||
BucketStage::Prefill,
|
||||
20,
|
||||
&[
|
||||
"w9", "w3", "w1", "w1", "W3", " w2", "absent-0", "absent-1", "absent-2",
|
||||
],
|
||||
);
|
||||
let selector = BucketSelector::new(Some(BucketConfig {
|
||||
buckets: vec![scan, set],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
}));
|
||||
let request = BucketRequest {
|
||||
input_tokens: 128,
|
||||
expected_peak_sequence_tokens: None,
|
||||
ttft_slo_ms: None,
|
||||
tps_slo: None,
|
||||
};
|
||||
|
||||
let domains = selector.prefill_domains(&workers, request);
|
||||
let ids = |index: usize| {
|
||||
domains[index]
|
||||
.workers
|
||||
.iter()
|
||||
.map(|worker| worker.id.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(ids(0), ["w1", "w3"]);
|
||||
assert_eq!(ids(1), ["w1", "w3", "w9"]);
|
||||
|
||||
let candidate = CacheCandidate {
|
||||
worker: Arc::clone(&workers[9]),
|
||||
matched_prefix_tokens: 0,
|
||||
uncached_tokens: 128,
|
||||
candidate_range_id: "global".into(),
|
||||
max_pending_prefill_tokens: None,
|
||||
};
|
||||
assert_eq!(
|
||||
selector
|
||||
.bind_prefill_cache_candidate(candidate, request)
|
||||
.expect("w9 belongs to the hash-indexed bucket")
|
||||
.candidate_range_id,
|
||||
"set"
|
||||
);
|
||||
assert_eq!(
|
||||
selector
|
||||
.prefill_affinity_domain(&workers, &workers[9], request)
|
||||
.expect("w9 has a bucket affinity")
|
||||
.id,
|
||||
"set"
|
||||
);
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! E2E test for the cache-aware-zmq policy.
|
||||
//!
|
||||
//! Drives a real ZMQ PUB socket → `KvEventIndex` subscriber pipeline →
|
||||
//! `HashTree` → `CacheAwareZmqPolicy::select`. Verifies that an event
|
||||
//! published by one worker's PUB causes subsequent selection to route
|
||||
//! to that worker (cache-aware affinity).
|
||||
//!
|
||||
//! API constraint: the subscriber registry builds endpoints as
|
||||
//! `tcp://{host}:{port_base + dp_rank}` where `port_base` is in the
|
||||
//! per-worker `EventConfig`. Both mock workers below share
|
||||
//! `127.0.0.1` as host, so both subscribe to the same PUB socket and
|
||||
//! both end up indexed in the tree. The tiebreak (lowest active_load)
|
||||
//! picks the worker we want; same shape as the SMG version of this
|
||||
//! test.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use zeromq::SocketSend;
|
||||
|
||||
use sgl_router::config::CacheAwareConfig;
|
||||
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;
|
||||
use sgl_router::workers::Worker;
|
||||
|
||||
use super::zmq_helpers::{
|
||||
build_multipart, encode_block_stored_event, encode_event_batch, make_pub_bound,
|
||||
};
|
||||
|
||||
fn build_worker(url: &str, model: &str) -> Arc<Worker> {
|
||||
Arc::new(Worker::new(WorkerSpec {
|
||||
id: WorkerId(url.into()),
|
||||
url: url.into(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId(model.into())],
|
||||
bootstrap_port: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// E2E: real PUB socket publishes a `BlockStored` for worker A's
|
||||
/// hash chain. The `CacheAwareZmqPolicy`'s shared `KvEventIndex`
|
||||
/// receives it, applies it to the tree, and the next `select` call
|
||||
/// picks worker A.
|
||||
///
|
||||
/// Both workers share `127.0.0.1` as host so both subscribers connect
|
||||
/// to the same PUB and both get indexed under their KvWorkerIds — the
|
||||
/// same shape as the SMG e2e test. We tie-break on min-load: worker B
|
||||
/// is bumped above worker A so the matched-worker pick prefers A.
|
||||
#[tokio::test]
|
||||
async fn zmq_indexer_routes_to_publishing_worker_e2e() {
|
||||
let model_id = ModelId("tiny".into());
|
||||
|
||||
// 1. Tokenizer registry — use the in-tree tiny fixture.
|
||||
let cfg = sgl_router::config::Config {
|
||||
server: sgl_router::config::ServerConfig {
|
||||
host: "0".into(),
|
||||
port: 0,
|
||||
},
|
||||
observability: Default::default(),
|
||||
model: sgl_router::config::ModelConfig {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: sgl_router::config::PolicyKind::CacheAwareZmq,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
discovery: sgl_router::config::DiscoveryBackend::StaticUrls(
|
||||
sgl_router::config::StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
},
|
||||
),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
|
||||
// 2. Bind a real PUB socket on an OS-assigned port.
|
||||
let (mut pub_a, port) = make_pub_bound().await;
|
||||
|
||||
// 3. Compute the hash chain for the routing prompt.
|
||||
let text = "hello world hello world hello world";
|
||||
let tok = tokenizers.get("tiny").unwrap();
|
||||
let token_ids = sgl_router::tokenizer::adapter::encode(&tok, text).unwrap();
|
||||
let block_size = 4u32;
|
||||
let hashes = compute_block_hashes(&token_ids, block_size as usize);
|
||||
assert!(!hashes.is_empty(), "tiny tokenizer must yield ≥1 block");
|
||||
|
||||
// 4. Build the KvEventIndex + policy. The policy holds an
|
||||
// Arc<HashTree> that the index also owns; events the index
|
||||
// receives mutate the same tree the policy reads.
|
||||
let kv_index = KvEventIndex::new();
|
||||
// Mirror what `KvEventIndex::add_worker` would do in production: seed
|
||||
// the oracle with the worker-reported page_size before any cache
|
||||
// lookup happens. The integration path calls `add_worker` further
|
||||
// down, but here we want the policy to know `block_size` immediately.
|
||||
let block_size_oracle = kv_index.block_size_oracle();
|
||||
block_size_oracle.try_set(block_size).unwrap();
|
||||
let policy = CacheAwareZmqPolicy::new(
|
||||
CacheAwareConfig {
|
||||
cache_threshold: 0.0,
|
||||
balance_abs_threshold: 32,
|
||||
balance_rel_threshold: 1.1,
|
||||
kv_indexer_endpoint: None,
|
||||
},
|
||||
kv_index.tree(),
|
||||
Arc::clone(&tokenizers),
|
||||
block_size_oracle,
|
||||
EngineLoadTable::new(),
|
||||
);
|
||||
|
||||
// 5. Register two workers. They share `127.0.0.1` so both
|
||||
// subscribers connect to the same PUB; preresolved EventConfig
|
||||
// points at the bound port.
|
||||
let url_a = "http://127.0.0.1:30000";
|
||||
let url_b = "http://127.0.0.1:30001";
|
||||
let preresolved = EventConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port_base: port,
|
||||
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;
|
||||
kv_index.add_worker(url_b, Some(preresolved)).await;
|
||||
|
||||
// SUB sockets take a moment to handshake. The polling loop below
|
||||
// soaks up any extra latency; this is just a publish-before-SUB
|
||||
// guard.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// 6. Publish a BlockStored event for the routing prompt's chain.
|
||||
let event_bytes = encode_block_stored_event(&hashes, None, &token_ids, block_size);
|
||||
let payload = encode_event_batch(0.0, vec![event_bytes], Some(0));
|
||||
pub_a
|
||||
.send(build_multipart(1, payload))
|
||||
.await
|
||||
.expect("send block-stored event");
|
||||
|
||||
// 7. Bump worker B's load so the tie-break picks A among matched
|
||||
// workers. The bump stays below balance_abs_threshold so the
|
||||
// imbalance fast-path does not skip cache-aware selection.
|
||||
// Bind the guards to a Vec held for the rest of the test scope
|
||||
// so the counter stays > 0 through the polling loop.
|
||||
let w_a = build_worker(url_a, "tiny");
|
||||
let w_b = build_worker(url_b, "tiny");
|
||||
let _b_load: Vec<_> = (0..3).map(|_| w_b.load_guard()).collect();
|
||||
let workers = vec![Arc::clone(&w_a), Arc::clone(&w_b)];
|
||||
|
||||
// 8. Drive select until the event has been applied. The pipeline is
|
||||
// asynchronous (publish → SUB recv → mpsc → pump → tree); a
|
||||
// polling loop is less flaky than a fixed sleep.
|
||||
let body = serde_json::to_vec(&serde_json::json!({"prompt": text})).unwrap();
|
||||
let ctx = SelectionContext::new(&model_id, Some(&body));
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let mut chose_a = false;
|
||||
while start.elapsed() < Duration::from_secs(3) {
|
||||
if let Some(w) = policy.select(&workers, &ctx) {
|
||||
if w.url == url_a {
|
||||
chose_a = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(
|
||||
chose_a,
|
||||
"policy did not route to publishing worker A within timeout",
|
||||
);
|
||||
|
||||
// 9. Shutdown cleanly.
|
||||
let r = tokio::time::timeout(Duration::from_secs(2), kv_index.shutdown()).await;
|
||||
assert!(r.is_ok(), "kv_index shutdown should not hang");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use sgl_kv_indexer::PrefixOutcome;
|
||||
use sgl_router::policies::kv_events::{
|
||||
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
|
||||
};
|
||||
use sgl_router::policies::prefix_provider::RadixTreePrefixProvider;
|
||||
|
||||
#[test]
|
||||
fn radix_tree_reports_contiguous_prefix_depth_per_worker() {
|
||||
let tokens = [11_u32, 12, 13, 14];
|
||||
let hashes = compute_block_hashes(&tokens, 1);
|
||||
let tree = Arc::new(HashTree::new());
|
||||
let oracle = BlockSizeOracle::new();
|
||||
oracle.try_set(1).unwrap();
|
||||
|
||||
tree.insert(&KvWorkerId::new("http://deep".into(), 0), None, &hashes);
|
||||
tree.insert(
|
||||
&KvWorkerId::new("http://deep".into(), 1),
|
||||
None,
|
||||
&hashes[..3],
|
||||
);
|
||||
tree.insert(
|
||||
&KvWorkerId::new("http://shallow".into(), 0),
|
||||
None,
|
||||
&hashes[..2],
|
||||
);
|
||||
|
||||
let signal = RadixTreePrefixProvider::new(tree, oracle)
|
||||
.match_request_tokens(&tokens)
|
||||
.expect("established local tree must produce a prefix signal");
|
||||
let PrefixOutcome::Matched {
|
||||
matches,
|
||||
best_prefix_blocks,
|
||||
} = signal.outcome
|
||||
else {
|
||||
panic!("local radix-tree hit must be normalized as a match");
|
||||
};
|
||||
let depth_by_url: HashMap<_, _> = matches
|
||||
.into_iter()
|
||||
.map(|entry| (entry.address, entry.matched_prefix_blocks))
|
||||
.collect();
|
||||
|
||||
assert_eq!(signal.query_blocks, 4);
|
||||
assert_eq!(best_prefix_blocks, 4);
|
||||
assert_eq!(depth_by_url.get("http://deep"), Some(&4));
|
||||
assert_eq!(depth_by_url.get("http://shallow"), Some(&2));
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Observable contract for decode policies.
|
||||
//!
|
||||
//! Decode guards require complete, fresh native monitor samples. Short frames
|
||||
//! fall back to local load and must not appear as monitor-backed decisions.
|
||||
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::admission::{resolve_decode, CandidateDomain, DecisionReason};
|
||||
use sgl_router::policies::decode::{
|
||||
resolve_decode_with_capacity_fallback, DecodePolicy, DecodePowerOfTwoPolicy,
|
||||
DecodeSelectionContext, LegacyHostAffinityDecodePolicy,
|
||||
};
|
||||
use sgl_router::policies::engine_load::{EngineLoadSnapshot, NativeCacheWorkerLoad};
|
||||
use sgl_router::policies::SelectionProposal;
|
||||
use sgl_router::workers::Worker;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
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::Decode,
|
||||
model_ids: vec![ModelId("m".into())],
|
||||
bootstrap_port: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot(entries: &[(&Arc<Worker>, u64, u64, u64, u64)]) -> EngineLoadSnapshot {
|
||||
EngineLoadSnapshot::from_native_cache_workers(
|
||||
7,
|
||||
entries
|
||||
.iter()
|
||||
.map(|(worker, running, waiting, used, capacity)| {
|
||||
(
|
||||
worker.url.clone(),
|
||||
NativeCacheWorkerLoad {
|
||||
num_running_reqs: *running,
|
||||
num_waiting_reqs: *waiting,
|
||||
num_waiting_uncached_tokens: *waiting,
|
||||
num_used_tokens: *used,
|
||||
num_total_tokens: *used,
|
||||
max_total_num_tokens: *capacity,
|
||||
max_running_requests: 64,
|
||||
prefill_throughput_tokens_per_s: None,
|
||||
estimated_prefill_queue_ms: None,
|
||||
captured_at: Instant::now(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_p2_proposes_a_distinct_lower_pressure_primary_and_backup() {
|
||||
let busy = worker("busy");
|
||||
let idle = worker("idle");
|
||||
busy.active_requests.store(8, Ordering::Relaxed);
|
||||
idle.active_requests.store(1, Ordering::Relaxed);
|
||||
let domain = CandidateDomain::global_decode(&[Arc::clone(&busy), Arc::clone(&idle)]);
|
||||
let ctx = DecodeSelectionContext::new();
|
||||
|
||||
let proposal = DecodePowerOfTwoPolicy::new()
|
||||
.propose(&domain, &ctx)
|
||||
.expect("two decode candidates must produce a proposal");
|
||||
|
||||
assert_eq!(proposal.primary.id, idle.id);
|
||||
assert_eq!(
|
||||
proposal.backup.expect("P2 keeps the other sample").id,
|
||||
busy.id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_host_affinity_remains_an_explicit_single_primary_compatibility_policy() {
|
||||
let same_host = worker("host-a");
|
||||
let other_host = worker("host-b");
|
||||
let domain = CandidateDomain::global_decode(&[Arc::clone(&same_host), other_host]);
|
||||
let ctx = DecodeSelectionContext::new().with_prefill_url("http://host-a:9999");
|
||||
|
||||
let proposal = LegacyHostAffinityDecodePolicy
|
||||
.propose(&domain, &ctx)
|
||||
.expect("legacy policy selects one compatible decode worker");
|
||||
|
||||
assert_eq!(proposal.primary.id, same_host.id);
|
||||
assert!(
|
||||
proposal.backup.is_none(),
|
||||
"legacy semantics do not invent a backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_admission_uses_backup_before_scanning_domain() {
|
||||
let primary = worker("primary");
|
||||
let backup = worker("backup");
|
||||
let fallback = worker("fallback");
|
||||
let domain = CandidateDomain::global_decode(&[
|
||||
Arc::clone(&primary),
|
||||
Arc::clone(&backup),
|
||||
Arc::clone(&fallback),
|
||||
]);
|
||||
let loads = snapshot(&[
|
||||
(&primary, 4, 0, 950, 1_000),
|
||||
(&backup, 0, 0, 0, 1_000),
|
||||
(&fallback, 0, 0, 0, 1_000),
|
||||
]);
|
||||
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
|
||||
|
||||
let decision =
|
||||
resolve_decode(&domain, &proposal, 64, &loads).expect("admitted backup must be selected");
|
||||
|
||||
assert_eq!(decision.selected.id, backup.id);
|
||||
assert_eq!(decision.reason, DecisionReason::BackupPrimaryAdmission);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_guard_can_escape_a_primary_to_lower_dynamic_pressure_backup() {
|
||||
let primary = worker("primary");
|
||||
let backup = worker("backup");
|
||||
let domain = CandidateDomain::global_decode(&[Arc::clone(&primary), Arc::clone(&backup)]);
|
||||
let loads = snapshot(&[(&primary, 3, 2, 900, 2_000), (&backup, 1, 0, 100, 2_000)]);
|
||||
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
|
||||
|
||||
let decision =
|
||||
resolve_decode(&domain, &proposal, 64, &loads).expect("both candidates are admitted");
|
||||
|
||||
assert_eq!(decision.selected.id, backup.id);
|
||||
assert_eq!(decision.reason, DecisionReason::BackupPressureGuard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_all_capacity_rejected_falls_back_to_power_of_two_within_domain() {
|
||||
let primary = worker("primary");
|
||||
let backup = worker("backup");
|
||||
let workers = vec![Arc::clone(&primary), Arc::clone(&backup)];
|
||||
let domain = CandidateDomain::global_decode(&workers);
|
||||
let loads = snapshot(&[
|
||||
(&primary, 0, 0, 1_000, 1_000),
|
||||
(&backup, 0, 10, 1_000, 1_000),
|
||||
]);
|
||||
let proposal = SelectionProposal::with_backup(Arc::clone(&primary), Arc::clone(&backup));
|
||||
|
||||
let decision = resolve_decode_with_capacity_fallback(&domain, &proposal, 64, &loads)
|
||||
.expect("capacity exhaustion must degrade within the decode domain");
|
||||
|
||||
assert_eq!(decision.selected.id, primary.id);
|
||||
assert_eq!(decision.reason, DecisionReason::CapacityFallbackPowerOfTwo);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ async fn two_independent_subscribers_converge_to_same_tree_state() {
|
||||
&& mb.workers.contains(&key);
|
||||
if converged {
|
||||
// Both trees agree on count AND on the worker that holds the
|
||||
// prefix. This is what the cache-aware-zmq policy reads to
|
||||
// prefix. This is what the Radix Tree provider reads to
|
||||
// pick a worker; both routers picking the same key here
|
||||
// means they would route the same prompt to the same worker.
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
mod zmq_helpers;
|
||||
|
||||
mod cache_aware_zmq;
|
||||
mod bucket_domains;
|
||||
mod cache_prefix_provider;
|
||||
mod decode;
|
||||
mod fused_score;
|
||||
mod kv_events_hash_parity;
|
||||
mod kv_events_tree_concurrent;
|
||||
|
||||
Reference in New Issue
Block a user