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

411 lines
14 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.
"""Helper functions for constructing scheduler specs and events."""
import os
from collections.abc import Mapping, Sequence
from typing import Any
import torch
from tokenspeed_scheduler import (
Cache,
ExecutionEvent,
ForwardEvent,
PagedCacheGroupConfig,
PagedCacheGroupFamily,
PagedCacheRetention,
PrefixCacheAdjunctSpec,
RequestSpec,
SchedulerConfig,
)
_CACHE_EVENT_TYPES = {
"WriteBackDoneEvent": Cache.WriteBackDoneEvent,
"PrefetchDoneEvent": Cache.PrefetchDoneEvent,
}
# Emitted only by the flat host tier (FlatMemoryExecutor); the radix executors
# never produce it, so radix behavior is unchanged. hasattr-guarded: the flat
# tier requires a flat-built (post-C3) ext anyway, and an older radix ext must
# keep importing this module.
if hasattr(Cache, "LoadBackDoneEvent"):
_CACHE_EVENT_TYPES["LoadBackDoneEvent"] = Cache.LoadBackDoneEvent
_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
# Pool-spec string -> scheduler enum (pool_to_paged_cache_groups).
_RETENTION_MAP = {
"full_history": PagedCacheRetention.FullHistory,
"sliding_window": PagedCacheRetention.SlidingWindow,
}
_FAMILY_MAP = {
"history": PagedCacheGroupFamily.History,
"state": PagedCacheGroupFamily.State,
}
def make_spec(rid: str, tokens: list[int]) -> RequestSpec:
spec = RequestSpec()
spec.request_id = rid
spec.tokens = tokens
return spec
def make_config(
num_device_pages: int,
max_scheduled_tokens: int,
max_batch_size: int,
page_size: int,
num_host_pages: int,
disable_l2_cache: bool,
enable_l3_storage: bool,
prefetch_threshold: int,
role: str,
enable_kv_cache_events: bool = False,
decode_input_tokens: int = 1,
overlap_schedule_depth: int = 0,
disable_prefix_cache: bool = False,
enable_mamba: bool = False,
mamba_cache_chunk_size: int = 64,
mamba_pool_total_chunks: int = 0,
enable_mamba_l2: bool = False,
mamba_l2_host_slots: int = 0,
paged_cache_groups: Sequence["PagedCacheGroupConfig"] | None = None,
enable_mixed_prefill_decode: bool = False,
prefix_cache_adjunct: "PrefixCacheAdjunctSpec | None" = None,
) -> SchedulerConfig:
cfg = SchedulerConfig()
cfg.num_device_pages = num_device_pages
cfg.max_scheduled_tokens = max_scheduled_tokens
cfg.max_batch_size = max_batch_size
cfg.block_size = page_size
cfg.num_host_pages = num_host_pages
cfg.enable_l3_storage = enable_l3_storage
cfg.prefetch_threshold = prefetch_threshold
cfg.enable_kv_cache_events = enable_kv_cache_events
if role == "prefill":
cfg.role = SchedulerConfig.Role.P
elif role == "decode":
cfg.role = SchedulerConfig.Role.D
else:
cfg.role = SchedulerConfig.Role.Fused
cfg.decode_input_tokens = decode_input_tokens
cfg.overlap_schedule_depth = overlap_schedule_depth
cfg.disable_prefix_cache = disable_prefix_cache
cfg.disable_l2_cache = disable_l2_cache
cfg.enable_mamba = enable_mamba
cfg.mamba_cache_chunk_size = mamba_cache_chunk_size
cfg.mamba_pool_total_chunks = mamba_pool_total_chunks
cfg.enable_mamba_l2 = enable_mamba_l2
cfg.mamba_l2_host_slots = mamba_l2_host_slots
cfg.enable_mixed_prefill_decode = enable_mixed_prefill_decode
if paged_cache_groups:
cfg.paged_cache_groups = list(paged_cache_groups)
# Opt-in; unset means paged-cache groups are transport-only.
if prefix_cache_adjunct is not None:
cfg.prefix_cache_adjunct = prefix_cache_adjunct
return cfg
def pool_to_paged_cache_groups(pool: Any) -> list:
"""Convert a KV pool's paged_cache_group_specs to scheduler configs."""
specs = pool.paged_cache_group_specs
if not specs:
return []
counts = pool.paged_cache_group_page_counts
out = []
for spec in specs:
retention = _RETENTION_MAP.get(spec.retention)
if retention is None:
raise ValueError(
f"pool_to_paged_cache_groups: unsupported retention "
f"{spec.retention!r} for group {spec.group_id!r}"
)
family = _FAMILY_MAP.get(spec.family)
if family is None:
raise ValueError(
f"pool_to_paged_cache_groups: unsupported family "
f"{spec.family!r} for group {spec.group_id!r}"
)
kwargs = dict(
group_id=spec.group_id,
rows_per_page=int(spec.rows_per_page),
entry_stride_tokens=int(spec.entry_stride_tokens),
total_pages=int(counts[spec.group_id]),
retention=retention,
family=family,
)
if spec.retention == "sliding_window":
kwargs["sliding_window_tokens"] = int(spec.sliding_window_tokens)
out.append(PagedCacheGroupConfig(**kwargs))
return out
def pool_to_prefix_cache_adjunct_spec(
required_group_ids: Sequence[str],
) -> "PrefixCacheAdjunctSpec":
"""Build a PrefixCacheAdjunctSpec from required group ids."""
if not required_group_ids:
raise ValueError(
"pool_to_prefix_cache_adjunct_spec: required_group_ids must be non-empty"
)
spec = PrefixCacheAdjunctSpec()
spec.required_groups = [str(gid) for gid in required_group_ids]
return spec
def should_use_overlap_schedule(
*,
disable_overlap_schedule: bool,
disaggregation_mode: str,
) -> bool:
"""Return whether the runtime can use the overlapped scheduler loop."""
if disable_overlap_schedule:
return False
if disaggregation_mode in ("prefill", "encode"):
# prefill drain + KV send run only on the non-overlap loop; encode has no LM loop.
return False
return True
def make_extend_result_event(
request_id: str, tokens: Sequence[int] = ()
) -> "ForwardEvent.ExtendResult":
fe = ForwardEvent.ExtendResult()
fe.request_id = request_id
fe.tokens = list(tokens)
return fe
def make_finish_event(request_id: str) -> "ForwardEvent.Finish":
fe = ForwardEvent.Finish()
fe.request_id = request_id
return fe
def make_abort_event(request_id: str) -> "ForwardEvent.Abort":
"""Finish without caching: AbortEvent skips the radix-tree insert and
never enters Draining, so no host-KV writeback (target or draft) is
issued. Used for numerically-corrupted requests whose KV must not be
reused.
"""
fe = ForwardEvent.Abort()
fe.request_id = request_id
return fe
def make_update_reserve_tokens_event(request_id: str, new_reserve_num_tokens: int):
fe = ForwardEvent.UpdateReserveNumTokens()
fe.request_id = request_id
fe.reserve_num_tokens_in_next_schedule_event = new_reserve_num_tokens
return fe
def advance_forward(scheduler, forward_events: list) -> None:
ec = ExecutionEvent()
for fe in forward_events:
ec.add_event(fe)
scheduler.advance(ec)
def cache_event_to_payload(event) -> dict:
kind = type(event).__name__
if kind not in _CACHE_EVENT_TYPES:
raise ValueError(f"Unsupported cache event type: {kind}")
return {
"kind": kind,
"op_id": int(event.op_id),
"success": bool(event.success),
"request_id": getattr(event, "request_id", ""),
}
def cache_event_from_payload(payload: dict):
kind = payload["kind"]
if kind not in _CACHE_EVENT_TYPES:
raise ValueError(f"Unsupported cache event type: {kind}")
event = _CACHE_EVENT_TYPES[kind]()
event.op_id = int(payload["op_id"])
event.success = bool(payload["success"])
request_id = payload.get("request_id", "")
if request_id:
event.request_id = request_id
return event
def cache_event_key(payload: dict) -> tuple[str, int]:
return payload["kind"], int(payload["op_id"])
def pop_common_cache_event_payloads(
pending_payloads_by_rank: Sequence[Sequence[dict]],
) -> list[dict]:
if not pending_payloads_by_rank:
return []
rank_maps = []
common_keys = None
for payloads in pending_payloads_by_rank:
rank_map = {cache_event_key(payload): payload for payload in payloads}
rank_maps.append(rank_map)
rank_keys = set(rank_map)
common_keys = rank_keys if common_keys is None else common_keys & rank_keys
if not common_keys:
return []
ready_payloads = []
for key in sorted(common_keys, key=lambda item: (item[1], item[0])):
payload = dict(rank_maps[0][key])
payload["success"] = all(rank_map[key]["success"] for rank_map in rank_maps)
ready_payloads.append(payload)
return ready_payloads
def cache_sync_debug_enabled() -> bool:
value = os.getenv("TS_DEBUG_CACHE_SYNC", "")
return value.strip().lower() in _TRUTHY_ENV_VALUES
def _block_tables_from_forward_op(
forward_op: Any,
*,
attr: str,
device: "torch.device | str",
num_reqs: int | None,
) -> dict[str, torch.Tensor]:
raw_tables = getattr(forward_op, attr, None)
if raw_tables is None:
return {}
device = torch.device(device) if isinstance(device, str) else device
items = (
list(raw_tables.items())
if isinstance(raw_tables, Mapping)
else list(raw_tables)
)
out: dict[str, torch.Tensor] = {}
for key_obj, table in items:
key = str(key_obj)
rows = list(table)
if num_reqs is not None and len(rows) != num_reqs:
# No exemption for empty row lists: a silently dropped group
# would hand the flat CUDA-graph replay a per-group hole.
raise ValueError(
f"{attr}[{key}] has {len(rows)} rows but forward op reported "
f"num_reqs={num_reqs}"
)
if not rows:
# Idle/empty op: callers treat the resulting {} as "no tables".
continue
max_pages = max((len(row) for row in rows), default=0)
if max_pages == 0:
out[key] = torch.empty((len(rows), 0), dtype=torch.int32, device=device)
continue
# One flattened Python list -> single tensor construct (holes stay 0,
# ragged tails pad with -1), instead of O(bs) tiny per-row tensors.
flat_values: list[int] = []
for row in rows:
row_values = list(row)
flat_values.extend(row_values)
flat_values.extend([-1] * (max_pages - len(row_values)))
# pin_memory as a ctor arg: builds the staging tensor pinned in one
# pass instead of tensor(...).pin_memory()'s second host copy.
flat = torch.tensor(
flat_values,
dtype=torch.int32,
device="cpu",
pin_memory=device.type == "cuda",
).view(len(rows), max_pages)
out[key] = flat.to(device, non_blocking=True)
return out
def paged_cache_block_tables_from_forward_op(
forward_op: Any,
device: "torch.device | str",
*,
num_reqs: int | None = None,
) -> dict[str, torch.Tensor]:
return _block_tables_from_forward_op(
forward_op,
attr="paged_cache_block_tables",
device=device,
num_reqs=num_reqs,
)
def flat_block_tables_from_forward_op(
forward_op: Any,
device: "torch.device | str",
*,
num_reqs: int | None = None,
) -> dict[str, torch.Tensor]:
"""Bridge the flat per-group block tables to GPU int32 tensors: absolute
page indices, null hole = 0 preserved, ragged-row padding -1. No
base-offset companion -- the flat path never compacts.
"""
return _block_tables_from_forward_op(
forward_op,
attr="flat_block_tables",
device=device,
num_reqs=num_reqs,
)
def paged_cache_block_table_base_offsets_from_forward_op(
forward_op: Any,
device: "torch.device | str",
*,
num_reqs: int | None = None,
) -> tuple[dict[str, torch.Tensor], dict[str, int]]:
"""Convert forward op compact-table base offsets to int32 tensors.
Returns (gpu_offsets_per_group, cpu_max_per_group). The CPU max is captured
before H2D so callers can size graph-replay buffers without a GPU max + D2H
sync. Empty rows yield max=0; missing keys are absent from the max dict.
"""
raw = getattr(forward_op, "paged_cache_block_table_base_offsets", None)
if raw is None:
return {}, {}
device = torch.device(device) if isinstance(device, str) else device
items = list(raw.items()) if isinstance(raw, Mapping) else list(raw)
out: dict[str, torch.Tensor] = {}
max_per_group: dict[str, int] = {}
for key_obj, offsets in items:
key = str(key_obj)
rows = list(offsets)
if num_reqs is not None and rows and len(rows) != num_reqs:
raise ValueError(
f"paged_cache_block_table_base_offsets[{key}] has {len(rows)} "
f"rows but forward op reported num_reqs={num_reqs}"
)
if not rows:
max_per_group[key] = 0
continue
max_per_group[key] = int(max(rows))
cpu = torch.tensor(rows, dtype=torch.int32, device="cpu")
if device.type == "cuda":
out[key] = cpu.pin_memory().to(device, non_blocking=True)
else:
out[key] = cpu.to(device)
return out, max_per_group