[bugfix] validate AR sampled-token logprob alignment (#5273)
Signed-off-by: hbhflw2000 <417911774@qq.com> Signed-off-by: Samit <285365963@qq.com> Co-authored-by: Samit <285365963@qq.com>
This commit is contained in:
1 parent
807db6efd7
commit
d3e0171cd7
2 files changed
+249
-7
No files matched your search
@@ -0,0 +1,181 @@
|
||||
"""Tests for the AR sampled-token logprob contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from vllm.v1.engine import FinishReason
|
||||
from vllm.v1.outputs import LogprobsLists
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler, _slice_sampled_logprobs
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _logprobs(
|
||||
token_ids: list[list[int]],
|
||||
values: list[list[float]],
|
||||
*,
|
||||
cumulative_rows: list[int] | None = None,
|
||||
) -> LogprobsLists:
|
||||
return LogprobsLists(
|
||||
logprob_token_ids=np.asarray(token_ids, dtype=np.int32),
|
||||
logprobs=np.asarray(values, dtype=np.float32),
|
||||
sampled_token_ranks=np.zeros(len(token_ids), dtype=np.int32),
|
||||
cu_num_generated_tokens=cumulative_rows,
|
||||
)
|
||||
|
||||
|
||||
def test_slices_requested_rows_and_preserves_sampled_token_values() -> None:
|
||||
all_logprobs = _logprobs(
|
||||
[[3, 8], [4, 9], [11, 2], [12, 1]],
|
||||
[[-0.3, -1.0], [-0.4, -1.1], [-0.11, -2.0], [-0.12, -2.1]],
|
||||
cumulative_rows=[0, 2],
|
||||
)
|
||||
|
||||
sliced = _slice_sampled_logprobs(all_logprobs, req_index=1, sampled_token_ids=[11, 12])
|
||||
|
||||
np.testing.assert_array_equal(sliced.logprob_token_ids[:, 0], [11, 12])
|
||||
np.testing.assert_allclose(sliced.logprobs[:, 0], [-0.11, -0.12])
|
||||
|
||||
|
||||
def test_rejects_missing_logprobs() -> None:
|
||||
with pytest.raises(RuntimeError, match="model runner returned none"):
|
||||
_slice_sampled_logprobs(None, req_index=0, sampled_token_ids=[7])
|
||||
|
||||
|
||||
def test_rejects_row_count_mismatch() -> None:
|
||||
logprobs = _logprobs([[7, 1]], [[-0.7, -1.0]])
|
||||
|
||||
with pytest.raises(RuntimeError, match="row count does not match"):
|
||||
_slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7, 8])
|
||||
|
||||
|
||||
def test_rejects_sampled_token_mismatch() -> None:
|
||||
logprobs = _logprobs([[9, 7]], [[-0.9, -1.0]])
|
||||
|
||||
with pytest.raises(RuntimeError, match="generated_token=7 logprob_token=9"):
|
||||
_slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7])
|
||||
|
||||
|
||||
def test_rejects_non_finite_sampled_logprob() -> None:
|
||||
logprobs = _logprobs([[7, 1]], [[float("nan"), -1.0]])
|
||||
|
||||
with pytest.raises(RuntimeError, match="non-finite values at rows \\[0\\]"):
|
||||
_slice_sampled_logprobs(logprobs, req_index=0, sampled_token_ids=[7])
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, request_id: str) -> None:
|
||||
self.request_id = request_id
|
||||
self.client_index = 0
|
||||
self.status = RequestStatus.RUNNING
|
||||
self.sampling_params = SimpleNamespace(num_logprobs=1)
|
||||
self.num_computed_tokens = 0
|
||||
self.num_output_placeholders = 0
|
||||
self.has_encoder_inputs = False
|
||||
self.pooling_params = None
|
||||
self.resumable = True
|
||||
self.stop_reason = None
|
||||
self.trace_headers = None
|
||||
self.num_nans_in_logits = 0
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
return RequestStatus.is_finished(self.status)
|
||||
|
||||
def get_finished_reason(self):
|
||||
return RequestStatus.get_finished_reason(self.status)
|
||||
|
||||
def take_events(self):
|
||||
return None
|
||||
|
||||
def take_prefill_stats(self):
|
||||
return None
|
||||
|
||||
|
||||
class _RequestQueue(list):
|
||||
def remove_requests(self, requests) -> None:
|
||||
for request in requests:
|
||||
if request in self:
|
||||
self.remove(request)
|
||||
|
||||
|
||||
def test_invalid_logprobs_finish_only_the_affected_scheduler_request() -> None:
|
||||
"""A bad runner response must not bring down unrelated batch requests."""
|
||||
bad = _Request("bad")
|
||||
good = _Request("good")
|
||||
scheduler = SimpleNamespace(
|
||||
perf_metrics=None,
|
||||
connector=None,
|
||||
chunk_transfer_adapter=None,
|
||||
requests={bad.request_id: bad, good.request_id: good},
|
||||
running=[bad, good],
|
||||
waiting=_RequestQueue(),
|
||||
skipped_waiting=_RequestQueue(),
|
||||
structured_output_manager=SimpleNamespace(should_advance=lambda _request: False),
|
||||
transfer_triggered_requests=set(),
|
||||
active_kv_transfers=set(),
|
||||
pending_stop_after_extraction=set(),
|
||||
waiting_for_transfer_free=set(),
|
||||
_new_prompt_len_snapshot={},
|
||||
finished_req_ids=set(),
|
||||
finished_req_ids_dict=defaultdict(set),
|
||||
kv_cache_manager=SimpleNamespace(take_events=lambda: None),
|
||||
kv_event_publisher=SimpleNamespace(publish=lambda _events: None),
|
||||
)
|
||||
update_calls: list[str] = []
|
||||
|
||||
def update_request(request, token_ids):
|
||||
update_calls.append(request.request_id)
|
||||
return token_ids, False
|
||||
|
||||
def free_request(request):
|
||||
scheduler.requests.pop(request.request_id, None)
|
||||
scheduler.finished_req_ids.add(request.request_id)
|
||||
scheduler.finished_req_ids_dict[request.client_index].add(request.request_id)
|
||||
return None
|
||||
|
||||
scheduler._update_request_with_output = update_request
|
||||
scheduler._process_kv_transfer_trigger = lambda _request, _tokens: False
|
||||
scheduler._handle_stopped_request = lambda _request: True
|
||||
scheduler._free_request = free_request
|
||||
scheduler.make_spec_decoding_stats = lambda *args, **kwargs: None
|
||||
scheduler.make_stats = lambda *args, **kwargs: None
|
||||
scheduler._capture_omni_connector_output = lambda _output: None
|
||||
|
||||
scheduler_output = SimpleNamespace(
|
||||
num_scheduled_tokens={"bad": 1, "good": 1},
|
||||
scheduled_spec_decode_tokens={},
|
||||
num_invalid_spec_tokens=0,
|
||||
)
|
||||
model_runner_output = SimpleNamespace(
|
||||
sampled_token_ids=[[7], [8]],
|
||||
logprobs=_logprobs(
|
||||
[[9, 1], [8, 2]],
|
||||
[[-0.9, -1.0], [-0.8, -1.0]],
|
||||
cumulative_rows=[0, 1],
|
||||
),
|
||||
prompt_logprobs_dict={},
|
||||
pooler_output=None,
|
||||
num_nans_in_logits=None,
|
||||
kv_connector_output=None,
|
||||
cudagraph_stats=None,
|
||||
req_id_to_index={"bad": 0, "good": 1},
|
||||
routed_experts=None,
|
||||
)
|
||||
|
||||
outputs = OmniARScheduler.update_from_output(scheduler, scheduler_output, model_runner_output)
|
||||
output_by_id = {output.request_id: output for output in outputs[0].outputs}
|
||||
|
||||
assert update_calls == ["good"]
|
||||
assert bad.status is RequestStatus.FINISHED_ERROR
|
||||
assert bad.stop_reason is not None and "misaligned" in bad.stop_reason
|
||||
assert "bad" not in scheduler.requests
|
||||
assert output_by_id["bad"].finish_reason is FinishReason.ERROR
|
||||
assert output_by_id["bad"].new_token_ids == []
|
||||
assert output_by_id["good"].new_token_ids == [8]
|
||||
np.testing.assert_array_equal(output_by_id["good"].new_logprobs.logprob_token_ids[:, 0], [8])
|
||||
@@ -5,6 +5,7 @@ from collections.abc import Iterable
|
||||
from time import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from vllm.compilation.cuda_graph import CUDAGraphStat
|
||||
from vllm.distributed.kv_events import KVEventBatch
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
@@ -36,6 +37,51 @@ from vllm_omni.outputs import OmniConnectorOutput
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class SampledLogprobContractError(RuntimeError):
|
||||
"""The model runner returned unusable sampled-token logprobs."""
|
||||
|
||||
|
||||
def _slice_sampled_logprobs(logprobs: Any, req_index: int, sampled_token_ids: list[int]) -> Any:
|
||||
"""Slice and validate the sampled-token logprobs for one AR request."""
|
||||
if logprobs is None:
|
||||
raise SampledLogprobContractError("AR logprobs were requested, but the model runner returned none")
|
||||
|
||||
sliced = logprobs.slice_request(req_index, len(sampled_token_ids))
|
||||
token_rows = np.asarray(sliced.logprob_token_ids)
|
||||
value_rows = np.asarray(sliced.logprobs)
|
||||
expected_rows = len(sampled_token_ids)
|
||||
|
||||
if token_rows.ndim != 2 or value_rows.ndim != 2:
|
||||
raise SampledLogprobContractError(
|
||||
"AR sampled-token logprobs must be rank-2 arrays, "
|
||||
f"got token_ids={token_rows.shape} logprobs={value_rows.shape}"
|
||||
)
|
||||
if token_rows.shape[0] != expected_rows or value_rows.shape[0] != expected_rows:
|
||||
raise SampledLogprobContractError(
|
||||
"AR sampled-token logprob row count does not match generated tokens: "
|
||||
f"tokens={expected_rows} token_id_rows={token_rows.shape[0]} "
|
||||
f"logprob_rows={value_rows.shape[0]}"
|
||||
)
|
||||
if expected_rows == 0:
|
||||
return sliced
|
||||
if token_rows.shape[1] == 0 or value_rows.shape[1] == 0:
|
||||
raise SampledLogprobContractError("AR sampled-token logprob rows are empty")
|
||||
|
||||
sampled = np.asarray(sampled_token_ids)
|
||||
if not np.array_equal(token_rows[:, 0], sampled):
|
||||
mismatch = np.flatnonzero(token_rows[:, 0] != sampled)
|
||||
first = int(mismatch[0])
|
||||
raise SampledLogprobContractError(
|
||||
"AR sampled-token logprobs are misaligned: "
|
||||
f"row={first} generated_token={int(sampled[first])} "
|
||||
f"logprob_token={int(token_rows[first, 0])}"
|
||||
)
|
||||
if not np.isfinite(value_rows[:, 0]).all():
|
||||
bad_rows = np.flatnonzero(~np.isfinite(value_rows[:, 0])).tolist()
|
||||
raise SampledLogprobContractError(f"AR sampled-token logprobs contain non-finite values at rows {bad_rows}")
|
||||
return sliced
|
||||
|
||||
|
||||
class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
"""Synchronous AutoRegressive scheduler for vLLM-Omni. This class is also
|
||||
used as a base class for the OmniARAsyncScheduler and holds most of the
|
||||
@@ -362,6 +408,27 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
|
||||
req_index = model_runner_output.req_id_to_index[req_id]
|
||||
generated_token_ids = sampled_token_ids[req_index] if sampled_token_ids else []
|
||||
status_before_stop = request.status
|
||||
new_logprobs = None
|
||||
logprob_validation_failed = False
|
||||
|
||||
# Validate before mutating request token state. A bad runner output
|
||||
# is request-local: terminate only this request and keep processing
|
||||
# the rest of the batch.
|
||||
if (
|
||||
generated_token_ids
|
||||
and request.sampling_params is not None
|
||||
and request.sampling_params.num_logprobs is not None
|
||||
):
|
||||
try:
|
||||
new_logprobs = _slice_sampled_logprobs(logprobs, req_index, generated_token_ids)
|
||||
except SampledLogprobContractError as exc:
|
||||
logger.error("Invalid AR sampled-token logprobs for request %s: %s", req_id, exc)
|
||||
request.status = RequestStatus.FINISHED_ERROR
|
||||
request.stop_reason = str(exc)
|
||||
request.resumable = False
|
||||
generated_token_ids = []
|
||||
logprob_validation_failed = True
|
||||
|
||||
scheduled_spec_token_ids = scheduler_output.scheduled_spec_decode_tokens.get(req_id)
|
||||
if scheduled_spec_token_ids and generated_token_ids:
|
||||
@@ -391,16 +458,14 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
if request.has_encoder_inputs:
|
||||
self._free_encoder_inputs(request)
|
||||
|
||||
stopped = False
|
||||
stopped = logprob_validation_failed
|
||||
is_segment_finished = False
|
||||
finished = False
|
||||
new_logprobs = None
|
||||
new_token_ids = generated_token_ids
|
||||
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
|
||||
mm_output = mm_outputs[req_index] if mm_outputs else None
|
||||
inter_stage_output = inter_stage_outputs[req_index] if inter_stage_outputs else None
|
||||
kv_transfer_params = None
|
||||
status_before_stop = request.status
|
||||
finish_reason = None
|
||||
routed_experts = None
|
||||
|
||||
@@ -471,10 +536,6 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler):
|
||||
else:
|
||||
stopped_preempted_reqs.add(request)
|
||||
|
||||
# Extract sample logprobs if needed.
|
||||
if request.sampling_params is not None and request.sampling_params.num_logprobs is not None and logprobs:
|
||||
new_logprobs = logprobs.slice_request(req_index, len(new_token_ids))
|
||||
|
||||
if num_nans_in_logits is not None and req_id in num_nans_in_logits:
|
||||
request.num_nans_in_logits = num_nans_in_logits[req_id]
|
||||
|
||||
|
||||
Reference in new issue
Block a user