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

1913 lines
85 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.
import faulthandler
import signal
import time
from collections import OrderedDict
from dataclasses import dataclass
import psutil
import setproctitle
import torch
import torch.distributed as dist
import zmq
from tokenspeed_scheduler import PD, Cache, ExecutionEvent, ForwardEvent, Scheduler
from tokenspeed.runtime.cache.executor.flat_memory_executor import (
FlatMemoryExecutor,
)
from tokenspeed.runtime.cache.executor.memory_executor import (
MemoryExecutor,
MemoryExecutorConfig,
)
from tokenspeed.runtime.cache.transfer.types import CacheKind
from tokenspeed.runtime.configs.model_config import ModelConfig
from tokenspeed.runtime.configs.paged_cache_spec import (
scheduler_ext_flat_kvcache,
validate_flat_scheduler_config,
)
from tokenspeed.runtime.distributed.process_group_manager import (
process_group_manager as pg_manager,
)
from tokenspeed.runtime.engine.generation_output_processor import OutputProcesser
from tokenspeed.runtime.engine.memory_occupation import MemoryOccupationController
from tokenspeed.runtime.engine.pause import PauseController
from tokenspeed.runtime.engine.request_handler import RequestHandler
from tokenspeed.runtime.engine.scheduler_utils import (
advance_forward,
cache_event_from_payload,
cache_event_key,
cache_event_to_payload,
cache_sync_debug_enabled,
make_config,
pool_to_paged_cache_groups,
pool_to_prefix_cache_adjunct_spec,
pop_common_cache_event_payloads,
should_use_overlap_schedule,
)
from tokenspeed.runtime.execution.distributed_initializer import (
DistributedConfig,
DistributedInitializer,
)
from tokenspeed.runtime.execution.factory import (
ModelExecutorConfig,
create_model_executor,
create_model_runner,
)
from tokenspeed.runtime.execution.forward_batch_info import ForwardMode
from tokenspeed.runtime.execution.types import ModelExecutionResult
from tokenspeed.runtime.grammar.capturable_grammar import GrammarStepInputs
from tokenspeed.runtime.layers.attention.registry import create_attn_components
from tokenspeed.runtime.metrics.collector import EngineMetrics
from tokenspeed.runtime.pd.decode_executor import DisaggDecodeExecutor
from tokenspeed.runtime.pd.factory import (
create_kv_transfer,
get_kv_args,
)
from tokenspeed.runtime.pd.kv_events import (
EventPublisherFactory,
KVEventBatch,
NullEventPublisher,
drain_scheduler_kv_events,
scheduler_kv_events_to_wire_events,
)
from tokenspeed.runtime.pd.mooncake.entities import KVManagerArgs
from tokenspeed.runtime.pd.prefill_executor import DisaggPrefillExecutor
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
from tokenspeed.runtime.utils import (
configure_logger,
get_colorful_logger,
get_zmq_socket,
)
from tokenspeed.runtime.utils.exceptions import get_exception_traceback
from tokenspeed.runtime.utils.nvtx import nvtx_range
from tokenspeed.runtime.utils.process import register_usr_signal
from tokenspeed.runtime.utils.server_args import PortArgs, ServerArgs
from tokenspeed.runtime.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
logger = get_colorful_logger(__name__)
def calc_l3_query_hashes(scheduler, tokens: list[int]) -> list[str]:
return scheduler.calc_rolling_hash(tokens, apply_match=True)
# Sleep between iterations while frozen (PAUSED_ALL) so the keep-mode pause does
# not busy-spin a CPU core waiting for /resume.
_PAUSED_IDLE_SLEEP_S = 0.001
def _forward_op_executes_model_forward(forward_op, *, is_disagg_decode: bool) -> bool:
"""Return whether ``forward_op`` will enter the model forward path.
On decode-side PD, EXTEND ops only start remote KV receive; the model
forward runs after the remote prefill completes and the scheduler advances
the request into decode. Treating those EXTEND ops as model work makes
idle DP ranks enter dummy collectives that the active rank will not match.
"""
if forward_op is None:
return False
if sum(forward_op.input_lengths) <= 0:
return False
if is_disagg_decode and forward_op.num_extends() > 0:
return False
return True
class _NullSender:
"""No-op ZMQ sender for non-rank-0 workers."""
@staticmethod
def send_pyobj(x):
return None
@dataclass(frozen=True)
class DpForwardMetadata:
global_num_tokens: list[int]
global_batch_size: list[int]
global_forward_mode: list[int]
all_decode_or_idle: bool
all_extend: bool
need_idle_forward: bool
class EventLoop:
def __init__(
self,
server_args: ServerArgs,
port_args: PortArgs,
gpu_id: int,
attn_tp_rank: int,
dp_rank: int,
global_rank: int,
) -> None:
# Do not pass server_args further down the stack after this point.
self.server_args = server_args
self.port_args = port_args
self.gpu_id = gpu_id
self.global_rank = global_rank
self.model_config = self._load_model_config(server_args.model)
if server_args.speculative_draft_model_path is not None:
draft_model_config = self._load_model_config(
server_args.speculative_draft_model_path,
is_draft_worker=True,
)
else:
draft_model_config = None
min_per_gpu_mem = self._init_distributed()
target, draft = create_model_runner(
server_args, self.model_config, draft_model_config, gpu_id, global_rank
)
self.use_overlap_schedule = should_use_overlap_schedule(
disable_overlap_schedule=server_args.disable_overlap_schedule,
disaggregation_mode=server_args.disaggregation_mode,
)
self.overlap_schedule_depth = int(self.use_overlap_schedule)
decode_input_tokens = (
server_args.speculative_num_draft_tokens
if server_args.speculative_algorithm is not None
else 1
)
(
attn_backend,
token_to_kv_pool,
draft_attn_backend,
draft_token_to_kv_pool,
self.max_total_num_tokens,
mamba_pool_total_chunks,
mamba_pool,
) = create_attn_components(
server_args,
self.model_config,
gpu_id,
global_rank,
min_per_gpu_mem,
server_args.enable_memory_saver,
draft_model_config,
decode_input_tokens=decode_input_tokens,
overlap_schedule_depth=self.overlap_schedule_depth,
)
num_total_pages = self.max_total_num_tokens // server_args.block_size
hf_config = getattr(self.model_config, "hf_config", None)
text_config = getattr(hf_config, "text_config", None) if hf_config else None
has_mamba = getattr(self.model_config, "mambaish_config", None) is not None or (
text_config is not None and hasattr(text_config, "mamba2_cache_params")
)
mapping = server_args.mapping
# The C++ scheduler's req_pool_idx range is rank-local and 1-based:
# real rows are 1..max_batch_size, row 0 is reserved, and CUDA graph
# padding needs one non-real sink row after the scheduler-owned range.
per_rank_max_batch = server_args.max_num_seqs // max(mapping.attn.dp_size, 1)
req_pool_padding_index = per_rank_max_batch + 1
model_executor_config = ModelExecutorConfig.from_server_args(
server_args=server_args,
model_config=self.model_config,
max_req_pool_size=req_pool_padding_index,
gpu_id=gpu_id,
global_rank=global_rank,
num_total_pages=num_total_pages,
overlap_schedule_depth=self.overlap_schedule_depth,
)
self.model_executor = create_model_executor(
server_args=server_args,
config=model_executor_config,
model_runner=target,
draft_model_runner=draft,
attn_backend=attn_backend,
token_to_kv_pool=token_to_kv_pool,
draft_attn_backend=draft_attn_backend,
draft_token_to_kv_pool=draft_token_to_kv_pool,
mamba_pool=mamba_pool,
)
# Reserve one token slot because request validation uses a strict
# ``< max_req_len`` check against the model context length.
self.max_req_input_len = self.model_config.context_len - 1
self.attn_tp_size = server_args.attn_tp_size or mapping.attn.tp_size
self.world_size = server_args.world_size or mapping.world_size
self.attn_tp_rank = attn_tp_rank
self.attn_tp_cpu_group = pg_manager.get_process_group(
"gloo", server_args.mapping.attn.tp_group
)
self._pending_cache_event_payloads: OrderedDict[tuple[str, int], dict] = (
OrderedDict()
)
# All ranks submit identical cache plans (the C++ scheduler is mirrored),
# so a local in-flight counter mirrors across ranks: if it's 0 here, no
# rank has anything pending. Lets us skip the TP collective in
# _commit_cache_results entirely when nothing is in flight.
self._num_inflight_cache_ops = 0
self.dp_rank = dp_rank
self.dp_size = mapping.attn.dp_size
self.has_dp = mapping.has_attn_dp
if self.has_dp:
self.world_cpu_group = pg_manager.get_process_group(
"gloo", mapping.world_group
)
self._dp_local_info = torch.zeros(1, 3, dtype=torch.int32)
self._dp_global_info = torch.zeros(mapping.world_size, 3, dtype=torch.int32)
if not server_args.enable_kvstore:
logger.warning(
"KVStore L2 cache will not be used during normal execution, but it will still be used when retraction happens."
)
mamba_l2_host_slots = 0
if has_mamba and server_args.enable_mamba_l2:
if server_args.mamba_l2_host_slots > 0:
mamba_l2_host_slots = server_args.mamba_l2_host_slots
elif server_args.mamba_l2_host_gb > 0 and mamba_pool is not None:
slot_bytes = int(
mamba_pool.conv_state.shape[0]
* (
mamba_pool.conv_state[0, 0].nbytes
+ mamba_pool.ssm_state[0, 0].nbytes
)
)
mamba_l2_host_slots = int(
server_args.mamba_l2_host_gb * (1024**3) // max(slot_bytes, 1)
)
else:
mamba_l2_host_slots = max(
int(mamba_pool_total_chunks * server_args.mamba_l2_ratio), 1
)
mem_cfg = MemoryExecutorConfig(
layer_num=self.model_config.num_hidden_layers,
page_size=server_args.block_size,
host_ratio=server_args.kvstore_ratio,
host_size_gb=server_args.kvstore_size,
host_parallel_count=max(
int(getattr(server_args.mapping, "nprocs_per_node", 1) or 1), 1
),
io_backend=server_args.kvstore_io_backend,
host_layout=server_args.kvstore_mem_layout,
storage_backend=server_args.kvstore_storage_backend,
storage_backend_extra_config=server_args.kvstore_storage_backend_extra_config,
model_name=server_args.model,
enable_mamba_l2=server_args.enable_mamba_l2,
mamba_l2_host_slots=mamba_l2_host_slots,
mamba_l2_layout=server_args.mamba_l2_layout,
mamba_l2_io_backend=server_args.mamba_l2_io_backend,
)
if scheduler_ext_flat_kvcache() and server_args.enable_kvstore:
if server_args.kvstore_storage_backend is not None:
raise NotImplementedError(
"flat scheduler build (TOKENSPEED_FLAT_KVCACHE) has no L3 "
"storage tier yet; unset --kvstore-storage-backend."
)
self.memory_executor = FlatMemoryExecutor(
device_pool=token_to_kv_pool,
host_ratio=server_args.kvstore_ratio,
host_size_gb=server_args.kvstore_size,
)
num_host_pages = self.memory_executor.num_host_pages
elif not token_to_kv_pool.supports_hierarchical_kv_cache:
if server_args.enable_kvstore:
raise NotImplementedError(
"This KV cache pool does not support hierarchical cache "
"(kvstore); pass --disable-kvstore."
)
self.memory_executor = None
num_host_pages = 0
else:
self.memory_executor = MemoryExecutor(
device_pool=token_to_kv_pool,
config=mem_cfg,
is_dp_attention_enabled=self.has_dp,
tp_group=self.attn_tp_cpu_group,
draft_device_pool=draft_token_to_kv_pool,
mamba_pool=mamba_pool,
)
num_host_pages = self.memory_executor.host_pool.page_num
# Flat host tier acks loadbacks (LoadBackDoneEvent), so they join the
# inflight accounting in _submit_cache_ops; radix loadbacks never ack.
self._loadback_acks_expected = getattr(
self.memory_executor, "emits_loadback_acks", False
)
self._kv_events_enabled = (
EventPublisherFactory.is_enabled(server_args.kv_events_config)
and attn_tp_rank == 0
)
if has_mamba and server_args.max_mamba_cache_size is None:
logger.info(
f"Mamba radix cache enabled without explicit max_mamba_cache_size. "
f"Auto-derived mamba_pool_total_chunks={mamba_pool_total_chunks} "
f"(ratio={server_args.mamba_full_memory_ratio})."
)
# Adjunct enabled only when pool opts in AND prefix-caching switch is on.
paged_cache_groups = pool_to_paged_cache_groups(token_to_kv_pool)
validate_flat_scheduler_config(
flat_kvcache_ext=scheduler_ext_flat_kvcache(),
paged_cache_groups=paged_cache_groups,
attn_backend=attn_backend,
kv_pool=token_to_kv_pool,
speculative_algorithm=server_args.speculative_algorithm,
)
self._paged_cache_groups = paged_cache_groups
prefix_cache_adjunct = None
required_groups = token_to_kv_pool.prefix_cache_required_group_ids
if required_groups is not None and server_args.enable_prefix_caching:
prefix_cache_adjunct = pool_to_prefix_cache_adjunct_spec(required_groups)
scheduler_cfg = make_config(
num_device_pages=self.max_total_num_tokens // server_args.block_size,
max_scheduled_tokens=server_args.chunked_prefill_size,
max_batch_size=per_rank_max_batch,
page_size=server_args.block_size,
num_host_pages=num_host_pages,
disable_l2_cache=not server_args.enable_kvstore,
enable_l3_storage=server_args.kvstore_storage_backend is not None,
prefetch_threshold=4, # Keep this hard-coded until it becomes configurable.
role=server_args.disaggregation_mode,
enable_kv_cache_events=self._kv_events_enabled,
decode_input_tokens=decode_input_tokens,
overlap_schedule_depth=self.overlap_schedule_depth,
disable_prefix_cache=not server_args.enable_prefix_caching,
enable_mamba=has_mamba,
mamba_cache_chunk_size=server_args.mamba_cache_chunk_size,
mamba_pool_total_chunks=mamba_pool_total_chunks,
enable_mamba_l2=server_args.enable_mamba_l2,
mamba_l2_host_slots=mamba_l2_host_slots,
paged_cache_groups=paged_cache_groups,
enable_mixed_prefill_decode=server_args.enable_mixed_batch,
prefix_cache_adjunct=prefix_cache_adjunct,
)
logger.info(
"Scheduler config: block_size=%s num_device_pages=%s "
"max_scheduled_tokens=%s decode_input_tokens=%s "
"overlap_schedule_depth=%s disable_l2_cache=%s "
"max_batch_size=%s (global max_num_seqs=%s, dp_size=%s) "
"mamba_pool_total_chunks=%s enable_mamba=%s "
"disable_prefix_cache=%s paged_cache_groups=%s",
scheduler_cfg.block_size,
scheduler_cfg.num_device_pages,
scheduler_cfg.max_scheduled_tokens,
scheduler_cfg.decode_input_tokens,
scheduler_cfg.overlap_schedule_depth,
scheduler_cfg.disable_l2_cache,
scheduler_cfg.max_batch_size,
server_args.max_num_seqs,
self.dp_size,
mamba_pool_total_chunks,
has_mamba,
scheduler_cfg.disable_prefix_cache,
[group.group_id for group in paged_cache_groups],
)
self.scheduler = Scheduler(scheduler_cfg)
token_to_kv_pool.bind_paged_cache_scheduler(self.scheduler)
if attn_tp_rank == 0:
self.kv_event_publisher = EventPublisherFactory.create(
server_args.kv_events_config,
attn_dp_rank=dp_rank,
)
else:
self.kv_event_publisher = NullEventPublisher(attn_dp_rank=dp_rank)
self._init_interprocess_comm()
# Pause/resume control state. Shared with the request handler, which
# drives the control-request side; the event loop reads the gate.
self._pause = PauseController(self.send_to_tokenizer)
# GPU-memory data plane (release/resume_memory_occupation). Reuses the
# pause controller's drain machinery; frees memory via the memory-saver
# adapter once the scheduler drains. See memory_occupation.py.
# Releasing KV is only safe if any prefix cache it backs can be cleared:
# either prefix caching is off, or the scheduler exposes a reset. Decide
# once here (static config) and let the controller reject unsafe releases.
kv_cache_release_allowed = (
not self.server_args.enable_prefix_caching
or callable(getattr(self.scheduler, "reset_prefix_cache", None))
)
self._memory = MemoryOccupationController(
send_func=self.send_to_tokenizer,
pause_controller=self._pause,
adapter=TorchMemorySaverAdapter.create(
enable=self.server_args.enable_memory_saver
),
enabled=self.server_args.enable_memory_saver,
reset_caches_fn=self._reset_caches_for_release,
kv_repair_fn=self._kv_repair_after_wake,
kv_cache_release_allowed=kv_cache_release_allowed,
)
self.metrics = EngineMetrics(
labels={
"model_name": server_args.served_model_name,
"app_key": server_args.app_key or "",
"dp_rank": str(dp_rank),
},
enabled=(
server_args.enable_metrics
and attn_tp_rank == 0
and "prometheus" in (server_args.metrics_reporters or [])
),
)
self.request_handler = RequestHandler(
server_args=self.server_args,
hf_eos_token_id=self.model_config.hf_eos_token_id,
max_req_len=self.model_config.context_len - 1,
vocab_size=self.model_config.vocab_size,
recv_func=self.recv_from_tokenizer,
send_func=self.send_to_tokenizer,
get_load_fn=self._get_load,
architectures=self.model_config.hf_config.architectures,
pause_controller=self._pause,
memory_controller=self._memory,
)
self.output_processor = OutputProcesser(
send_to_tokenizer=self.send_to_tokenizer,
attn_tp_rank=attn_tp_rank,
spec_algorithm=self.server_args.speculative_algorithm,
spec_num_tokens=(
self.server_args.speculative_num_draft_tokens
if self.server_args.speculative_algorithm is not None
else None
),
stream_interval=self.server_args.stream_interval,
enable_log_request_stats=self.server_args.enable_log_request_stats,
metrics=self.metrics,
)
self.prefetch_threshold = scheduler_cfg.prefetch_threshold
if server_args.disaggregation_mode != "null":
kv_args = get_kv_args(
global_rank,
global_rank,
server_args.disaggregation_ib_device,
token_to_kv_pool,
draft_token_to_kv_pool,
mamba_pool,
)
pd_manager_args = KVManagerArgs(
bootstrap_port=server_args.disaggregation_bootstrap_port,
dist_init_addr=server_args.dist_init_addr,
world_size=server_args.world_size or mapping.world_size,
dp_size=server_args.data_parallel_size or mapping.attn.dp_size,
attn_tp_rank=attn_tp_rank,
attn_dp_rank=dp_rank,
is_mla_backend=False,
draft_is_mla_backend=False,
enable_metrics=False,
enable_mla_l1_5_cache=server_args.enable_mla_l1_5_cache,
served_model_name=server_args.served_model_name,
app_key=server_args.app_key,
metrics_reporters=server_args.metrics_reporters,
enable_dp_attention=self.has_dp,
)
self.kv_transfer = create_kv_transfer(
mode=server_args.disaggregation_mode,
backend=server_args.disaggregation_transfer_backend,
args=pd_manager_args,
kv_args=kv_args,
gloo_group=self.attn_tp_cpu_group,
page_size=token_to_kv_pool.page_size,
)
self._setup_pd_layerwise_transfer(
server_args.disaggregation_layerwise_interval
)
# EPD: a multimodal prefill node is also the encode->prefill embedding
# SINK (independent of kv_transfer, its P->D KV source) -- it receives
# each image's embedding from encode workers over Mooncake so the
# prefill skips the vision tower. The admission controller owns the
# receive jobs, the rank-synced admission drain, and the optional NCCL
# row-shard reassembly; None for decode/encode/text-only nodes.
from tokenspeed.runtime.pd.epd.prefill_receiver import (
make_epd_prefill_admission,
)
self.epd_admission = make_epd_prefill_admission(
server_args,
global_rank,
model_config=self.model_config,
model_executor=self.model_executor,
mapping=mapping,
attn_tp_rank=self.attn_tp_rank,
attn_tp_size=self.attn_tp_size,
attn_tp_cpu_group=self.attn_tp_cpu_group,
pg_manager=pg_manager,
)
# Staged EPD request payloads (request_id -> (spec, state, bootstrap)),
# held here while the controller (rid-keyed, like kv_transfer) runs the
# async receive; popped in _drain_ready_epd_embeddings on admit/abort.
self._epd_staged: dict = {}
else:
self.kv_transfer = None
self.epd_admission = None
self._epd_staged: dict = {}
def _setup_pd_layerwise_transfer(self, interval: int) -> None:
if not isinstance(self.kv_transfer, DisaggPrefillExecutor):
return
if interval <= 0:
return
from tokenspeed.runtime.pd.utils import StepCounter
step_counter = StepCounter(self.model_executor.device, self.gpu_id)
self.model_executor.attn_backend.register_step_counter(step_counter)
if self.model_executor.draft_attn_backend is not None:
self.model_executor.draft_attn_backend.register_step_counter(step_counter)
self.kv_transfer.register_layerwise_step_counter(step_counter, interval)
def _is_epd_request(self, state) -> bool:
"""True iff this request's images are encode-routed (smg injected per-image
encode handshakes) -- it must wait for its embeddings (staged via the EPD
admission controller, polled in _drain_ready_epd_embeddings) before being
scheduled. Caller guards on self.epd_admission (only a multimodal prefill
node has one); everything else admits immediately.
"""
mm = getattr(state, "multimodal_inputs", None)
return mm is not None and any(
getattr(it, "encode_handshake", None) for it in mm.mm_items
)
def _assert_epd_embeddings_received(self, multimodal_context) -> None:
"""EPD invariant: every handshaked item is filled with its embedding by the
async EPD admission drain (EpdPrefillAdmission.drain) BEFORE admission, so by
it is already encoded. This is a defensive check, not a receive: a handshaked
item that reached the forward un-received leaked past async admission (the
only EPD admission path) -- fail loud instead of running the tower or
publishing shard-only rows. No-op for non-EPD / text-only requests.
"""
if (
self.epd_admission is None
or multimodal_context is None
or not multimodal_context.has_extend_inputs()
):
return
for mm in multimodal_context.mm_inputs:
if mm is None:
continue
missing = [
i
for i, item in enumerate(mm.mm_items)
if getattr(item, "encode_handshake", None) is not None
and item.encoded is None
]
if missing:
raise RuntimeError(
f"EPD: handshaked items {missing} reached the prefill forward "
"un-received; they must be admitted via the EPD admission drain"
)
def _drain_ready_epd_embeddings(self) -> None:
"""Admit EPD requests whose async embedding receives completed this cycle.
The EpdPrefillAdmission controller DECIDES (poll + rank-lockstep MIN
all-reduce + reassemble) and returns (admitted, failed); here we ACT on
those decisions with the EventLoop's collaborators -- register/abort the
P->D sender, submit admitted requests, finish failed ones. No-op (and no
collective) on non-EPD nodes.
"""
if self.epd_admission is None:
return
# Pause gate: withhold EPD admission while paused, mirroring the non-EPD
# admit_blocked gate -- else the drain below would submit and RUN reassembled
# specs during the pause. Staged receives wait in _pending until resume.
# Rank-safe: admit_blocked is rank-identical, so all ranks skip together.
if self._pause.admit_blocked:
return
admitted_ids, failed_ids = self.epd_admission.drain()
for rid in failed_ids:
spec, state, bootstrap = self._epd_staged.pop(rid)
# Signal the dual-dispatched decode that this request failed so its KV
# receiver fails (FailedEvent -> _process_kv_transfer_events abort)
# instead of waiting forever for KV the prefill will never send. The
# prefill never registered a P->D sender (deferred to admission), so the
# decode has no other reliable way to learn (heartbeat only trips on a
# dead prefill /health). Best-effort: only reaches decodes that already
# pre-allocated.
if (
isinstance(self.kv_transfer, DisaggPrefillExecutor)
and bootstrap is not None
):
try:
self.kv_transfer.abort(rid, bootstrap)
except Exception as exc: # never let it wedge the loop
logger.warning(
"EPD abort->decode signal failed for rid=%s: %s",
rid,
exc,
)
state.set_finish_with_abort("EPD embedding receive failed or timed out")
self.output_processor.publish_finished_at_admission(rid, state)
admitted_specs = []
for rid in admitted_ids:
spec, state, bootstrap = self._epd_staged.pop(rid)
# Aborted mid-receive (no abort path, so drain still returns it admitted):
# don't register the P->D sender or submit -- that runs a wasted forward
# and leaks the sender. Stream its finish instead.
if state.finished:
self.output_processor.publish_finished_at_admission(rid, state)
continue
# Register the P->D sender now (deferred from admission) -- the request
# is about to enter the scheduler.
if self.kv_transfer is not None:
self.kv_transfer.register(rid, bootstrap)
admitted_specs.append(spec)
if admitted_specs:
self.scheduler.submit_requests(admitted_specs)
elif self.epd_admission.has_pending():
# Nothing advanced this cycle but requests are still receiving; yield the
# GIL so the Python daemon transfer/recv threads run (rank-consistent:
# admitted/leftover are rank-identical here).
time.sleep(0.0005)
def _commit_cache_results(self) -> None:
if self.memory_executor is None:
return
cache_results = self.memory_executor.poll_results()
self._num_inflight_cache_ops -= len(cache_results)
for event in cache_results:
payload = cache_event_to_payload(event)
self._pending_cache_event_payloads[cache_event_key(payload)] = payload
# The gather below is a collective, but cache-op completion is async and
# not lock-step across ranks, so local state (_num_inflight_cache_ops /
# _pending_cache_event_payloads) diverges transiently. A rank-local skip
# would let some ranks gather while others return, deadlocking the group.
# Agree on the skip via a cheap single-int all_reduce.
# NOTE: For non-DFLASH algorithms, cache ops are deterministic across
# ranks, so the local short-circuit is safe and avoids collective overhead.
local_has_work = bool(
self._num_inflight_cache_ops != 0 or self._pending_cache_event_payloads
)
if self.server_args.speculative_algorithm == "DFLASH":
if not self._cache_group_has_work(local_has_work):
return
else:
if not local_has_work:
return
ready_payloads = self._pop_ready_cache_event_payloads()
if not ready_payloads:
return
logger.debug(
"[cache_poll] got %s synchronized results, advancing scheduler",
len(ready_payloads),
)
ec = ExecutionEvent()
for payload in ready_payloads:
e = cache_event_from_payload(payload)
logger.debug(
"[cache_poll] event: op_id=%s success=%s type=%s request_id=%s",
e.op_id,
e.success,
type(e).__name__,
getattr(e, "request_id", "N/A"),
)
ec.add_event(e)
self.scheduler.advance(ec)
logger.debug("[cache_poll] scheduler.advance() done")
self._publish_scheduler_kv_events()
def _publish_scheduler_kv_events(self) -> None:
raw_events = drain_scheduler_kv_events(
self.scheduler,
enabled=self._kv_events_enabled,
)
if not raw_events:
return
events = scheduler_kv_events_to_wire_events(raw_events)
if not events:
return
self.kv_event_publisher.publish(
KVEventBatch(ts=time.time(), events=events, attn_dp_rank=self.dp_rank)
)
def _cache_group_has_work(self, local_has_work: bool) -> bool:
"""Whether ANY attn-tp rank has cache work this step (unanimous via a
single-int MAX all_reduce, far cheaper than the payload gather it
guards). Deciding from rank-local state alone deadlocks the group; see
_commit_cache_results.
Args:
local_has_work: This rank's view of whether any cache op is in
flight or any polled payload awaits commit.
Returns:
``True`` if any rank has work (all must gather); ``False`` only when
every rank is idle.
"""
if self.attn_tp_size == 1:
return local_has_work
flag = torch.tensor([1 if local_has_work else 0], dtype=torch.int32)
dist.all_reduce(flag, op=dist.ReduceOp.MAX, group=self.attn_tp_cpu_group)
return bool(flag.item())
def _pop_ready_cache_event_payloads(self) -> list[dict]:
local_payloads = list(self._pending_cache_event_payloads.values())
if self.attn_tp_size == 1:
ready_payloads = local_payloads
else:
gathered_payloads = [None] * self.attn_tp_size
dist.all_gather_object(
gathered_payloads,
local_payloads,
group=self.attn_tp_cpu_group,
)
ready_payloads = pop_common_cache_event_payloads(gathered_payloads)
if self.attn_tp_rank == 0 and cache_sync_debug_enabled():
pending_ops = [
[(payload["kind"], payload["op_id"]) for payload in rank_payloads]
for rank_payloads in gathered_payloads
]
if len({tuple(rank_ops) for rank_ops in pending_ops}) > 1:
logger.info(
"[cache_sync] rank=%s pending_ops=%s ready_ops=%s",
self.global_rank,
pending_ops,
[
(payload["kind"], payload["op_id"])
for payload in ready_payloads
],
)
for payload in ready_payloads:
self._pending_cache_event_payloads.pop(cache_event_key(payload), None)
return ready_payloads
def _dispatch_forward(
self,
forward_op,
sampling_params_list,
execution_plan,
dp_metadata=None,
stats=None,
grammar_inputs=None,
):
"""Execute one forward step; return (results, on_first_token).
results is None when the step produces no model output (Path 2/3).
Both event_loop and event_loop_overlap call this method; they differ
only in *when* they call post_process on the returned results.
Path 1 — no PD: run forward, return (results, None)
Path 2 — decode, extend: trigger RDMA receive, return (None, None)
Path 3 — prefill, decode: send KV to decode side, return (None, None)
Path 4 — prefill, extend: run prefill forward, return (results, on_first_token)
"""
if stats is None:
stats = {}
dp_global_num_tokens = (
dp_metadata.global_num_tokens if dp_metadata is not None else None
)
dp_global_bs = (
dp_metadata.global_batch_size if dp_metadata is not None else None
)
dp_all_decode_or_idle = (
dp_metadata.all_decode_or_idle if dp_metadata is not None else False
)
dp_all_extend = dp_metadata.all_extend if dp_metadata is not None else False
multimodal_context = self._get_multimodal_context_for_forward(forward_op)
self.model_executor.update_block_table(forward_op)
if self.kv_transfer is None:
# Path 1: normal (no disaggregation)
self.model_executor.reset_valid_cache_length(forward_op)
return (
self.model_executor.execute_forward_op_with_log(
forward_op,
sampling_params_list,
dp_global_num_tokens=dp_global_num_tokens,
dp_global_bs=dp_global_bs,
dp_all_decode_or_idle=dp_all_decode_or_idle,
dp_all_extend=dp_all_extend,
grammar_inputs=grammar_inputs,
multimodal_context=multimodal_context,
**stats,
),
None,
)
elif isinstance(self.kv_transfer, DisaggDecodeExecutor):
# Decode node
if forward_op.num_extends() > 0:
# Path 2: new requests waiting for remote KV — trigger RDMA receive
self.kv_transfer.reset_valid_cache_length(
forward_op,
self.model_executor.runtime_states,
self.model_executor.execution_stream,
self.model_executor.device,
)
self.kv_transfer.execute(forward_op)
self.model_executor.reset_remote_prefill_mamba_inputs(forward_op)
return None, None
else:
# Path 3b: decode batch — normal forward
self.model_executor.reset_valid_cache_length(forward_op)
return (
self.model_executor.execute_forward_op_with_log(
forward_op,
sampling_params_list,
dp_global_num_tokens=dp_global_num_tokens,
dp_global_bs=dp_global_bs,
dp_all_decode_or_idle=dp_all_decode_or_idle,
dp_all_extend=dp_all_extend,
multimodal_context=multimodal_context,
**stats,
),
None,
)
else:
# Prefill node (only reached from event_loop, never event_loop_overlap)
if not isinstance(self.kv_transfer, DisaggPrefillExecutor):
raise TypeError("kv_transfer must be a DisaggPrefillExecutor.")
if forward_op.num_extends() == 0:
# Path 3: all prefill done — send KV to decode side
self.kv_transfer.execute(forward_op)
return None, None
else:
# Path 4: extend batch — run prefill forward
self.model_executor.reset_valid_cache_length(forward_op)
self.kv_transfer.prepare_prefill(forward_op)
# EPD invariant: handshaked items are filled by the async
# EPD admission drain before admission; assert none reached
# the forward un-received (no-op for non-EPD / text-only requests).
self._assert_epd_embeddings_received(multimodal_context)
return (
self.model_executor.execute_forward_op_with_log(
forward_op,
sampling_params_list,
dp_global_num_tokens=dp_global_num_tokens,
dp_global_bs=dp_global_bs,
dp_all_decode_or_idle=dp_all_decode_or_idle,
dp_all_extend=dp_all_extend,
grammar_inputs=grammar_inputs,
multimodal_context=multimodal_context,
capture_next_input_ids=True,
**stats,
),
self.kv_transfer.store_prefill_token,
)
def _get_multimodal_context_for_forward(self, forward_op):
if not self.model_config.is_multimodal_active:
return None
num_extends = forward_op.num_extends()
mm_inputs = []
has_mm = False
for index, rid in enumerate(forward_op.request_ids):
state = self.output_processor.rid_to_state.get(rid)
if state is not None and index < num_extends:
state.maybe_extend_multimodal_mrope_positions()
item = getattr(state, "multimodal_inputs", None) if state else None
mm_inputs.append(item)
has_mm = has_mm or item is not None
if not has_mm:
return None
from tokenspeed.runtime.multimodal.inputs import MultimodalForwardContext
return MultimodalForwardContext(
mm_inputs=mm_inputs,
extend_prefix_lens=list(forward_op.extend_prefix_lens),
extend_seq_lens=list(forward_op.input_lengths[:num_extends]),
)
def _build_mamba_layerwise_cow(
self, execution_plan, forward_op
) -> dict[int, list[int]]:
if forward_op is None:
return {}
loaded_mamba_slots: set[int] = set()
for cache_op in execution_plan.cache:
if not isinstance(cache_op, Cache.LoadBackOp):
continue
dst_by_kind = getattr(cache_op, "dst_pages_by_kind", None)
if dst_by_kind is None:
dst_groups = getattr(cache_op, "dst_pages", [])
else:
dst_groups = dst_by_kind.get(CacheKind.MAMBA.value, [])
for dst_pages in dst_groups:
loaded_mamba_slots.update(int(page) for page in dst_pages)
if not loaded_mamba_slots:
return {}
cow_src_indices = getattr(forward_op, "mamba_cow_src_indices", None)
working_indices = getattr(forward_op, "mamba_pool_indices", None)
if cow_src_indices is None or working_indices is None:
return {}
cow_by_src: dict[int, list[int]] = {}
for cow_src, working in zip(list(cow_src_indices), list(working_indices)):
cow_src = int(cow_src)
working = int(working)
if cow_src < 0 or working < 0 or cow_src not in loaded_mamba_slots:
continue
cow_dsts = cow_by_src.setdefault(cow_src, [])
if working not in cow_dsts:
cow_dsts.append(working)
return cow_by_src
def _submit_cache_ops(self, execution_plan) -> None:
if self.memory_executor is None:
return
forward_op = self._get_forward_op(execution_plan)
mamba_layerwise_cow = self._build_mamba_layerwise_cow(
execution_plan, forward_op
)
if mamba_layerwise_cow:
self.model_executor.set_layerwise_mamba_cow_done(mamba_layerwise_cow)
self.memory_executor.set_mamba_layerwise_cow(mamba_layerwise_cow)
self.memory_executor.submit_plan(execution_plan)
for op in execution_plan.cache:
if isinstance(op, Cache.WriteBackOp):
self._num_inflight_cache_ops += len(op.op_ids)
elif isinstance(op, Cache.LoadBackOp):
# Radix loadbacks are fire-and-forget (no ack, nothing in
# flight); the flat host tier acks one LoadBackDone per op_id.
if self._loadback_acks_expected:
self._num_inflight_cache_ops += len(op.op_ids)
elif isinstance(op, (Cache.PrefetchOp, Cache.BackUpOp)):
self._num_inflight_cache_ops += 1
else:
raise ValueError(f"unsupported cache op kind: {type(op).__name__}")
self._setup_layerwise_loadback(execution_plan)
def _setup_layerwise_loadback(self, execution_plan) -> None:
host_exec = getattr(self.memory_executor, "host_exec", None)
available_pools = (
getattr(host_exec, "pools", {}) if host_exec is not None else {}
)
consumer_indices_by_kind: dict[CacheKind, list[int]] = {
kind: [] for kind in available_pools
}
for cache_op in execution_plan.cache:
if isinstance(cache_op, Cache.LoadBackOp):
for op_id in cache_op.op_ids:
for kind in consumer_indices_by_kind:
producer_idx = self.memory_executor.get_producer_index(
kind, op_id
)
if (
producer_idx is not None
and producer_idx not in consumer_indices_by_kind[kind]
):
consumer_indices_by_kind[kind].append(producer_idx)
for kind, consumer_indices in consumer_indices_by_kind.items():
self.memory_executor.set_consumer(
kind, consumer_indices if consumer_indices else -1
)
def _flush_mamba_retract_states(self, forward_op) -> None:
"""Copy draft->working mamba states when retract occurred (no forward scheduled)."""
if forward_op is not None:
return
if self.model_executor.drafter is None:
return
if self.model_executor.runtime_states.mamba_pool is None:
return
self.model_executor.flush_mamba_draft_to_working_on_retract()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _load_model_config(
self, model_path: str, is_draft_worker: bool = False
) -> ModelConfig:
server_args = self.server_args
quantization = server_args.quantization
if is_draft_worker:
quantization = server_args.speculative_draft_model_quantization
return ModelConfig(
model_path,
trust_remote_code=server_args.trust_remote_code,
revision=server_args.revision,
context_length=server_args.max_model_len,
model_override_args=server_args.hf_overrides,
dtype=server_args.dtype,
quantization=quantization,
server_args=server_args,
is_draft_worker=is_draft_worker,
)
def _init_distributed(self) -> float:
max_num_input_tokens = (
self.server_args.chunked_prefill_size
if self.server_args.chunked_prefill_size > 0
else self.server_args.max_prefill_tokens + self.server_args.max_model_len
)
distributed_config = DistributedConfig.from_server_args(
server_args=self.server_args,
port_args=self.port_args,
gpu_id=self.gpu_id,
global_rank=self.global_rank,
hidden_size=self.model_config.hidden_size,
max_num_tokens=max_num_input_tokens,
)
return DistributedInitializer.initialize(distributed_config)
def _init_interprocess_comm(self):
context = zmq.Context(2)
if self.attn_tp_rank == 0:
self.recv_from_tokenizer = get_zmq_socket(
context, zmq.PULL, self.port_args.scheduler_input_ipc_name, False
)
self.send_to_tokenizer = get_zmq_socket(
context, zmq.PUSH, self.port_args.tokenizer_ipc_name, False
)
else:
self.recv_from_tokenizer = None
self.send_to_tokenizer = _NullSender()
# ------------------------------------------------------------------
# Shared step helpers
# ------------------------------------------------------------------
def _reap_or_keep_buffered_spec(self, spec) -> bool:
"""Resolve a buffered spec on resume; return True if it should be admitted.
A buffered spec was already registered in ``rid_to_state`` before it was
withheld, so if it was aborted while paused it never reached the
scheduler and the forward path can never reap it. Handle that here:
- state missing -> already published and reaped; drop silently.
- state finished -> aborted in place. Stream a terminating finish for
pause-initiated aborts (the passive client is still waiting) and drop
the registered state so the rid does not leak; client-initiated aborts
already tore down their own state, so just reap.
- otherwise -> still live; admit it.
"""
state = self.output_processor.rid_to_state.get(spec.request_id)
if state is None:
return False
if state.finished:
self.output_processor.reap_finished_orphan(spec.request_id, state)
return False
return True
def _process_new_requests(self):
recv_reqs = self.request_handler.recv_reqs()
# Snapshot the pause state before dispatch: process_requests may flip it
# mid-batch. If it was not blocked before but is after, a pause control
# message was processed in this very batch — which is what makes the
# FIFO edge below detectable (see TODO(pause-fifo)).
pause_blocked_before = self._pause.admit_blocked
new_req_specs, new_req_states, bootstrap_infos, abort_rids = (
self.request_handler.process_requests(recv_reqs)
)
# Sweep TTL-expired abort markers every iteration. Without this
# the map only gets cleaned inside ``mark_abort``, so a burst of
# stale-cancel traffic followed by silence leaves the last batch
# of entries sitting past their TTL (and potentially re-aborting
# reused rids). Amortized O(1): expired entries are always at
# the front of the insertion-ordered dict.
self.output_processor.sweep_pending_aborts()
# Abort both registered and grammar-queued requests. Without the
# grammar_manager.mark_abort call, a request aborted mid-compile
# would finish compiling and get admitted before being noticed.
grammar_manager = self.request_handler.grammar_manager
for rid in abort_rids:
self.output_processor.mark_abort(rid)
grammar_manager.mark_abort(rid)
# A pause(mode="abort") cancels every in-flight request through the same
# marker path as a client abort; they finish on their next scheduled
# step, then the drain check resolves the pause reply.
if self._pause.consume_abort_all():
for rid in list(self.output_processor.rid_to_state.keys()):
# notify_client=True: pause aborts a passive client's request,
# so it must receive a terminating finish (unlike a client abort).
self.output_processor.mark_abort(rid, notify_client=True)
grammar_manager.mark_abort(rid)
# abort/wait also cancel requests still compiling in the grammar queue:
# they are not yet in rid_to_state or the scheduler, so the sweep above
# and the drain check both miss them. A finished state makes the next
# get_ready_grammar_requests pass publish them instead of admitting, so
# they never run under post-resume weights or strand the drain.
if self._pause.consume_cancel_grammar():
for _, state, _ in grammar_manager.grammar_queue:
state.set_finish_with_abort("Aborted by pause", notify_client=True)
# On resume, flush specs buffered while paused even when no new request
# arrives this iteration. This must run before the ``if not ready:
# return`` guard below, which would otherwise strand buffered specs
# until the next inbound request. Specs aborted while paused are reaped
# in place (terminating finish + state cleanup) rather than admitted, so
# they don't burn a scheduler slot or leak their rid — see
# ``_reap_or_keep_buffered_spec``.
if not self._pause.admit_blocked and self._pause.buffered_specs:
specs = [
spec
for spec in self._pause.take_buffered_specs()
if self._reap_or_keep_buffered_spec(spec)
]
if specs:
self.scheduler.submit_requests(specs)
# Partition new requests by grammar readiness. Compile-bound requests
# are queued in GrammarManager and admitted in a later iteration when
# their futures resolve (see _drain_ready_grammar_requests below).
ready = []
for spec, state, bootstrap in zip(
new_req_specs, new_req_states, bootstrap_infos
):
# Requests pre-marked finished (e.g. invalid session ID aborted
# in RequestHandler) skip grammar compilation entirely — we'd
# just be wasting a compile slot on a response we're about to
# abort anyway, and the terminal response would be delayed by
# the compile/timeout window.
if state.finished:
ready.append((spec, state, bootstrap))
continue
if grammar_manager.process_req_with_grammar(state):
ready.append((spec, state, bootstrap))
else:
grammar_manager.add_to_queue(spec, state, bootstrap)
# Drain any previously-queued requests whose grammar just finished
# compiling. With attn_tp > 1 this also drives the per-iter all_gather
# that keeps grammar admission in sync across ranks.
ready.extend(grammar_manager.get_ready_grammar_requests())
if not ready:
return
admitted_specs = []
for spec, state, bootstrap in ready:
# Grammar-aborted (invalid grammar, timed-out compile, or missing
# backend) requests must not enter the scheduler — they have no
# valid grammar to mask logits with, and we don't want to spend a
# prefill slot on a request that's already finished. Publish the
# finish_reason directly so the client still gets a response.
if state.finished:
self.output_processor.publish_finished_at_admission(
spec.request_id, state
)
continue
if isinstance(self.kv_transfer, DisaggDecodeExecutor):
state.computed_length = state.input_length
self.output_processor.register(spec.request_id, state)
is_epd = self.epd_admission is not None and self._is_epd_request(state)
# EPD: DEFER the P->D sender registration to admission (in
# EpdPrefillAdmission.drain, just before submit_requests). Registering
# it now -- while the request is staged and NOT yet in the C++ scheduler
# -- would let DisaggPrefillExecutor.generate_events poll the sender and
# emit a BootstrappedEvent that the scheduler's requests_.at(rid) THROWS
# on (no such request yet). Non-EPD requests register now (submitted this
# same call).
if self.kv_transfer is not None and not is_epd:
self.kv_transfer.register(spec.request_id, bootstrap)
if self.memory_executor is not None:
hashes = calc_l3_query_hashes(self.scheduler, spec.tokens)
if hashes and len(hashes) > self.prefetch_threshold:
hit_pages = self.memory_executor.query_l3_pages(hashes)
logger.debug(
"[cache_op] L3 query: rid=%s hash_pages=%s hit_pages=%s threshold=%s",
spec.request_id,
len(hashes),
hit_pages,
self.prefetch_threshold,
)
spec.rolling_hashes = hashes
spec.storage_hit_pages = hit_pages
# EPD prefill: hold a request whose images are encode-routed OUT of the
# scheduler until its per-image embeddings have been received (started
# here, polled in EpdPrefillAdmission.drain, which registers the P->D
# sender + submits once ready). It is output_processor-registered above;
# the sender registration + submission are both deferred. Non-EPD
# requests admit immediately as before. Rank-identical because `ready` is
# rank-synced (recv_reqs broadcast + grammar gather).
if is_epd:
self.epd_admission.stage(
spec.request_id, state.multimodal_inputs.mm_items
)
self._epd_staged[spec.request_id] = (spec, state, bootstrap)
else:
admitted_specs.append(spec)
# Pause gate: while paused, withhold new requests from the scheduler
# (running requests keep stepping); buffered specs are flushed on resume
# above, ahead of any newly-admitted ones, preserving FIFO order.
#
# TODO(pause-fifo): recv_reqs() drains the socket non-blocking, so a
# generate request that arrived *before* a pause control message can be
# coalesced into the same batch and reach here after the pause flipped
# admit_blocked. Such a pre-pause request is buffered as post-pause work
# instead of running (wait) / being aborted (abort). Correct handling
# needs the batch processed as an ordered stream that respects the
# control request's FIFO position. Tracked as a follow-up; until then we
# warn when the coalescing condition is observed so it is not silent.
if self._pause.admit_blocked:
if admitted_specs and not pause_blocked_before:
logger.warning(
"Pause engaged in the same recv batch as %d generate "
"request(s) (rids=%s); their FIFO order relative to the "
"pause is not preserved, so a pre-pause request may be "
"buffered as post-pause work and run only after resume. "
"See TODO(pause-fifo).",
len(admitted_specs),
[spec.request_id for spec in admitted_specs],
)
self._pause.buffer_specs(admitted_specs)
return
if admitted_specs:
self.scheduler.submit_requests(admitted_specs)
@nvtx_range("loop:commit", color="rapids")
def _commit_forward_results(
self,
forward_op,
results: ModelExecutionResult,
on_first_token=None,
):
self.request_handler.forward_ct += 1
forward_mode = ForwardMode.from_num_extends(
forward_op.num_extends(),
len(forward_op.request_ids),
)
self.request_handler._profile_batch_predicate(forward_mode)
# post_process_forward_op calls sync() — after this, CPU tensors are ready
is_prefill_instance = isinstance(self.kv_transfer, DisaggPrefillExecutor)
request_changes = self.output_processor.post_process_forward_op(
forward_op,
results,
is_prefill_instance=is_prefill_instance,
on_first_token=on_first_token,
)
# Accumulate decode stats from synced results (no GPU sync)
if forward_op.num_extends() <= 0:
bs = len(forward_op.request_ids)
self.model_executor.accumulate_decode_stats(results, bs)
return request_changes
def _get_forward_op(self, execution_plan):
"""Return the next forward op from the given plan, or None if there is nothing to run."""
forward_ops = execution_plan.forward
if len(forward_ops) == 0 or len(forward_ops[0].request_ids) == 0:
return None
return forward_ops[0]
def _handle_flat_oom_terminals(self, execution_plan) -> None:
"""Surface flat-KV OOM terminals to their clients as abort finishes.
The C++ flat scheduler terminalizes a request that can never fit the
flat pool (AbortEvent inside the scheduler; the reaper reclaims its
resources) and reports its id on ``plan.flat_oom_request_ids``
(always empty on radix builds). The scheduler side is already fully
torn down — do NOT send a ForwardEvent.Abort back — but the client is
still waiting on the response stream, so finish the request with an
abort here (mirrors the PD FailedEvent handling above, minus the
scheduler abort).
"""
oom_rids = getattr(execution_plan, "flat_oom_request_ids", None)
if not oom_rids:
return
for rid in oom_rids:
state = self.output_processor.rid_to_state.get(rid)
if state is None:
# rid already gone (e.g. a client abort raced ahead).
logger.debug(
"flat OOM terminal for rid=%s: state missing; skipping", rid
)
continue
if state.finished:
# Already carries a finish (an abort raced ahead of the
# terminal). C++ reports this rid exactly once and no future
# forward op will reap it, so resolve it here (same orphan
# rule as _reap_or_keep_buffered_spec).
self.output_processor.reap_finished_orphan(rid, state)
continue
state.set_finish_with_abort(
"flat KV cache cannot fit this request: prompt exceeds pool "
"capacity (OOM)"
)
self.output_processor.publish_finished_at_admission(rid, state)
def _process_kv_transfer_events(self, kv_transfer_events: list) -> list:
processed = []
for event in kv_transfer_events:
processed.append(event)
if isinstance(event, PD.SucceededEvent) and isinstance(
self.kv_transfer, DisaggPrefillExecutor
):
req_id = event.request_id
processed.extend(self.output_processor.finish_prefill_request(req_id))
elif isinstance(event, PD.RemotePrefillDoneEvent):
req_id = event.request_id
bootstrap_token = event.bootstrap_token
self.output_processor.on_remote_prefill_done(req_id, bootstrap_token)
if isinstance(self.kv_transfer, DisaggDecodeExecutor):
candidate_info = self.kv_transfer.pop_remote_spec_candidate_ids(
req_id
)
if candidate_info is not None:
req_pool_idx, candidate_ids = candidate_info
self.model_executor.write_remote_spec_candidate_ids(
req_pool_idx, candidate_ids
)
elif isinstance(event, PD.FailedEvent):
# A PD/EPD transfer failed: the decode KV receiver timed out (e.g. the
# prefill aborted on embedding timeout so the KV never arrives), or a
# transfer errored. The C++ scheduler's FailedEvent handler is a no-op,
# so without this the request is never finished and the CLIENT HANGS
# FOREVER (the decode is its response stream). Finish it with an abort
# (streams the error to the client) and abort it in the scheduler so its
# slot/KV is freed. AbortEvent is valid from the decode-waiting state
# (forward_events.cpp: AbortEvent(Prefilling&&) -> Finished).
req_id = event.request_id
state = self.output_processor.rid_to_state.get(req_id)
if state is not None:
state.set_finish_with_abort(
"PD/EPD remote transfer failed or timed out"
)
self.output_processor.publish_finished_at_admission(req_id, state)
abort = ForwardEvent.Abort()
abort.request_id = req_id
processed.append(abort)
return processed
def _get_load(self):
"""Return load metrics for the DP load balancer."""
from tokenspeed.runtime.engine.io_struct import GetLoadReqOutput
available = self.scheduler.available_kv_pages()
num_total_pages = self.max_total_num_tokens // self.server_args.block_size
num_used_pages = num_total_pages - available
num_waiting = self.scheduler.waiting_size()
# num_reqs: running + waiting (used by SHORTEST_QUEUE balancing)
num_running = len(self.output_processor.rid_to_state)
return GetLoadReqOutput(
dp_rank=self.dp_rank,
num_reqs=num_running + num_waiting,
num_waiting_reqs=num_waiting,
num_pages=num_used_pages,
)
def _dp_sync_and_check(self, forward_op) -> DpForwardMetadata:
"""Synchronize DP ranks with CPU-only metadata.
All ranks call this before GPU forward work. The gathered metadata is
used for eager token-aware collectives and for choosing a common padded
CUDA graph shape during decode.
"""
import torch.distributed as dist
executes_model_forward = _forward_op_executes_model_forward(
forward_op,
is_disagg_decode=isinstance(self.kv_transfer, DisaggDecodeExecutor),
)
num_tokens = sum(forward_op.input_lengths) if executes_model_forward else 0
batch_size = len(forward_op.request_ids) if executes_model_forward else 0
if not executes_model_forward:
forward_mode = ForwardMode.IDLE
else:
forward_mode = ForwardMode.from_num_extends(
forward_op.num_extends(),
batch_size,
)
self._dp_local_info[0, 0] = num_tokens
self._dp_local_info[0, 1] = batch_size
self._dp_local_info[0, 2] = int(forward_mode)
dist.all_gather_into_tensor(
self._dp_global_info,
self._dp_local_info,
group=self.world_cpu_group,
)
global_num_tokens = self._dp_global_info[:, 0].tolist()
global_batch_size = self._dp_global_info[:, 1].tolist()
global_forward_mode = self._dp_global_info[:, 2].tolist()
any_rank_has_work = max(global_num_tokens) > 0
need_idle_forward = num_tokens == 0 and any_rank_has_work
all_decode_or_idle = all(
mode
in (
int(ForwardMode.DECODE),
int(ForwardMode.IDLE),
)
for mode in global_forward_mode
)
# Replicated prefill-graph gate (see PrefillGraph._select_bucket).
all_extend = all(
mode == int(ForwardMode.EXTEND) for mode in global_forward_mode
)
return DpForwardMetadata(
global_num_tokens=global_num_tokens,
global_batch_size=global_batch_size,
global_forward_mode=global_forward_mode,
all_decode_or_idle=all_decode_or_idle,
all_extend=all_extend,
need_idle_forward=need_idle_forward,
)
def _get_scheduler_stats(self):
"""Query scheduler for page usage and queue depth."""
available = self.scheduler.available_kv_pages()
active = self.scheduler.active_kv_pages()
num_total_pages = self.max_total_num_tokens // self.server_args.block_size
return {
"num_active_pages": active,
"num_cached_pages": num_total_pages - available,
"num_queue_reqs": self.scheduler.waiting_size(),
}
def _record_scheduler_iteration_metrics(
self, stats: dict, num_iteration_tokens: int
) -> None:
self.metrics.record_scheduler_iteration(
running=len(self.output_processor.rid_to_state),
waiting=stats["num_queue_reqs"],
num_active_pages=stats["num_active_pages"],
num_total_pages=self.max_total_num_tokens // self.server_args.block_size,
num_iteration_tokens=num_iteration_tokens,
)
# ------------------------------------------------------------------
# Pause / resume helpers
# ------------------------------------------------------------------
def _reset_caches_for_release(self) -> None:
"""Invalidate the prefix/radix cache before KV is discarded on release.
KV pages are re-mapped + zeroed on wake, so any retained prefix entry
would be stale. The unsafe case (prefix caching on with no reset) is
rejected up front in ``MemoryOccupationController.handle_release`` via
``kv_cache_release_allowed``, so by the time we get here either a reset
exists or prefix caching is off (nothing to invalidate).
"""
reset = getattr(self.scheduler, "reset_prefix_cache", None)
if callable(reset):
reset()
def _kv_pools(self) -> list:
"""All KV pools whose pages are tagged ``kv_cache`` — the target pool and
the draft pool in speculative-decoding runs. Release/repair must walk the
SAME set, so both derive it here rather than enumerating pools by hand."""
pools = []
for attr in ("token_to_kv_pool", "draft_token_to_kv_pool"):
pool = getattr(self.model_executor, attr, None)
if pool is not None:
pools.append(pool)
return pools
def _kv_repair_after_wake(self) -> None:
"""Zero re-mapped KV buffers (garbage after re-map) for every KV pool,
including the draft pool in spec-decode runs — its allocations are tagged
``kv_cache`` too, so a wake that skipped it would feed the draft model
stale KV. FP8 KV scales ride with the weights region, so no scale reset
is needed here."""
for pool in self._kv_pools():
if hasattr(pool, "clear_kv_buffers"):
pool.clear_kv_buffers()
def _paused_idle_step(self, prev_forward_op=None, prev_results=None) -> None:
"""Run one iteration under ``PAUSED_ALL`` (keep mode): no new forward
work, but keep DP ranks in lockstep, service the drain check, and yield
the CPU so the freeze does not busy-spin a core."""
if prev_results is not None:
request_changes = self._commit_forward_results(
prev_forward_op, prev_results
)
advance_forward(self.scheduler, request_changes)
self._publish_scheduler_kv_events()
if self.has_dp:
dp_metadata = self._dp_sync_and_check(None)
# While memory is released the weights region is unmapped; an idle
# forward runs the model and would read freed memory. All DP ranks
# release together, so skipping the idle forward stays consistent
# across ranks (the small DP sync above still runs to keep lockstep).
if dp_metadata.need_idle_forward and not self._pause.released:
self.model_executor.execute_idle_forward(
dp_metadata.global_num_tokens,
dp_metadata.global_batch_size,
dp_metadata.all_decode_or_idle,
)
self._pause.maybe_finish_drain(self.scheduler)
time.sleep(_PAUSED_IDLE_SLEEP_S)
# ------------------------------------------------------------------
# Event loops
# ------------------------------------------------------------------
def event_loop(self):
"""Non-overlapping scheduler loop."""
while True:
self._process_new_requests()
# EPD prefill: admit requests whose async embedding receives completed
# this cycle (rank-synced). Fixed position right after
# _process_new_requests so the drain's TP collective ordering is
# rank-identical every cycle.
self._drain_ready_epd_embeddings()
self._commit_cache_results()
if self._pause.forward_blocked:
self._paused_idle_step()
continue
execution_plan = self.scheduler.next_execution_plan()
self._publish_scheduler_kv_events()
self._handle_flat_oom_terminals(execution_plan)
self._submit_cache_ops(execution_plan)
forward_op = self._get_forward_op(execution_plan)
self._flush_mamba_retract_states(forward_op)
stats = self._get_scheduler_stats()
num_iter_tokens = (
sum(forward_op.input_lengths) if forward_op is not None else 0
)
# DP sync: all ranks must participate even when idle.
dp_metadata = None
if self.has_dp:
dp_metadata = self._dp_sync_and_check(forward_op)
if dp_metadata.need_idle_forward:
self.model_executor.execute_idle_forward(
dp_metadata.global_num_tokens,
dp_metadata.global_batch_size,
dp_metadata.all_decode_or_idle,
)
self._record_scheduler_iteration_metrics(stats, num_iter_tokens)
continue
request_changes = []
if forward_op is not None:
sampling_params_list = self._gather_sampling_params(forward_op)
grammar_inputs = self._gather_grammar_state(forward_op)
self._mark_stats_scheduled(forward_op)
results, on_first_token = self._dispatch_forward(
forward_op,
sampling_params_list,
execution_plan,
dp_metadata=dp_metadata,
stats=stats,
grammar_inputs=grammar_inputs,
)
if results is not None:
request_changes.extend(
self._commit_forward_results(
forward_op, results, on_first_token
)
)
if self.kv_transfer is not None:
kv_transfer_events = self.kv_transfer.generate_events()
request_changes.extend(
self._process_kv_transfer_events(kv_transfer_events)
)
if request_changes:
advance_forward(self.scheduler, request_changes)
self._publish_scheduler_kv_events()
# Resolve a deferred abort/wait pause reply once in-flight work drains.
self._pause.maybe_finish_drain(self.scheduler)
self._record_scheduler_iteration_metrics(stats, num_iter_tokens)
def _mark_stats_scheduled(self, forward_op) -> None:
# Stamp the pre-forward "scheduled" time on each request's stats tracker
# so the queue/prefill split is anchored before the forward (idempotent:
# only the first forward a request appears in sets it). --enable-log-request-stats.
if not self.server_args.enable_log_request_stats or forward_op is None:
return
now = time.time()
rid_to_state = self.output_processor.rid_to_state
for rid in forward_op.request_ids:
st = rid_to_state.get(rid)
if st is not None:
st.stats.mark_scheduled(now)
def _gather_sampling_params(self, forward_op) -> list[SamplingParams]:
"""Look up per-request SamplingParams from the output processor. The
sampling backend does its own flip detection + RNG state management
internally, so we only need the scalar params here."""
return [
self.output_processor.rid_to_state[rid].sampling_params
for rid in forward_op.request_ids
]
def _gather_grammar_state(self, forward_op) -> GrammarStepInputs | None:
"""Build ``GrammarStepInputs`` for the current batch, or ``None``.
Returns ``None`` when no request in this batch has a grammar — the
model_executor short-circuits then. Otherwise carries the grammars
list + per-EXTEND-slot ``advance_mask`` (False on intermediate
chunked-prefill chunks, since the sampled token is discarded by
post_process and must not advance the matcher).
"""
rid_to_state = self.output_processor.rid_to_state
grammars = [rid_to_state[rid].grammar for rid in forward_op.request_ids]
if not any(grammars):
return None
advance_mask = None
num_extends = forward_op.num_extends()
if num_extends > 0:
bs = len(forward_op.request_ids)
extend_prefix_lens = forward_op.extend_prefix_lens
extend_input_lengths = forward_op.input_lengths[:num_extends]
advance_mask = [True] * bs
for i in range(num_extends):
rid = forward_op.request_ids[i]
# This chunk completes prefill iff it processes the final
# token of the prompt; intermediate chunks don't.
advance_mask[i] = (
extend_prefix_lens[i] + extend_input_lengths[i]
>= rid_to_state[rid].input_length
)
return GrammarStepInputs(grammars=grammars, advance_mask=advance_mask)
def event_loop_overlap(self):
"""
Overlapping scheduler loop: post-process the previous step's results
while the current step's forward pass is in flight.
"""
# EPD invariant: the async embedding drain (EpdPrefillAdmission.drain)
# that admits EPD requests runs ONLY in event_loop(), never here. A
# prefill node that receives encode embeddings must therefore run the
# non-overlap loop -- should_use_overlap_schedule enforces this by forcing
# prefill -> non-overlap. Assert it rather than trusting that external
# coupling: if a prefill ever reached this loop, every EPD request would
# stage into the admission controller and hang forever with no drain.
assert self.epd_admission is None, (
"EPD prefill must run the non-overlap event_loop(); the embedding "
"drain is not wired into event_loop_overlap()"
)
prev_results: ModelExecutionResult = None
prev_forward_op = None
while True:
# Order this iter's default-stream writes (KVAllocator,
# update_block_table, prefix_cache writes to req_to_page)
# after the prev iter's forward on execution_stream that
# reads the same tensor. Non-blocking on host.
torch.cuda.default_stream().wait_stream(
self.model_executor.execution_stream
)
self._process_new_requests()
self._commit_cache_results()
if self._pause.forward_blocked:
# Freeze: commit any in-flight (overlapped) step — a forward
# already on the GPU can't be un-launched — then idle.
self._paused_idle_step(prev_forward_op, prev_results)
prev_results = None
prev_forward_op = None
continue
execution_plan = self.scheduler.next_execution_plan()
self._publish_scheduler_kv_events()
self._handle_flat_oom_terminals(execution_plan)
self._submit_cache_ops(execution_plan)
forward_op = self._get_forward_op(execution_plan)
self._flush_mamba_retract_states(forward_op)
stats = self._get_scheduler_stats()
num_iter_tokens = (
sum(forward_op.input_lengths) if forward_op is not None else 0
)
grammar_inputs = None
if forward_op is not None:
# Gather both sampling params and grammar state BEFORE the
# prev_results commit below — that commit can finish requests
# and pop them from output_processor.rid_to_state, which would
# KeyError when we look up rids that are still in the current
# forward_op.
sampling_params_list = self._gather_sampling_params(forward_op)
grammar_inputs = self._gather_grammar_state(forward_op)
# DP sync: all ranks must participate even when idle.
dp_metadata = None
if self.has_dp:
dp_metadata = self._dp_sync_and_check(forward_op)
if dp_metadata.need_idle_forward:
if prev_results is not None:
request_changes = self._commit_forward_results(
prev_forward_op, prev_results
)
advance_forward(self.scheduler, request_changes)
self._publish_scheduler_kv_events()
prev_results = None
prev_forward_op = None
self.model_executor.execute_idle_forward(
dp_metadata.global_num_tokens,
dp_metadata.global_batch_size,
dp_metadata.all_decode_or_idle,
)
self._record_scheduler_iteration_metrics(stats, num_iter_tokens)
continue
# ---- dispatch current forward first (async GPU launch) ----
# Issue curr's forward before committing prev so the GPU runs curr
# while the CPU syncs/post-processes prev. Committing prev first
# would block the CPU on prev's copy_event and leave the GPU idle
# until dispatch — visible as a gap between forwards in the trace.
#
# Eager grammar exception: setup_grammar_step reads each matcher's
# current state to fill the bitmask. Under the overlap pattern the
# matcher hasn't been advanced yet by prev's accept_token (commit
# below), so the fill would use a one-step-stale state and let the
# model sample a token the matcher then rejects. Capturable
# grammar dodges this with an in-graph hostfunc that advances
# before fill; eager has no equivalent, so we commit prev first
# whenever this batch carries grammars. Costs the dispatch/commit
# overlap for grammar batches but is correct.
request_changes = []
curr_has_grammar = grammar_inputs is not None
eager_grammar_needs_advance = (
curr_has_grammar
and prev_results is not None
and self.model_executor.eager_grammar_buffers is not None
)
if eager_grammar_needs_advance:
request_changes.extend(
self._commit_forward_results(prev_forward_op, prev_results)
)
prev_results = None
prev_forward_op = None
curr_results = None
if forward_op is not None:
self._mark_stats_scheduled(forward_op)
curr_results, _ = self._dispatch_forward(
forward_op,
sampling_params_list,
execution_plan,
dp_metadata=dp_metadata,
stats=stats,
grammar_inputs=grammar_inputs,
)
# ---- post-process previous step (overlapped with current forward) ----
if prev_results is not None:
request_changes.extend(
self._commit_forward_results(prev_forward_op, prev_results)
)
# ---- collect KV transfer events ----
if self.kv_transfer is not None:
kv_transfer_events = self.kv_transfer.generate_events()
request_changes.extend(
self._process_kv_transfer_events(kv_transfer_events)
)
if request_changes:
advance_forward(self.scheduler, request_changes)
self._publish_scheduler_kv_events()
# Resolve a deferred abort/wait pause reply once in-flight work drains.
self._pause.maybe_finish_drain(self.scheduler)
self._record_scheduler_iteration_metrics(stats, num_iter_tokens)
prev_results = curr_results
prev_forward_op = forward_op
def run_event_loop(
server_args: ServerArgs,
port_args: PortArgs,
pipe_writer,
):
mapping = server_args.mapping
gpu_id = mapping.rank % mapping.nprocs_per_node + server_args.base_gpu_id
attn_tp_rank = mapping.attn.tp_rank
dp_rank = mapping.attn.dp_rank
global_rank = mapping.rank
setproctitle.setproctitle(f"tokenspeed::scheduler_{dp_rank}")
faulthandler.enable()
parent_process = psutil.Process().parent()
register_usr_signal()
prefix = f" ATTN TP RANK {attn_tp_rank}"
configure_logger(server_args, prefix=prefix)
try:
if server_args.disaggregation_mode == "encode":
# The encode role is LM-free; run the lightweight vision-tower loop
# instead of building the full EventLoop (KV/LM scheduler).
from tokenspeed.runtime.pd.epd.encode_loop import (
run_encode_loop,
)
run_encode_loop(server_args, port_args, pipe_writer, gpu_id, global_rank)
return
event_loop = EventLoop(
server_args,
port_args,
gpu_id,
attn_tp_rank,
dp_rank,
global_rank,
)
pipe_writer.send(
{
"status": "ready",
"max_total_num_tokens": event_loop.max_total_num_tokens,
"max_req_input_len": event_loop.max_req_input_len,
"max_num_seqs": server_args.max_num_seqs,
"chunked_prefill_size": server_args.chunked_prefill_size,
"max_model_len": event_loop.model_config.context_len,
}
)
if event_loop.has_dp:
# All DP schedulers must finish initialization before any rank enters
# the loop and starts the first DP metadata collective.
dist.barrier(group=event_loop.world_cpu_group)
if event_loop.use_overlap_schedule:
event_loop.event_loop_overlap()
else:
event_loop.event_loop()
except Exception:
traceback = get_exception_traceback()
logger.error("Scheduler hit an exception: %s", traceback)
parent_process.send_signal(signal.SIGUSR1)