feat(agentic router, 1/N): Add LoadBasedPolicy (#26480)
This commit is contained in:
@@ -573,6 +573,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--policy load_based` parses to the load-based selector.
|
||||||
|
#[test]
|
||||||
|
fn parses_load_based_policy() {
|
||||||
|
let c = into_config_owned(with_model(&[
|
||||||
|
"--worker-urls",
|
||||||
|
"http://10.0.0.1:30000",
|
||||||
|
"--policy",
|
||||||
|
"load_based",
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(c.model.policy, PolicyKind::LoadBased);
|
||||||
|
}
|
||||||
|
|
||||||
/// clap rejects `--cb-threshold 0` because the field is `NonZeroU32`.
|
/// clap rejects `--cb-threshold 0` because the field is `NonZeroU32`.
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_zero_cb_threshold() {
|
fn rejects_zero_cb_threshold() {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ impl Default for ActiveLoadConfig {
|
|||||||
/// policy factory.
|
/// policy factory.
|
||||||
///
|
///
|
||||||
/// Accepted on the CLI (`--policy`) as `round_robin` / `random` /
|
/// Accepted on the CLI (`--policy`) as `round_robin` / `random` /
|
||||||
/// `power_of_two` / `cache_aware_zmq`.
|
/// `power_of_two` / `load_based` / `cache_aware_zmq`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
|
||||||
pub enum PolicyKind {
|
pub enum PolicyKind {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -80,6 +80,9 @@ pub enum PolicyKind {
|
|||||||
Random,
|
Random,
|
||||||
#[value(name = "power_of_two")]
|
#[value(name = "power_of_two")]
|
||||||
PowerOfTwo,
|
PowerOfTwo,
|
||||||
|
/// Selects the currently least-loaded worker.
|
||||||
|
#[value(name = "load_based")]
|
||||||
|
LoadBased,
|
||||||
/// Cache-aware routing fed by SGLang's ZMQ KV-cache event publisher.
|
/// Cache-aware routing fed by SGLang's ZMQ KV-cache event publisher.
|
||||||
/// Requires the model to have a tokenizer loaded; cache_aware tuning
|
/// Requires the model to have a tokenizer loaded; cache_aware tuning
|
||||||
/// lives on `ModelConfig::cache_aware`.
|
/// lives on `ModelConfig::cache_aware`.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::discovery::ModelId;
|
|||||||
use crate::policies::{
|
use crate::policies::{
|
||||||
cache_aware_zmq::CacheAwareZmqPolicy,
|
cache_aware_zmq::CacheAwareZmqPolicy,
|
||||||
kv_events::{BlockSizeOracle, HashTree},
|
kv_events::{BlockSizeOracle, HashTree},
|
||||||
|
load_based::LoadBasedPolicy,
|
||||||
power_of_two::PowerOfTwoChoicesPolicy,
|
power_of_two::PowerOfTwoChoicesPolicy,
|
||||||
random::RandomPolicy,
|
random::RandomPolicy,
|
||||||
round_robin::RoundRobinPolicy,
|
round_robin::RoundRobinPolicy,
|
||||||
@@ -32,6 +33,7 @@ pub fn build_policy(
|
|||||||
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
|
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
|
||||||
PolicyKind::Random => Arc::new(RandomPolicy::new()),
|
PolicyKind::Random => Arc::new(RandomPolicy::new()),
|
||||||
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
|
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
|
||||||
|
PolicyKind::LoadBased => Arc::new(LoadBasedPolicy::new()),
|
||||||
PolicyKind::CacheAwareZmq => {
|
PolicyKind::CacheAwareZmq => {
|
||||||
let cache_cfg = model.cache_aware.unwrap_or_default();
|
let cache_cfg = model.cache_aware.unwrap_or_default();
|
||||||
Arc::new(CacheAwareZmqPolicy::new(
|
Arc::new(CacheAwareZmqPolicy::new(
|
||||||
@@ -54,6 +56,7 @@ pub fn build_policy_kind_only(kind: PolicyKind) -> Arc<dyn Policy> {
|
|||||||
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
|
PolicyKind::RoundRobin => Arc::new(RoundRobinPolicy::new()),
|
||||||
PolicyKind::Random => Arc::new(RandomPolicy::new()),
|
PolicyKind::Random => Arc::new(RandomPolicy::new()),
|
||||||
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
|
PolicyKind::PowerOfTwo => Arc::new(PowerOfTwoChoicesPolicy::new()),
|
||||||
|
PolicyKind::LoadBased => Arc::new(LoadBasedPolicy::new()),
|
||||||
PolicyKind::CacheAwareZmq => {
|
PolicyKind::CacheAwareZmq => {
|
||||||
// Provide an empty tree + empty tokenizer registry + fresh
|
// Provide an empty tree + empty tokenizer registry + fresh
|
||||||
// oracle so the test policy is constructible. Production
|
// oracle so the test policy is constructible. Production
|
||||||
@@ -144,6 +147,7 @@ mod tests {
|
|||||||
let _ = build_policy_kind_only(PolicyKind::RoundRobin);
|
let _ = build_policy_kind_only(PolicyKind::RoundRobin);
|
||||||
let _ = build_policy_kind_only(PolicyKind::Random);
|
let _ = build_policy_kind_only(PolicyKind::Random);
|
||||||
let _ = build_policy_kind_only(PolicyKind::PowerOfTwo);
|
let _ = build_policy_kind_only(PolicyKind::PowerOfTwo);
|
||||||
|
let _ = build_policy_kind_only(PolicyKind::LoadBased);
|
||||||
let _ = build_policy_kind_only(PolicyKind::CacheAwareZmq);
|
let _ = build_policy_kind_only(PolicyKind::CacheAwareZmq);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,4 +177,18 @@ mod tests {
|
|||||||
"expected CacheAwareZmqPolicy debug repr, got: {dbg}",
|
"expected CacheAwareZmqPolicy debug repr, got: {dbg}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_based_builds_via_factory() {
|
||||||
|
let cfg = cfg_with_model("modelA", PolicyKind::LoadBased);
|
||||||
|
let tree = Arc::new(HashTree::new());
|
||||||
|
let tokenizers = Arc::new(TokenizerRegistry::default());
|
||||||
|
let reg = build_registry(&cfg, tree, tokenizers, BlockSizeOracle::new()).unwrap();
|
||||||
|
let p = reg.get(&ModelId("modelA".into())).unwrap();
|
||||||
|
let dbg = format!("{p:?}");
|
||||||
|
assert!(
|
||||||
|
dbg.contains("LoadBasedPolicy"),
|
||||||
|
"expected LoadBasedPolicy debug repr, got: {dbg}",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
use crate::policies::{Policy, SelectionContext};
|
||||||
|
use crate::workers::Worker;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Deterministic load-based policy.
|
||||||
|
///
|
||||||
|
/// Chooses the candidate with the lowest current `Worker::active_load`.
|
||||||
|
/// Ties follow the candidate slice order, which is registry-dependent.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LoadBasedPolicy;
|
||||||
|
|
||||||
|
impl LoadBasedPolicy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pick_min_load(workers: &[Arc<Worker>]) -> Option<Arc<Worker>> {
|
||||||
|
workers
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|w| w.active_load())
|
||||||
|
.map(Arc::clone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Policy for LoadBasedPolicy {
|
||||||
|
fn select(&self, workers: &[Arc<Worker>], _ctx: &SelectionContext<'_>) -> Option<Arc<Worker>> {
|
||||||
|
Self::pick_min_load(workers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
|
||||||
|
|
||||||
|
fn worker(id: &str) -> Arc<Worker> {
|
||||||
|
Arc::new(Worker::new(WorkerSpec {
|
||||||
|
id: WorkerId(id.into()),
|
||||||
|
url: format!("http://{id}:30000"),
|
||||||
|
mode: WorkerMode::Plain,
|
||||||
|
model_ids: vec![ModelId("tiny".into())],
|
||||||
|
bootstrap_port: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_returns_none() {
|
||||||
|
let policy = LoadBasedPolicy::new();
|
||||||
|
let model = ModelId("tiny".into());
|
||||||
|
let ctx = SelectionContext::new(&model, None);
|
||||||
|
assert!(policy.select(&[], &ctx).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn picks_lowest_active_load() {
|
||||||
|
let policy = LoadBasedPolicy::new();
|
||||||
|
let model = ModelId("tiny".into());
|
||||||
|
let ctx = SelectionContext::new(&model, None);
|
||||||
|
let w0 = worker("w0");
|
||||||
|
let w1 = worker("w1");
|
||||||
|
let _g0 = w0.load_guard();
|
||||||
|
assert_eq!(
|
||||||
|
policy.select(&[w0, Arc::clone(&w1)], &ctx).unwrap().id,
|
||||||
|
w1.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod active_load;
|
|||||||
pub mod cache_aware_zmq;
|
pub mod cache_aware_zmq;
|
||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod kv_events;
|
pub mod kv_events;
|
||||||
|
pub mod load_based;
|
||||||
pub mod power_of_two;
|
pub mod power_of_two;
|
||||||
pub mod random;
|
pub mod random;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
@@ -26,6 +27,7 @@ use std::sync::Arc;
|
|||||||
pub struct SelectionContext<'a> {
|
pub struct SelectionContext<'a> {
|
||||||
model: &'a ModelId,
|
model: &'a ModelId,
|
||||||
request_body: Option<&'a [u8]>,
|
request_body: Option<&'a [u8]>,
|
||||||
|
routing_key: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> SelectionContext<'a> {
|
impl<'a> SelectionContext<'a> {
|
||||||
@@ -33,6 +35,19 @@ impl<'a> SelectionContext<'a> {
|
|||||||
Self {
|
Self {
|
||||||
model,
|
model,
|
||||||
request_body,
|
request_body,
|
||||||
|
routing_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_routing_key(
|
||||||
|
model: &'a ModelId,
|
||||||
|
request_body: Option<&'a [u8]>,
|
||||||
|
routing_key: Option<&'a str>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
model,
|
||||||
|
request_body,
|
||||||
|
routing_key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +58,10 @@ impl<'a> SelectionContext<'a> {
|
|||||||
pub fn request_body(&self) -> Option<&[u8]> {
|
pub fn request_body(&self) -> Option<&[u8]> {
|
||||||
self.request_body
|
self.request_body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn routing_key(&self) -> Option<&str> {
|
||||||
|
self.routing_key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait Policy: Send + Sync + std::fmt::Debug {
|
pub trait Policy: Send + Sync + std::fmt::Debug {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
"""Real-GPU acceptance coverage for ``policy = "load_based"``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from infra.gateway import Gateway
|
||||||
|
from infra.model_pool import spawn_worker
|
||||||
|
from infra.model_specs import get_model_spec
|
||||||
|
|
||||||
|
_ACTIVE_RE = re.compile(
|
||||||
|
r'^sgl_router_active_load\{worker_url="([^"]+)",kind="prefill_tokens"\}\s+(-?\d+)'
|
||||||
|
)
|
||||||
|
_REQ_TOTAL_RE = re.compile(
|
||||||
|
r"^sgl_router_requests_total\{([^}]*)\}\s+(\d+(?:\.\d+)?)\s*$"
|
||||||
|
)
|
||||||
|
_LABEL_RE = re.compile(r'(\w+)="([^"]*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _chat(
|
||||||
|
router_url: str, model_id: str, prompt: str, max_tokens: int
|
||||||
|
) -> httpx.Response:
|
||||||
|
return httpx.post(
|
||||||
|
f"{router_url}/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": model_id,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"ignore_eos": True,
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
timeout=240.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_prefill_loads(router_url: str) -> dict[str, int]:
|
||||||
|
resp = httpx.get(f"{router_url}/metrics", timeout=5.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
loads: dict[str, int] = {}
|
||||||
|
for line in resp.text.splitlines():
|
||||||
|
match = _ACTIVE_RE.match(line)
|
||||||
|
if match:
|
||||||
|
loads[match.group(1)] = int(match.group(2))
|
||||||
|
return loads
|
||||||
|
|
||||||
|
|
||||||
|
def _success_counts(router_url: str) -> dict[str, int]:
|
||||||
|
resp = httpx.get(f"{router_url}/metrics", timeout=5.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for line in resp.text.splitlines():
|
||||||
|
match = _REQ_TOTAL_RE.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
labels = dict(_LABEL_RE.findall(match.group(1)))
|
||||||
|
if labels.get("outcome") != "success":
|
||||||
|
continue
|
||||||
|
worker_url = labels.get("worker_url")
|
||||||
|
if worker_url:
|
||||||
|
counts[worker_url] = counts.get(worker_url, 0) + int(float(match.group(2)))
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_busy_worker(router_url: str, worker_urls: list[str]) -> str:
|
||||||
|
deadline = time.time() + 60.0
|
||||||
|
while time.time() < deadline:
|
||||||
|
loads = _active_prefill_loads(router_url)
|
||||||
|
busy = [url for url in worker_urls if loads.get(url, 0) > 0]
|
||||||
|
if busy:
|
||||||
|
return busy[0]
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(
|
||||||
|
f"no worker became busy; last loads={_active_prefill_loads(router_url)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _single_success_delta(before: dict[str, int], after: dict[str, int]) -> str:
|
||||||
|
deltas = {
|
||||||
|
url: after.get(url, 0) - before.get(url, 0) for url in set(before) | set(after)
|
||||||
|
}
|
||||||
|
winners = [url for url, delta in deltas.items() if delta == 1]
|
||||||
|
assert len(winners) == 1, f"expected exactly one success delta, got {deltas}"
|
||||||
|
return winners[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.real_gpu
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_load_based_routes_to_the_cooler_worker(
|
||||||
|
router_binary, # noqa: ARG001 - fixture forces release-binary presence
|
||||||
|
gpu_allocator,
|
||||||
|
) -> None:
|
||||||
|
spec = get_model_spec("qwen3-0.6b")
|
||||||
|
gpus = gpu_allocator.acquire(2)
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
spawn_worker("qwen3-0.6b", gpu_ids=[gpus[0]]) as worker_a,
|
||||||
|
spawn_worker("qwen3-0.6b", gpu_ids=[gpus[1]]) as worker_b,
|
||||||
|
Gateway() as router,
|
||||||
|
):
|
||||||
|
worker_urls = [worker_a.url, worker_b.url]
|
||||||
|
router.start_regular(
|
||||||
|
model_id=spec["model"],
|
||||||
|
tokenizer_path=spec["model"],
|
||||||
|
worker_urls=worker_urls,
|
||||||
|
policy="load_based",
|
||||||
|
timeout=120.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
def long_request() -> None:
|
||||||
|
try:
|
||||||
|
resp = _chat(
|
||||||
|
router.base_url,
|
||||||
|
spec["model"],
|
||||||
|
"Write a long numbered list of routing test facts.",
|
||||||
|
1024,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
except BaseException as exc: # noqa: BLE001
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=long_request, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
busy_worker = _wait_for_busy_worker(router.base_url, worker_urls)
|
||||||
|
|
||||||
|
before = _success_counts(router.base_url)
|
||||||
|
resp = _chat(
|
||||||
|
router.base_url,
|
||||||
|
spec["model"],
|
||||||
|
"Answer with one short sentence.",
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
routed_worker = _single_success_delta(
|
||||||
|
before, _success_counts(router.base_url)
|
||||||
|
)
|
||||||
|
assert routed_worker != busy_worker
|
||||||
|
|
||||||
|
thread.join(timeout=240.0)
|
||||||
|
assert not thread.is_alive(), "long request did not finish"
|
||||||
|
assert not errors, errors
|
||||||
|
finally:
|
||||||
|
gpu_allocator.release(gpus)
|
||||||
Reference in New Issue
Block a user