Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

233 lines
7.1 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Unit tests for OutputProcesser.post_process_forward_op after a flat retract.
Flat retract rebases the victim's generated tokens into its prefill window
(C++ RebasePrefill), so on re-admission the op's ``prefill_lengths`` exceeds
the original prompt length that RequestState.prefill_finished compares
against. A mid-chunk re-prefill slot must emit NO ExtendResultEvent (the C++
FSM is still Prefilling and would throw) and stream NO token (the sampled
token is garbage). The gate is the op's own chunking criterion:
``extend_prefix_lens[i] + input_lengths[i] < prefill_lengths[i]``.
"""
from __future__ import annotations
from tokenspeed.runtime.engine.generation_output_processor import (
OutputProcesser,
RequestState,
)
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
class _Sender:
def __init__(self):
self.items = []
def send_pyobj(self, obj):
self.items.append(obj)
class _Tokenizer:
eos_token_id = None
additional_stop_token_ids = None
def decode(self, ids):
return "".join(str(i) for i in ids)
class _Metrics:
enabled = False
def record_nan_abort(self):
pass
class _Scalar:
def __init__(self, v):
self._v = v
def item(self):
return self._v
class _Vec:
"""Minimal tensor stand-in: indexable scalars plus tolist()."""
def __init__(self, vals):
self._vals = list(vals)
def __getitem__(self, i):
return _Scalar(self._vals[i])
def tolist(self):
return list(self._vals)
class _Results:
"""ModelExecutionResult stub: one sampled token per slot."""
def __init__(self, output_tokens, output_lengths):
self.output_tokens = _Vec(output_tokens)
self.output_lengths = _Vec(output_lengths)
self.output_logprobs = None
self.output_nan_flags = None
self.grammar_completion = None
self.next_input_ids = None
def sync(self):
pass
class _ForwardOp:
"""Forward-op stub exposing the flat binding's per-slot prefill_lengths."""
def __init__(
self,
request_ids,
extend_prefix_lens,
input_lengths,
num_extends,
prefill_lengths=None,
):
self.request_ids = request_ids
self.extend_prefix_lens = extend_prefix_lens
self.input_lengths = input_lengths
self.request_pool_indices = list(range(len(request_ids)))
self._num_extends = num_extends
if prefill_lengths is not None:
self.prefill_lengths = prefill_lengths
def num_extends(self):
return self._num_extends
def _state(input_ids: list[int]) -> RequestState:
return RequestState(
prompt_input_ids=input_ids,
sampling_params=SamplingParams(max_new_tokens=8, stop=[], ignore_eos=True),
stream=False,
tokenizer=_Tokenizer(),
)
def _proc(sender: _Sender) -> OutputProcesser:
return OutputProcesser(sender, attn_tp_rank=0, metrics=_Metrics())
def _kinds(events) -> list[str]:
return [type(e).__name__ for e in events]
def test_mid_chunk_readmit_slot_emits_nothing():
# Retract-rebase shape: prompt is 3 tokens but the op's prefill length is 9
# (prompt + 6 generated tokens rebased into the prefill window). First
# re-admission chunk covers 4+4=8 < 9 — mid-chunk, C++ owes no result.
sender = _Sender()
proc = _proc(sender)
state = _state([1, 2, 3])
proc.rid_to_state["victim"] = state
op = _ForwardOp(
request_ids=["victim"],
extend_prefix_lens=[4],
input_lengths=[4],
num_extends=1,
prefill_lengths=[9],
)
changes = proc.post_process_forward_op(op, _Results([777], [1]))
# The old prompt-length gate saw computed(8) >= prompt(3) and emitted an
# ExtendResultEvent (C++ Prefilling FSM throws) plus one garbage token.
assert "ExtendResult" not in _kinds(changes)
assert changes == []
assert state.output_ids == []
assert sender.items == []
assert "victim" in proc.rid_to_state
def test_final_chunk_readmit_slot_emits_result():
# Same rebased request, last chunk: 8+1 == 9 — the op's criterion matches
# the C++ PrefillDone decision, byte-identical to a fresh request whose
# final chunk reaches its prompt length.
sender = _Sender()
proc = _proc(sender)
state = _state([1, 2, 3])
proc.rid_to_state["victim"] = state
op = _ForwardOp(
request_ids=["victim"],
extend_prefix_lens=[8],
input_lengths=[1],
num_extends=1,
prefill_lengths=[9],
)
changes = proc.post_process_forward_op(op, _Results([777], [1]))
assert "ExtendResult" in _kinds(changes)
assert state.output_ids == [777]
assert len(sender.items) == 1
def test_decode_slot_unaffected_by_prefill_lengths():
# Decode slots never consult the extend gate; result flows as before.
sender = _Sender()
proc = _proc(sender)
state = _state([1, 2, 3])
state.computed_length = 3 # prefill already done
proc.rid_to_state["dec"] = state
op = _ForwardOp(
request_ids=["dec"],
extend_prefix_lens=[],
input_lengths=[1],
num_extends=0,
prefill_lengths=[],
)
changes = proc.post_process_forward_op(op, _Results([555], [1]))
kinds = _kinds(changes)
assert "ExtendResult" in kinds
assert "UpdateReserveNumTokens" in kinds
assert state.output_ids == [555]
def test_op_without_prefill_lengths_falls_back_to_prompt_gate():
# Older/radix binding without the field: the prefill_finished gate still
# suppresses mid-chunk output for fresh requests.
sender = _Sender()
proc = _proc(sender)
state = _state([1, 2, 3, 4, 5, 6, 7, 8])
proc.rid_to_state["fresh"] = state
op = _ForwardOp(
request_ids=["fresh"],
extend_prefix_lens=[0],
input_lengths=[4],
num_extends=1,
prefill_lengths=None,
)
changes = proc.post_process_forward_op(op, _Results([888], [1]))
assert changes == []
assert state.output_ids == []
assert sender.items == []