Skip to content

Commit 703e1c8

Browse files
committed
[Router] Add prefix_min_match_length threshold to PrefixAwareRouter
Add a configurable --prefix-min-match-length option for the prefixaware routing logic. When the longest prefix match is shorter than this threshold, the request falls back to QPS-based routing instead of using the matched endpoint, mitigating load hotspots caused by long shared prefixes (e.g. common system prompts). Defaults to 0, which preserves the original behavior. Partially addresses #957. Signed-off-by: Max Li <hitliqiwei@gmail.com>
1 parent 8909ed7 commit 703e1c8

4 files changed

Lines changed: 160 additions & 3 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
from typing import Any, Dict
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
5+
6+
from vllm_router.routers.routing_logic import (
7+
PrefixAwareRouter,
8+
cleanup_routing_logic,
9+
)
10+
11+
12+
@pytest.fixture(autouse=True)
13+
def cleanup_router():
14+
cleanup_routing_logic()
15+
yield
16+
cleanup_routing_logic()
17+
18+
19+
class EndpointInfo:
20+
def __init__(self, url: str):
21+
self.url = url
22+
23+
24+
class RequestStats:
25+
def __init__(self, qps: float):
26+
self.qps = qps
27+
28+
29+
class Request:
30+
def __init__(self, headers: Dict[str, str], body: Dict[str, Any] = None):
31+
self.headers = headers
32+
self.body = body
33+
34+
35+
@pytest.mark.asyncio
36+
async def test_route_falls_back_to_qps_when_match_below_threshold():
37+
"""
38+
When the longest prefix match is shorter than prefix_min_match_length,
39+
the request should NOT use the matched endpoint. It should fall back to
40+
QPS-based routing and pick the engine with the lowest QPS.
41+
"""
42+
43+
endpoints = [
44+
EndpointInfo(url="http://engine1.com"),
45+
EndpointInfo(url="http://engine2.com"),
46+
]
47+
request_stats = {
48+
"http://engine1.com": RequestStats(qps=10),
49+
"http://engine2.com": RequestStats(qps=5),
50+
}
51+
request = Request(headers={})
52+
request_json = {"prompt": "some prompt text"}
53+
54+
router = PrefixAwareRouter(prefix_min_match_length=4096)
55+
56+
fake_hashtrie = AsyncMock()
57+
fake_hashtrie.longest_prefix_match.return_value = (
58+
100,
59+
{"http://engine1.com"},
60+
)
61+
router.hashtrie = fake_hashtrie
62+
63+
url = await router.route_request(
64+
endpoints, None, request_stats, request, request_json
65+
)
66+
67+
assert url == "http://engine2.com"
68+
69+
fake_hashtrie.insert.assert_not_awaited()
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_route_uses_matched_endpoint_when_match_above_threshold():
74+
"""
75+
When the longest prefix match is no shorter than prefix_min_match_length,
76+
the request should use the matched endpoint.
77+
"""
78+
79+
endpoints = [
80+
EndpointInfo(url="http://engine1.com"),
81+
EndpointInfo(url="http://engine2.com"),
82+
]
83+
request_stats = {}
84+
request = Request(headers={})
85+
request_json = {"prompt": "some prompt text"}
86+
87+
router = PrefixAwareRouter(prefix_min_match_length=50)
88+
89+
fake_hashtrie = AsyncMock()
90+
fake_hashtrie.longest_prefix_match.return_value = (
91+
5000,
92+
{"http://engine1.com"},
93+
)
94+
router.hashtrie = fake_hashtrie
95+
96+
url = await router.route_request(
97+
endpoints, None, request_stats, request, request_json
98+
)
99+
100+
assert url == "http://engine1.com"
101+
102+
fake_hashtrie.insert.assert_awaited_once()
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_default_threshold_zero_preserves_original_behavior():
107+
"""
108+
When --prefix-min-match-length is not provided, prefix_min_match_length
109+
defaults to 0. Even when there is no prefix match at all (match_length 0),
110+
the request still uses the matched endpoint instead of falling back,
111+
preserving the original behavior before this change.
112+
"""
113+
114+
endpoints = [
115+
EndpointInfo(url="http://engine1.com"),
116+
EndpointInfo(url="http://engine2.com"),
117+
]
118+
request_stats = {}
119+
request = Request(headers={})
120+
request_json = {"prompt": "some prompt text"}
121+
122+
router = PrefixAwareRouter()
123+
124+
fake_hashtrie = AsyncMock()
125+
fake_hashtrie.longest_prefix_match.return_value = (
126+
0,
127+
{"http://engine1.com"},
128+
)
129+
router.hashtrie = fake_hashtrie
130+
131+
url = await router.route_request(
132+
endpoints, None, request_stats, request, request_json
133+
)
134+
135+
assert url == "http://engine1.com"
136+
137+
fake_hashtrie.insert.assert_awaited_once()

