LMetric routing policy (OSDI'26) + A/B results vs linear baseline
Implement LMetric (P_tokens × BS multiplication score) from "Simple is
Better" (Zhang et al., OSDI'26) as alternative routing policy for
combined mode. Key changes:
- cache_aware_proxy.py: add --policy {linear,lmetric} flag, track
pending_prefill_tokens and num_requests per instance, /stats endpoint
- run_lmetric_ab.sh: automated A/B script for fair comparison
Results (200 req, fresh restart, same trace):
Linear: TTFT50=1.086 TPOT90=0.077 E2E50=5.423
LMetric: TTFT50=1.099 TPOT90=0.073 E2E50=5.205
Delta: TTFT +1.2% TPOT -5.9% E2E -4.0%
LMetric improves TPOT/E2E modestly through better load balancing, but
routing policy headroom is limited vs elastic P2P offload (-44% E2E).
TODO: vLLM → Redis → router pipeline for exact state ablation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,12 @@ Supports two modes:
|
||||
--combined URL [URL ...]: PD co-located instances (normal vLLM, no KV transfer)
|
||||
--prefill URL BP --decode URL: PD disaggregated instances (Mooncake KV transfer)
|
||||
|
||||
Routing policy (same for both modes):
|
||||
score = ongoing_tokens / avg_ongoing - ALPHA * cache_hit_ratio
|
||||
Normalized load prevents "rich get richer"; cache bonus gives affinity.
|
||||
Session affinity: multi-turn sessions stick to same instance.
|
||||
Routing policies (--policy):
|
||||
linear (default): score = ongoing_tokens - ALPHA * cache_hit_tokens
|
||||
lmetric: score = P_tokens * BS (LMetric, OSDI'26)
|
||||
P_tokens = pending_prefill_tokens + new_uncached_tokens
|
||||
BS = num_requests (waiting + running)
|
||||
Session affinity: multi-turn sessions stick to same instance (all policies).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -39,6 +41,8 @@ class InstanceState:
|
||||
)
|
||||
self.ongoing_tokens = 0
|
||||
self.ongoing_decode_tokens = 0 # subset: tokens in decode phase
|
||||
self.pending_prefill_tokens = 0 # tokens for requests still in prefill
|
||||
self.num_requests = 0 # total in-flight requests (waiting + running)
|
||||
self.engine_id: dict[int, str] = {}
|
||||
self.dp_size = 1
|
||||
self.cached_blocks: set[int] = set()
|
||||
@@ -109,6 +113,39 @@ def pick_instance(instances: list[InstanceState], token_ids: list[int] | None,
|
||||
return instances[best_idx], best_idx
|
||||
|
||||
|
||||
def pick_instance_lmetric(instances: list[InstanceState], token_ids: list[int] | None,
|
||||
session_id: str | None, input_length: int,
|
||||
affinity: dict[str, int]) -> tuple[InstanceState, int]:
|
||||
"""LMetric routing: score = P_tokens × BS (OSDI'26).
|
||||
|
||||
P_tokens = pending_prefill_tokens on instance + new request's uncached tokens.
|
||||
BS = num_requests on instance + 1 (counting the new request).
|
||||
"""
|
||||
avg_load = max(sum(i.ongoing_tokens for i in instances) / len(instances), 1.0)
|
||||
|
||||
if session_id and session_id in affinity:
|
||||
idx = affinity[session_id]
|
||||
if idx < len(instances):
|
||||
inst = instances[idx]
|
||||
if inst.ongoing_tokens <= avg_load * OVERLOAD_FACTOR:
|
||||
return inst, idx
|
||||
|
||||
best_idx, best_score = 0, float("inf")
|
||||
for i, inst in enumerate(instances):
|
||||
cache_hit = inst.estimate_cache_hit(token_ids)
|
||||
new_prefill = max(0, input_length - cache_hit)
|
||||
p_tokens = inst.pending_prefill_tokens + new_prefill
|
||||
bs = inst.num_requests + 1
|
||||
score = p_tokens * bs
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_idx = i
|
||||
|
||||
if session_id:
|
||||
affinity[session_id] = best_idx
|
||||
return instances[best_idx], best_idx
|
||||
|
||||
|
||||
global_args = None
|
||||
combined_instances: list[InstanceState] = []
|
||||
prefill_instances: list[InstanceState] = []
|
||||
@@ -159,7 +196,8 @@ async def lifespan(app: FastAPI):
|
||||
await init_prefill_bootstrap(combined_instances, app.state.ready)
|
||||
else:
|
||||
app.state.ready.set()
|
||||
print(f"Combined mode: {len(combined_instances)} instances, offload={'ON' if global_args.offload else 'OFF'}")
|
||||
policy = getattr(global_args, 'policy', 'linear')
|
||||
print(f"Combined mode: {len(combined_instances)} instances, policy={policy}, offload={'ON' if global_args.offload else 'OFF'}")
|
||||
else:
|
||||
is_pd_sep = True
|
||||
for url, bp in global_args.prefill:
|
||||
@@ -219,8 +257,10 @@ async def _handle_combined(api, req_data, token_ids, input_length, session_id, h
|
||||
session-sticky instance. Only works if instances have kv_role=kv_both.
|
||||
Falls back to co-located if --no-offload or instances lack Mooncake.
|
||||
"""
|
||||
best_inst, best_idx = pick_instance(combined_instances, token_ids, session_id,
|
||||
input_length, session_affinity)
|
||||
policy = getattr(global_args, 'policy', 'linear') if global_args else 'linear'
|
||||
picker = pick_instance_lmetric if policy == 'lmetric' else pick_instance
|
||||
best_inst, best_idx = picker(combined_instances, token_ids, session_id,
|
||||
input_length, session_affinity)
|
||||
cache_hit = best_inst.estimate_cache_hit(token_ids)
|
||||
estimated_new = max(0, input_length - cache_hit)
|
||||
|
||||
@@ -267,6 +307,8 @@ async def _handle_combined(api, req_data, token_ids, input_length, session_id, h
|
||||
if use_offload:
|
||||
d_idx = best_idx
|
||||
p_inst.ongoing_tokens += input_length # reserve immediately
|
||||
p_inst.pending_prefill_tokens += estimated_new
|
||||
p_inst.num_requests += 1
|
||||
|
||||
breakdown["route_class"] = "HEAVY_P2P"
|
||||
breakdown["offload_reason"] = offload_reason
|
||||
@@ -288,23 +330,31 @@ async def _handle_combined(api, req_data, token_ids, input_length, session_id, h
|
||||
|
||||
inst = best_inst
|
||||
breakdown["routed_to"] = inst.url
|
||||
breakdown["policy"] = policy
|
||||
inst.ongoing_tokens += input_length
|
||||
inst.pending_prefill_tokens += estimated_new
|
||||
inst.num_requests += 1
|
||||
|
||||
async def generate():
|
||||
first_token = True
|
||||
prefill_done = False
|
||||
try:
|
||||
async with inst.client.stream("POST", api, json=req_data, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
inst.ongoing_decode_tokens += input_length
|
||||
async for chunk in resp.aiter_bytes():
|
||||
if first_token:
|
||||
if not prefill_done:
|
||||
inst.pending_prefill_tokens -= estimated_new
|
||||
inst.ongoing_decode_tokens += input_length
|
||||
breakdown["t_first_token"] = _time.monotonic()
|
||||
first_token = False
|
||||
prefill_done = True
|
||||
yield chunk
|
||||
inst.record_prefix(token_ids)
|
||||
finally:
|
||||
if not prefill_done:
|
||||
inst.pending_prefill_tokens -= estimated_new
|
||||
else:
|
||||
inst.ongoing_decode_tokens -= input_length
|
||||
inst.ongoing_tokens -= input_length
|
||||
inst.ongoing_decode_tokens -= input_length
|
||||
inst.num_requests -= 1
|
||||
breakdown["t_done"] = _time.monotonic()
|
||||
_breakdown_log.append(breakdown)
|
||||
|
||||
@@ -342,14 +392,19 @@ async def _handle_heavy_offload(api, req_data, headers, token_ids, input_length,
|
||||
_breakdown_log.append(breakdown)
|
||||
global _offload_inflight
|
||||
_offload_inflight = max(0, _offload_inflight - 1)
|
||||
p_inst.num_requests -= 1
|
||||
raise HTTPException(status_code=502, detail="Prefill failed: %s" % e)
|
||||
finally:
|
||||
p_inst.ongoing_tokens -= input_length
|
||||
p_inst.pending_prefill_tokens -= breakdown.get("estimated_new_tokens", 0)
|
||||
_offload_inflight = max(0, _offload_inflight - 1)
|
||||
|
||||
p_inst.num_requests -= 1
|
||||
|
||||
# Step 2: Stream decode on d_inst (pulls KV from Mooncake)
|
||||
d_inst.ongoing_tokens += input_length
|
||||
d_inst.ongoing_decode_tokens += input_length
|
||||
d_inst.num_requests += 1
|
||||
breakdown["t_decode_sent"] = _time.monotonic()
|
||||
|
||||
parsed = urllib.parse.urlparse(str(p_inst.client.base_url))
|
||||
@@ -377,6 +432,7 @@ async def _handle_heavy_offload(api, req_data, headers, token_ids, input_length,
|
||||
finally:
|
||||
d_inst.ongoing_tokens -= input_length
|
||||
d_inst.ongoing_decode_tokens -= input_length
|
||||
d_inst.num_requests -= 1
|
||||
breakdown["t_done"] = _time.monotonic()
|
||||
_breakdown_log.append(breakdown)
|
||||
|
||||
@@ -485,6 +541,20 @@ async def get_breakdown():
|
||||
return _breakdown_log
|
||||
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Return per-instance live state for debugging."""
|
||||
instances = combined_instances or prefill_instances + decode_instances
|
||||
return [{
|
||||
"url": inst.url,
|
||||
"ongoing_tokens": inst.ongoing_tokens,
|
||||
"pending_prefill_tokens": inst.pending_prefill_tokens,
|
||||
"ongoing_decode_tokens": inst.ongoing_decode_tokens,
|
||||
"num_requests": inst.num_requests,
|
||||
"cached_blocks": len(inst.cached_blocks),
|
||||
} for inst in instances]
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Unified cache-aware global scheduler")
|
||||
p.add_argument("--port", type=int, default=8000)
|
||||
@@ -502,6 +572,8 @@ def parse_args():
|
||||
help="Enable Mooncake KV offload for HEAVY requests (requires kv_both instances)")
|
||||
p.add_argument("--bootstrap-ports", type=str, default="",
|
||||
help="Comma-separated bootstrap ports for combined instances (for offload mode)")
|
||||
p.add_argument("--policy", type=str, default="linear", choices=["linear", "lmetric"],
|
||||
help="Routing policy: linear (default) or lmetric (P_tokens × BS, OSDI'26)")
|
||||
args = p.parse_args()
|
||||
|
||||
args.prefill = []
|
||||
|
||||
Reference in New Issue
Block a user