Signed-off-by: Zihua Wu <13583761+lucifer1004@users.noreply.github.com> Co-authored-by: Kimi Code <noreply@moonshot.cn> Co-authored-by: Yongye Zhu <zyy1102000@gmail.com>
230 lines
8.2 KiB
Python
230 lines
8.2 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Which rows the PP sampled-token broadcast must carry."""
|
|
|
|
from unittest.mock import Mock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.v1.worker.gpu import model_runner, pp_utils
|
|
|
|
|
|
def _cuda_handler(max_sample_len=6):
|
|
handler = object.__new__(pp_utils.PPHandler)
|
|
handler.is_last_rank = True
|
|
handler.disabled = False
|
|
handler.max_sample_len = max_sample_len
|
|
handler.last_rank = 1
|
|
handler.broadcast_group = Mock()
|
|
handler.device = torch.device("cuda")
|
|
handler.main_stream = torch.cuda.current_stream()
|
|
handler.broadcast_stream = torch.cuda.Stream()
|
|
return handler
|
|
|
|
|
|
def _batch(num_computed, prefill_len, num_scheduled, idx_mapping=None):
|
|
num_reqs = len(num_computed)
|
|
if idx_mapping is None:
|
|
idx_mapping = list(range(num_reqs))
|
|
return Mock(
|
|
num_reqs=num_reqs,
|
|
num_computed_tokens_np=np.array(num_computed, dtype=np.int32),
|
|
prefill_len_np=np.array(prefill_len, dtype=np.int32),
|
|
num_scheduled_tokens=np.array(num_scheduled, dtype=np.int32),
|
|
idx_mapping=torch.tensor(idx_mapping, dtype=torch.int64),
|
|
)
|
|
|
|
|
|
def test_excludes_non_final_prefill_chunks():
|
|
"""Unchanged behaviour: a chunk that does not finish its prefill is skipped."""
|
|
# Row 0 is a middle prefill chunk and produces no sample; row 1 finishes its
|
|
# prefill this step and therefore does.
|
|
batch = _batch(
|
|
num_computed=[512, 1000],
|
|
prefill_len=[4096, 1004],
|
|
num_scheduled=[448, 4],
|
|
)
|
|
|
|
mask = pp_utils.compute_need_sampled_mask(batch)
|
|
|
|
assert mask is not None
|
|
assert mask.tolist() == [False, True]
|
|
|
|
|
|
def test_none_when_no_row_samples():
|
|
"""Unchanged behaviour: an all-prefill batch needs no broadcast at all."""
|
|
batch = _batch(
|
|
num_computed=[0, 512],
|
|
prefill_len=[4096, 4096],
|
|
num_scheduled=[448, 448],
|
|
)
|
|
|
|
assert pp_utils.compute_need_sampled_mask(batch) is None
|
|
|
|
|
|
def test_keeps_decoding_request_past_its_length_cap():
|
|
"""A decoding request must never be dropped from the broadcast.
|
|
|
|
Speculative decoding advances `num_computed_tokens` several tokens per step,
|
|
so it can overrun `prompt_len + max_tokens` while the scheduler is still
|
|
running the request. Predicting "this one is finishing" and skipping its
|
|
broadcast freezes the earlier pipeline stages' `last_sampled_tokens` and
|
|
`draft_tokens` while the last rank keeps advancing its own, and the stages
|
|
then diverge permanently.
|
|
"""
|
|
batch = _batch(
|
|
# 14176 computed tokens is already past this request's own
|
|
# prompt_len + max_tokens; the scheduler is still running it.
|
|
num_computed=[14176],
|
|
prefill_len=[12175],
|
|
num_scheduled=[8],
|
|
)
|
|
|
|
mask = pp_utils.compute_need_sampled_mask(batch)
|
|
|
|
assert mask is not None
|
|
assert mask.tolist() == [True]
|
|
|
|
|
|
def test_decode_row_ahead_of_a_prefill_chunk():
|
|
"""Row order does not matter: only whether the row finishes its prefill."""
|
|
batch = _batch(
|
|
num_computed=[10, 512],
|
|
prefill_len=[8, 4096],
|
|
num_scheduled=[1, 448],
|
|
)
|
|
|
|
mask = pp_utils.compute_need_sampled_mask(batch)
|
|
|
|
assert mask is not None
|
|
assert mask.tolist() == [True, False]
|
|
|
|
|
|
def test_disabled_handler_skips_broadcast_and_receive(monkeypatch):
|
|
"""While disabled (warmup), neither side enqueues a broadcast op."""
|
|
sent = []
|
|
monkeypatch.setattr(
|
|
pp_utils.torch.distributed,
|
|
"broadcast",
|
|
lambda *args, **kwargs: sent.append((args, kwargs)),
|
|
)
|
|
|
|
handler = object.__new__(pp_utils.PPHandler)
|
|
handler.set_disabled(True)
|
|
|
|
handler.is_last_rank = False
|
|
assert handler.receive(Mock()) is False
|
|
|
|
handler.is_last_rank = True
|
|
assert handler.broadcast(Mock(), Mock(), Mock(), Mock()) is None
|
|
|
|
assert sent == []
|
|
|
|
handler.set_disabled(False)
|
|
assert handler.disabled is False
|
|
|
|
|
|
def test_alloc_combined_keeps_unbind_views_16_byte_aligned():
|
|
"""Triton specializes on pointer alignment: an unaligned `num_rejected`
|
|
would compile a second `_post_update_kernel` variant at serving time,
|
|
where the in-flight broadcast NCCL kernel can block the module load."""
|
|
for num_reqs in range(1, 9):
|
|
combined = pp_utils._alloc_combined(num_reqs, torch.device("cpu"))
|
|
num_sampled, num_rejected = combined.unbind(dim=0)
|
|
assert num_sampled.data_ptr() % 16 == 0
|
|
assert num_rejected.data_ptr() % 16 == 0
|
|
assert combined.shape[1] >= num_reqs
|
|
|
|
|
|
def test_warmup_pp_decode_update_matches_serving_specialization(monkeypatch):
|
|
"""The warmup launch must hit the same triton specialization as serving.
|
|
|
|
A mismatch means the first real ``update_pp_decode_requests`` recompiles
|
|
mid-serving, where the in-flight broadcast NCCL kernel blocks the CUDA
|
|
module load and deadlocks the pipeline.
|
|
"""
|
|
calls = []
|
|
monkeypatch.setattr(model_runner, "post_update", lambda *args: calls.append(args))
|
|
|
|
runner = object.__new__(model_runner.GPUModelRunner)
|
|
runner.device = torch.device("cpu")
|
|
runner.pp_handler = Mock(max_sample_len=3)
|
|
runner.req_states = Mock()
|
|
|
|
runner.warmup_pp_decode_update()
|
|
|
|
assert len(calls) == 1
|
|
args = calls[0]
|
|
idx_mapping, _, _, output_bin_counts = args[:4]
|
|
sampled_tokens, num_sampled, num_rejected, query_start_loc = args[4:8]
|
|
broadcast_drafts, draft_tokens_out = args[10:12]
|
|
assert idx_mapping.tolist() == [-1] and idx_mapping.dtype == torch.int64
|
|
assert output_bin_counts is None
|
|
assert query_start_loc is None
|
|
assert sampled_tokens.shape == (1, 3) and sampled_tokens.dtype == torch.int64
|
|
assert num_sampled.dtype == torch.int32
|
|
assert num_rejected.dtype == torch.int32
|
|
# Spec-enabled PP handlers receive drafts over the broadcast; the warmup
|
|
# must compile that specialization (non-None draft pointers) too.
|
|
assert broadcast_drafts.shape == (1, 2) and broadcast_drafts.dtype == torch.int64
|
|
assert draft_tokens_out is runner.req_states.draft_tokens
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA stream")
|
|
def test_broadcast_pads_plain_sampler_rows_to_max_sample_len(monkeypatch):
|
|
"""The wire shape must not depend on whether the batch carried drafts:
|
|
the receiver always allocates [num_reqs, max_sample_len], and a NCCL
|
|
broadcast with mismatched counts hangs the receiver."""
|
|
sent = []
|
|
monkeypatch.setattr(
|
|
pp_utils.torch.distributed,
|
|
"broadcast",
|
|
lambda tensor, **kw: sent.append(tensor),
|
|
)
|
|
handler = _cuda_handler()
|
|
batch = _batch(num_computed=[10], prefill_len=[8], num_scheduled=[1])
|
|
|
|
handler.broadcast(
|
|
torch.zeros(1, 1, dtype=torch.int64, device="cuda"), # plain sampler
|
|
torch.ones(1, dtype=torch.int32, device="cuda"),
|
|
torch.zeros(1, dtype=torch.int32, device="cuda"),
|
|
batch,
|
|
)
|
|
|
|
assert sent[0].shape == (1, 6)
|
|
assert sent[1].shape == (2, 4)
|
|
torch.accelerator.synchronize()
|
|
|
|
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA stream")
|
|
def test_broadcast_drafts_gathers_fresh_rows_from_the_table(monkeypatch):
|
|
sent = []
|
|
monkeypatch.setattr(
|
|
pp_utils.torch.distributed,
|
|
"broadcast",
|
|
lambda tensor, **kw: sent.append(tensor),
|
|
)
|
|
handler = _cuda_handler()
|
|
# The batch's single row maps to request-state row 2.
|
|
batch = _batch(num_computed=[10], prefill_len=[8], num_scheduled=[1])
|
|
batch.idx_mapping = torch.tensor([2], dtype=torch.int64, device="cuda")
|
|
table = torch.arange(20, dtype=torch.int64, device="cuda").view(4, 5)
|
|
|
|
handler.broadcast_drafts(table, batch)
|
|
|
|
assert sent[0].shape == (1, 5)
|
|
# The payload is a gather into a fresh tensor: propose() overwrites its
|
|
# persistent buffer on the next step, possibly before this send completes.
|
|
assert sent[0].data_ptr() != table.data_ptr()
|
|
assert sent[0].cpu().tolist() == [table[2].cpu().tolist()]
|
|
|
|
# An all-prefill batch sends nothing (receive() enqueues nothing either).
|
|
sent.clear()
|
|
prefill_batch = _batch(num_computed=[0], prefill_len=[4096], num_scheduled=[448])
|
|
prefill_batch.idx_mapping = prefill_batch.idx_mapping.cuda()
|
|
handler.broadcast_drafts(table, prefill_batch)
|
|
assert sent == []
|
|
torch.accelerator.synchronize()
|