Skip to content

Commit f4abc4d

Browse files
fix(vllm-router): keep roundrobin state per endpoint set / model (#916)
Closes #915 Signed-off-by: Linus Schlumberger <linus.schlumberger@siemens.com> Co-authored-by: Rui Zhang <51696593+ruizhang0101@users.noreply.github.com>
1 parent cd48c75 commit f4abc4d

2 files changed

Lines changed: 103 additions & 27 deletions

File tree

src/tests/test_roundrobin_router.py

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import random
2+
from collections import Counter
23
from typing import Dict, List, Tuple
34

4-
from vllm_router.routers.routing_logic import RoundRobinRouter
5+
import pytest
6+
7+
from vllm_router.routers.routing_logic import RoundRobinRouter, cleanup_routing_logic
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def cleanup_router():
12+
cleanup_routing_logic()
13+
yield
14+
cleanup_routing_logic()
515

616

717
class EndpointInfo:
@@ -21,7 +31,7 @@ def __init__(self, headers: Dict[str, str]):
2131

2232
class EngineStats:
2333
def __init__(self):
24-
return
34+
pass
2535

2636

2737
def generate_request_args(
@@ -47,6 +57,11 @@ def generate_request(request_type="http") -> Request:
4757
return Request({"type": request_type})
4858

4959

60+
def assert_even_distribution(route_counts: Counter):
61+
counts = route_counts.values()
62+
assert max(counts) - min(counts) <= 1
63+
64+
5065
def test_roundrobin_logic(
5166
dynamic_discoveries: int = 10, max_endpoints: int = 1000, max_requests: int = 10000
5267
):
@@ -55,20 +70,66 @@ def test_roundrobin_logic(
5570
"""
5671
router = RoundRobinRouter()
5772

58-
def _fixed_router_check(num_endpoints: int, num_requests: int) -> bool:
59-
# Make num_requests requests to the router and check even output distribution
73+
def assert_router_stays_balanced(num_endpoints: int, num_requests: int):
6074
endpoints, engine_stats, request_stats = generate_request_args(num_endpoints)
61-
output_distribution = {}
62-
for request_idx in range(num_requests):
75+
route_counts = Counter()
76+
for _ in range(num_requests):
6377
request = generate_request()
6478
url = router.route_request(endpoints, engine_stats, request_stats, request)
65-
output_distribution[url] = output_distribution.get(url, 0) + 1
66-
request_counts = output_distribution.values()
67-
return max(request_counts) - min(request_counts) <= 1
79+
route_counts[url] += 1
80+
81+
assert_even_distribution(route_counts)
6882

6983
for _ in range(dynamic_discoveries):
7084
num_endpoints = random.randint(1, max_endpoints)
7185
num_requests = random.randint(1, max_requests)
72-
# Perform router check
73-
res = _fixed_router_check(num_endpoints, num_requests)
74-
assert res
86+
assert_router_stays_balanced(num_endpoints, num_requests)
87+
88+
89+
def test_roundrobin_keeps_state_per_endpoint_set():
90+
router = RoundRobinRouter()
91+
request = generate_request()
92+
engine_stats = {}
93+
request_stats = {}
94+
endpoints_a = [EndpointInfo(url="a0"), EndpointInfo(url="a1")]
95+
endpoints_b = [EndpointInfo(url="b0"), EndpointInfo(url="b1")]
96+
route_counts_a = Counter()
97+
route_counts_b = Counter()
98+
99+
for _ in range(100):
100+
url_a = router.route_request(endpoints_a, engine_stats, request_stats, request)
101+
route_counts_a[url_a] += 1
102+
url_b = router.route_request(endpoints_b, engine_stats, request_stats, request)
103+
route_counts_b[url_b] += 1
104+
105+
assert_even_distribution(route_counts_a)
106+
assert_even_distribution(route_counts_b)
107+
108+
109+
def test_roundrobin_keeps_state_when_endpoint_order_changes():
110+
router = RoundRobinRouter()
111+
request = generate_request()
112+
engine_stats = {}
113+
request_stats = {}
114+
endpoints_a = [EndpointInfo(url="a0"), EndpointInfo(url="a1")]
115+
endpoints_a_reordered = [EndpointInfo(url="a1"), EndpointInfo(url="a0")]
116+
117+
assert (
118+
router.route_request(endpoints_a, engine_stats, request_stats, request) == "a0"
119+
)
120+
assert (
121+
router.route_request(
122+
endpoints_a_reordered,
123+
engine_stats,
124+
request_stats,
125+
request,
126+
)
127+
== "a1"
128+
)
129+
130+
131+
def test_roundrobin_rejects_empty_endpoint_list():
132+
router = RoundRobinRouter()
133+
134+
with pytest.raises(ValueError, match="at least one endpoint"):
135+
router.route_request([], {}, {}, generate_request())

src/vllm_router/routers/routing_logic.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ class RoutingLogic(str, enum.Enum):
5959

6060

6161
class RoutingInterface(metaclass=SingletonABCMeta):
62-
6362
def _qps_routing(
6463
self, endpoints: List[EndpointInfo], request_stats: Dict[str, RequestStats]
6564
) -> str:
@@ -140,15 +139,32 @@ def route_request(
140139
class RoundRobinRouter(RoutingInterface):
141140
# TODO (ApostaC): when available engines in the endpoints changes, the
142141
# algorithm may not be "perfectly" round-robin.
142+
143+
# Upper bound on cached endpoint-set entries to prevent unbounded memory
144+
# growth when endpoints change dynamically (add / remove / update).
145+
_MAX_CACHE_SIZE = 1024
146+
143147
def __init__(self):
144148
if hasattr(self, "_initialized"):
145149
return
146-
self.req_id = 0
147-
self.sorted_endpoints = []
148-
self.last_endpoints_id = None
149-
self.last_endpoints_hash = None
150+
self._next_index: dict[tuple[str, ...], int] = {}
151+
self._sorted_cache: dict[frozenset[str], tuple[str, ...]] = {}
150152
self._initialized = True
151153

154+
def _endpoint_key(self, endpoints: List[EndpointInfo]) -> tuple[str, ...]:
155+
"""Return a stable, sorted key for the endpoint set (cached after first sort)."""
156+
if not endpoints:
157+
raise ValueError("RoundRobinRouter requires at least one endpoint")
158+
159+
urls = frozenset(e.url for e in endpoints)
160+
key = self._sorted_cache.get(urls)
161+
if key is None:
162+
if len(self._sorted_cache) >= self._MAX_CACHE_SIZE:
163+
self._sorted_cache.clear()
164+
key = tuple(sorted(urls))
165+
self._sorted_cache[urls] = key
166+
return key
167+
152168
def route_request(
153169
self,
154170
endpoints: List[EndpointInfo],
@@ -168,16 +184,15 @@ def route_request(
168184
indicating the request-level performance of each engine
169185
request (Request): The incoming request
170186
"""
171-
endpoints_id = id(endpoints)
172-
if endpoints_id != self.last_endpoints_id:
173-
current_hash = hash(tuple(e.url for e in endpoints))
174-
if current_hash != self.last_endpoints_hash:
175-
self.sorted_endpoints = sorted(endpoints, key=lambda e: e.url)
176-
self.last_endpoints_hash = current_hash
177-
self.last_endpoints_id = endpoints_id
178-
chosen = self.sorted_endpoints[self.req_id % len(self.sorted_endpoints)]
179-
self.req_id += 1
180-
return chosen.url
187+
endpoint_urls = self._endpoint_key(endpoints)
188+
idx = self._next_index.get(endpoint_urls, 0)
189+
if (
190+
len(self._next_index) >= self._MAX_CACHE_SIZE
191+
and endpoint_urls not in self._next_index
192+
):
193+
self._next_index.clear()
194+
self._next_index[endpoint_urls] = idx + 1
195+
return endpoint_urls[idx % len(endpoint_urls)]
181196

182197

183198
class SessionRouter(RoutingInterface):

0 commit comments

Comments
 (0)