[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;
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Content-based routing test for both cache-aware-zmq index backends.
|
||||
|
||||
Two SGLang workers publish KV events to two routers at once: one runs the
|
||||
local ``KvEventIndex`` (SUB straight to the workers) and one runs against an
|
||||
external KV Indexer fed by a ``kv-indexer-bridge`` per worker. Every
|
||||
subscriber attaches before the single warmup, so one pair of disjoint
|
||||
prefixes exercises both index backends without a second model load.
|
||||
|
||||
Assert on content, not on convergence: a broken event path degrades
|
||||
``cache_aware_zmq`` to content-blind min-load, which routes both prefixes to
|
||||
one worker and so fails at least one assertion below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from infra.gateway import Gateway
|
||||
from infra.model_pool import spawn_worker
|
||||
from infra.model_specs import get_model_spec
|
||||
|
||||
# Disjoint prefixes — share no common content. Under the chat template both
|
||||
# render with the same leading role header (``<|im_start|>user`` ...; Qwen3 has
|
||||
# no BOS token), so the first block(s) may hash identically; the disjoint
|
||||
# content then diverges
|
||||
# well within the matched region, making each worker's HashTree contribution
|
||||
# uniquely identifying.
|
||||
#
|
||||
# Length matters: each prefix must span ≥2 SGLang blocks at the default
|
||||
# block_size of 64 tokens so the worker actually emits BlockStored
|
||||
# events. Below that, the publisher stays quiet and we'd be testing
|
||||
# min-load by accident — the exact failure mode this test exists to
|
||||
# rule out.
|
||||
_PREFIX_X_BODY = (
|
||||
"Apricot bouquet cinnamon dewdrop elderflower fennel garlic "
|
||||
"hibiscus indigo jasmine kumquat lavender mint nutmeg oregano "
|
||||
"paprika quince rosemary saffron tarragon. "
|
||||
)
|
||||
PREFIX_X = (_PREFIX_X_BODY * 8).strip()
|
||||
|
||||
_PREFIX_Y_BODY = (
|
||||
"Zephyr yellow xylophone wombat vortex umbrella thistle saffron "
|
||||
"quartz peppermint orchid nightshade marigold lemongrass kale "
|
||||
"juniper iris hyacinth gardenia foxglove. "
|
||||
)
|
||||
PREFIX_Y = (_PREFIX_Y_BODY * 8).strip()
|
||||
|
||||
|
||||
_REQ_TOTAL_RE = re.compile(
|
||||
r"^sgl_router_worker_requests_total\{([^}]*)\}\s+(\d+(?:\.\d+)?)\s*$"
|
||||
)
|
||||
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||
|
||||
|
||||
def _open_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _run(binary: Path, env: dict[str, str], log_path: Path):
|
||||
with log_path.open("w") as log:
|
||||
process = subprocess.Popen(
|
||||
[str(binary)],
|
||||
env={**os.environ, **env},
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
yield process
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
def _wait_for_indexer(process: subprocess.Popen, port: int, log_path: Path) -> None:
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"KV Indexer exited during startup:\n{log_path.read_text()}"
|
||||
)
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError("timed out waiting for KV Indexer")
|
||||
|
||||
|
||||
def _wait_for_bridge(process: subprocess.Popen, log_path: Path) -> None:
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
output = log_path.read_text(errors="replace")
|
||||
if "bridge session established" in output:
|
||||
# ZMQ connect is asynchronous; let the subscription reach the PUB.
|
||||
time.sleep(0.5)
|
||||
return
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(f"KV Indexer Bridge exited during startup:\n{output}")
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"timed out waiting for KV Indexer Bridge:\n{output}")
|
||||
|
||||
|
||||
def _dump_logs(logs: dict[str, Path]) -> None:
|
||||
"""Print the tail of each Indexer/Bridge log so a routing failure is debuggable."""
|
||||
for name, path in logs.items():
|
||||
tail = path.read_text(errors="replace")[-4000:] if path.exists() else "<no log>"
|
||||
print(f"\n----- {name} -----\n{tail}")
|
||||
|
||||
|
||||
def _success_counts_by_worker(router_url: str) -> dict[str, int]:
|
||||
"""Scrape ``/metrics`` and return ``{worker_url: success_count}``."""
|
||||
r = httpx.get(f"{router_url}/metrics", timeout=5.0)
|
||||
r.raise_for_status()
|
||||
counts: dict[str, int] = {}
|
||||
for line in r.text.splitlines():
|
||||
m = _REQ_TOTAL_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
labels = dict(_LABEL_RE.findall(m.group(1)))
|
||||
if labels.get("outcome") != "success":
|
||||
continue
|
||||
worker = labels.get("worker_url")
|
||||
if not worker:
|
||||
continue
|
||||
try:
|
||||
counts[worker] = counts.get(worker, 0) + int(float(m.group(2)))
|
||||
except ValueError:
|
||||
continue
|
||||
return counts
|
||||
|
||||
|
||||
def _send_chat(url: str, model_id: str, prompt: str) -> int:
|
||||
"""POST one chat completion; return the HTTP status."""
|
||||
r = httpx.post(
|
||||
f"{url}/v1/chat/completions",
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 4,
|
||||
"stream": False,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
return r.status_code
|
||||
|
||||
|
||||
def _direct_warm(worker_url: str, model_id: str, prefix: str) -> None:
|
||||
"""Send one ``/v1/chat/completions`` request with ``prefix`` DIRECTLY to a worker.
|
||||
|
||||
The KV-event publisher emits ``BlockStored`` as the request's
|
||||
prompt blocks commit to that worker's cache; routers subscribed to
|
||||
the publisher receive the event and add ``(block_hash → worker)``
|
||||
entries to their ``HashTree``. The test then exercises those
|
||||
entries by routing through the router.
|
||||
|
||||
Direct-warming (rather than going through a router) is the load-
|
||||
bearing detail: routing through a router would itself choose which
|
||||
worker to populate, so the two workers' HashTree state would no
|
||||
longer be uniquely identifying.
|
||||
|
||||
Token alignment with the router — the workers run with the model's
|
||||
real chat template (no override), so the engine caches blocks keyed
|
||||
on chat-templated tokens (role markers + content + generation prompt).
|
||||
``cache_aware_zmq`` mirrors this: for a chat request on a model that
|
||||
ships a chat template, it renders the same template and tokenizes the
|
||||
result before hashing, so warm and route hash the same blocks.
|
||||
"""
|
||||
r = httpx.post(
|
||||
f"{worker_url}/v1/chat/completions",
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prefix}],
|
||||
"max_tokens": 4,
|
||||
"stream": False,
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
assert r.status_code == 200, (
|
||||
f"direct warm to {worker_url} failed: HTTP {r.status_code} {r.text!r}"
|
||||
)
|
||||
|
||||
|
||||
def _route_through(router_url: str, model_id: str, prompt: str) -> str:
|
||||
"""Send one request through ``router_url``; return which worker handled it.
|
||||
|
||||
Computed by diffing the per-worker success-counter on ``/metrics``
|
||||
around the call. Asserts exactly one worker absorbed the request
|
||||
(no partial counts, no cancellation race).
|
||||
"""
|
||||
before = _success_counts_by_worker(router_url)
|
||||
code = _send_chat(router_url, model_id, prompt)
|
||||
assert code == 200, f"request to {router_url} failed: HTTP {code}"
|
||||
after = _success_counts_by_worker(router_url)
|
||||
deltas = {w: after.get(w, 0) - before.get(w, 0) for w in set(after) | set(before)}
|
||||
winners = [w for w, d in deltas.items() if d > 0]
|
||||
assert len(winners) == 1, (
|
||||
f"expected exactly one worker delta on {router_url}, got {deltas}"
|
||||
)
|
||||
return winners[0]
|
||||
|
||||
|
||||
@pytest.mark.real_gpu
|
||||
@pytest.mark.slow
|
||||
def test_routers_route_by_prefix_content(
|
||||
router_binary,
|
||||
gpu_allocator,
|
||||
tmp_path,
|
||||
):
|
||||
"""Both the local ZMQ index and the external Indexer must route by content."""
|
||||
spec = get_model_spec("qwen3-0.6b")
|
||||
gpus = gpu_allocator.acquire(2)
|
||||
indexer_port = _open_port()
|
||||
indexer_endpoint = f"http://127.0.0.1:{indexer_port}"
|
||||
indexer_binary = router_binary.parent / "kv-indexer-server"
|
||||
bridge_binary = router_binary.parent / "kv-indexer-bridge"
|
||||
logs = {
|
||||
name: tmp_path / f"{name}.log" for name in ("indexer", "bridge-x", "bridge-y")
|
||||
}
|
||||
try:
|
||||
with (
|
||||
spawn_worker(
|
||||
"qwen3-0.6b",
|
||||
gpu_ids=[gpus[0]],
|
||||
enable_kv_events=True,
|
||||
) as worker_x,
|
||||
spawn_worker(
|
||||
"qwen3-0.6b",
|
||||
gpu_ids=[gpus[1]],
|
||||
enable_kv_events=True,
|
||||
) as worker_y,
|
||||
_run(
|
||||
indexer_binary,
|
||||
{"KV_INDEXER_LISTEN_ADDR": f"127.0.0.1:{indexer_port}"},
|
||||
logs["indexer"],
|
||||
) as indexer,
|
||||
):
|
||||
_wait_for_indexer(indexer, indexer_port, logs["indexer"])
|
||||
worker_urls = [worker_x.url, worker_y.url]
|
||||
|
||||
def bridge_env(worker, worker_id: str) -> dict[str, str]:
|
||||
assert worker.kv_events_endpoint is not None
|
||||
return {
|
||||
"KV_INDEXER_WORKER_ID": worker_id,
|
||||
"KV_INDEXER_WORKER_ADDRESS": worker.url,
|
||||
"KV_INDEXER_ENDPOINT": indexer_endpoint,
|
||||
"SGLANG_KV_EVENT_ENDPOINT": worker.kv_events_endpoint.replace(
|
||||
"*", "127.0.0.1"
|
||||
),
|
||||
"SGLANG_KV_EVENT_TOPIC": "kv",
|
||||
}
|
||||
|
||||
with (
|
||||
_run(
|
||||
bridge_binary, bridge_env(worker_x, "worker-x"), logs["bridge-x"]
|
||||
) as bridge_x,
|
||||
_run(
|
||||
bridge_binary, bridge_env(worker_y, "worker-y"), logs["bridge-y"]
|
||||
) as bridge_y,
|
||||
Gateway() as local,
|
||||
Gateway() as external,
|
||||
):
|
||||
local.start_regular(
|
||||
model_id=spec["model"],
|
||||
tokenizer_path=spec["model"],
|
||||
worker_urls=worker_urls,
|
||||
policy="cache_aware_zmq",
|
||||
timeout=120.0,
|
||||
)
|
||||
external.start_regular(
|
||||
model_id=spec["model"],
|
||||
tokenizer_path=spec["model"],
|
||||
worker_urls=worker_urls,
|
||||
policy="cache_aware_zmq",
|
||||
kv_indexer_endpoint=indexer_endpoint,
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
_wait_for_bridge(bridge_x, logs["bridge-x"])
|
||||
_wait_for_bridge(bridge_y, logs["bridge-y"])
|
||||
|
||||
_direct_warm(worker_x.url, spec["model"], PREFIX_X)
|
||||
_direct_warm(worker_y.url, spec["model"], PREFIX_Y)
|
||||
time.sleep(2.0)
|
||||
|
||||
try:
|
||||
for router, label in (
|
||||
(local, "local-index"),
|
||||
(external, "external-indexer"),
|
||||
):
|
||||
landed = _route_through(
|
||||
router.base_url, spec["model"], PREFIX_X
|
||||
)
|
||||
assert landed == worker_x.url, (
|
||||
f"router {label}: PREFIX_X must route to {worker_x.url}; landed on {landed}"
|
||||
)
|
||||
landed = _route_through(
|
||||
router.base_url, spec["model"], PREFIX_Y
|
||||
)
|
||||
assert landed == worker_y.url, (
|
||||
f"router {label}: PREFIX_Y must route to {worker_y.url}; landed on {landed}"
|
||||
)
|
||||
except Exception:
|
||||
_dump_logs(logs)
|
||||
raise
|
||||
finally:
|
||||
gpu_allocator.release(gpus)
|
||||
@@ -186,7 +186,7 @@ class Gateway:
|
||||
each worker's mode (plain) and any disaggregation
|
||||
metadata are learned from ``/server_info``.
|
||||
policy: Policy kind — ``round_robin``, ``random``, ``power_of_two``,
|
||||
or ``cache_aware_zmq``.
|
||||
or ``cache_aware``.
|
||||
kv_indexer_endpoint: Optional external KV Indexer gRPC endpoint.
|
||||
timeout: How long to wait for ``/readyz`` before giving up.
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"ttft_slo_policy": "disabled",
|
||||
"tps_slo_policy": "disabled",
|
||||
"buckets": [
|
||||
{
|
||||
"id": "short",
|
||||
"stage": "prefill",
|
||||
"rank": 0,
|
||||
"worker_ids": [
|
||||
"http://127.0.0.1:31000",
|
||||
"http://127.0.0.1:31001",
|
||||
"http://127.0.0.1:31002",
|
||||
"http://127.0.0.1:31003"
|
||||
],
|
||||
"min_extend_tokens": 0,
|
||||
"max_extend_tokens": 2048,
|
||||
"max_context_tokens": 32768,
|
||||
"max_pending_prefill_tokens": 65536
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"stage": "prefill",
|
||||
"rank": 1,
|
||||
"worker_ids": [
|
||||
"http://127.0.0.1:31004",
|
||||
"http://127.0.0.1:31005",
|
||||
"http://127.0.0.1:31006",
|
||||
"http://127.0.0.1:31007"
|
||||
],
|
||||
"min_extend_tokens": 2049,
|
||||
"max_context_tokens": 32768,
|
||||
"max_pending_prefill_tokens": 65536
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! HTTP contract for static P/D buckets.
|
||||
//!
|
||||
//! Buckets narrow the candidate domain before policy selection. Prefill SLO
|
||||
//! profiles may override rank, while decode uses `input_tokens + max_tokens`.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use sgl_kv_indexer::{PrefixIndex, PrefixIndexError, PrefixMatch, PrefixOutcome};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, AffinityConfig, BucketConfig, BucketSpec, BucketStage, CacheAwareConfig,
|
||||
CachePrefixProvider, Config, DiscoveryBackend, KvIndexerEndpointConfig, ModelConfig,
|
||||
ObservabilityConfig, PolicyKind, ProxyConfig, ServerConfig, SessionAffinityMode,
|
||||
SloBucketPolicy, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
|
||||
use sgl_router::policies::factory::build_registry_with_defaults;
|
||||
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::WorkerRegistry;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn bucket(id: &str, stage: BucketStage, rank: u32, worker_id: &str) -> BucketSpec {
|
||||
BucketSpec {
|
||||
id: id.into(),
|
||||
stage,
|
||||
rank,
|
||||
worker_ids: vec![worker_id.into()],
|
||||
min_extend_tokens: None,
|
||||
max_extend_tokens: None,
|
||||
min_sequence_tokens: None,
|
||||
max_sequence_tokens: None,
|
||||
max_context_tokens: Some(16_384),
|
||||
ttft_p95_at_capacity_ms: None,
|
||||
tps_p05_at_capacity: None,
|
||||
max_pending_prefill_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_app_context(
|
||||
specs: Vec<WorkerSpec>,
|
||||
bucket_config: BucketConfig,
|
||||
policy: PolicyKind,
|
||||
affinity: Option<AffinityConfig>,
|
||||
) -> AppContext {
|
||||
let 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,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: Some(bucket_config),
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
affinity,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
discovery: DiscoveryBackend::StaticUrls(StaticUrlsDiscoveryConfig {
|
||||
urls: vec!["http://placeholder:0".into()],
|
||||
}),
|
||||
proxy: ProxyConfig::default(),
|
||||
active_load: ActiveLoadConfig::default(),
|
||||
};
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&config).unwrap());
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
for spec in specs {
|
||||
let _ = registry.add(spec);
|
||||
}
|
||||
let policies = Arc::new(build_registry_with_defaults(&config).unwrap());
|
||||
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
|
||||
AppContext::new(config, tokenizers, proxy, registry, policies)
|
||||
}
|
||||
|
||||
fn build_ctx(
|
||||
specs: Vec<WorkerSpec>,
|
||||
bucket_config: BucketConfig,
|
||||
policy: PolicyKind,
|
||||
affinity: Option<AffinityConfig>,
|
||||
) -> Arc<AppContext> {
|
||||
Arc::new(build_app_context(specs, bucket_config, policy, affinity))
|
||||
}
|
||||
|
||||
struct FakePrefixIndex {
|
||||
address: Option<String>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FakePrefixIndex {
|
||||
fn matched(address: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
address: Some(address),
|
||||
calls: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn no_signal() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
address: None,
|
||||
calls: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl PrefixIndex for FakePrefixIndex {
|
||||
async fn match_prefix(&self, hashes: Vec<i64>) -> Result<PrefixOutcome, PrefixIndexError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(address) = &self.address else {
|
||||
return Ok(PrefixOutcome::Empty);
|
||||
};
|
||||
let matched_prefix_blocks =
|
||||
u32::try_from(hashes.len().saturating_sub(1)).unwrap_or(u32::MAX);
|
||||
Ok(PrefixOutcome::Matched {
|
||||
matches: vec![PrefixMatch {
|
||||
address: address.clone(),
|
||||
matched_prefix_blocks,
|
||||
worker_id: "fake-index-worker".into(),
|
||||
}],
|
||||
best_prefix_blocks: matched_prefix_blocks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct TwoPrefixIndex {
|
||||
best_address: String,
|
||||
lower_ranked_address: String,
|
||||
}
|
||||
|
||||
impl TwoPrefixIndex {
|
||||
fn new(best_address: String, lower_ranked_address: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
best_address,
|
||||
lower_ranked_address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl PrefixIndex for TwoPrefixIndex {
|
||||
async fn match_prefix(&self, hashes: Vec<i64>) -> Result<PrefixOutcome, PrefixIndexError> {
|
||||
let best_prefix_blocks = u32::try_from(hashes.len().saturating_sub(1)).unwrap_or(u32::MAX);
|
||||
let lower_ranked_prefix_blocks = (best_prefix_blocks / 2).max(1);
|
||||
Ok(PrefixOutcome::Matched {
|
||||
matches: vec![
|
||||
PrefixMatch {
|
||||
address: self.best_address.clone(),
|
||||
matched_prefix_blocks: best_prefix_blocks,
|
||||
worker_id: "best-index-worker".into(),
|
||||
},
|
||||
PrefixMatch {
|
||||
address: self.lower_ranked_address.clone(),
|
||||
matched_prefix_blocks: lower_ranked_prefix_blocks,
|
||||
worker_id: "lower-index-worker".into(),
|
||||
},
|
||||
],
|
||||
best_prefix_blocks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cache_ctx(
|
||||
specs: Vec<WorkerSpec>,
|
||||
bucket_config: BucketConfig,
|
||||
prefix_index: Arc<dyn PrefixIndex>,
|
||||
) -> Arc<AppContext> {
|
||||
build_cache_ctx_with_affinity(
|
||||
specs,
|
||||
bucket_config,
|
||||
prefix_index,
|
||||
AffinityConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn build_cache_ctx_with_affinity(
|
||||
specs: Vec<WorkerSpec>,
|
||||
bucket_config: BucketConfig,
|
||||
prefix_index: Arc<dyn PrefixIndex>,
|
||||
affinity: AffinityConfig,
|
||||
) -> Arc<AppContext> {
|
||||
let mut context =
|
||||
build_app_context(specs, bucket_config, PolicyKind::CacheAware, Some(affinity));
|
||||
context.config.model.cache_aware = Some(CacheAwareConfig {
|
||||
prefix_provider: CachePrefixProvider::Indexer,
|
||||
kv_indexer_endpoint: Some(KvIndexerEndpointConfig {
|
||||
url: "http://fake-indexer".into(),
|
||||
query_timeout_ms: 100,
|
||||
query_max_inflight: 32,
|
||||
}),
|
||||
});
|
||||
context.prefix_index = Some(prefix_index);
|
||||
context.block_size_oracle.try_set(1).unwrap();
|
||||
Arc::new(context)
|
||||
}
|
||||
|
||||
fn worker_spec(id: &str, url: String, mode: WorkerMode) -> WorkerSpec {
|
||||
WorkerSpec {
|
||||
id: WorkerId(id.into()),
|
||||
url,
|
||||
mode,
|
||||
model_ids: vec![ModelId("tiny".into())],
|
||||
bootstrap_port: (mode == WorkerMode::Prefill).then_some(8997),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_native_load(
|
||||
ctx: &AppContext,
|
||||
worker_url: &str,
|
||||
num_total_tokens: u64,
|
||||
max_total_num_tokens: u64,
|
||||
) {
|
||||
ctx.engine_load.set(
|
||||
worker_url,
|
||||
0,
|
||||
LoadStat {
|
||||
num_running_reqs: 0,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: num_total_tokens,
|
||||
max_total_num_tokens,
|
||||
native_cache: Some(NativeCacheRankLoad {
|
||||
num_waiting_uncached_tokens: 0,
|
||||
num_total_tokens,
|
||||
max_running_requests: 64,
|
||||
total_prefill_uncached_tokens: 1,
|
||||
total_prefill_busy_us: 1,
|
||||
}),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
}
|
||||
|
||||
fn chat_request(ttft_slo_ms: Option<u64>, max_tokens: Option<u64>) -> Request<Body> {
|
||||
chat_request_with_content("bucket routing", ttft_slo_ms, max_tokens, None)
|
||||
}
|
||||
|
||||
fn chat_request_with_content(
|
||||
content: &str,
|
||||
ttft_slo_ms: Option<u64>,
|
||||
max_tokens: Option<u64>,
|
||||
session_id: Option<&str>,
|
||||
) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header("content-type", "application/json");
|
||||
if let Some(ttft_slo_ms) = ttft_slo_ms {
|
||||
builder = builder.header("x-sgl-ttft-slo-ms", ttft_slo_ms.to_string());
|
||||
}
|
||||
if let Some(session_id) = session_id {
|
||||
builder = builder.header("x-session-id", session_id);
|
||||
}
|
||||
builder
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"model": "tiny",
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": max_tokens,
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn wait_for_prefill(mock: &crate::common::mock_worker::MockWorker) {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if mock.captured.lock().unwrap().last_body.is_some() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("selected prefill worker must receive the detached request");
|
||||
}
|
||||
|
||||
async fn wait_for_prefill_body_containing(
|
||||
mock: &crate::common::mock_worker::MockWorker,
|
||||
expected: &str,
|
||||
) -> Vec<u8> {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
let captured = mock.captured.lock().unwrap().last_body.clone();
|
||||
if let Some(body) = captured {
|
||||
if String::from_utf8_lossy(&body).contains(expected) {
|
||||
return body.to_vec();
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("selected prefill worker must receive the expected request body")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefill_slo_first_uses_eligible_ttft_bucket_before_lower_rank_bucket() {
|
||||
let cheap = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let fast = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut cheap_bucket = bucket("p-cheap", BucketStage::Prefill, 10, "p-cheap");
|
||||
cheap_bucket.ttft_p95_at_capacity_ms = Some(400);
|
||||
let mut fast_bucket = bucket("p-fast", BucketStage::Prefill, 20, "p-fast");
|
||||
fast_bucket.ttft_p95_at_capacity_ms = Some(100);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
cheap_bucket,
|
||||
fast_bucket,
|
||||
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::SloFirst,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p-cheap", cheap.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-fast", fast.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::PowerOfTwo,
|
||||
None,
|
||||
);
|
||||
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request(Some(200), Some(16)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_for_prefill(&fast).await;
|
||||
assert!(
|
||||
cheap.captured.lock().unwrap().last_body.is_none(),
|
||||
"lower-rank but TTFT-ineligible P Bucket must not be dispatched first"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefill_tries_later_compatible_bucket_before_capacity_fallback() {
|
||||
let full = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let available = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
bucket("p-full", BucketStage::Prefill, 10, "p-full"),
|
||||
bucket("p-available", BucketStage::Prefill, 20, "p-available"),
|
||||
bucket("d", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p-full", full.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-available", available.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::PowerOfTwo,
|
||||
None,
|
||||
);
|
||||
set_native_load(&ctx, &full.url, 100, 100);
|
||||
set_native_load(&ctx, &available.url, 0, 10_000);
|
||||
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request(None, Some(16)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_for_prefill(&available).await;
|
||||
assert!(
|
||||
full.captured.lock().unwrap().last_body.is_none(),
|
||||
"capacity fallback must wait until all compatible prefill buckets are exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decode_bucket_uses_input_plus_requested_output_budget() {
|
||||
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let short_decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let long_decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut short_bucket = bucket("d-short", BucketStage::Decode, 20, "d-short");
|
||||
short_bucket.max_sequence_tokens = Some(1_024);
|
||||
let mut long_bucket = bucket("d-long", BucketStage::Decode, 30, "d-long");
|
||||
long_bucket.min_sequence_tokens = Some(1_025);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
bucket("p", BucketStage::Prefill, 10, "p"),
|
||||
short_bucket,
|
||||
long_bucket,
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d-short", short_decode.url.clone(), WorkerMode::Decode),
|
||||
worker_spec("d-long", long_decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::PowerOfTwo,
|
||||
None,
|
||||
);
|
||||
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request(None, Some(2_000)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-sgl-decode-url")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(long_decode.url.as_str()),
|
||||
"peak sequence length must exclude the short Decode Bucket"
|
||||
);
|
||||
assert!(
|
||||
long_decode.captured.lock().unwrap().last_body.is_some(),
|
||||
"the selected long Decode worker is awaited before the response"
|
||||
);
|
||||
assert!(
|
||||
short_decode.captured.lock().unwrap().last_body.is_none(),
|
||||
"the incompatible short Decode Bucket must not receive the request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decode_tries_later_compatible_bucket_before_capacity_fallback() {
|
||||
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let full = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let available = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
bucket("p", BucketStage::Prefill, 10, "p"),
|
||||
bucket("d-full", BucketStage::Decode, 20, "d-full"),
|
||||
bucket("d-available", BucketStage::Decode, 30, "d-available"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d-full", full.url.clone(), WorkerMode::Decode),
|
||||
worker_spec("d-available", available.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::PowerOfTwo,
|
||||
None,
|
||||
);
|
||||
set_native_load(&ctx, &full.url, 100, 100);
|
||||
set_native_load(&ctx, &available.url, 0, 10_000);
|
||||
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request(None, Some(16)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-sgl-decode-url")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(available.url.as_str()),
|
||||
"capacity fallback must wait until all compatible decode buckets are exhausted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefill_only_bucket_configuration_keeps_global_decode_routing() {
|
||||
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![bucket("p", BucketStage::Prefill, 10, "p")],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::PowerOfTwo,
|
||||
None,
|
||||
);
|
||||
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request(None, Some(16)))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-sgl-decode-url")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(decode.url.as_str()),
|
||||
"a Prefill-only Bucket rollout must retain the Step 1 global Decode domain"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_rebind_session_affinity_can_keep_a_cross_length_bucket_primary() {
|
||||
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
|
||||
short_bucket.max_extend_tokens = Some(256);
|
||||
short_bucket.max_context_tokens = Some(16_384);
|
||||
short_bucket.ttft_p95_at_capacity_ms = Some(80);
|
||||
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
|
||||
long_bucket.min_extend_tokens = Some(257);
|
||||
long_bucket.max_context_tokens = Some(16_384);
|
||||
long_bucket.ttft_p95_at_capacity_ms = Some(300);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
short_bucket,
|
||||
long_bucket,
|
||||
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::SloFirst,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::SessionAware,
|
||||
Some(AffinityConfig {
|
||||
session_affinity_mode: SessionAffinityMode::GlobalRebind,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let app = build_router(ctx);
|
||||
|
||||
let first = app
|
||||
.clone()
|
||||
.oneshot(chat_request_with_content(
|
||||
"short",
|
||||
Some(120),
|
||||
Some(8),
|
||||
Some("s-1"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
wait_for_prefill(&short).await;
|
||||
|
||||
let long_content = "length ".repeat(128);
|
||||
let second = app
|
||||
.oneshot(chat_request_with_content(
|
||||
&long_content,
|
||||
Some(120),
|
||||
Some(8),
|
||||
Some("s-1"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
let short_body = wait_for_prefill_body_containing(&short, &long_content).await;
|
||||
assert!(
|
||||
String::from_utf8_lossy(&short_body).contains(&long_content),
|
||||
"the second, long request must retain the existing cross-Bucket session primary"
|
||||
);
|
||||
assert!(
|
||||
long.captured.lock().unwrap().last_body.is_none(),
|
||||
"target length Bucket is skipped only because the primary's own Hard TTFT profile is eligible"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_preserve_establishes_then_reuses_a_new_assignment() {
|
||||
let prefill = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
bucket("p", BucketStage::Prefill, 10, "p"),
|
||||
bucket("d", BucketStage::Decode, 20, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let ctx = build_ctx(
|
||||
vec![
|
||||
worker_spec("p", prefill.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
PolicyKind::SessionAware,
|
||||
Some(AffinityConfig {
|
||||
session_affinity_mode: SessionAffinityMode::GlobalPreserve,
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let app = build_router(Arc::clone(&ctx));
|
||||
|
||||
for content in ["first global request", "second global request"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(chat_request_with_content(
|
||||
content,
|
||||
None,
|
||||
Some(8),
|
||||
Some("global-session"),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
let metrics = ctx.metrics.render();
|
||||
assert!(
|
||||
metrics.contains(
|
||||
r#"sgl_router_policy_decisions_total{policy="session_aware",reason="assigned"} 1"#
|
||||
),
|
||||
"the first global-preserve request must establish an assignment: {metrics}"
|
||||
);
|
||||
assert!(
|
||||
metrics.contains(
|
||||
r#"sgl_router_policy_decisions_total{policy="session_aware",reason="session_primary"} 1"#
|
||||
),
|
||||
"the second global-preserve request must reuse the assignment: {metrics}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_winner_uses_target_uncached_work_before_prompt_length_bucket() {
|
||||
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
|
||||
short_bucket.max_extend_tokens = Some(8);
|
||||
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
|
||||
long_bucket.min_extend_tokens = Some(9);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
short_bucket,
|
||||
long_bucket,
|
||||
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let index = FakePrefixIndex::matched(short.url.clone());
|
||||
let prefix_index: Arc<dyn PrefixIndex> = index.clone();
|
||||
let ctx = build_cache_ctx(
|
||||
vec![
|
||||
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
prefix_index,
|
||||
);
|
||||
|
||||
let content = "cached-prefix ".repeat(128);
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request_with_content(&content, None, Some(8), None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_for_prefill(&short).await;
|
||||
assert!(
|
||||
long.captured.lock().unwrap().last_body.is_none(),
|
||||
"a cache winner with small target-specific uncached work must not be replaced by the full-length Bucket"
|
||||
);
|
||||
assert_eq!(
|
||||
index.calls.load(Ordering::Relaxed),
|
||||
1,
|
||||
"the async Indexer query must run once at ingress, not once per Bucket"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_candidate_bucket_binding_happens_before_candidate_limit() {
|
||||
let best = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let lower_ranked = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut best_bucket = bucket("p-best", BucketStage::Prefill, 10, "p-best");
|
||||
best_bucket.min_extend_tokens = Some(32);
|
||||
let mut lower_ranked_bucket = bucket("p-lower", BucketStage::Prefill, 20, "p-lower");
|
||||
lower_ranked_bucket.min_extend_tokens = Some(32);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
best_bucket,
|
||||
lower_ranked_bucket,
|
||||
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let index: Arc<dyn PrefixIndex> =
|
||||
TwoPrefixIndex::new(best.url.clone(), lower_ranked.url.clone());
|
||||
let ctx = build_cache_ctx_with_affinity(
|
||||
vec![
|
||||
worker_spec("p-best", best.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-lower", lower_ranked.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
index,
|
||||
AffinityConfig {
|
||||
cache_candidate_min_workers: 1,
|
||||
cache_candidate_ratio: 0.0,
|
||||
cache_candidate_max_workers: 1,
|
||||
..AffinityConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
let content = "cached bucket candidate ".repeat(256);
|
||||
let response = build_router(Arc::clone(&ctx))
|
||||
.oneshot(chat_request_with_content(&content, None, Some(8), None))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_for_prefill(&lower_ranked).await;
|
||||
assert!(
|
||||
best.captured.lock().unwrap().last_body.is_none(),
|
||||
"the top Indexer hit is Bucket-incompatible and must not consume K=1"
|
||||
);
|
||||
assert!(
|
||||
ctx.metrics.render().contains(
|
||||
r#"sgl_router_policy_decisions_total{policy="cache_aware",reason="cache_candidate"} 1"#
|
||||
),
|
||||
"the compatible lower-ranked cache holder must remain a cache candidate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_no_signal_restarts_normal_prompt_length_bucket_fallback() {
|
||||
let short = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let long = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let mut short_bucket = bucket("p-short", BucketStage::Prefill, 10, "p-short");
|
||||
short_bucket.max_extend_tokens = Some(8);
|
||||
let mut long_bucket = bucket("p-long", BucketStage::Prefill, 20, "p-long");
|
||||
long_bucket.min_extend_tokens = Some(9);
|
||||
let bucket_config = BucketConfig {
|
||||
buckets: vec![
|
||||
short_bucket,
|
||||
long_bucket,
|
||||
bucket("d-catch-all", BucketStage::Decode, 30, "d"),
|
||||
],
|
||||
ttft_slo_policy: SloBucketPolicy::Disabled,
|
||||
tps_slo_policy: SloBucketPolicy::Disabled,
|
||||
};
|
||||
let index = FakePrefixIndex::no_signal();
|
||||
let prefix_index: Arc<dyn PrefixIndex> = index.clone();
|
||||
let ctx = build_cache_ctx(
|
||||
vec![
|
||||
worker_spec("p-short", short.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("p-long", long.url.clone(), WorkerMode::Prefill),
|
||||
worker_spec("d", decode.url.clone(), WorkerMode::Decode),
|
||||
],
|
||||
bucket_config,
|
||||
prefix_index,
|
||||
);
|
||||
|
||||
let content = "uncached-prompt ".repeat(128);
|
||||
let response = build_router(ctx)
|
||||
.oneshot(chat_request_with_content(&content, None, Some(8), None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
wait_for_prefill(&long).await;
|
||||
assert!(
|
||||
short.captured.lock().unwrap().last_body.is_none(),
|
||||
"without a cache winner the request must restart the normal full-input Bucket path"
|
||||
);
|
||||
assert_eq!(index.calls.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
@@ -12,20 +12,11 @@
|
||||
//! doesn't render tool schemas, so its ids would diverge from the engine).
|
||||
//! * A request with multimodal (array) content → `input_ids` omitted (a text
|
||||
//! tokenizer can't represent image content).
|
||||
//!
|
||||
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches
|
||||
//! the built-in V4 chat encoder — the engine-equivalent path — without a
|
||||
//! template fixture.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::{json, Value};
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
|
||||
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;
|
||||
@@ -37,36 +28,9 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::cache_aware_fixture::{config, MODEL};
|
||||
use crate::common::mock_worker::MockWorker;
|
||||
|
||||
const MODEL: &str = "deepseek-v4-tiny";
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
host: "0".into(),
|
||||
port: 0,
|
||||
},
|
||||
observability: ObservabilityConfig::default(),
|
||||
model: ModelConfig {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::CacheAwareZmq,
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ctx(url: String) -> Arc<AppContext> {
|
||||
let cfg = config();
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
@@ -82,18 +46,9 @@ fn build_ctx(url: String) -> Arc<AppContext> {
|
||||
model_ids: vec![ModelId(MODEL.into())],
|
||||
bootstrap_port: None,
|
||||
});
|
||||
// Use the real loaded tokenizers (not the empty-registry test default) so
|
||||
// the cache-aware policy can tokenize at ingress.
|
||||
let policies = Arc::new(
|
||||
build_registry(
|
||||
&cfg,
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::clone(&tokenizers),
|
||||
BlockSizeOracle::new(),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
// Use the configured tokenizer so the chat path can emit input_ids.
|
||||
let policies =
|
||||
Arc::new(build_registry(&cfg, Arc::new(HashTree::new()), BlockSizeOracle::new()).unwrap());
|
||||
let proxy = Arc::new(Proxy::new(Duration::from_secs(5)).unwrap());
|
||||
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ fn config_for(_worker_url: &str) -> Config {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
|
||||
@@ -13,7 +13,7 @@ use sgl_router::config::{
|
||||
|
||||
pub const MODEL: &str = "deepseek-v4-tiny";
|
||||
|
||||
/// A single-model `cache_aware_zmq` router. Discovery is a placeholder because
|
||||
/// A single-model native `cache_aware` router. Discovery is a placeholder because
|
||||
/// every caller installs its own `WorkerRegistry`.
|
||||
pub fn config() -> Config {
|
||||
Config {
|
||||
@@ -25,11 +25,13 @@ pub fn config() -> Config {
|
||||
model: ModelConfig {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::CacheAwareZmq,
|
||||
policy: PolicyKind::CacheAware,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: Some(CacheAwareConfig::default()),
|
||||
sticky: None,
|
||||
affinity: None,
|
||||
sticky: None,
|
||||
fused: None,
|
||||
eligibility: None,
|
||||
},
|
||||
|
||||
@@ -16,8 +16,8 @@ use sgl_kv_indexer::pb::{
|
||||
use sgl_kv_indexer::{
|
||||
server_builder, GrpcPrefixIndex, InMemoryKvIndexerBackend, KvIndexerService, PrefixIndexConfig,
|
||||
};
|
||||
use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind};
|
||||
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;
|
||||
@@ -36,7 +36,20 @@ use crate::common::mock_worker::MockWorker;
|
||||
async fn external_indexer_routes_to_the_cached_worker() {
|
||||
let cached = MockWorker::start(vec![]).await;
|
||||
let uncached = MockWorker::start(vec![]).await;
|
||||
let cfg = config();
|
||||
let mut cfg = config();
|
||||
cfg.model.policy = PolicyKind::CacheAware;
|
||||
cfg.model
|
||||
.cache_aware
|
||||
.as_mut()
|
||||
.expect("fixture includes cache-aware configuration")
|
||||
.prefix_provider = CachePrefixProvider::Indexer;
|
||||
cfg.model.affinity = Some(AffinityConfig {
|
||||
cache_affinity_min_matched_tokens: Some(0),
|
||||
cache_candidate_min_workers: 1,
|
||||
cache_candidate_ratio: 1.0,
|
||||
cache_candidate_max_workers: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let body = json!({
|
||||
"model": MODEL,
|
||||
@@ -68,6 +81,7 @@ async fn external_indexer_routes_to_the_cached_worker() {
|
||||
hashes: hashes.clone(),
|
||||
component_masks: Vec::new(),
|
||||
block_sizes: Vec::new(),
|
||||
parent_block_hash: None,
|
||||
}],
|
||||
worker_address: cached.url.clone(),
|
||||
cache_spec: None,
|
||||
@@ -89,16 +103,8 @@ async fn external_indexer_routes_to_the_cached_worker() {
|
||||
}
|
||||
let oracle = BlockSizeOracle::new();
|
||||
oracle.try_set(1).unwrap();
|
||||
let policies = Arc::new(
|
||||
build_registry(
|
||||
&cfg,
|
||||
Arc::new(HashTree::new()),
|
||||
Arc::clone(&tokenizers),
|
||||
Arc::clone(&oracle),
|
||||
EngineLoadTable::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let policies =
|
||||
Arc::new(build_registry(&cfg, Arc::new(HashTree::new()), Arc::clone(&oracle)).unwrap());
|
||||
let mut ctx = AppContext::new(
|
||||
cfg,
|
||||
tokenizers,
|
||||
|
||||
@@ -35,6 +35,8 @@ async fn failover_when_one_worker_dies() {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: Some(CircuitBreakerConfig {
|
||||
threshold: std::num::NonZeroU32::new(1).unwrap(), // open after first failure
|
||||
cool_down_secs: 30,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! guards, or in the SSE pump's `tx.send().await` race — all of which
|
||||
//! would be silently skipped by a synthetic-handler test.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::future::join_all;
|
||||
use sgl_router::config::{
|
||||
ActiveLoadConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, PolicyKind,
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
@@ -44,6 +44,8 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
@@ -130,12 +132,11 @@ async fn shutdown_drains_100_inflight_streaming_chat_completions() {
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mut handles = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let responses = join_all((0..N).map(|i| {
|
||||
let c = client.clone();
|
||||
let u = url.clone();
|
||||
let b = body.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
async move {
|
||||
let resp = c
|
||||
.post(&u)
|
||||
.header("content-type", "application/json")
|
||||
@@ -146,33 +147,34 @@ async fn shutdown_drains_100_inflight_streaming_chat_completions() {
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("client {i} non-2xx: {}", resp.status()));
|
||||
}
|
||||
let bytes: Bytes = resp
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("client {i} body: {e}"))?;
|
||||
Ok::<Bytes, String>(bytes)
|
||||
}));
|
||||
}
|
||||
Ok::<_, String>((i, resp))
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let responses: Vec<_> = responses
|
||||
.into_iter()
|
||||
.collect::<Result<_, _>>()
|
||||
.expect("every client received response headers before shutdown");
|
||||
|
||||
// 4. Let every request grab a connection and start receiving data.
|
||||
// 100 ms is past the first chunk delay (60 ms) for every stream
|
||||
// but well before the last chunk fires.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// 5. Trigger shutdown. axum stops accepting new connections but
|
||||
// MUST drain the 100 already-attached streams.
|
||||
// 4. Each response header confirms that its request is in flight. Trigger
|
||||
// shutdown only after the full cohort connects, then verify that Axum
|
||||
// drains all 100 existing streams.
|
||||
let started = Instant::now();
|
||||
shutdown_tx.send(()).unwrap();
|
||||
|
||||
// 6. Every in-flight request must complete with a `[DONE]` terminator
|
||||
// 5. Every in-flight request must complete with a `[DONE]` terminator
|
||||
// — proving the stream was NOT truncated by shutdown.
|
||||
let mut bytes_total: usize = 0;
|
||||
let mut done_count: usize = 0;
|
||||
for h in handles {
|
||||
let result = h
|
||||
for result in join_all(responses.into_iter().map(|(i, response)| async move {
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.expect("client task panicked")
|
||||
.expect("client completed");
|
||||
.map_err(|e| format!("client {i} body: {e}"))
|
||||
}))
|
||||
.await
|
||||
{
|
||||
let result = result.expect("client body completed");
|
||||
bytes_total += result.len();
|
||||
let body_str = String::from_utf8_lossy(&result);
|
||||
if body_str.contains("data: [DONE]") {
|
||||
|
||||
@@ -31,6 +31,8 @@ async fn forwards_whitelisted_headers_strips_others() {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
mod common;
|
||||
|
||||
mod bucket_routing;
|
||||
mod cache_aware_input_ids;
|
||||
mod chat_routing;
|
||||
mod external_indexer_routing;
|
||||
@@ -18,6 +19,7 @@ mod graceful_shutdown;
|
||||
mod header_forwarding;
|
||||
mod pd_bootstrap_injection;
|
||||
mod pd_pool_isolation;
|
||||
mod radix_tree_routing;
|
||||
mod roundrobin_input_ids;
|
||||
mod shared_prefill_admission;
|
||||
mod sticky_input_ids;
|
||||
|
||||
@@ -32,7 +32,7 @@ use sgl_router::server::app_context::AppContext;
|
||||
use sgl_router::tokenizer::TokenizerRegistry;
|
||||
use sgl_router::workers::WorkerRegistry;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn config() -> Config {
|
||||
@@ -46,6 +46,8 @@ fn config() -> Config {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
@@ -98,7 +100,7 @@ async fn await_captured_body(
|
||||
timeout: Duration,
|
||||
label: &str,
|
||||
) -> Bytes {
|
||||
let start = std::time::Instant::now();
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
// Release the `std::sync::Mutex` guard before the sleep.await
|
||||
// (clippy: await_holding_lock).
|
||||
@@ -189,6 +191,43 @@ async fn pd_mode_chat_injects_bootstrap_fields_into_both_bodies() {
|
||||
assert_eq!(bootstrap_port(&dj), Some(8997));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn round_robin_pd_prefill_does_not_track_dispatch_timestamps() {
|
||||
let prefill =
|
||||
crate::common::mock_worker::MockWorker::start_hanging(Duration::from_millis(200)).await;
|
||||
let decode = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let ctx = build_ctx(vec![
|
||||
WorkerSpec {
|
||||
id: WorkerId("p1".into()),
|
||||
url: prefill.url.clone(),
|
||||
mode: WorkerMode::Prefill,
|
||||
model_ids: vec![ModelId("tiny".into())],
|
||||
bootstrap_port: Some(8997),
|
||||
},
|
||||
WorkerSpec {
|
||||
id: WorkerId("d1".into()),
|
||||
url: decode.url.clone(),
|
||||
mode: WorkerMode::Decode,
|
||||
model_ids: vec![ModelId("tiny".into())],
|
||||
bootstrap_port: None,
|
||||
},
|
||||
]);
|
||||
let prefill_worker = ctx
|
||||
.registry
|
||||
.workers_for(&ModelId("tiny".into()))
|
||||
.into_iter()
|
||||
.find(|worker| worker.id.0 == "p1")
|
||||
.expect("prefill worker is registered");
|
||||
let cutoff = Instant::now() - Duration::from_secs(1);
|
||||
let request = tokio::spawn(build_router(Arc::clone(&ctx)).oneshot(chat_request()));
|
||||
|
||||
await_captured_body(&prefill, Duration::from_secs(2), "prefill").await;
|
||||
assert_eq!(prefill_worker.active_load(), 1);
|
||||
assert_eq!(prefill_worker.slots_acquired_since(cutoff), 0);
|
||||
|
||||
assert_eq!(request.await.unwrap().unwrap().status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
/// Plain-mode (non-PD) requests do NOT carry any `bootstrap_*` field.
|
||||
/// The injection step is gated on `worker.mode() == Prefill`; plain
|
||||
/// workers serve the chat route directly without disagg bootstrapping.
|
||||
|
||||
@@ -45,6 +45,8 @@ fn config() -> Config {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
@@ -205,31 +207,19 @@ async fn pd_mode_chat_dispatch_fans_to_both_prefill_and_decode() {
|
||||
assert!(!prefill_body.is_empty());
|
||||
}
|
||||
|
||||
/// Task C: PD-mode chat request carries an `x-sgl-decode-url` header
|
||||
/// pointing at the host-affinity decode peer. With two prefill workers
|
||||
/// on different hosts and a decode worker on each, the affinity helper
|
||||
/// MUST pick the decode peer co-located with the chosen prefill.
|
||||
///
|
||||
/// Round-robin will select prefill workers deterministically (alphabetic
|
||||
/// dashmap order is not guaranteed; the test fires several requests so
|
||||
/// at least one lands on each prefill, and asserts the per-host pairing
|
||||
/// holds across all of them).
|
||||
/// PD-mode chat request carries an `x-sgl-decode-url` header for the final
|
||||
/// Decode decision. Step 1 defaults to Decode P2; the header remains an
|
||||
/// observability contract regardless of which Decode policy produced it.
|
||||
#[tokio::test]
|
||||
async fn pd_mode_chat_dispatch_sets_decode_affinity_header() {
|
||||
async fn pd_mode_chat_dispatch_sets_final_decode_header() {
|
||||
use std::collections::HashSet;
|
||||
let prefill_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let prefill_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode_a = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
let decode_b = crate::common::mock_worker::MockWorker::start(vec![]).await;
|
||||
// MockWorker URLs always bind to `127.0.0.1`, so every worker
|
||||
// shares the same host string and the affinity helper's
|
||||
// same-host branch is moot here — the helper still returns a
|
||||
// decode peer via the load-tiebreak fallback. The unit tests in
|
||||
// `policies::registry::tests::decoder_picks_same_host_when_available`
|
||||
// carry the real burden of pinning the host-affinity rules; this
|
||||
// integration test only asserts the wiring is in place (the
|
||||
// `x-sgl-decode-url` header IS set on PD requests, and the
|
||||
// value is one of the registered decode worker URLs).
|
||||
// MockWorker URLs all bind to `127.0.0.1`; this test deliberately does
|
||||
// not assert a host relation. It pins only the HTTP wiring: the final D
|
||||
// selected by the role-local policy is reflected on the P request.
|
||||
let ctx = build_ctx(vec![
|
||||
WorkerSpec {
|
||||
id: WorkerId("p1".into()),
|
||||
@@ -268,9 +258,8 @@ async fn pd_mode_chat_dispatch_sets_decode_affinity_header() {
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// Every request that hit a prefill mock MUST carry the decode-hint
|
||||
// header. The header value MUST be one of the two registered
|
||||
// decode worker URLs.
|
||||
// Every request that hit a prefill mock MUST carry the final-decode
|
||||
// header. The value MUST be one of the two registered Decode URLs.
|
||||
let decode_urls: HashSet<String> = [decode_a.url.clone(), decode_b.url.clone()]
|
||||
.into_iter()
|
||||
.collect();
|
||||
@@ -346,9 +335,9 @@ async fn pd_mode_prefill_only_returns_no_decode_workers_available() {
|
||||
}
|
||||
|
||||
/// PD-mode chat response carries `x-sgl-decode-url` so external tests
|
||||
/// can observe decode affinity end-to-end (without sniffing the proxy
|
||||
/// can observe final Decode selection end-to-end (without sniffing the proxy
|
||||
/// hop into the upstream prefill worker). Mirrors the request-side
|
||||
/// behavior asserted by `pd_mode_chat_dispatch_sets_decode_affinity_header`.
|
||||
/// behavior asserted by `pd_mode_chat_dispatch_sets_final_decode_header`.
|
||||
#[tokio::test]
|
||||
async fn pd_mode_chat_response_carries_decode_affinity_header() {
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use serde_json::json;
|
||||
use sgl_router::config::{AffinityConfig, CachePrefixProvider, PolicyKind};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::factory::build_registry;
|
||||
use sgl_router::policies::kv_events::{
|
||||
compute_block_hashes, BlockSizeOracle, HashTree, KvWorkerId,
|
||||
};
|
||||
use sgl_router::policies::prefix_provider::RadixTreePrefixProvider;
|
||||
use sgl_router::policies::request_tokens_for;
|
||||
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::WorkerRegistry;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::common::cache_aware_fixture::{config, MODEL};
|
||||
use crate::common::mock_worker::MockWorker;
|
||||
|
||||
#[tokio::test]
|
||||
async fn radix_tree_routes_cache_aware_request_to_cached_worker() {
|
||||
let cached = MockWorker::start(vec![]).await;
|
||||
let uncached = MockWorker::start(vec![]).await;
|
||||
let mut cfg = config();
|
||||
cfg.model.policy = PolicyKind::CacheAware;
|
||||
cfg.model.cache_aware.as_mut().unwrap().prefix_provider = CachePrefixProvider::RadixTree;
|
||||
cfg.model.affinity = Some(AffinityConfig {
|
||||
cache_affinity_min_matched_tokens: Some(0),
|
||||
cache_candidate_min_workers: 1,
|
||||
cache_candidate_ratio: 1.0,
|
||||
cache_candidate_max_workers: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
|
||||
let body = json!({
|
||||
"model": MODEL,
|
||||
"messages": [{"role": "user", "content": "local radix cache hit"}],
|
||||
});
|
||||
let tokens = request_tokens_for(&tokenizers, &ModelId(MODEL.into()), &body)
|
||||
.expect("test prompt tokenizes");
|
||||
let hashes = compute_block_hashes(&tokens.ids, 1);
|
||||
assert!(!hashes.is_empty());
|
||||
|
||||
let tree = Arc::new(HashTree::new());
|
||||
tree.insert(&KvWorkerId::new(cached.url.clone(), 0), None, &hashes);
|
||||
let registry = Arc::new(WorkerRegistry::default());
|
||||
for url in [&cached.url, &uncached.url] {
|
||||
registry
|
||||
.add(WorkerSpec {
|
||||
id: WorkerId(url.clone()),
|
||||
url: url.clone(),
|
||||
mode: WorkerMode::Plain,
|
||||
model_ids: vec![ModelId(MODEL.into())],
|
||||
bootstrap_port: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let oracle = BlockSizeOracle::new();
|
||||
oracle.try_set(1).unwrap();
|
||||
let policies = Arc::new(build_registry(&cfg, Arc::clone(&tree), Arc::clone(&oracle)).unwrap());
|
||||
let mut ctx = AppContext::new(
|
||||
cfg,
|
||||
tokenizers,
|
||||
Arc::new(Proxy::new(Duration::from_secs(5)).unwrap()),
|
||||
registry,
|
||||
policies,
|
||||
);
|
||||
ctx.radix_tree_prefix_provider = Some(RadixTreePrefixProvider::new(tree, Arc::clone(&oracle)));
|
||||
ctx.block_size_oracle = oracle;
|
||||
|
||||
let response = build_router(Arc::new(ctx))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/chat/completions")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(cached.captured.lock().unwrap().last_body.is_some());
|
||||
assert!(uncached.captured.lock().unwrap().last_body.is_none());
|
||||
}
|
||||
@@ -42,6 +42,8 @@ fn config() -> Config {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
|
||||
@@ -11,7 +11,7 @@ use sgl_router::config::{
|
||||
ProxyConfig, ServerConfig, StaticUrlsDiscoveryConfig,
|
||||
};
|
||||
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||
use sgl_router::policies::engine_load::LoadStat;
|
||||
use sgl_router::policies::engine_load::{LoadStat, NativeCacheRankLoad};
|
||||
use sgl_router::policies::{
|
||||
CacheCandidate, CacheCandidateProposal, Policy, PolicyRegistry, PrefillProposal, ProposalKind,
|
||||
SelectionContext, SelectionProposal,
|
||||
@@ -128,6 +128,7 @@ impl Policy for CacheCandidatesPolicy {
|
||||
max_pending_prefill_tokens: None,
|
||||
}],
|
||||
cache_switch_margin_tokens: 0,
|
||||
..Default::default()
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -147,6 +148,8 @@ fn config(policy: PolicyKind) -> Config {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
@@ -286,17 +289,30 @@ async fn chat_commits_the_admitted_prefill_backup() {
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let now = Instant::now();
|
||||
let native_load = |total_prefill_uncached_tokens, total_prefill_busy_us| LoadStat {
|
||||
num_running_reqs: 1,
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
native_cache: Some(NativeCacheRankLoad {
|
||||
num_waiting_uncached_tokens: 0,
|
||||
num_total_tokens: 100,
|
||||
max_running_requests: 16,
|
||||
total_prefill_uncached_tokens,
|
||||
total_prefill_busy_us,
|
||||
}),
|
||||
};
|
||||
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(),
|
||||
native_load(1, 1),
|
||||
now - Duration::from_secs(1),
|
||||
);
|
||||
fixture
|
||||
.ctx
|
||||
.engine_load
|
||||
.set(&fixture.workers[0].url, 0, native_load(2, 2), now);
|
||||
|
||||
assert_eq!(send_chat(&fixture.ctx).await, StatusCode::OK);
|
||||
assert!(fixture.backends[0]
|
||||
@@ -339,6 +355,13 @@ async fn capacity_exhaustion_does_not_return_503() {
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
native_cache: Some(NativeCacheRankLoad {
|
||||
num_waiting_uncached_tokens: 0,
|
||||
num_total_tokens: 100,
|
||||
max_running_requests: 16,
|
||||
total_prefill_uncached_tokens: 1,
|
||||
total_prefill_busy_us: 1,
|
||||
}),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
@@ -415,6 +438,13 @@ async fn chat_records_cache_candidates_exhausted() {
|
||||
num_waiting_reqs: 0,
|
||||
num_tokens: 100,
|
||||
max_total_num_tokens: 100,
|
||||
native_cache: Some(NativeCacheRankLoad {
|
||||
num_waiting_uncached_tokens: 0,
|
||||
num_total_tokens: 100,
|
||||
max_running_requests: 16,
|
||||
total_prefill_uncached_tokens: 1,
|
||||
total_prefill_busy_us: 1,
|
||||
}),
|
||||
},
|
||||
Instant::now(),
|
||||
);
|
||||
|
||||
@@ -54,6 +54,8 @@ fn config() -> Config {
|
||||
id: MODEL.into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::Sticky,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
// Push eviction far out so the background sweeper never fires
|
||||
|
||||
@@ -42,6 +42,8 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::Sticky,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: Some(StickyConfig {
|
||||
|
||||
@@ -38,6 +38,8 @@ fn config(_worker_url: &str) -> Config {
|
||||
id: "tiny".into(),
|
||||
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
|
||||
policy: PolicyKind::RoundRobin,
|
||||
decode_policy: Default::default(),
|
||||
bucket_config: None,
|
||||
circuit_breaker: None,
|
||||
cache_aware: None,
|
||||
sticky: None,
|
||||
|
||||
Reference in New Issue
Block a user