src/vllm_router/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ def initialize_all(app: FastAPI, args):
279279
prefill_model_labels=args.prefill_model_labels,
280280
decode_model_labels=args.decode_model_labels,
281281
kv_aware_threshold=args.kv_aware_threshold,
282+
prefix_min_match_length=args.prefix_min_match_length,
282283
max_instance_failover_reroute_attempts=args.max_instance_failover_reroute_attempts,
283284
lmcache_health_check_interval=args.lmcache_health_check_interval,
284285
lmcache_worker_timeout=args.lmcache_worker_timeout,

src/vllm_router/parsers/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,16 @@ def parse_args():
447447
help="The threshold for kv-aware routing.",
448448
)
449449

450+
parser.add_argument(
451+
"--prefix-min-match-length",
452+
type=int,
453+
default=0,
454+
help="The minimum prefix match length, in characters, required for "
455+
"prefixaware routing to reuse a matched endpoint. If the longest "
456+
"prefix match is shorter than this value, the request falls back "
457+
"to QPS-based routing. Defaults to 0, which disables the threshold.",
458+
)
459+
450460
parser.add_argument(
451461
"--max-instance-failover-reroute-attempts",
452462
type=int,

src/vllm_router/routers/routing_logic.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -436,12 +436,16 @@ class PrefixAwareRouter(RoutingInterface):
436436
In this class, we assume that there is no eviction of prefix cache.
437437
"""
438438

439-
def __init__(self: int):
439+
def __init__(
440+
self,
441+
prefix_min_match_length: int = 0,
442+
):
440443
if hasattr(self, "_initialized"):
441444
return
442445
from vllm_router.prefix.hashtrie import HashTrie
443446

444447
self.hashtrie = HashTrie()
448+
self.prefix_min_match_length = prefix_min_match_length
445449
self._initialized = True
446450

447451
async def route_request(
@@ -496,10 +500,13 @@ async def route_request(
496500
prompt = request_json["prompt"]
497501

498502
available_endpoints = set(endpoint.url for endpoint in endpoints)
499-
_, matched_endpoint = await self.hashtrie.longest_prefix_match(
503+
match_length, matched_endpoint = await self.hashtrie.longest_prefix_match(
500504
prompt, available_endpoints
501505
)
502506

507+
if match_length < self.prefix_min_match_length:
508+
return self._qps_routing(endpoints, request_stats)
509+
503510
selected_endpoint = random.choice(list(matched_endpoint))
504511

505512
await self.hashtrie.insert(prompt, selected_endpoint)
@@ -684,7 +691,9 @@ def initialize_routing_logic(
684691
router.start_kv_manager()
685692
elif routing_logic == RoutingLogic.PREFIXAWARE:
686693
logger.info("Initializing prefix-aware routing logic")
687-
router = PrefixAwareRouter()
694+
router = PrefixAwareRouter(
695+
prefix_min_match_length=kwargs.get("prefix_min_match_length", 0),
696+
)
688697
elif routing_logic == RoutingLogic.DISAGGREGATED_PREFILL:
689698
logger.info("Initializing disaggregated prefill routing logic")
690699
router = DisaggregatedPrefillRouter(

0 commit comments

Comments
 (0)