Support token low usage watermark in prefill delayer (#16814)

This commit is contained in:
fzyzcjy
2026-01-09 23:07:18 +08:00
committed by GitHub
parent 71e9c31cb8
commit 8eeffbe9aa
5 changed files with 241 additions and 22 deletions
+1
View File
@@ -224,6 +224,7 @@ class Envs:
SGLANG_SCHEDULER_SKIP_ALL_GATHER = EnvBool(False) SGLANG_SCHEDULER_SKIP_ALL_GATHER = EnvBool(False)
SGLANG_SCHEDULER_DECREASE_PREFILL_IDLE = EnvBool(False) SGLANG_SCHEDULER_DECREASE_PREFILL_IDLE = EnvBool(False)
SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES = EnvInt(30) SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES = EnvInt(30)
SGLANG_PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK = EnvFloat(None)
SGLANG_DATA_PARALLEL_BUDGET_INTERVAL = EnvInt(1) SGLANG_DATA_PARALLEL_BUDGET_INTERVAL = EnvInt(1)
# Test: pd-disaggregation # Test: pd-disaggregation
+53 -8
View File
@@ -31,6 +31,7 @@ class _NegotiateOutput(NamedTuple):
output_allow: bool output_allow: bool
output_reason: str output_reason: str
num_prefillable: int num_prefillable: int
num_token_watermark_force_allow: int
class PrefillDelayer: class PrefillDelayer:
@@ -41,15 +42,19 @@ class PrefillDelayer:
cpu_group, cpu_group,
server_args, server_args,
max_delay_passes: int, max_delay_passes: int,
token_usage_low_watermark: Optional[float],
metrics_collector: Optional["SchedulerMetricsCollector"] = None, metrics_collector: Optional["SchedulerMetricsCollector"] = None,
): ):
self._max_delay_passes = max_delay_passes self._max_delay_passes = max_delay_passes
self._token_usage_low_watermark = token_usage_low_watermark
logger.info( logger.info(
f"PrefillDelayer initialized with max_delay_passes={self._max_delay_passes}" f"PrefillDelayer initialized with "
f"max_delay_passes={self._max_delay_passes} "
f"token_usage_low_watermark={self._token_usage_low_watermark}"
) )
self._global_info_buffer = torch.empty( self._global_info_buffer = torch.empty(
(dp_size, attn_tp_size, 1), (dp_size, attn_tp_size, 2),
dtype=torch.int64, dtype=torch.int64,
device="cpu", device="cpu",
) )
@@ -70,33 +75,53 @@ class PrefillDelayer:
), "To use PrefillDelayer, disable_overlap_schedule must be False." ), "To use PrefillDelayer, disable_overlap_schedule must be False."
def _negotiate_should_allow_prefill( def _negotiate_should_allow_prefill(
self, local_prefillable: bool self, local_prefillable: bool, token_usage: float
) -> _NegotiateOutput: ) -> _NegotiateOutput:
out = self._negotiate_should_allow_prefill_pure( out = self._negotiate_should_allow_prefill_pure(
prev_state=self._curr_state, prev_state=self._curr_state,
local_prefillable=local_prefillable, local_prefillable=local_prefillable,
token_usage=token_usage,
) )
self._curr_state = out.next_state self._curr_state = out.next_state
return out return out
# (Almost) pure function, do not modify self state
def _negotiate_should_allow_prefill_pure( def _negotiate_should_allow_prefill_pure(
self, self,
prev_state: Optional[_State], prev_state: Optional[_State],
local_prefillable: bool, local_prefillable: bool,
token_usage: float,
) -> _NegotiateOutput: ) -> _NegotiateOutput:
global_prefillable = self._gather_info(local_prefillable=local_prefillable) # Compute local states
local_token_watermark_force_allow = (
local_prefillable
and ((x := self._token_usage_low_watermark) is not None)
and (token_usage < x)
)
# Gather global states
global_prefillable, global_token_watermark_force_allow = self._gather_info(
local_prefillable=local_prefillable,
local_token_watermark_force_allow=local_token_watermark_force_allow,
)
# Compute derived global states
if global_prefillable.min().item() > 0: if global_prefillable.min().item() > 0:
prefillable_status = "all" prefillable_status = "all"
elif global_prefillable.max().item() == 0: elif global_prefillable.max().item() == 0:
prefillable_status = "none" prefillable_status = "none"
else: else:
prefillable_status = "mixed" prefillable_status = "mixed"
global_exists_token_watermark_force_allow = (
global_token_watermark_force_allow.max().item() > 0
)
debug_info = dict( debug_info = dict(
input_estimation=prefillable_status, input_estimation=prefillable_status,
num_prefillable=global_prefillable.sum().item(), num_prefillable=global_prefillable.sum().item(),
num_token_watermark_force_allow=global_token_watermark_force_allow.sum().item(),
) )
# Compute outputs
if prefillable_status == "all": if prefillable_status == "all":
exist_previous_wait = prev_state is not None exist_previous_wait = prev_state is not None
return _NegotiateOutput( return _NegotiateOutput(
@@ -108,11 +133,20 @@ class PrefillDelayer:
elif prefillable_status == "none": elif prefillable_status == "none":
return _NegotiateOutput( return _NegotiateOutput(
next_state=None, next_state=None,
# It does not matter whether we allow or not, thus we allow for simplicity
output_allow=True, output_allow=True,
output_reason="", output_reason="",
**debug_info, **debug_info,
) )
elif prefillable_status == "mixed": elif prefillable_status == "mixed":
if global_exists_token_watermark_force_allow:
return _NegotiateOutput(
next_state=None,
output_allow=True,
output_reason="token_watermark",
**debug_info,
)
prev_delayed_count = prev_state.delayed_count if prev_state else 0 prev_delayed_count = prev_state.delayed_count if prev_state else 0
if prev_delayed_count < self._max_delay_passes - 1: if prev_delayed_count < self._max_delay_passes - 1:
next_state = prev_state or _State() next_state = prev_state or _State()
@@ -133,9 +167,11 @@ class PrefillDelayer:
else: else:
raise NotImplementedError raise NotImplementedError
def _gather_info(self, local_prefillable: bool): def _gather_info(
self, local_prefillable: bool, local_token_watermark_force_allow: bool
):
local_info = torch.tensor( local_info = torch.tensor(
[int(local_prefillable)], [int(local_prefillable), int(local_token_watermark_force_allow)],
device="cpu", device="cpu",
dtype=torch.int64, dtype=torch.int64,
) )
@@ -145,12 +181,13 @@ class PrefillDelayer:
group=self._cpu_group, group=self._cpu_group,
) )
tp0_info = self._global_info_buffer[:, 0, :] tp0_info = self._global_info_buffer[:, 0, :]
return tp0_info[:, 0] return tp0_info[:, 0], tp0_info[:, 1]
class PrefillDelayerSinglePassExecutor: class PrefillDelayerSinglePassExecutor:
def __init__(self, prefill_delayer: PrefillDelayer): def __init__(self, prefill_delayer: PrefillDelayer, token_usage: float):
self._prefill_delayer = prefill_delayer self._prefill_delayer = prefill_delayer
self._token_usage = token_usage
self._result: Optional[_NegotiateOutput] = None self._result: Optional[_NegotiateOutput] = None
@property @property
@@ -171,6 +208,7 @@ class PrefillDelayerSinglePassExecutor:
if not self._called: if not self._called:
self._result = self._prefill_delayer._negotiate_should_allow_prefill( self._result = self._prefill_delayer._negotiate_should_allow_prefill(
local_prefillable=local_prefillable, local_prefillable=local_prefillable,
token_usage=self._token_usage,
) )
return self._result.output_allow return self._result.output_allow
@@ -187,6 +225,13 @@ def _record_single_pass_result(
f"(num_prefillable={output.num_prefillable}, " f"(num_prefillable={output.num_prefillable}, "
f"actual_execution={actual_execution})" f"actual_execution={actual_execution})"
) )
elif output.output_allow and (output.output_reason == "token_watermark"):
logger.info(
f"PrefillDelayer force allow prefill due to low watermark. "
f"(num_prefillable={output.num_prefillable}, "
f"num_token_watermark_force_allow={output.num_token_watermark_force_allow}, "
f"actual_execution={actual_execution})"
)
else: else:
assert output.output_reason in { assert output.output_reason in {
"", "",
+5 -1
View File
@@ -775,6 +775,9 @@ class Scheduler(
self.metrics_collector if self.enable_metrics else None self.metrics_collector if self.enable_metrics else None
), ),
max_delay_passes=envs.SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES.get(), max_delay_passes=envs.SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES.get(),
token_usage_low_watermark=(
envs.SGLANG_PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK.get()
),
) )
# Enable preemption for priority scheduling. # Enable preemption for priority scheduling.
self.try_preemption = self.enable_priority_scheduling self.try_preemption = self.enable_priority_scheduling
@@ -1846,8 +1849,9 @@ class Scheduler(
def get_new_batch_prefill(self) -> Optional[ScheduleBatch]: def get_new_batch_prefill(self) -> Optional[ScheduleBatch]:
prefill_delayer_single_pass = None prefill_delayer_single_pass = None
if self.prefill_delayer: if self.prefill_delayer:
_, token_usage, _, _ = self._get_token_info()
prefill_delayer_single_pass = PrefillDelayerSinglePassExecutor( prefill_delayer_single_pass = PrefillDelayerSinglePassExecutor(
self.prefill_delayer self.prefill_delayer, token_usage=token_usage
) )
ret = self._get_new_batch_prefill_raw( ret = self._get_new_batch_prefill_raw(
+2 -2
View File
@@ -826,9 +826,9 @@ class SchedulerMetricsCollector:
self.prefill_delayer_outcomes_total.labels( self.prefill_delayer_outcomes_total.labels(
**self.labels, **self.labels,
input_estimation=input_estimation, input_estimation=input_estimation,
output_allow=str(int(output_allow)), output_allow=str(output_allow).lower(),
output_reason=output_reason, output_reason=output_reason,
actual_execution=str(int(actual_execution)), actual_execution=str(actual_execution).lower(),
).inc(1) ).inc(1)
def increment_retracted_reqs( def increment_retracted_reqs(
+180 -11
View File
@@ -1,11 +1,14 @@
import asyncio
import os import os
import re import re
import time
import unittest import unittest
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from types import SimpleNamespace from types import SimpleNamespace
from typing import List from typing import List, Optional
import openai
import requests import requests
import torch import torch
import torch.multiprocessing as mp import torch.multiprocessing as mp
@@ -32,12 +35,14 @@ WORLD_SIZE = os.environ.get("SGLANG_TEST_WORLD_SIZE", "8")
@dataclass @dataclass
class NegotiateCall: class NegotiateCall:
prefillable: List[bool] prefillable: List[bool]
token_usage: List[float]
@dataclass @dataclass
class NegotiateTestCase: class NegotiateTestCase:
name: str name: str
max_delay_passes: int max_delay_passes: int
token_usage_low_watermark: Optional[float]
calls: List[NegotiateCall] calls: List[NegotiateCall]
expected_allow: bool expected_allow: bool
expected_reason: str expected_reason: str
@@ -63,11 +68,13 @@ def _run_negotiate_test(rank, world_size, test_cases, results_queue, port):
disable_overlap_schedule=False, disable_overlap_schedule=False,
), ),
max_delay_passes=case.max_delay_passes, max_delay_passes=case.max_delay_passes,
token_usage_low_watermark=case.token_usage_low_watermark,
) )
for call in case.calls: for call in case.calls:
result = delayer._negotiate_should_allow_prefill( result = delayer._negotiate_should_allow_prefill(
local_prefillable=call.prefillable[rank], local_prefillable=call.prefillable[rank],
token_usage=call.token_usage[rank],
) )
results_queue.put((rank, case.name, result.output_allow, result.output_reason)) results_queue.put((rank, case.name, result.output_allow, result.output_reason))
@@ -79,8 +86,12 @@ _NEGOTIATE_TEST_CASES = [
NegotiateTestCase( NegotiateTestCase(
name="all_prefillable", name="all_prefillable",
max_delay_passes=100, max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[ calls=[
NegotiateCall(prefillable=[True, True, True, True]), NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
], ],
expected_allow=True, expected_allow=True,
expected_reason="no_wait", expected_reason="no_wait",
@@ -88,9 +99,16 @@ _NEGOTIATE_TEST_CASES = [
NegotiateTestCase( NegotiateTestCase(
name="all_prefillable_with_previous_wait", name="all_prefillable_with_previous_wait",
max_delay_passes=100, max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[ calls=[
NegotiateCall(prefillable=[True, False, True, False]), NegotiateCall(
NegotiateCall(prefillable=[True, True, True, True]), prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, True, True, True],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
], ],
expected_allow=True, expected_allow=True,
expected_reason="wait_success", expected_reason="wait_success",
@@ -98,8 +116,12 @@ _NEGOTIATE_TEST_CASES = [
NegotiateTestCase( NegotiateTestCase(
name="none_prefillable", name="none_prefillable",
max_delay_passes=100, max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[ calls=[
NegotiateCall(prefillable=[False, False, False, False]), NegotiateCall(
prefillable=[False, False, False, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
], ],
expected_allow=True, expected_allow=True,
expected_reason="", expected_reason="",
@@ -107,8 +129,51 @@ _NEGOTIATE_TEST_CASES = [
NegotiateTestCase( NegotiateTestCase(
name="mixed_delay", name="mixed_delay",
max_delay_passes=100, max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[ calls=[
NegotiateCall(prefillable=[True, False, True, False]), NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
)
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="mixed_watermark_force_allow",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
],
expected_allow=True,
expected_reason="token_watermark",
),
NegotiateTestCase(
name="mixed_watermark_disabled",
max_delay_passes=100,
token_usage_low_watermark=None,
calls=[
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
],
expected_allow=False,
expected_reason="delay",
),
NegotiateTestCase(
name="mixed_watermark_not_prefillable",
max_delay_passes=100,
token_usage_low_watermark=0.8,
calls=[
NegotiateCall(
prefillable=[False, False, True, False],
token_usage=[0.5, 0.9, 0.9, 0.9],
)
], ],
expected_allow=False, expected_allow=False,
expected_reason="delay", expected_reason="delay",
@@ -116,10 +181,20 @@ _NEGOTIATE_TEST_CASES = [
NegotiateTestCase( NegotiateTestCase(
name="mixed_timeout", name="mixed_timeout",
max_delay_passes=3, max_delay_passes=3,
token_usage_low_watermark=0.8,
calls=[ calls=[
NegotiateCall(prefillable=[True, False, True, False]), NegotiateCall(
NegotiateCall(prefillable=[True, False, True, False]), prefillable=[True, False, True, False],
NegotiateCall(prefillable=[True, False, True, False]), token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
NegotiateCall(
prefillable=[True, False, True, False],
token_usage=[0.9, 0.9, 0.9, 0.9],
),
], ],
expected_allow=True, expected_allow=True,
expected_reason="wait_timeout", expected_reason="wait_timeout",
@@ -172,6 +247,7 @@ class TestPrefillDelayerThroughputOnlineServing(CustomTestCase):
self, self,
test_name="online_serving", test_name="online_serving",
other_launch_args=[ other_launch_args=[
# Not really needed, only to test support non-FCFS algorithms
"--schedule-policy", "--schedule-policy",
"lpm", "lpm",
], ],
@@ -196,6 +272,7 @@ class TestPrefillDelayerThroughputOfflineGen(CustomTestCase):
random_input_len=30000, random_input_len=30000,
random_output_len=500, random_output_len=500,
), ),
token_usage_low_watermark=0.8,
min_improvement_pct=20, min_improvement_pct=20,
) )
@@ -206,11 +283,13 @@ def _run_throughput_comparison(
other_launch_args, other_launch_args,
other_benchmark_args, other_benchmark_args,
min_improvement_pct: float, min_improvement_pct: float,
token_usage_low_watermark: float = None,
): ):
common_kwargs = dict( common_kwargs = dict(
debug_name=test_name, debug_name=test_name,
other_launch_args=other_launch_args, other_launch_args=other_launch_args,
other_benchmark_args=other_benchmark_args, other_benchmark_args=other_benchmark_args,
token_usage_low_watermark=token_usage_low_watermark,
) )
res_enabled = _run_throughput_test(prefill_delayer=True, **common_kwargs) res_enabled = _run_throughput_test(prefill_delayer=True, **common_kwargs)
res_disabled = _run_throughput_test(prefill_delayer=False, **common_kwargs) res_disabled = _run_throughput_test(prefill_delayer=False, **common_kwargs)
@@ -229,15 +308,17 @@ def _run_throughput_test(
prefill_delayer: bool, prefill_delayer: bool,
other_launch_args, other_launch_args,
other_benchmark_args, other_benchmark_args,
token_usage_low_watermark: float = None,
): ):
model = "Qwen/Qwen3-0.6B" model = "Qwen/Qwen3-0.6B"
base_url = DEFAULT_URL_FOR_TEST base_url = DEFAULT_URL_FOR_TEST
process = _launch_server( process = _launch_server(
prefill_delayer=prefill_delayer,
model=model, model=model,
base_url=base_url, base_url=base_url,
other_args=other_launch_args, other_args=other_launch_args,
prefill_delayer=prefill_delayer, token_usage_low_watermark=token_usage_low_watermark,
) )
try: try:
@@ -290,6 +371,87 @@ def _assert_throughput_improvement(
) )
class TestPrefillDelayerTokenUsageLowWatermark(CustomTestCase):
def test_1_with_low_watermark(self):
# The kv cache size here is deliberately small, thus we use smaller token usage
self._run(token_usage_low_watermark=0.5)
def test_2_without_low_watermark(self):
self._run(token_usage_low_watermark=None)
def _run(self, token_usage_low_watermark):
model = "Qwen/Qwen3-0.6B"
base_url = DEFAULT_URL_FOR_TEST
world_size = int(WORLD_SIZE)
process = _launch_server(
model=model,
base_url=base_url,
prefill_delayer=True,
other_args=["--max-total-tokens", "50000"],
# e.g. gen throughput is 370 tok/s on H200.
# Will need a different threshold on B200
max_delay_passes=3000,
token_usage_low_watermark=token_usage_low_watermark,
)
async def run_test():
client = openai.AsyncClient(base_url=f"{base_url}/v1", api_key="EMPTY")
long_prompt = "Hello " * 5000
async def send_blocking_request():
return await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": long_prompt}],
max_tokens=10000,
extra_body={"data_parallel_rank": 0},
)
async def send_normal_request(dp_rank, req_idx):
start = time.time()
await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Say hi"}],
max_tokens=10,
extra_body={"data_parallel_rank": dp_rank},
)
elapsed = time.time() - start
return dp_rank, req_idx, elapsed
asyncio.create_task(send_blocking_request())
await asyncio.sleep(3)
num_reqs_per_rank = 10
results = await asyncio.gather(
*[
send_normal_request(dp_rank, req_idx)
for dp_rank in range(1, world_size)
for req_idx in range(num_reqs_per_rank)
]
)
enabled = token_usage_low_watermark is not None
thresh = 5
for dp_rank, req_idx, elapsed in results:
print(f"DP rank {dp_rank} req {req_idx} completed in {elapsed:.2f}s")
self.assertTrue(
(elapsed < thresh) if enabled else (elapsed > thresh),
f"DP rank {dp_rank} req {req_idx}: elapsed={elapsed:.2f}s, thresh={thresh}, enabled={enabled}. "
f"Maybe you need a different `max_delay_passes` when using hardware other than H200.",
)
try:
asyncio.run(run_test())
metrics_text = _print_prefill_delayer_metrics(base_url, expect_metrics=True)
if token_usage_low_watermark is not None:
total = _sum_prometheus_metric_values(metrics_text, "token_watermark")
self.assertGreater(total, 0, "Expected token_watermark > 0")
print(f"total token_watermark: {total}")
finally:
kill_process_tree(process.pid)
class TestPrefillDelayerAccuracy(CustomTestCase): class TestPrefillDelayerAccuracy(CustomTestCase):
def test_1_mgsm_en_has_prefill_delayer(self): def test_1_mgsm_en_has_prefill_delayer(self):
self._run_accuracy_test(prefill_delayer=True) self._run_accuracy_test(prefill_delayer=True)
@@ -305,8 +467,10 @@ class TestPrefillDelayerAccuracy(CustomTestCase):
model=model, model=model,
base_url=base_url, base_url=base_url,
other_args=[ other_args=[
# Not really needed, only to test support non-FCFS algorithms
"--schedule-policy", "--schedule-policy",
"lpm", "lpm",
# Use this to ensure prefill delayer will be run
"--max-total-tokens", "--max-total-tokens",
"4096", "4096",
], ],
@@ -334,12 +498,17 @@ def _launch_server(
prefill_delayer: bool, prefill_delayer: bool,
other_args, other_args,
max_delay_passes: int = 100, max_delay_passes: int = 100,
token_usage_low_watermark: float = None,
): ):
os.environ["SGLANG_PREFILL_DELAYER_DEBUG_LOG"] = "1" os.environ["SGLANG_PREFILL_DELAYER_DEBUG_LOG"] = "1"
with envs.SGLANG_SCHEDULER_DECREASE_PREFILL_IDLE.override( with envs.SGLANG_SCHEDULER_DECREASE_PREFILL_IDLE.override(
prefill_delayer prefill_delayer
), envs.SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES.override(max_delay_passes): ), envs.SGLANG_PREFILL_DELAYER_MAX_DELAY_PASSES.override(
max_delay_passes
), envs.SGLANG_PREFILL_DELAYER_TOKEN_USAGE_LOW_WATERMARK.override(
token_usage_low_watermark
):
return popen_launch_server( return popen_launch_server(
model, model,
base_url, base_url,