Skip to content

Commit e0c1a2e

Browse files
ywc668ruizhang0101
andauthored
[Router] Add prefix_min_match_length threshold to PrefixAwareRouter (#959)
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> Co-authored-by: Rui Zhang <51696593+ruizhang0101@users.noreply.github.com>
1 parent 9b78da7 commit e0c1a2e

8 files changed

Lines changed: 179 additions & 3 deletions

File tree

helm/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ This table documents all available configuration values for the Production Stack
240240
| `routerSpec.staticBackends` | string | `""` | Comma-separated list of backend addresses if serviceDiscovery is "static" |
241241
| `routerSpec.staticModels` | string | `""` | Comma-separated list of model names if serviceDiscovery is "static" |
242242
| `routerSpec.routingLogic` | string | `"roundrobin"` | Routing logic: `"roundrobin"`, `"session"`, `"prefixaware"`, or `"kvaware"` |
243+
| `routerSpec.prefixMinMatchLength` | integer | `0` | Minimum prefix match length for `prefixaware` routing to reuse a matched endpoint; below this, requests fall back to QPS routing. Quantized to the prefix chunk size (default 128). `0` disables it |
243244
| `routerSpec.sessionKey` | string | `""` | Session key if using "session" routing logic |
244245
| `routerSpec.extraArgs` | list | `[]` | Extra command line arguments to pass to the router |
245246
| `routerSpec.extraVolumes` | list | `[]` | Additional volumes to add to the router pod, in Kubernetes volume format |

helm/templates/deployment-router.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ spec:
116116
{{- end }}
117117
- "--routing-logic"
118118
- "{{ .Values.routerSpec.routingLogic }}"
119+
{{- if .Values.routerSpec.prefixMinMatchLength }}
120+
- "--prefix-min-match-length"
121+
- "{{ .Values.routerSpec.prefixMinMatchLength }}"
122+
{{- end }}
119123
{{- if .Values.routerSpec.sessionKey }}
120124
- "--session-key"
121125
- "{{ .Values.routerSpec.sessionKey }}"

helm/values.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,10 @@
710710
"description": "Customized pod annotations for the router pods",
711711
"type": "object"
712712
},
713+
"prefixMinMatchLength": {
714+
"description": "Minimum prefix match length required for prefixaware routing to reuse a matched endpoint. If the longest prefix match is shorter than this value, the request falls back to QPS-based routing. Prefix matches are computed in chunks (chunk size defaults to 128 characters), so this threshold is effectively quantized to that granularity. Defaults to 0, which disables it.",
715+
"type": "integer"
716+
},
713717
"priorityClassName": {
714718
"description": "Priority Class",
715719
"type": "string"

helm/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,13 @@ routerSpec:
515515
# -- routing logic, supports "roundrobin", "session", "prefixaware", "kvaware" or "disaggregated_prefill"
516516
routingLogic: "roundrobin" # @schema enum:[roundrobin,session,prefixaware,kvaware,disaggregated_prefill]
517517

518+
# -- Minimum prefix match length required for prefixaware routing to reuse a
519+
# matched endpoint. If the longest prefix match is shorter than this value,
520+
# the request falls back to QPS-based routing. Prefix matches are computed in
521+
# chunks (chunk size defaults to 128 characters), so this threshold is
522+
# effectively quantized to that granularity. Defaults to 0, which disables it.
523+
prefixMinMatchLength: 0
524+
518525
# -- session key if using "session" routing logic
519526
sessionKey: ""
520527

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+
0,
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=128)
88+
89+
fake_hashtrie = AsyncMock()
90+
fake_hashtrie.longest_prefix_match.return_value = (
91+
4096,
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,19 @@ 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 required for prefixaware "
455+
"routing to reuse a matched endpoint. If the longest prefix "
456+
"match is shorter than this value, the request falls back to "
457+
"QPS-based routing. Note: prefix matches are computed in "
458+
"chunks (chunk_size, default 128 characters), so this "
459+
"threshold is effectively quantized to that granularity. "
460+
"Defaults to 0, which disables the threshold.",
461+
)
462+
450463
parser.add_argument(
451464
"--max-instance-failover-reroute-attempts",
452465
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)