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
411 lines
14 KiB
Python
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
|