Files
vllm-omni/vllm_omni/worker_v2/omni_model_runner.py
T
59db6a4285 [Rebase] Rebase to vLLM 0.30.0 (#7820)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Signed-off-by: Gao Han <hgaoaf@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
2026-09-22 15:52:01 +00:00

669 lines
29 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Omni v2 GPU model runner hooks."""
from __future__ import annotations
import threading
from typing import TYPE_CHECKING, Any, cast
import torch
from vllm.config.compilation import CUDAGraphMode
from vllm.forward_context import (
set_forward_context,
)
from vllm.logger import init_logger
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
from vllm.v1.worker.gpu.input_batch import set_dummy_context
from vllm.v1.worker.gpu.lora_utils import get_num_active_loras_for_dispatch
from vllm.v1.worker.gpu.mm.lora import set_active_mm_loras
from vllm.v1.worker.gpu.model_runner import (
BatchDescriptor,
BatchExecutionDescriptor,
ExecuteModelState,
GPUModelRunner,
IntermediateTensors,
build_slot_mappings_by_layer,
)
from vllm.v1.worker.gpu.sample.sampler import Sampler
from vllm_omni.core.sched.omni_scheduling_coordinator import (
uses_native_mrv2_data_plane,
)
from vllm_omni.model_executor.models.output_templates import OmniOutput
from vllm_omni.worker.sampling_utils import sanitize_sampling_params_min_tokens_stop_ids
from vllm_omni.worker_v2.model_states import init_omni_model_state
from vllm_omni.worker_v2.model_states.intermediate_buffer import (
_resolve_additional_information,
)
from vllm_omni.worker_v2.model_states.omni_model_state import OmniModelState
from vllm_omni.worker_v2.omni_data_plane import OmniRunnerDataPlane
if TYPE_CHECKING:
from vllm.v1.worker.gpu.mm.encoder_cache import EncoderCache
from vllm_omni.worker_v2.native_output_worker import NativeOutputWorker
logger = init_logger(__name__)
_model_state_patch_lock = threading.RLock()
def _needs_capture_tensor_unwrap(model: Any) -> bool:
return bool(getattr(model, "_returns_tuple", False))
class OmniGPUModelRunner(GPUModelRunner):
"""Thin layer over v2 ``GPUModelRunner`` for Omni lifecycle hooks."""
supports_mm_inputs: bool
encoder_cache: EncoderCache | None
_native_output_materializer: NativeOutputWorker | None
model_state: OmniModelState
_last_aux_output: Any
_last_multimodal_outputs: dict[str, Any] | None
_model_returns_tuple: bool
def _configure_cudagraph_output_contract(self) -> None:
"""Select the CUDA graph output contract declared by the model."""
self._model_returns_tuple = _needs_capture_tensor_unwrap(self.model)
self._exclude_full_graph = self._model_returns_tuple or hasattr(self.model, "_last_captured_layers")
@staticmethod
def _prepare_cudagraph_capture_output(model_output: Any) -> Any:
"""Unwrap model outputs for the upstream PIECEWISE capture warmup."""
if isinstance(model_output, OmniOutput):
return model_output.text_hidden_states
if isinstance(model_output, tuple) and len(model_output) == 2:
return model_output[0]
return model_output
def _add_legacy_forward_inputs(self, model_inputs: dict[str, Any], input_batch: Any) -> None:
"""Supply forward-only metadata for pipelines not on the native plane."""
if self._omni_data_plane is not None:
return
model_inputs.setdefault("sampling_metadata", getattr(input_batch, "sampling_metadata", None))
logits_index = getattr(
input_batch,
"logits_indices",
getattr(input_batch, "logits_index", None),
)
model_inputs.setdefault("logits_index", logits_index)
model_inputs.setdefault("sampler", self.sampler)
def _get_mm_embeddings(
self,
scheduled_encoder_inputs: dict[str, list[int]],
input_batch: Any,
) -> torch.Tensor:
"""Call the native vLLM multimodal embedding contract."""
return self.model_state.prepare_inputs_embeds(
scheduled_encoder_inputs,
input_batch,
self.req_states,
)
def _prepare_mm_inputs(
self,
scheduler_output: SchedulerOutput,
input_batch: Any,
*,
dummy_run: bool,
) -> tuple[Any, torch.Tensor | None, Any]:
"""Prepare native vLLM multimodal model inputs."""
input_ids = input_batch.input_ids
inputs_embeds = None
ec_connector_output = None
if not self.supports_mm_inputs or not self.is_first_pp_rank:
return input_ids, inputs_embeds, ec_connector_output
if dummy_run:
inputs_embeds = self.model_state.dummy_inputs_embeds(input_batch.num_tokens_after_padding)
else:
scheduled_encoder_inputs = scheduler_output.scheduled_encoder_inputs
if self.lora_config is not None:
set_active_mm_loras(
model=self.model,
lora_manager=self.lora_manager,
encoder_cache=self.encoder_cache,
req_id_to_index=self.req_states.req_id_to_index,
lora_state=self.lora_state,
scheduled_encoder_inputs=scheduled_encoder_inputs,
)
with self.ec_connector.maybe_get_output(scheduler_output) as ec_connector_output:
inputs_embeds = self._get_mm_embeddings(
scheduled_encoder_inputs,
input_batch,
)
if inputs_embeds is not None and not self.model.requires_raw_input_tokens:
input_ids = None
return input_ids, inputs_embeds, ec_connector_output
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._validate_parallel_support()
self._omni_data_plane = (
OmniRunnerDataPlane(self.vllm_config, self.model_config)
if uses_native_mrv2_data_plane(
self.model_config,
use_v2_model_runner=True,
)
else None
)
def _validate_parallel_support(self) -> None:
parallel_config = self.vllm_config.parallel_config
if parallel_config.pipeline_parallel_size > 1:
raise NotImplementedError(
"Omni Model Runner V2 does not support pipeline parallel; "
"use model_runner: v1 when pipeline_parallel_size > 1"
)
if getattr(parallel_config, "prefill_context_parallel_size", 1) > 1:
raise NotImplementedError(
"Omni Model Runner V2 does not support prefill context parallelism; "
"use model_runner: v1 when prefill_context_parallel_size > 1"
)
def add_requests(self, scheduler_output: SchedulerOutput) -> None:
# The upstream Sampler stages static parameters only in add_request.
# With no new requests, its UVA snapshots remain valid and immutable.
# Keep the upstream path for custom/speculative samplers, whose staged
# writes may have a different lifecycle.
if not scheduler_output.scheduled_new_reqs and type(getattr(self, "sampler", None)) is Sampler:
return
logits_processor = getattr(self.model, "logits_processor", None)
logits_vocab = getattr(logits_processor, "vocab_size", None)
if isinstance(logits_vocab, int) and logits_vocab > 0:
for request_data in scheduler_output.scheduled_new_reqs:
sampling_params = request_data.sampling_params
if sampling_params is not None:
sanitize_sampling_params_min_tokens_stop_ids(
sampling_params,
logits_vocab,
)
super().add_requests(scheduler_output)
def shutdown(self) -> None:
# Every owner must release its resources even if an earlier drain fails.
try:
worker = getattr(self, "_native_output_materializer", None)
if worker is not None:
worker.close()
finally:
self._native_output_materializer = None
try:
if self._omni_data_plane is not None:
self._omni_data_plane.close()
finally:
super().shutdown()
def _prepare_native_data_plane(self, scheduler_output: SchedulerOutput) -> None:
plane = getattr(self, "_omni_data_plane", None)
if plane is None:
return
for request_data in getattr(scheduler_output, "scheduled_new_reqs", []):
if str(getattr(request_data, "req_id", "")).startswith("_warmup_"):
continue
plane.register_request(request_data)
plane.register_receivers(list(getattr(scheduler_output, "pending_input_registrations", [])))
natural_terminal_req_ids = set(getattr(scheduler_output, "data_plane_terminal_req_ids", set()))
aborted_req_ids = set(getattr(scheduler_output, "finished_req_ids", set())).difference(natural_terminal_req_ids)
if natural_terminal_req_ids:
plane.request_terminal(natural_terminal_req_ids)
if aborted_req_ids:
plane.abort_requests(aborted_req_ids)
def _sync_native_data_plane_payloads(
self,
scheduler_output: SchedulerOutput,
) -> None:
plane = getattr(self, "_omni_data_plane", None)
if plane is None:
return
gpu_keys: set[tuple[str, str]] = getattr(self.model, "gpu_resident_buffer_keys", set())
for req_id in scheduler_output.num_scheduled_tokens:
req_idx = self.req_states.req_id_to_index.get(req_id)
if req_idx is None:
continue
payload = plane.pop_local_stage_payload(req_id)
if isinstance(payload, dict) and payload:
self.model_state.intermediate_buffer.update(
req_idx,
payload,
gpu_keys,
)
def _uses_native_output_materializer(self) -> bool:
parallel = getattr(getattr(self, "vllm_config", None), "parallel_config", None)
# Multi-rank executors already consume outputs asynchronously and choose
# the replying rank themselves. Preserve that ownership contract.
return getattr(parallel, "tensor_parallel_size", 1) == 1 and getattr(self, "_omni_data_plane", None) is not None
def _materialize_native_output(self, output: Any) -> Any:
from vllm_omni.worker_v2.native_output_worker import NativeOutputWorker
worker = getattr(self, "_native_output_materializer", None)
if worker is None:
worker = NativeOutputWorker(getattr(self.vllm_config, "max_concurrent_batches", 2), self.device)
self._native_output_materializer = worker
return worker.submit(output, self._omni_data_plane)
def _finalize_native_data_plane_output(self, output: Any) -> Any:
plane = getattr(self, "_omni_data_plane", None)
if plane is None or output is None:
return output
# MRv2's data plane consumes the host-materialized token lists from
# the runner output. Do not let a future connector path fall back to
# reading sampler tensors and synchronizing the device here.
if hasattr(output, "sampled_token_ids_materialized") and getattr(output, "sampled_token_ids", None) is not None:
if not output.sampled_token_ids_materialized:
raise RuntimeError("MRv2 native data plane received non-materialized sampled_token_ids")
inter_stage_outputs = getattr(output, "inter_stage_outputs", None)
plane.enqueue_outputs(
req_ids=list(getattr(output, "req_ids", [])),
inter_stage_outputs=inter_stage_outputs,
sampled_token_ids=getattr(output, "sampled_token_ids", None),
)
output.inter_stage_outputs = None
output.omni_connector_output = plane.get_omni_connector_output()
return output
def _reserve_native_data_plane_outputs(self, req_ids: list[str]) -> None:
plane = getattr(self, "_omni_data_plane", None)
if plane is not None:
plane.reserve_outputs(req_ids)
def _attach_native_data_plane_signals(self, output: Any) -> Any:
plane = getattr(self, "_omni_data_plane", None)
if plane is None:
return output
return plane.attach_omni_connector_output(output)
def load_model(self, *args: Any, **kwargs: Any) -> None:
import vllm.v1.worker.gpu.model_runner as _mr_module
with _model_state_patch_lock:
_orig = _mr_module.init_model_state
_mr_module.init_model_state = init_omni_model_state
try:
super().load_model(*args, **kwargs)
finally:
_mr_module.init_model_state = _orig
self._last_aux_output = None
self._last_multimodal_outputs = None
self._configure_cudagraph_output_contract()
# Preprocess models own embedding buffers; encoder_runner sizing would mismatch.
if getattr(self.model, "has_preprocess", False) and self.supports_mm_inputs:
self.supports_mm_inputs = False
self.encoder_cache = None
# ------------------------------------------------------------------
# CUDA Graph: conditionally exclude FULL mode
# ------------------------------------------------------------------
def _exclude_unsupported_full_graphs(self) -> None:
# vLLM 0.29 keys dispatch candidates by (uniform token count, LoRA count).
manager = self.cudagraph_manager
assert manager is not None
manager._capture_descs.pop(CUDAGraphMode.FULL, None)
manager._candidates = {
key: [desc for desc in candidates if desc.cg_mode != CUDAGraphMode.FULL]
for key, candidates in manager._candidates.items()
}
logger.info("Excluded FULL CUDA graph capture for Omni model. PIECEWISE graphs will still be captured.")
def capture_model(self) -> int:
"""Handle CUDA graph capture for Omni models.
Tuple-returning models use PIECEWISE graphs; FULL replay requires
a tensor output.
For PIECEWISE capture, the warmup pass runs with
``CUDAGraphMode.NONE`` which hits ``torch.empty_like(hidden_states)``
in the cudagraph framework. If the model returns a tuple, that call
crashes. We temporarily wrap the model's forward to extract only the
tensor part during capture, then restore the original forward.
"""
if self._exclude_full_graph:
self._exclude_unsupported_full_graphs()
# Wrap model forward during capture so tuple returns don't crash
# torch.empty_like() in the PIECEWISE warmup pass.
if self._model_returns_tuple:
original_forward = self.model.forward
def _capture_forward(*args: Any, **kwargs: Any) -> Any:
output = original_forward(*args, **kwargs)
return self._prepare_cudagraph_capture_output(output)
self.model.forward = _capture_forward # type: ignore[assignment]
try:
result = super().capture_model()
finally:
self.model.forward = original_forward # type: ignore[assignment]
else:
result = super().capture_model()
capture_mtp = getattr(getattr(self, "model_state", None), "capture_mtp_graphs", None)
if callable(capture_mtp):
capture_mtp(self._dispatch_mtp_batch_descriptor)
return result
def _dispatch_mtp_batch_descriptor(self, num_mtp_reqs: int) -> Any:
capture_sizes = self.model_state._get_mtp_capture_sizes()
captured_bucket = next(
(size for size in sorted(capture_sizes) if num_mtp_reqs <= size <= self.scheduler_config.max_num_seqs),
None,
)
if captured_bucket is None:
return BatchExecutionDescriptor(
cg_mode=CUDAGraphMode.NONE,
num_tokens=num_mtp_reqs,
num_reqs=num_mtp_reqs,
)
return self.cudagraph_manager.dispatch(
captured_bucket,
captured_bucket,
1,
0,
)
def _dispatch_batch_descriptor(
self,
*,
num_reqs: int,
num_toks: int,
uniform_tok_count: int | None,
num_active_loras: int,
use_eager: bool,
max_query_len: int,
):
return dispatch_cg_and_sync_dp(
self.cudagraph_manager,
num_reqs,
num_toks,
uniform_tok_count,
self.dp_size,
self.dp_rank,
max_query_len=max_query_len,
need_eager=use_eager,
num_active_loras=num_active_loras,
)
@torch.inference_mode()
def execute_model(
self,
scheduler_output: SchedulerOutput,
intermediate_tensors: IntermediateTensors | None = None,
dummy_run: bool = False,
skip_attn_for_dummy_run: bool = False,
is_profile: bool = False,
context_len: int = 0,
valid_dummy_state_slots: bool = False,
) -> Any:
if not dummy_run:
self._prepare_native_data_plane(scheduler_output)
self.finish_requests(scheduler_output)
self.free_states(scheduler_output)
self.add_requests(scheduler_output)
self.update_requests(scheduler_output)
self._sync_native_data_plane_payloads(scheduler_output)
self.block_tables.apply_staged_writes()
if scheduler_output.total_num_scheduled_tokens == 0:
empty_output = self.kv_connector.no_forward(scheduler_output)
return self._attach_native_data_plane_signals(
self._merge_ec_connector_no_forward(scheduler_output, empty_output)
)
num_reqs = len(scheduler_output.num_scheduled_tokens)
num_toks = scheduler_output.total_num_scheduled_tokens
max_query_len = max(scheduler_output.num_scheduled_tokens.values())
batch_req_state, uniform_tok_count = self.gather_batch_req_state(scheduler_output, dummy_run)
if batch_req_state is not None:
num_toks = batch_req_state.num_tokens
num_active_loras = 0
if self.lora_config:
req_ids = list(scheduler_output.num_scheduled_tokens.keys())
num_active_loras = get_num_active_loras_for_dispatch(
self.lora_config,
self.lora_state,
req_ids,
dummy_run,
)
# Encoder-decoder models: disable compilation when encoder inputs
# are scheduled (dynamic cross-attention cache updates).
skip_compiled = self.is_encoder_decoder and bool(scheduler_output.scheduled_encoder_inputs)
batch_desc, dp_sync = self._dispatch_batch_descriptor(
num_reqs=num_reqs,
num_toks=num_toks,
uniform_tok_count=uniform_tok_count,
num_active_loras=num_active_loras,
use_eager=is_profile or skip_compiled,
max_query_len=max_query_len,
)
if batch_desc.num_tokens == 0:
empty_output = self.kv_connector.no_forward(scheduler_output)
return self._attach_native_data_plane_signals(
self._merge_ec_connector_no_forward(scheduler_output, empty_output)
)
if not dummy_run:
assert batch_req_state is not None
input_batch = self.prepare_inputs(scheduler_output, batch_req_state, batch_desc)
block_tables, slot_mappings = self.prepare_attn(input_batch)
self.model_state.preprocess_state(
input_batch,
block_tables,
self.kv_cache_config,
self.req_states.num_computed_tokens.gpu,
)
if self.lora_config:
lora_inputs = self.lora_state.make_lora_inputs(
input_batch.req_ids,
input_batch.idx_mapping_np,
input_batch.num_scheduled_tokens,
)
self._set_active_loras(*lora_inputs)
else:
from vllm.v1.worker.gpu.input_batch import InputBatch
input_batch = InputBatch.make_dummy(
batch_desc.num_reqs or num_reqs,
batch_desc.num_tokens,
self.input_buffers,
max_query_len=batch_desc.max_query_len,
)
if not skip_attn_for_dummy_run:
block_tables, slot_mappings = self.prepare_dummy_attn(input_batch, valid_dummy_state_slots)
if context_len:
set_dummy_context(
input_batch,
self.block_tables,
context_len,
self.kv_cache_config.num_blocks,
self.max_model_len,
)
else:
assert batch_desc.cg_mode != CUDAGraphMode.FULL, (
"Attention metadata is required for FULL CUDA graph dummy runs"
)
block_tables = None
slot_mappings = None
attn_metadata = None
slot_mappings_by_layer = None
if not (dummy_run and skip_attn_for_dummy_run):
assert slot_mappings is not None
slot_mappings_by_layer = build_slot_mappings_by_layer(slot_mappings, self.kv_cache_config)
assert block_tables is not None
attn_metadata = self.model_state.prepare_attn(
input_batch,
batch_desc.cg_mode,
block_tables,
slot_mappings,
self.attn_groups,
self.kv_cache_config,
for_capture=dummy_run and batch_desc.cg_mode == CUDAGraphMode.FULL,
)
input_ids, inputs_embeds, ec_connector_output = self._prepare_mm_inputs(
scheduler_output,
input_batch,
dummy_run=dummy_run,
)
model_inputs: dict[str, Any] = {
"input_ids": input_ids,
"positions": input_batch.positions,
"inputs_embeds": inputs_embeds,
"intermediate_tensors": intermediate_tensors,
**self.model_state.prepare_inputs(input_batch, self.req_states),
}
self._add_legacy_forward_inputs(model_inputs, input_batch)
# ★ PRE-FORWARD: per-request preprocess + batched MTP.
# Runs for ALL graph modes (FULL, PIECEWISE, NONE).
# For FULL graph: OmniModelState provides a static inputs_embeds
# buffer that was captured by the graph. Preprocess writes
# in-place to this buffer, and FULL graph replay reads the
# updated values from the same tensor address.
if not dummy_run:
self.model_state.run_preprocess(
input_batch,
model_inputs,
self.req_states,
self._dispatch_mtp_batch_descriptor,
)
self.eplb.prepare_forward(self.model_config, input_batch.num_tokens)
# --- Model forward ---
if batch_desc.cg_mode == CUDAGraphMode.FULL:
# FULL graph replay. Preprocess already wrote to the static
# inputs_embeds buffer above.
assert self.cudagraph_manager is not None
self.kv_connector.pre_forward(scheduler_output)
hidden_states = self.cudagraph_manager.run_fullgraph(batch_desc)
self._last_aux_output = None
self._last_multimodal_outputs = None
if hasattr(self.model, "_last_captured_layers"):
self.model._last_captured_layers = self._last_aux_output
else:
batch_descriptor = BatchDescriptor(
num_tokens=input_batch.num_tokens_after_padding,
has_lora=self.lora_config is not None,
num_active_loras=batch_desc.num_active_loras,
)
with set_forward_context(
attn_metadata,
self.vllm_config,
num_tokens=input_batch.num_tokens_after_padding,
cudagraph_runtime_mode=batch_desc.cg_mode,
num_tokens_across_dp=dp_sync.num_tokens_across_dp if dp_sync is not None else None,
batch_descriptor=batch_descriptor,
slot_mapping=slot_mappings_by_layer,
skip_compiled=skip_compiled,
is_padding=input_batch.is_padding,
):
self.kv_connector.pre_forward(scheduler_output)
if batch_desc.cg_mode == CUDAGraphMode.PIECEWISE:
assert self.cudagraph_manager is not None
model_output = self.cudagraph_manager.run_pw_graph(
self.model,
model_inputs,
)
else:
model_output = self.model(**model_inputs)
# Extract hidden_states from model output.
self._last_aux_output = None
self._last_multimodal_outputs = None
if isinstance(model_output, OmniOutput):
hidden_states = model_output.text_hidden_states
if model_output.multimodal_outputs:
self._last_multimodal_outputs = cast(dict[str, Any], model_output.multimodal_outputs)
elif isinstance(model_output, tuple) and len(model_output) == 2:
hidden_states, self._last_aux_output = model_output
if hasattr(self.model, "_last_captured_layers"):
self.model._last_captured_layers = self._last_aux_output
else:
hidden_states = model_output
if not dummy_run and isinstance(hidden_states, torch.Tensor):
self.model_state.run_postprocess(hidden_states, input_batch)
routed_experts = None
if not dummy_run and (capturer := self.routed_experts_capturer) is not None:
assert slot_mappings is not None
routed_experts = capturer.get_routed_experts(slot_mappings, num_toks)
self.execute_model_state = ExecuteModelState(
input_batch=input_batch,
attn_metadata=attn_metadata,
slot_mappings_by_layer=slot_mappings_by_layer,
hidden_states=hidden_states,
aux_hidden_states=None,
finished_req_ids=scheduler_output.finished_req_ids,
dp_sync=dp_sync,
ec_connector_output=ec_connector_output,
routed_experts=routed_experts,
cudagraph_stats=None,
)
assert isinstance(hidden_states, torch.Tensor)
return None
# ------------------------------------------------------------------
# Request lifecycle: update intermediate buffer from cached requests
# ------------------------------------------------------------------
def update_requests(self, scheduler_output: SchedulerOutput) -> None:
"""Merge updated additional_information into intermediate_buffer.
In async_chunk mode, chunk_transfer_adapter attaches updated
additional_information (e.g. thinker_decode_embeddings) to
OmniCachedRequestData for cached requests every schedule step.
Upstream GPUModelRunner.update_requests does not handle this
field, so we merge it into the intermediate buffer here.
"""
super().update_requests(scheduler_output)
cached = scheduler_output.scheduled_cached_reqs
addl_info = getattr(cached, "additional_information", None)
if not addl_info:
return
for req_id, info in addl_info.items():
if info is None:
continue
req_idx = self.req_states.req_id_to_index.get(req_id)
if req_idx is None:
continue
resolved = _resolve_additional_information(info)
if resolved:
gpu_keys: set[tuple[str, str]] = getattr(self.model, "gpu_resident_buffer_keys", set())
self.model_state.intermediate_buffer.update(req_idx, resolved, gpu_keys)
# ------------------------------------------------------------------
# Request lifecycle: clean up intermediate buffer on finish
# ------------------------------------------------------------------
def finish_requests(self, scheduler_output: SchedulerOutput) -> None:
# IMPORTANT: Must query req_id_to_index BEFORE super().finish_requests()
# because super() calls req_states.remove_request(req_id) which pops the
# mapping and returns the slot index to free_indices.
finished = scheduler_output.finished_req_ids
on_finished = getattr(self.model, "on_requests_finished", None)
if finished and callable(on_finished):
# Request-owned state outlives generation slots and chunk boundaries.
# Notify even if the last chunk already released its runner slot.
on_finished(finished)
preempted = scheduler_output.preempted_req_ids
all_done = finished | preempted if preempted else finished
for req_id in all_done:
idx = self.req_states.req_id_to_index.get(req_id)
if idx is not None:
self.model_state.remove_request(idx)
super().finish_requests(scheduler_output)