389 lines
14 KiB
Python
389 lines
14 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|||
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||
|
|
"""Unit tests for the model-neutral CFG request-pairing scheduler patches.
|
||
|
|
|
||
|
|
The patches keep a cond/uncond request pair step-locked inside one engine:
|
||
|
|
admitted together, kept at equal ``num_computed_tokens``, finished together.
|
||
|
|
They are installed onto a lightweight fake scheduler here; vLLM's real
|
||
|
|
``Scheduler`` needs a full engine (and a GPU) to construct.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections import deque
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from vllm_omni.model_executor.models.common import cfg_pairing
|
||
|
|
from vllm_omni.model_executor.models.common.cfg_pairing import (
|
||
|
|
_equalize_cfg_pair_progress,
|
||
|
|
_patch_scheduler,
|
||
|
|
cfg_uncond_suffixes,
|
||
|
|
register_cfg_uncond_suffix,
|
||
|
|
)
|
||
|
|
|
||
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── fakes ─────────
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeRequest:
|
||
|
|
"""Minimal stand-in for ``vllm.v1.request.Request`` (identity equality)."""
|
||
|
|
|
||
|
|
def __init__(self, request_id: str, extra_args: dict[str, Any] | None = None, step_tokens: int = 1) -> None:
|
||
|
|
self.request_id = request_id
|
||
|
|
self.sampling_params = SimpleNamespace(extra_args=extra_args)
|
||
|
|
self.num_computed_tokens = 0
|
||
|
|
self.step_tokens = step_tokens
|
||
|
|
self.skip_reading_prefix_cache = False
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeWaitingQueue(deque):
|
||
|
|
"""FCFS waiting queue: vLLM's is also a ``deque`` subclass."""
|
||
|
|
|
||
|
|
def remove_requests(self, requests: list[_FakeRequest]) -> None:
|
||
|
|
keep = [req for req in self if not any(req is dropped for dropped in requests)]
|
||
|
|
self.clear()
|
||
|
|
self.extend(keep)
|
||
|
|
|
||
|
|
def prepend_request(self, request: _FakeRequest) -> None:
|
||
|
|
self.appendleft(request)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class _FakeSchedulerOutput:
|
||
|
|
num_scheduled_tokens: dict[str, int] = field(default_factory=dict)
|
||
|
|
total_num_scheduled_tokens: int = 0
|
||
|
|
|
||
|
|
|
||
|
|
def _make_scheduler_cls() -> type:
|
||
|
|
"""Fresh class per test so the in-place patches never stack."""
|
||
|
|
|
||
|
|
class FakeScheduler:
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.waiting = _FakeWaitingQueue()
|
||
|
|
self.running: list[_FakeRequest] = []
|
||
|
|
self.requests: dict[str, _FakeRequest] = {}
|
||
|
|
self.finished: list[set[str]] = []
|
||
|
|
self.observed_waiting: list[list[str]] = []
|
||
|
|
self.max_num_scheduled_tokens = 512
|
||
|
|
self.scheduler_config = SimpleNamespace(long_prefill_token_threshold=4096)
|
||
|
|
|
||
|
|
def add_request(self, request: _FakeRequest) -> None:
|
||
|
|
self.requests[request.request_id] = request
|
||
|
|
self.waiting.append(request)
|
||
|
|
|
||
|
|
def finish_requests(self, request_ids: Any, finished_status: Any) -> None:
|
||
|
|
if request_ids is None:
|
||
|
|
ids = set(self.requests)
|
||
|
|
elif isinstance(request_ids, str):
|
||
|
|
ids = {request_ids}
|
||
|
|
else:
|
||
|
|
ids = set(request_ids)
|
||
|
|
self.finished.append(ids)
|
||
|
|
self.running = [req for req in self.running if req.request_id not in ids]
|
||
|
|
for req_id in ids:
|
||
|
|
self.requests.pop(req_id, None)
|
||
|
|
|
||
|
|
def schedule(self) -> _FakeSchedulerOutput:
|
||
|
|
self.observed_waiting.append([req.request_id for req in self.waiting])
|
||
|
|
while self.waiting:
|
||
|
|
self.running.append(self.waiting.popleft())
|
||
|
|
output = _FakeSchedulerOutput()
|
||
|
|
for req in self.running:
|
||
|
|
req.num_computed_tokens += req.step_tokens
|
||
|
|
output.num_scheduled_tokens[req.request_id] = req.step_tokens
|
||
|
|
output.total_num_scheduled_tokens += req.step_tokens
|
||
|
|
return output
|
||
|
|
|
||
|
|
return FakeScheduler
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def scheduler() -> Any:
|
||
|
|
cls = _make_scheduler_cls()
|
||
|
|
_patch_scheduler(cls)
|
||
|
|
return cls()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _restore_suffix_registry():
|
||
|
|
original = set(cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES)
|
||
|
|
yield
|
||
|
|
cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES.clear()
|
||
|
|
cfg_pairing._UNCOND_REQUEST_ID_SUFFIXES.update(original)
|
||
|
|
|
||
|
|
|
||
|
|
def _add_pair(
|
||
|
|
scheduler: Any,
|
||
|
|
pair_id: str | None = None,
|
||
|
|
base_id: str = "req-1",
|
||
|
|
suffix: str = "__cfg_uncond",
|
||
|
|
) -> tuple[_FakeRequest, _FakeRequest]:
|
||
|
|
cond_extra: dict[str, Any] = {"cfg_role": "cond"}
|
||
|
|
uncond_extra: dict[str, Any] = {"cfg_role": "uncond"}
|
||
|
|
if pair_id is not None:
|
||
|
|
cond_extra["cfg_pair_id"] = pair_id
|
||
|
|
uncond_extra["cfg_pair_id"] = pair_id
|
||
|
|
cond = _FakeRequest(base_id, cond_extra)
|
||
|
|
uncond = _FakeRequest(f"{base_id}{suffix}", uncond_extra)
|
||
|
|
scheduler.add_request(cond)
|
||
|
|
scheduler.add_request(uncond)
|
||
|
|
return cond, uncond
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── pair-id derivation ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_pair_id_is_derived_from_request_id_when_absent(scheduler: Any) -> None:
|
||
|
|
cond, uncond = _add_pair(scheduler)
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {"req-1": {"cond": cond.request_id, "uncond": uncond.request_id}}
|
||
|
|
assert scheduler._cfg_req_to_pair == {cond.request_id: "req-1", uncond.request_id: "req-1"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_registered_companion_suffix_is_stripped(scheduler: Any) -> None:
|
||
|
|
register_cfg_uncond_suffix("::negative")
|
||
|
|
assert "::negative" in cfg_uncond_suffixes()
|
||
|
|
|
||
|
|
cond, uncond = _add_pair(scheduler, base_id="music-7", suffix="::negative")
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {"music-7": {"cond": cond.request_id, "uncond": uncond.request_id}}
|
||
|
|
|
||
|
|
|
||
|
|
def test_register_rejects_empty_suffix() -> None:
|
||
|
|
with pytest.raises(ValueError):
|
||
|
|
register_cfg_uncond_suffix("")
|
||
|
|
|
||
|
|
|
||
|
|
def test_uncond_without_known_suffix_keeps_its_own_request_id(scheduler: Any) -> None:
|
||
|
|
# Degenerate input: the companion never joins the cond pair, so the pair
|
||
|
|
# stays incomplete and the hold budget eventually releases it.
|
||
|
|
scheduler.add_request(_FakeRequest("solo-uncond", {"cfg_role": "uncond"}))
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {"solo-uncond": {"uncond": "solo-uncond"}}
|
||
|
|
|
||
|
|
|
||
|
|
def test_explicit_pair_id_takes_precedence(scheduler: Any) -> None:
|
||
|
|
cond, uncond = _add_pair(scheduler, pair_id="pair-A")
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {"pair-A": {"cond": cond.request_id, "uncond": uncond.request_id}}
|
||
|
|
assert cond.request_id not in scheduler._cfg_pairs
|
||
|
|
assert scheduler._cfg_req_to_pair[uncond.request_id] == "pair-A"
|
||
|
|
|
||
|
|
|
||
|
|
def test_cfg_requests_skip_prefix_cache_reads(scheduler: Any) -> None:
|
||
|
|
cond, uncond = _add_pair(scheduler)
|
||
|
|
|
||
|
|
assert cond.skip_reading_prefix_cache is True
|
||
|
|
assert uncond.skip_reading_prefix_cache is True
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── admission holding ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_lone_member_is_held_then_released(scheduler: Any) -> None:
|
||
|
|
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
|
||
|
|
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
assert scheduler.observed_waiting == [[]] # the base scheduler saw nothing
|
||
|
|
assert [req.request_id for req in scheduler.waiting] == ["req-1"]
|
||
|
|
assert scheduler.running == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_complete_pair_is_admitted_together(scheduler: Any) -> None:
|
||
|
|
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
|
||
|
|
scheduler.schedule()
|
||
|
|
scheduler.add_request(_FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"}))
|
||
|
|
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
assert scheduler.observed_waiting[-1] == ["req-1", "req-1__cfg_uncond"]
|
||
|
|
assert [req.request_id for req in scheduler.running] == ["req-1", "req-1__cfg_uncond"]
|
||
|
|
assert list(scheduler.waiting) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_hold_budget_expiry_drops_the_stale_pair(scheduler: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
monkeypatch.setattr(cfg_pairing, "_MAX_PAIR_HOLD_STEPS", 2)
|
||
|
|
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
|
||
|
|
|
||
|
|
scheduler.schedule()
|
||
|
|
scheduler.schedule()
|
||
|
|
assert scheduler.running == []
|
||
|
|
assert scheduler._cfg_pairs # still hoping for the partner
|
||
|
|
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {}
|
||
|
|
assert scheduler._cfg_req_to_pair == {}
|
||
|
|
assert [req.request_id for req in scheduler.running] == ["req-1"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_waiting_queue_reorder_puts_partners_adjacent(scheduler: Any) -> None:
|
||
|
|
scheduler.add_request(_FakeRequest("req-1", {"cfg_role": "cond"}))
|
||
|
|
scheduler.add_request(_FakeRequest("other", None))
|
||
|
|
scheduler.add_request(_FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"}))
|
||
|
|
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
assert scheduler.observed_waiting[-1] == ["req-1", "req-1__cfg_uncond", "other"]
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── progress equalization ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def _equalize_fixture(
|
||
|
|
cond_computed: int,
|
||
|
|
uncond_computed: int,
|
||
|
|
cond_sched: int,
|
||
|
|
uncond_sched: int,
|
||
|
|
) -> tuple[Any, _FakeSchedulerOutput, _FakeRequest, _FakeRequest]:
|
||
|
|
cond = _FakeRequest("req-1", {"cfg_role": "cond"})
|
||
|
|
uncond = _FakeRequest("req-1__cfg_uncond", {"cfg_role": "uncond"})
|
||
|
|
cond.num_computed_tokens = cond_computed
|
||
|
|
uncond.num_computed_tokens = uncond_computed
|
||
|
|
fake = SimpleNamespace(
|
||
|
|
_cfg_pairs={"req-1": {"cond": cond.request_id, "uncond": uncond.request_id}},
|
||
|
|
requests={cond.request_id: cond, uncond.request_id: uncond},
|
||
|
|
)
|
||
|
|
output = _FakeSchedulerOutput(
|
||
|
|
num_scheduled_tokens={cond.request_id: cond_sched, uncond.request_id: uncond_sched},
|
||
|
|
total_num_scheduled_tokens=cond_sched + uncond_sched,
|
||
|
|
)
|
||
|
|
return fake, output, cond, uncond
|
||
|
|
|
||
|
|
|
||
|
|
def test_equalize_pulls_the_ahead_member_back() -> None:
|
||
|
|
fake, output, cond, uncond = _equalize_fixture(10, 6, 5, 4)
|
||
|
|
|
||
|
|
_equalize_cfg_pair_progress(fake, output)
|
||
|
|
|
||
|
|
assert cond.num_computed_tokens == 6
|
||
|
|
assert uncond.num_computed_tokens == 6
|
||
|
|
assert output.num_scheduled_tokens == {cond.request_id: 1, uncond.request_id: 4}
|
||
|
|
assert output.total_num_scheduled_tokens == 5
|
||
|
|
|
||
|
|
|
||
|
|
def test_equalize_is_a_noop_when_the_pair_is_already_aligned() -> None:
|
||
|
|
fake, output, cond, uncond = _equalize_fixture(8, 8, 4, 4)
|
||
|
|
|
||
|
|
_equalize_cfg_pair_progress(fake, output)
|
||
|
|
|
||
|
|
assert (cond.num_computed_tokens, uncond.num_computed_tokens) == (8, 8)
|
||
|
|
assert output.total_num_scheduled_tokens == 8
|
||
|
|
|
||
|
|
|
||
|
|
def test_equalize_skips_an_infeasible_rollback() -> None:
|
||
|
|
# Clawing back 4 tokens would leave the cond row with 0 scheduled tokens,
|
||
|
|
# which the scheduler output cannot express; leave the pair to _drop_split_pairs.
|
||
|
|
fake, output, cond, uncond = _equalize_fixture(10, 6, 4, 4)
|
||
|
|
|
||
|
|
_equalize_cfg_pair_progress(fake, output)
|
||
|
|
|
||
|
|
assert cond.num_computed_tokens == 10
|
||
|
|
assert output.total_num_scheduled_tokens == 8
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── finish ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_finish_requests_finishes_the_partner(scheduler: Any) -> None:
|
||
|
|
cond, uncond = _add_pair(scheduler)
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
scheduler.finish_requests(cond.request_id, "FINISHED_STOPPED")
|
||
|
|
|
||
|
|
assert scheduler.finished == [{cond.request_id, uncond.request_id}]
|
||
|
|
assert scheduler._cfg_pairs == {}
|
||
|
|
assert scheduler._cfg_req_to_pair == {}
|
||
|
|
assert scheduler.running == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_finish_all_requests_clears_the_registry(scheduler: Any) -> None:
|
||
|
|
_add_pair(scheduler)
|
||
|
|
scheduler.schedule()
|
||
|
|
|
||
|
|
scheduler.finish_requests(None, "FINISHED_ABORTED")
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {}
|
||
|
|
assert scheduler._cfg_req_to_pair == {}
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── non-CFG traffic ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_plain_requests_are_untouched(scheduler: Any) -> None:
|
||
|
|
scheduler.add_request(_FakeRequest("plain-1", None))
|
||
|
|
scheduler.add_request(_FakeRequest("plain-2", {"seed": 3}))
|
||
|
|
|
||
|
|
output = scheduler.schedule()
|
||
|
|
|
||
|
|
assert scheduler._cfg_pairs == {}
|
||
|
|
assert scheduler.observed_waiting == [["plain-1", "plain-2"]]
|
||
|
|
assert output.num_scheduled_tokens == {"plain-1": 1, "plain-2": 1}
|
||
|
|
assert output.total_num_scheduled_tokens == 2
|
||
|
|
# No pair ever existed, so the hold bookkeeping is never allocated.
|
||
|
|
assert not hasattr(scheduler, "_cfg_hold_counts")
|
||
|
|
|
||
|
|
|
||
|
|
def test_default_suffix_matches_the_audex_stage_processor() -> None:
|
||
|
|
from vllm_omni.model_executor.stage_input_processors.audex import CFG_UNCOND_SUFFIX
|
||
|
|
|
||
|
|
assert CFG_UNCOND_SUFFIX in cfg_uncond_suffixes()
|
||
|
|
|
||
|
|
|
||
|
|
def test_audex_cfg_module_still_exports_the_patch_entry_points() -> None:
|
||
|
|
from vllm_omni.model_executor.models.audex.cfg import apply_cfg_patches, cfg_patches_applied
|
||
|
|
|
||
|
|
assert apply_cfg_patches is cfg_pairing.apply_cfg_patches
|
||
|
|
assert cfg_patches_applied is cfg_pairing.cfg_patches_applied
|
||
|
|
|
||
|
|
|
||
|
|
# ───────── install gate ─────────
|
||
|
|
|
||
|
|
|
||
|
|
def test_gate_triggers_on_a_cfg_logits_processor() -> None:
|
||
|
|
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
|
||
|
|
|
||
|
|
class AudexCFGLogitsProcessor:
|
||
|
|
pass
|
||
|
|
|
||
|
|
assert _stage_declares_cfg_pairs(SimpleNamespace(logits_processors=[AudexCFGLogitsProcessor]))
|
||
|
|
# vLLM keeps unresolved entries as dotted strings.
|
||
|
|
assert _stage_declares_cfg_pairs(
|
||
|
|
SimpleNamespace(logits_processors=["vllm_omni.model_executor.models.audex.cfg:AudexCFGLogitsProcessor"])
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_gate_triggers_on_a_declared_cfg_role_default() -> None:
|
||
|
|
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
|
||
|
|
|
||
|
|
assert _stage_declares_cfg_pairs(
|
||
|
|
SimpleNamespace(logits_processors=None, sampling_extra_args_keys=("cfg_role", "cfg_scale"))
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_gate_stays_off_for_non_cfg_stages() -> None:
|
||
|
|
from vllm_omni.engine.stage_init_utils import _stage_declares_cfg_pairs
|
||
|
|
|
||
|
|
assert not _stage_declares_cfg_pairs(SimpleNamespace(logits_processors=[], sampling_extra_args_keys=()))
|
||
|
|
assert not _stage_declares_cfg_pairs(
|
||
|
|
SimpleNamespace(logits_processors=["some.module:MinPLogitsProcessor"], sampling_extra_args_keys=("tta_rvq",))
|
||
|
|
)
|
||
|
|
assert not _stage_declares_cfg_pairs(SimpleNamespace())
|
||
|
|
|
||
|
|
|
||
|
|
def test_sampling_extra_args_keys_are_derived_from_stage_defaults() -> None:
|
||
|
|
from vllm_omni.engine.stage_init_utils import _sampling_extra_args_keys
|
||
|
|
|
||
|
|
assert _sampling_extra_args_keys({"extra_args": {"cfg_scale": 1.5, "cfg_role": "cond"}}) == (
|
||
|
|
"cfg_role",
|
||
|
|
"cfg_scale",
|
||
|
|
)
|
||
|
|
assert _sampling_extra_args_keys({"temperature": 0.1}) == ()
|
||
|
|
assert _sampling_extra_args_keys(None) == ()
|