refactor: decouple task cache from PagePool and unify steady-state detection
- TaskCacheRegistry -> TaskCacheManager (independent, held by scheduler) - TaskCacheState co-locates 5 parallel dicts into one dataclass - AllocationStrategy base class + PagedStrategy subclass (page_size is a parameter) - _rollback() helper for unified cleanup (no duplicate free paths) - Task._kv_len + prefill_done property (explicit, no output_tokens proxy) - Steady-state detection single-sourced in TaskCacheManager.bind() - PagePool is now pure physical layer (no task knowledge) - Removed dead _page_to_hash dict in RadixCache
This commit is contained in:
@@ -38,6 +38,7 @@ from astrai.inference.core import (
|
||||
RadixCache,
|
||||
ReqToTokenPool,
|
||||
Task,
|
||||
TaskCacheManager,
|
||||
TaskManager,
|
||||
TaskStatus,
|
||||
page_hash,
|
||||
@@ -67,6 +68,7 @@ __all__ = [
|
||||
"PagePool",
|
||||
"RadixCache",
|
||||
"ReqToTokenPool",
|
||||
"TaskCacheManager",
|
||||
"page_hash",
|
||||
"sample",
|
||||
"BaseSamplingStrategy",
|
||||
|
||||
@@ -7,6 +7,7 @@ from astrai.inference.core.cache import (
|
||||
PagePool,
|
||||
RadixCache,
|
||||
ReqToTokenPool,
|
||||
TaskCacheManager,
|
||||
page_hash,
|
||||
)
|
||||
from astrai.inference.core.executor import Executor
|
||||
@@ -21,6 +22,7 @@ __all__ = [
|
||||
"PagePool",
|
||||
"RadixCache",
|
||||
"ReqToTokenPool",
|
||||
"TaskCacheManager",
|
||||
"page_hash",
|
||||
"Executor",
|
||||
"InferenceScheduler",
|
||||
|
||||
+269
-282
@@ -1,19 +1,24 @@
|
||||
"""KV cache architecture: three-layer separation (SGLang-inspired).
|
||||
|
||||
Layer 1 — KVStorage: flat token-level K/V buffers [n_layers, size, H, D]
|
||||
Layer 2 — ReqToTokenPool: index table [req_idx, pos] → physical token slot
|
||||
Layer 3 — Allocator: slot/page allocation with ref-counting and LRU
|
||||
Layer 2 — ReqToTokenPool: index table [req_idx, pos] -> physical token slot
|
||||
Layer 3 — AllocationStrategy: slot/page allocation with ref-counting and LRU
|
||||
|
||||
PagePool owns the physical buffers and bind (KVCache assembly); it does
|
||||
not know about tasks. TaskCacheManager owns task_id -> TaskCacheState
|
||||
mapping and delegates physical slot allocation to the strategy.
|
||||
|
||||
PagePool orchestrates all three plus RadixCache (prefix addressing).
|
||||
KVCache is a pure dataclass passed to the model for direct buffer access.
|
||||
|
||||
Two modes:
|
||||
- contiguous (default): pre-allocated per-request blocks, no dynamic alloc
|
||||
- paged: shared pool with on-demand allocation, prefix caching support
|
||||
Two strategies (selected once at construction):
|
||||
- ContiguousStrategy: pre-allocated per-request blocks, no dynamic alloc
|
||||
- PagedStrategy: dynamic paged allocation; page_size is a parameter
|
||||
(1 = token-level, >1 = page-level with radix prefix)
|
||||
"""
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional, OrderedDict
|
||||
|
||||
import torch
|
||||
@@ -30,6 +35,20 @@ class _BindState:
|
||||
seq_lens: List[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskCacheState:
|
||||
"""Per-task cache allocation state.
|
||||
|
||||
Co-locating all task-owned cache state in one object makes the
|
||||
alloc/free/extend lifecycle atomic.
|
||||
"""
|
||||
|
||||
req_idx: int
|
||||
length: int = 0
|
||||
cached: int = 0
|
||||
pages: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def _is_steady_increment(
|
||||
prev_sig: Optional[tuple],
|
||||
prev_vals: Optional[List[int]],
|
||||
@@ -128,15 +147,11 @@ class RadixCache:
|
||||
self._page_size = page_size
|
||||
self._root = RadixNode()
|
||||
self._page_to_node: Dict[int, RadixNode] = {}
|
||||
# Retained as an introspection-compatible map; matching never relies on
|
||||
# this lossy value.
|
||||
self._page_to_hash: Dict[int, int] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def evict(self, idx: int):
|
||||
with self._lock:
|
||||
node = self._page_to_node.pop(idx, None)
|
||||
self._page_to_hash.pop(idx, None)
|
||||
if node is None:
|
||||
return
|
||||
node.page_idx = None
|
||||
@@ -169,7 +184,6 @@ class RadixCache:
|
||||
if logical_page_idx >= full_pages:
|
||||
return
|
||||
old = self._page_to_node.pop(page_idx, None)
|
||||
self._page_to_hash.pop(page_idx, None)
|
||||
if old is not None and old.parent is not None:
|
||||
old.parent.children.pop(old.tokens, None)
|
||||
|
||||
@@ -185,12 +199,8 @@ class RadixCache:
|
||||
if node.page_idx is not None and node.page_idx != page_idx:
|
||||
replaced = node.page_idx
|
||||
self._page_to_node.pop(replaced, None)
|
||||
self._page_to_hash.pop(replaced, None)
|
||||
node.page_idx = page_idx
|
||||
self._page_to_node[page_idx] = node
|
||||
self._page_to_hash[page_idx] = page_hash(
|
||||
token_ids, logical_page_idx, self._page_size
|
||||
)
|
||||
|
||||
def release(self, pages: List[int]) -> None:
|
||||
with self._lock:
|
||||
@@ -272,21 +282,6 @@ class KVCache:
|
||||
"""Pure data struct passed to model for KV cache I/O.
|
||||
|
||||
The attention layer does raw buffer indexing — no methods, no abstraction.
|
||||
|
||||
Attributes:
|
||||
k_buffer: [n_layers, size, n_kv_heads, head_dim]
|
||||
v_buffer: [n_layers, size, n_kv_heads, head_dim]
|
||||
req_to_token: [num_reqs, max_ctx_len] — index table
|
||||
req_pool_indices: [batch_size] — row indices into req_to_token
|
||||
seq_lens: [batch_size] — per-request total sequence lengths
|
||||
out_cache_loc: [batch, new_seq_len] or [batch, 1] — write indices
|
||||
max_len: max(seq_lens) as Python int — avoids GPU sync in decode
|
||||
kv_indptr: [batch+1] int32 — prefix sum of seq_lens, precomputed once
|
||||
per step so the attention backend avoids rebuilding it per layer.
|
||||
qo_indptr: [batch+1] int32 — prefill qo prefix-sum (None in decode)
|
||||
decode_o_part: split-KV o partial workspace (mirrors FlashInfer)
|
||||
decode_ml_part: split-KV m/l partial workspace (mirrors FlashInfer)
|
||||
decode_out: pre-allocated decode output buffer (graph-safe)
|
||||
"""
|
||||
|
||||
k_buffer: Tensor
|
||||
@@ -303,21 +298,123 @@ class KVCache:
|
||||
decode_out: Optional[Tensor] = None
|
||||
|
||||
|
||||
class AllocationStrategy(ABC):
|
||||
"""Physical slot allocation policy.
|
||||
|
||||
The base class provides contiguous-mode defaults (all no-ops): req_to_token
|
||||
is pre-filled at PagePool init, so no dynamic allocation is needed.
|
||||
PagedStrategy overrides every method to add dynamic allocation.
|
||||
"""
|
||||
|
||||
def alloc(self, state: TaskCacheState, prompt_ids: List[int]) -> bool:
|
||||
return True
|
||||
|
||||
def free(self, state: TaskCacheState) -> None:
|
||||
pass
|
||||
|
||||
def extend(self, state: TaskCacheState, pos: int) -> bool:
|
||||
return True
|
||||
|
||||
def write_indices(self, state: TaskCacheState, prompt_ids: List[int]) -> None:
|
||||
pass
|
||||
|
||||
def record_hashes(
|
||||
self, state: TaskCacheState, prompt_ids: List[int], start: int
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class PagedStrategy(AllocationStrategy):
|
||||
"""Dynamic paged allocation from a shared bitmask pool.
|
||||
|
||||
page_size is a parameter, not a separate strategy: at page_size=1 each
|
||||
allocated page *is* one token slot (``page * 1 + 0``), and prefix
|
||||
caching is simply disabled (``prefix=None``). The unified page
|
||||
formula ``pages[page_idx] * page_size + offset`` holds for both.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
alloc: Allocator,
|
||||
prefix: Optional[RadixCache],
|
||||
page_size: int,
|
||||
req_pool: ReqToTokenPool,
|
||||
device,
|
||||
):
|
||||
self._alloc = alloc
|
||||
self._prefix = prefix
|
||||
self._page_size = page_size
|
||||
self._req_pool = req_pool
|
||||
self._device = device
|
||||
|
||||
def alloc(self, state: TaskCacheState, prompt_ids: List[int]) -> bool:
|
||||
if self._prefix is not None:
|
||||
hits = self._prefix.lookup(prompt_ids)
|
||||
state.cached = len(hits) * self._page_size
|
||||
for p in hits:
|
||||
self._alloc.inc_ref(p)
|
||||
state.pages = list(hits)
|
||||
|
||||
remaining = len(prompt_ids) - state.cached
|
||||
if remaining <= 0:
|
||||
return True
|
||||
n_new = (remaining + self._page_size - 1) // self._page_size
|
||||
for _ in range(n_new):
|
||||
p = self._alloc.alloc()
|
||||
if p < 0:
|
||||
return False
|
||||
state.pages.append(p)
|
||||
return True
|
||||
|
||||
def free(self, state: TaskCacheState) -> None:
|
||||
if self._prefix is not None:
|
||||
for p in state.pages:
|
||||
keep = self._prefix.has_page(p)
|
||||
self._alloc.free(p, keep_cached=keep)
|
||||
if not keep:
|
||||
self._prefix.evict(p)
|
||||
else:
|
||||
for p in state.pages:
|
||||
self._alloc.free(p)
|
||||
|
||||
def extend(self, state: TaskCacheState, pos: int) -> bool:
|
||||
page_idx = pos // self._page_size
|
||||
if page_idx >= len(state.pages):
|
||||
p = self._alloc.alloc()
|
||||
if p < 0:
|
||||
return False
|
||||
state.pages.append(p)
|
||||
offset = pos % self._page_size
|
||||
self._req_pool.req_to_token[state.req_idx, pos] = (
|
||||
state.pages[page_idx] * self._page_size + offset
|
||||
)
|
||||
return True
|
||||
|
||||
def write_indices(self, state: TaskCacheState, prompt_ids: List[int]) -> None:
|
||||
total = len(prompt_ids)
|
||||
for pos in range(state.cached, total):
|
||||
page_idx = pos // self._page_size
|
||||
offset = pos % self._page_size
|
||||
if page_idx < len(state.pages):
|
||||
self._req_pool.req_to_token[state.req_idx, pos] = (
|
||||
state.pages[page_idx] * self._page_size + offset
|
||||
)
|
||||
|
||||
def record_hashes(
|
||||
self, state: TaskCacheState, prompt_ids: List[int], start: int
|
||||
) -> None:
|
||||
if self._prefix is None:
|
||||
return
|
||||
full = len(prompt_ids) // self._page_size
|
||||
for i in range(start, min(full, len(state.pages))):
|
||||
self._prefix.record(state.pages[i], prompt_ids, i)
|
||||
|
||||
|
||||
class PagePool:
|
||||
"""Top-level KV cache manager.
|
||||
"""Physical KV cache: buffers + req-pool + allocation strategy + bind.
|
||||
|
||||
Combines KVStorage + ReqToTokenPool + Allocator + RadixCache.
|
||||
|
||||
Args:
|
||||
n_layers: Number of transformer layers.
|
||||
n_kv_heads: Number of KV attention heads.
|
||||
head_dim: Dimension per head.
|
||||
max_batch_size: Maximum concurrent requests.
|
||||
max_seq_len: Maximum sequence length per request.
|
||||
device, dtype: Tensor device and dtype.
|
||||
page_size: Page size for paged mode (1 = token-level).
|
||||
n_tokens: Total token slots for paged mode. None = contiguous mode
|
||||
(pre-allocates max_batch_size * max_seq_len).
|
||||
Does not know about tasks — task lifecycle is managed by
|
||||
:class:`TaskCacheManager`, which holds a reference to this pool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -342,10 +439,7 @@ class PagePool:
|
||||
self.head_dim = head_dim
|
||||
|
||||
self.contiguous = n_tokens is None
|
||||
if self.contiguous:
|
||||
self.n_tokens = max_batch_size * max_seq_len
|
||||
else:
|
||||
self.n_tokens = n_tokens
|
||||
self.n_tokens = max_batch_size * max_seq_len if self.contiguous else n_tokens
|
||||
|
||||
self._storage = KVStorage(
|
||||
self.n_tokens, n_layers, n_kv_heads, head_dim, device, dtype
|
||||
@@ -357,248 +451,62 @@ class PagePool:
|
||||
self._req_pool.req_to_token[i] = torch.arange(
|
||||
i * max_seq_len, (i + 1) * max_seq_len, device=device
|
||||
)
|
||||
self._alloc: Optional[Allocator] = None
|
||||
self._prefix: Optional[RadixCache] = None
|
||||
self._strategy = AllocationStrategy()
|
||||
else:
|
||||
n_pages = self.n_tokens // page_size
|
||||
self._alloc = Allocator(n_pages)
|
||||
self._prefix = RadixCache(page_size) if page_size > 1 else None
|
||||
if self._prefix is not None:
|
||||
self._alloc.on_evict = self._prefix.evict
|
||||
|
||||
self._task_req: Dict[str, int] = {}
|
||||
self._task_len: Dict[int, int] = {}
|
||||
self._task_cached: Dict[str, int] = {}
|
||||
self._task_slots: Dict[str, List[int]] = {}
|
||||
self._task_pages: Dict[str, List[int]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Steady-state decode validation: when the same ordered task set
|
||||
# advances every sequence by exactly one token per step, bind_tasks
|
||||
# updates the stable buffers in-place instead of re-cumsumming.
|
||||
self._bind_state: Optional[_BindState] = None
|
||||
|
||||
# ---- task lifecycle ----
|
||||
|
||||
def task_alloc(self, task_id: str, prompt_ids: List[int]) -> bool:
|
||||
req_slots = self._req_pool.alloc(1)
|
||||
if req_slots is None:
|
||||
return False
|
||||
req_idx = req_slots[0]
|
||||
self._task_req[task_id] = req_idx
|
||||
|
||||
if self.contiguous:
|
||||
self._task_len[req_idx] = len(prompt_ids)
|
||||
self._task_cached[task_id] = 0
|
||||
return True
|
||||
|
||||
n_tokens_needed = len(prompt_ids)
|
||||
cached = 0
|
||||
|
||||
if self._prefix is not None:
|
||||
hits = self._prefix.lookup(prompt_ids)
|
||||
cached = len(hits) * self.page_size
|
||||
for p in hits:
|
||||
self._alloc.inc_ref(p)
|
||||
self._task_pages[task_id] = list(hits)
|
||||
self._task_slots[task_id] = []
|
||||
else:
|
||||
self._task_pages[task_id] = []
|
||||
self._task_slots[task_id] = []
|
||||
|
||||
remaining = n_tokens_needed - cached
|
||||
if remaining > 0:
|
||||
if self.page_size == 1:
|
||||
slots = self._alloc_tokens(remaining)
|
||||
if slots is None:
|
||||
for p in self._task_pages[task_id]:
|
||||
self._alloc.free(p)
|
||||
self._task_pages.pop(task_id, None)
|
||||
self._task_slots.pop(task_id, None)
|
||||
self._req_pool.free([req_idx])
|
||||
del self._task_req[task_id]
|
||||
return False
|
||||
self._task_slots[task_id] = slots
|
||||
else:
|
||||
n_new_pages = (remaining + self.page_size - 1) // self.page_size
|
||||
new_pages = []
|
||||
for _ in range(n_new_pages):
|
||||
p = self._alloc.alloc()
|
||||
if p < 0:
|
||||
for hp in self._task_pages[task_id]:
|
||||
self._alloc.free(hp)
|
||||
for np_ in new_pages:
|
||||
self._alloc.free(np_)
|
||||
self._task_pages.pop(task_id, None)
|
||||
self._task_slots.pop(task_id, None)
|
||||
self._req_pool.free([req_idx])
|
||||
del self._task_req[task_id]
|
||||
return False
|
||||
new_pages.append(p)
|
||||
self._task_pages[task_id].extend(new_pages)
|
||||
|
||||
self._write_req_to_token(task_id, prompt_ids, cached)
|
||||
self._task_len[req_idx] = len(prompt_ids)
|
||||
self._task_cached[task_id] = cached
|
||||
return True
|
||||
|
||||
def task_free(self, task_id: str):
|
||||
req_idx = self._task_req.pop(task_id, None)
|
||||
if req_idx is None:
|
||||
return
|
||||
self._bind_state = None
|
||||
self._task_len.pop(req_idx, None)
|
||||
self._task_cached.pop(task_id, None)
|
||||
|
||||
if not self.contiguous:
|
||||
if self._prefix is not None:
|
||||
for p in self._task_pages.get(task_id, []):
|
||||
keep = self._prefix.has_page(p)
|
||||
self._alloc.free(p, keep_cached=keep)
|
||||
if not keep:
|
||||
self._prefix.evict(p)
|
||||
else:
|
||||
for p in self._task_pages.get(task_id, []):
|
||||
self._alloc.free(p)
|
||||
if self.page_size == 1:
|
||||
for slot in self._task_slots.get(task_id, []):
|
||||
self._alloc.free(slot)
|
||||
self._task_pages.pop(task_id, None)
|
||||
self._task_slots.pop(task_id, None)
|
||||
|
||||
self._req_pool.free([req_idx])
|
||||
|
||||
def task_extend(self, task_id: str, pos: int) -> bool:
|
||||
req_idx = self._task_req.get(task_id)
|
||||
if req_idx is None or pos >= self.max_seq_len:
|
||||
return False
|
||||
|
||||
# Paged mode must also claim a physical slot for the new token;
|
||||
# contiguous mode's block is pre-allocated so this is a no-op.
|
||||
if not self.contiguous and not self._extend_slot(task_id, req_idx, pos):
|
||||
return False
|
||||
|
||||
self._task_len[req_idx] = pos + 1
|
||||
return True
|
||||
|
||||
def _extend_slot(self, task_id: str, req_idx: int, pos: int) -> bool:
|
||||
"""Allocate the physical slot for one extended token (paged mode)."""
|
||||
if self.page_size == 1:
|
||||
slots = self._alloc_tokens(1)
|
||||
if slots is None:
|
||||
return False
|
||||
self._task_slots.setdefault(task_id, []).extend(slots)
|
||||
self._req_pool.req_to_token[req_idx, pos] = slots[0]
|
||||
return True
|
||||
|
||||
page_idx = pos // self.page_size
|
||||
existing = self._task_pages.get(task_id, [])
|
||||
if page_idx >= len(existing):
|
||||
p = self._alloc.alloc()
|
||||
if p < 0:
|
||||
return False
|
||||
existing.append(p)
|
||||
self._task_pages[task_id] = existing
|
||||
page_offset = pos % self.page_size
|
||||
page = existing[page_idx]
|
||||
token_slot = page * self.page_size + page_offset
|
||||
self._req_pool.req_to_token[req_idx, pos] = token_slot
|
||||
return True
|
||||
|
||||
def task_cached(self, task_id: str) -> int:
|
||||
return self._task_cached.get(task_id, 0)
|
||||
|
||||
def task_record_hashes(
|
||||
self, task_id: str, prompt_ids: List[int], start_logical_page: int = 0
|
||||
):
|
||||
if self._prefix is None:
|
||||
return
|
||||
pages = self._task_pages.get(task_id, [])
|
||||
full_pages = len(prompt_ids) // self.page_size
|
||||
for i in range(start_logical_page, min(full_pages, len(pages))):
|
||||
self._prefix.record(pages[i], prompt_ids, i)
|
||||
|
||||
def task_cacheable_ids(
|
||||
self, task_id: str, prompt_ids: List[int], output_ids: List[int]
|
||||
):
|
||||
"""Return the sequence whose KV entries are already materialized.
|
||||
|
||||
The first sampled output is produced by prompt prefill, and the last
|
||||
sampled output has not been decoded into KV yet. Therefore the cache
|
||||
can safely retain the prompt plus every output except the last one.
|
||||
"""
|
||||
return list(prompt_ids) + list(output_ids[:-1])
|
||||
|
||||
# ---- bind for forward ----
|
||||
alloc = Allocator(n_pages)
|
||||
prefix = RadixCache(page_size) if page_size > 1 else None
|
||||
if prefix is not None:
|
||||
alloc.on_evict = prefix.evict
|
||||
self._strategy = PagedStrategy(
|
||||
alloc, prefix, page_size, self._req_pool, device
|
||||
)
|
||||
|
||||
def bind_tasks(
|
||||
self,
|
||||
task_ids: List[str],
|
||||
req_indices: List[int],
|
||||
seq_lens: List[int],
|
||||
workspace: InferenceWorkspace,
|
||||
device: Optional[torch.device] = None,
|
||||
start_pos: Optional[int] = None,
|
||||
incremental: bool = False,
|
||||
) -> KVCache:
|
||||
if device is None:
|
||||
device = workspace.device
|
||||
req_indices = [self._task_req[tid] for tid in task_ids]
|
||||
# Per-request lengths come from the pool's own tracking (task_alloc
|
||||
# sets len(prompt_ids); task_extend sets pos+1), so callers need not
|
||||
# pass them.
|
||||
seq_lens = [self._task_len[req_idx] for req_idx in req_indices]
|
||||
b = len(task_ids)
|
||||
sig = tuple(task_ids)
|
||||
b = len(req_indices)
|
||||
|
||||
# Write into the caller's workspace buffers (fixed addresses, sized
|
||||
# to max_batch/max_seq at init) — the sole owner of the per-step
|
||||
# KV bind tensors.
|
||||
rpi_buf = workspace.req_pool_indices
|
||||
sl_buf = workspace.seq_lens
|
||||
kvp_buf = workspace.kv_indptr
|
||||
inc_buf = workspace.inc
|
||||
ocl_buf = workspace.out_cache_loc
|
||||
|
||||
prev = self._bind_state
|
||||
incremental = (
|
||||
start_pos is None
|
||||
and prev is not None
|
||||
and _is_steady_increment(prev.sig, prev.seq_lens, sig, seq_lens)
|
||||
)
|
||||
if incremental:
|
||||
# Steady-state decode: advance the stable buffers in-place.
|
||||
# Normal-mode buffers keep ``+=`` legal regardless of whether
|
||||
# this runs inside ``torch.inference_mode()``.
|
||||
sl_buf[:b] += 1
|
||||
kvp_buf[: b + 1] += inc_buf[: b + 1]
|
||||
req_pool_indices = rpi_buf[:b]
|
||||
seq_lens_t = sl_buf[:b]
|
||||
kv_indptr = kvp_buf[: b + 1]
|
||||
else:
|
||||
# Cold path: fill the stable buffers from fresh host tensors.
|
||||
rpi_buf[:b].copy_(
|
||||
torch.tensor(req_indices, dtype=torch.long, device=device)
|
||||
)
|
||||
sl_buf[:b].copy_(torch.tensor(seq_lens, dtype=torch.long, device=device))
|
||||
kvp_buf[: b + 1].zero_()
|
||||
kvp_buf[1 : b + 1] = sl_buf[:b].cumsum(0).to(torch.int32)
|
||||
req_pool_indices = rpi_buf[:b]
|
||||
seq_lens_t = sl_buf[:b]
|
||||
kv_indptr = kvp_buf[: b + 1]
|
||||
self._bind_state = _BindState(sig, list(seq_lens))
|
||||
|
||||
req_pool_indices = rpi_buf[:b]
|
||||
seq_lens_t = sl_buf[:b]
|
||||
kv_indptr = kvp_buf[: b + 1]
|
||||
|
||||
if start_pos is not None:
|
||||
seq_len = seq_lens[0]
|
||||
out_cache_loc = self._req_pool.req_to_token[
|
||||
req_pool_indices, start_pos:seq_len
|
||||
]
|
||||
# Ragged query segmentation for the prefill kernel, computed once
|
||||
# (was rebuilt per layer in CudaBackend.fwd_prefill).
|
||||
q_len = seq_len - start_pos
|
||||
workspace.qo_indptr[: b + 1].copy_(
|
||||
torch.arange(b + 1, dtype=torch.int32, device=device) * q_len
|
||||
)
|
||||
qo_indptr = workspace.qo_indptr[: b + 1]
|
||||
decode_o_part, decode_ml_part = None, None
|
||||
decode_out = None
|
||||
decode_o_part = decode_ml_part = decode_out = None
|
||||
else:
|
||||
write_pos = seq_lens_t - 1
|
||||
loc = self._req_pool.req_to_token[req_pool_indices, write_pos].unsqueeze(-1)
|
||||
@@ -624,37 +532,116 @@ class PagePool:
|
||||
decode_out=decode_out,
|
||||
)
|
||||
|
||||
# ---- internals ----
|
||||
|
||||
def _alloc_tokens(self, n: int) -> Optional[List[int]]:
|
||||
if self.page_size != 1:
|
||||
raise RuntimeError("_alloc_tokens is for page_size=1 only")
|
||||
slots = []
|
||||
for _ in range(n):
|
||||
p = self._alloc.alloc()
|
||||
if p < 0:
|
||||
for s in slots:
|
||||
self._alloc.free(s)
|
||||
return None
|
||||
slots.append(p)
|
||||
return slots
|
||||
class TaskCacheManager:
|
||||
"""Task <-> KV slot lifecycle manager.
|
||||
|
||||
def _write_req_to_token(self, task_id: str, prompt_ids: List[int], cached: int):
|
||||
req_idx = self._task_req[task_id]
|
||||
total = len(prompt_ids)
|
||||
Sole owner of task state. Owns the task_id -> TaskCacheState map and
|
||||
delegates physical slot allocation to the strategy, and KV bind to
|
||||
PagePool. Held directly by the scheduler — not as a PagePool attribute.
|
||||
"""
|
||||
|
||||
if self.page_size == 1:
|
||||
slots = self._task_slots.get(task_id, [])
|
||||
all_slots = slots[: total - cached]
|
||||
if all_slots:
|
||||
self._req_pool.req_to_token[req_idx, cached:total] = torch.tensor(
|
||||
all_slots, dtype=torch.long, device=self.device
|
||||
)
|
||||
else:
|
||||
pages = self._task_pages.get(task_id, [])
|
||||
for pos in range(cached, total):
|
||||
page_idx = pos // self.page_size
|
||||
page_offset = pos % self.page_size
|
||||
if page_idx < len(pages):
|
||||
token_slot = pages[page_idx] * self.page_size + page_offset
|
||||
self._req_pool.req_to_token[req_idx, pos] = token_slot
|
||||
def __init__(
|
||||
self,
|
||||
strategy: AllocationStrategy,
|
||||
req_pool: ReqToTokenPool,
|
||||
max_seq_len: int,
|
||||
pool: PagePool,
|
||||
):
|
||||
self._strategy = strategy
|
||||
self._req_pool = req_pool
|
||||
self._max_seq_len = max_seq_len
|
||||
self._pool = pool
|
||||
self._states: Dict[str, TaskCacheState] = {}
|
||||
self._bind_state: Optional[_BindState] = None
|
||||
|
||||
def task_alloc(self, task_id: str, prompt_ids: List[int]) -> bool:
|
||||
self._bind_state = None
|
||||
req_slots = self._req_pool.alloc(1)
|
||||
if req_slots is None:
|
||||
return False
|
||||
state = TaskCacheState(req_idx=req_slots[0])
|
||||
self._states[task_id] = state
|
||||
if not self._strategy.alloc(state, prompt_ids):
|
||||
self._rollback(state, task_id)
|
||||
return False
|
||||
self._strategy.write_indices(state, prompt_ids)
|
||||
state.length = len(prompt_ids)
|
||||
return True
|
||||
|
||||
def _rollback(self, state: TaskCacheState, task_id: str):
|
||||
self._strategy.free(state)
|
||||
self._req_pool.free([state.req_idx])
|
||||
self._states.pop(task_id, None)
|
||||
|
||||
def task_free(self, task_id: str):
|
||||
self._bind_state = None
|
||||
state = self._states.pop(task_id, None)
|
||||
if state is None:
|
||||
return
|
||||
self._strategy.free(state)
|
||||
self._req_pool.free([state.req_idx])
|
||||
|
||||
def task_extend(self, task_id: str, pos: int) -> bool:
|
||||
state = self._states.get(task_id)
|
||||
if state is None or pos >= self._max_seq_len:
|
||||
return False
|
||||
if not self._strategy.extend(state, pos):
|
||||
return False
|
||||
state.length = pos + 1
|
||||
return True
|
||||
|
||||
def task_cached(self, task_id: str) -> int:
|
||||
state = self._states.get(task_id)
|
||||
return state.cached if state is not None else 0
|
||||
|
||||
def task_record_hashes(
|
||||
self, task_id: str, prompt_ids: List[int], start_logical_page: int = 0
|
||||
):
|
||||
state = self._states.get(task_id)
|
||||
if state is not None:
|
||||
self._strategy.record_hashes(state, prompt_ids, start_logical_page)
|
||||
|
||||
@staticmethod
|
||||
def task_cacheable_ids(task_id: str, prompt_ids: List[int], output_ids: List[int]):
|
||||
"""Return the sequence whose KV entries are already materialized.
|
||||
|
||||
The first sampled output is produced by prompt prefill, and the last
|
||||
sampled output has not been decoded into KV yet.
|
||||
"""
|
||||
return list(prompt_ids) + list(output_ids[:-1])
|
||||
|
||||
def bind(
|
||||
self,
|
||||
task_ids: List[str],
|
||||
workspace: InferenceWorkspace,
|
||||
device: Optional[torch.device] = None,
|
||||
start_pos: Optional[int] = None,
|
||||
) -> KVCache:
|
||||
states = [self._states[tid] for tid in task_ids]
|
||||
req_indices = [s.req_idx for s in states]
|
||||
seq_lens = [s.length for s in states]
|
||||
sig = tuple(req_indices)
|
||||
|
||||
prev = self._bind_state
|
||||
incremental = (
|
||||
start_pos is None
|
||||
and prev is not None
|
||||
and _is_steady_increment(prev.sig, prev.seq_lens, sig, seq_lens)
|
||||
)
|
||||
self._bind_state = _BindState(sig, list(seq_lens))
|
||||
self._bind_was_steady = incremental
|
||||
|
||||
return self._pool.bind_tasks(
|
||||
req_indices,
|
||||
seq_lens,
|
||||
workspace,
|
||||
device=device,
|
||||
start_pos=start_pos,
|
||||
incremental=incremental,
|
||||
)
|
||||
|
||||
@property
|
||||
def bind_was_steady(self) -> bool:
|
||||
"""Whether the most recent ``bind()`` was a steady-state increment."""
|
||||
return self._bind_was_steady
|
||||
|
||||
@@ -13,7 +13,7 @@ from astrai.extension.attention_backend import (
|
||||
attn_backend,
|
||||
get_backend,
|
||||
)
|
||||
from astrai.inference.core.cache import PagePool, _is_steady_increment
|
||||
from astrai.inference.core.cache import PagePool, TaskCacheManager
|
||||
from astrai.inference.core.graph import CudaGraphContext
|
||||
from astrai.inference.core.task import Task
|
||||
from astrai.inference.core.workspace import InferenceWorkspace
|
||||
@@ -99,6 +99,7 @@ def _build_sampling_batch_info(tasks: List[Task], device) -> SamplingBatchInfo:
|
||||
def _warmup_cuda_graphs(
|
||||
model: AutoModel,
|
||||
pool: PagePool,
|
||||
task_cache: TaskCacheManager,
|
||||
ws: InferenceWorkspace,
|
||||
gctx: CudaGraphContext,
|
||||
max_batch_size: int,
|
||||
@@ -113,12 +114,12 @@ def _warmup_cuda_graphs(
|
||||
# that follows. Custom .so kernels do NOT need this — they are pre-built.
|
||||
warmup_len = 64
|
||||
tid = "_warmup_prefill"
|
||||
if pool.task_alloc(tid, list(range(warmup_len))):
|
||||
if task_cache.task_alloc(tid, list(range(warmup_len))):
|
||||
with (
|
||||
torch.inference_mode(),
|
||||
timed("warmup prefill", logger),
|
||||
):
|
||||
kv = pool.bind_tasks([tid], ws, start_pos=0)
|
||||
kv = task_cache.bind([tid], ws, start_pos=0)
|
||||
ids_in = torch.arange(warmup_len, device=dev).unsqueeze(0)
|
||||
pos_in = ids_in
|
||||
model(
|
||||
@@ -127,7 +128,7 @@ def _warmup_cuda_graphs(
|
||||
kv_cache=kv,
|
||||
position_ids=pos_in,
|
||||
)
|
||||
pool.task_free(tid)
|
||||
task_cache.task_free(tid)
|
||||
|
||||
batch_sizes = [1]
|
||||
n = 2
|
||||
@@ -142,12 +143,12 @@ def _warmup_cuda_graphs(
|
||||
prompt_tokens = [list(range(prompt_len)) for _ in range(b)]
|
||||
alloc_ok = True
|
||||
for tid, pt in zip(task_ids, prompt_tokens):
|
||||
if not pool.task_alloc(tid, pt):
|
||||
if not task_cache.task_alloc(tid, pt):
|
||||
alloc_ok = False
|
||||
break
|
||||
if not alloc_ok:
|
||||
for tid in task_ids:
|
||||
pool.task_free(tid)
|
||||
task_cache.task_free(tid)
|
||||
continue
|
||||
|
||||
with (
|
||||
@@ -159,8 +160,8 @@ def _warmup_cuda_graphs(
|
||||
seq_pos = step
|
||||
ws.position_ids[:b] = seq_pos
|
||||
for tid in task_ids:
|
||||
pool.task_extend(tid, seq_pos)
|
||||
kv = pool.bind_tasks(task_ids, ws)
|
||||
task_cache.task_extend(tid, seq_pos)
|
||||
kv = task_cache.bind(task_ids, ws)
|
||||
input_mask = ws.decode_mask(ws.position_ids[:b], ws.max_seq_len)
|
||||
ids_buf = ws.fill_input_ids([step] * b)
|
||||
gctx.forward(
|
||||
@@ -173,7 +174,7 @@ def _warmup_cuda_graphs(
|
||||
)
|
||||
|
||||
for tid in task_ids:
|
||||
pool.task_free(tid)
|
||||
task_cache.task_free(tid)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
|
||||
@@ -184,11 +185,13 @@ class Executor:
|
||||
self,
|
||||
model: AutoModel,
|
||||
kv_cache: PagePool,
|
||||
task_cache: TaskCacheManager,
|
||||
device: Optional[str] = None,
|
||||
dtype: Optional[torch.dtype] = None,
|
||||
):
|
||||
self.model = model
|
||||
self.kv_cache = kv_cache
|
||||
self.task_cache = task_cache
|
||||
self.device = device or next(model.parameters()).device
|
||||
self.dtype = dtype or next(model.parameters()).dtype
|
||||
|
||||
@@ -228,6 +231,7 @@ class Executor:
|
||||
_warmup_cuda_graphs(
|
||||
self.model,
|
||||
self.kv_cache,
|
||||
self.task_cache,
|
||||
self._workspace,
|
||||
self._graph_ctx,
|
||||
max_batch_size=self.kv_cache.max_batch_size,
|
||||
@@ -321,7 +325,7 @@ class Executor:
|
||||
input_ids,
|
||||
input_mask=input_mask,
|
||||
position_ids=position_ids,
|
||||
kv_cache=self.kv_cache.bind_tasks(
|
||||
kv_cache=self.task_cache.bind(
|
||||
task_ids,
|
||||
self._workspace,
|
||||
start_pos=start_pos,
|
||||
@@ -363,26 +367,25 @@ class Executor:
|
||||
task_ids = [t.task_id for t in tasks]
|
||||
cur_positions = [t.next_pos for t in tasks]
|
||||
|
||||
sig = tuple(task_ids)
|
||||
cached = self._decode_cache
|
||||
prev_sig = cached.task_sig if cached is not None else None
|
||||
prev_pos = cached.positions if cached is not None else None
|
||||
if _is_steady_increment(prev_sig, prev_pos, sig, cur_positions):
|
||||
info = cached.sampling_info
|
||||
kv_cache = self.task_cache.bind(task_ids, ws)
|
||||
|
||||
if self.task_cache.bind_was_steady:
|
||||
info = (
|
||||
self._decode_cache.sampling_info
|
||||
if self._decode_cache is not None
|
||||
else _build_sampling_batch_info(tasks, self.device)
|
||||
)
|
||||
ws.position_ids[:b] += 1
|
||||
self._decode_cache = DecodeSteadyState(sig, cur_positions, info)
|
||||
else:
|
||||
info = _build_sampling_batch_info(tasks, self.device)
|
||||
ws.position_ids[:b].copy_(
|
||||
torch.tensor(cur_positions, dtype=torch.long, device=self.device)
|
||||
)
|
||||
self._decode_cache = DecodeSteadyState(sig, cur_positions, info)
|
||||
self._decode_cache = DecodeSteadyState(tuple(task_ids), cur_positions, info)
|
||||
|
||||
total_len = max(cur_positions) + 1
|
||||
input_mask = ws.decode_mask(ws.position_ids[:b], total_len)
|
||||
|
||||
kv_cache = self.kv_cache.bind_tasks(task_ids, ws)
|
||||
|
||||
# ---- forward (graph replay or live run + capture) ----
|
||||
|
||||
use_graph = (
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from astrai.inference.core.cache import PagePool
|
||||
from astrai.inference.core.cache import PagePool, TaskCacheManager
|
||||
from astrai.inference.core.executor import Executor
|
||||
from astrai.inference.core.metrics import MetricsCollector
|
||||
from astrai.inference.core.task import STOP, Task, TaskManager, TaskStatus
|
||||
@@ -59,6 +59,13 @@ class InferenceScheduler:
|
||||
|
||||
self._metrics = MetricsCollector()
|
||||
|
||||
self._task_cache = TaskCacheManager(
|
||||
strategy=self._cache._strategy,
|
||||
req_pool=self._cache._req_pool,
|
||||
max_seq_len=self.max_seq_len,
|
||||
pool=self._cache,
|
||||
)
|
||||
|
||||
self._task_mgr = TaskManager(
|
||||
tokenizer=tokenizer,
|
||||
max_batch_size=max_batch_size,
|
||||
@@ -69,6 +76,7 @@ class InferenceScheduler:
|
||||
self._executor = Executor(
|
||||
model=model,
|
||||
kv_cache=self._cache,
|
||||
task_cache=self._task_cache,
|
||||
device=self.device,
|
||||
dtype=self.dtype,
|
||||
)
|
||||
@@ -81,7 +89,7 @@ class InferenceScheduler:
|
||||
|
||||
def remove_task(self, task_id: str):
|
||||
for task in self._task_mgr.remove_task(task_id):
|
||||
self._cache.task_free(task.task_id)
|
||||
self._task_cache.task_free(task.task_id)
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
return self._task_mgr.get_stats()
|
||||
@@ -109,9 +117,7 @@ class InferenceScheduler:
|
||||
already appended to ``output_ids``) and tasks that hit the
|
||||
sequence cap and were marked ``ABORTED``.
|
||||
"""
|
||||
cache = self._cache
|
||||
|
||||
to_prefill = [t for t in tasks if t.output_tokens == 0 and t.prompt_ids]
|
||||
to_prefill = [t for t in tasks if not t.prefill_done and t.prompt_ids]
|
||||
prefilled_ids = set()
|
||||
produced: List[Task] = []
|
||||
if to_prefill:
|
||||
@@ -120,7 +126,9 @@ class InferenceScheduler:
|
||||
|
||||
groups: Dict[Tuple[int, int], List[Task]] = {}
|
||||
for t in to_prefill:
|
||||
start_pos = min(cache.task_cached(t.task_id), len(t.prompt_ids) - 1)
|
||||
start_pos = min(
|
||||
self._task_cache.task_cached(t.task_id), len(t.prompt_ids) - 1
|
||||
)
|
||||
groups.setdefault((len(t.prompt_ids), start_pos), []).append(t)
|
||||
|
||||
for (prompt_len, start_pos), group in groups.items():
|
||||
@@ -132,12 +140,13 @@ class InferenceScheduler:
|
||||
for t, out in zip(prefilled, step_out):
|
||||
t.output_ids.append(out[0] if return_logprobs else out)
|
||||
t.output_tokens += 1
|
||||
t.mark_prefill_done()
|
||||
prefilled_ids.add(t.task_id)
|
||||
produced.append(t)
|
||||
|
||||
start_logical_page = start_pos // getattr(cache, "page_size", 64)
|
||||
start_logical_page = start_pos // self._cache.page_size
|
||||
for t in group:
|
||||
cache.task_record_hashes(
|
||||
self._task_cache.task_record_hashes(
|
||||
t.task_id, t.prompt_ids, start_logical_page
|
||||
)
|
||||
|
||||
@@ -146,7 +155,7 @@ class InferenceScheduler:
|
||||
for t in tasks:
|
||||
if t.task_id in prefilled_ids:
|
||||
continue
|
||||
if cache.task_extend(t.task_id, t.next_pos):
|
||||
if self._task_cache.task_extend(t.task_id, t.next_pos):
|
||||
decoded.append(t)
|
||||
else:
|
||||
t.status = TaskStatus.ABORTED
|
||||
@@ -160,25 +169,25 @@ class InferenceScheduler:
|
||||
for t, out in zip(decoded, step_out):
|
||||
t.output_ids.append(out[0] if return_logprobs else out)
|
||||
t.output_tokens += 1
|
||||
t.advance_kv()
|
||||
produced.append(t)
|
||||
|
||||
return produced, aborted
|
||||
|
||||
def _run_generation_loop(self):
|
||||
stop_ids = self._task_mgr.tokenizer.stop_ids
|
||||
cache = self._cache
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
finished = self._task_mgr.remove_finished_tasks(stop_ids)
|
||||
for task in finished:
|
||||
if task.status == TaskStatus.FINISHED:
|
||||
cache.task_record_hashes(
|
||||
self._task_cache.task_record_hashes(
|
||||
task.task_id,
|
||||
cache.task_cacheable_ids(
|
||||
self._task_cache.task_cacheable_ids(
|
||||
task.task_id, task.prompt_ids, task.output_ids
|
||||
),
|
||||
)
|
||||
cache.task_free(task.task_id)
|
||||
self._task_cache.task_free(task.task_id)
|
||||
|
||||
active = self._task_mgr.get_active_tasks()
|
||||
available = self._task_mgr.max_batch_size - len(active)
|
||||
@@ -186,7 +195,7 @@ class InferenceScheduler:
|
||||
candidates = self._task_mgr.pull_candidates(available)
|
||||
failed = []
|
||||
for task in candidates:
|
||||
if cache.task_alloc(task.task_id, task.prompt_ids):
|
||||
if self._task_cache.task_alloc(task.task_id, task.prompt_ids):
|
||||
self._task_mgr.activate(task)
|
||||
else:
|
||||
failed.append(task)
|
||||
@@ -216,7 +225,7 @@ class InferenceScheduler:
|
||||
logger.error(f"Scheduler loop crashed: {e}", exc_info=True)
|
||||
for task in self._task_mgr.get_active_tasks():
|
||||
self._task_mgr.invoke_callback(task.task_id, STOP)
|
||||
cache.task_free(task.task_id)
|
||||
self._task_cache.task_free(task.task_id)
|
||||
for task in self._task_mgr.get_waiting_tasks():
|
||||
self._task_mgr.invoke_callback(task.task_id, STOP)
|
||||
self._task_mgr.clear_queues()
|
||||
@@ -237,10 +246,10 @@ class InferenceScheduler:
|
||||
self._loop_thread = None
|
||||
for task in self._task_mgr.get_active_tasks():
|
||||
self._task_mgr.invoke_callback(task.task_id, STOP)
|
||||
self._cache.task_free(task.task_id)
|
||||
self._task_cache.task_free(task.task_id)
|
||||
for task in self._task_mgr.get_waiting_tasks():
|
||||
self._task_mgr.invoke_callback(task.task_id, STOP)
|
||||
self._cache.task_free(task.task_id)
|
||||
self._task_cache.task_free(task.task_id)
|
||||
self._task_mgr.clear_queues()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
@@ -279,7 +288,6 @@ class InferenceScheduler:
|
||||
``List[Tuple[List[int], List[float]]]``.
|
||||
"""
|
||||
stop_ids = self._task_mgr.tokenizer.stop_ids
|
||||
cache = self._cache
|
||||
seq_cap = self.max_seq_len
|
||||
|
||||
tasks: List[Task] = []
|
||||
@@ -305,7 +313,7 @@ class InferenceScheduler:
|
||||
frequency_penalty=frequency_penalty,
|
||||
rep_window=rep_window,
|
||||
)
|
||||
if not cache.task_alloc(task.task_id, task.prompt_ids):
|
||||
if not self._task_cache.task_alloc(task.task_id, task.prompt_ids):
|
||||
tasks.append(None)
|
||||
continue
|
||||
task.input_tokens = len(task.prompt_ids)
|
||||
@@ -324,7 +332,7 @@ class InferenceScheduler:
|
||||
self._metrics.mark_finished(
|
||||
t.task_id, t.input_tokens, t.output_tokens
|
||||
)
|
||||
cache.task_free(t.task_id)
|
||||
self._task_cache.task_free(t.task_id)
|
||||
|
||||
results: List[Any] = []
|
||||
for t in tasks:
|
||||
|
||||
@@ -77,8 +77,18 @@ class Task:
|
||||
self.output_logprobs: List[float] = []
|
||||
self.input_tokens: int = 0
|
||||
self.output_tokens: int = 0
|
||||
self._kv_len: int = 0
|
||||
self._decoder: Optional[StreamDecoder] = None
|
||||
|
||||
def mark_prefill_done(self):
|
||||
"""Prompt KV is materialized by prefill; first output sampled but
|
||||
not yet written to KV."""
|
||||
self._kv_len = self.input_tokens
|
||||
|
||||
def advance_kv(self):
|
||||
"""One more position written to KV (after a decode forward)."""
|
||||
self._kv_len += 1
|
||||
|
||||
def decode_new_token(self, tokenizer: AutoTokenizer) -> str:
|
||||
"""Decode the last appended output token, buffering incomplete
|
||||
multi-byte sequences across calls.
|
||||
@@ -91,8 +101,13 @@ class Task:
|
||||
|
||||
@property
|
||||
def next_pos(self) -> int:
|
||||
# The first output is sampled from prefill and enters KV on the next step.
|
||||
return self.input_tokens + max(0, len(self.output_ids) - 1)
|
||||
"""KV position where the next decode step will write."""
|
||||
return self._kv_len
|
||||
|
||||
@property
|
||||
def prefill_done(self) -> bool:
|
||||
"""True when all prompt KV entries are materialized."""
|
||||
return self._kv_len >= self.input_tokens > 0
|
||||
|
||||
def is_finished(self, stop_ids: List[int]) -> bool:
|
||||
if self.max_tokens is not None and self.output_tokens >= self.max_tokens:
|
||||
|
||||
+35
-13
@@ -7,7 +7,7 @@ import torch
|
||||
|
||||
from astrai.config import BaseModelConfig, ConfigFactory
|
||||
from astrai.extension import ATTN_BACKEND, AttentionBackendFactory, attn_backend
|
||||
from astrai.inference.core.cache import PagePool
|
||||
from astrai.inference.core.cache import PagePool, TaskCacheManager
|
||||
from astrai.inference.core.graph import CudaGraphContext
|
||||
from astrai.inference.core.workspace import InferenceWorkspace
|
||||
from astrai.model import AutoModel, AutoRegressiveLM
|
||||
@@ -91,9 +91,19 @@ class GenerationBenchmark:
|
||||
dtype=pool.dtype,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_task_cache(pool: PagePool) -> TaskCacheManager:
|
||||
return TaskCacheManager(
|
||||
strategy=pool._strategy,
|
||||
req_pool=pool._req_pool,
|
||||
max_seq_len=pool.max_seq_len,
|
||||
pool=pool,
|
||||
)
|
||||
|
||||
def _run_prefill(
|
||||
self,
|
||||
pool: PagePool,
|
||||
task_cache: TaskCacheManager,
|
||||
batch_size: int,
|
||||
prompt_len: int,
|
||||
workspace: InferenceWorkspace,
|
||||
@@ -112,9 +122,9 @@ class GenerationBenchmark:
|
||||
|
||||
task_ids = [f"bench_{i}" for i in range(batch_size)]
|
||||
for tid in task_ids:
|
||||
pool.task_alloc(tid, list(range(prompt_len)))
|
||||
task_cache.task_alloc(tid, list(range(prompt_len)))
|
||||
|
||||
kv_cache = pool.bind_tasks(task_ids, workspace, self.device, start_pos=0)
|
||||
kv_cache = task_cache.bind(task_ids, workspace, self.device, start_pos=0)
|
||||
with torch.inference_mode(), attn_backend(self.backend):
|
||||
self.model(
|
||||
input_ids,
|
||||
@@ -128,6 +138,7 @@ class GenerationBenchmark:
|
||||
def _run_decode_step(
|
||||
self,
|
||||
pool: PagePool,
|
||||
task_cache: TaskCacheManager,
|
||||
task_ids: list,
|
||||
seq_len: int,
|
||||
workspace: InferenceWorkspace,
|
||||
@@ -141,11 +152,11 @@ class GenerationBenchmark:
|
||||
)
|
||||
total_len = seq_len + 1
|
||||
for tid in task_ids:
|
||||
pool.task_extend(tid, seq_len)
|
||||
task_cache.task_extend(tid, seq_len)
|
||||
input_mask = position_ids[:, :, None] >= torch.arange(
|
||||
total_len, device=self.device
|
||||
)
|
||||
kv_cache = pool.bind_tasks(task_ids, workspace, self.device)
|
||||
kv_cache = task_cache.bind(task_ids, workspace, self.device)
|
||||
with torch.inference_mode(), attn_backend(self.backend):
|
||||
self.model(
|
||||
input_ids,
|
||||
@@ -164,9 +175,10 @@ class GenerationBenchmark:
|
||||
|
||||
pool = self._make_pool(batch_size, prompt_length)
|
||||
workspace = self._make_workspace(pool, self.config)
|
||||
task_cache = self._make_task_cache(pool)
|
||||
task_ids = [f"bench_prefill_{i}" for i in range(batch_size)]
|
||||
for tid in task_ids:
|
||||
pool.task_alloc(tid, list(range(prompt_length)))
|
||||
task_cache.task_alloc(tid, list(range(prompt_length)))
|
||||
|
||||
input_ids = torch.randint(
|
||||
0, self.config.vocab_size, (batch_size, prompt_length), device=self.device
|
||||
@@ -179,7 +191,7 @@ class GenerationBenchmark:
|
||||
input_mask = position_ids.unsqueeze(-1) >= torch.arange(
|
||||
prompt_length, device=self.device
|
||||
)
|
||||
kv_cache = pool.bind_tasks(task_ids, workspace, self.device, start_pos=0)
|
||||
kv_cache = task_cache.bind(task_ids, workspace, self.device, start_pos=0)
|
||||
|
||||
for _ in range(3):
|
||||
with torch.inference_mode(), attn_backend(self.backend):
|
||||
@@ -240,7 +252,10 @@ class GenerationBenchmark:
|
||||
max_seq_len = prompt_length + 5 + gen_length * num_trials
|
||||
pool = self._make_pool(batch_size, max_seq_len)
|
||||
workspace = self._make_workspace(pool, self.config)
|
||||
task_ids = self._run_prefill(pool, batch_size, prompt_length, workspace)
|
||||
task_cache = self._make_task_cache(pool)
|
||||
task_ids = self._run_prefill(
|
||||
pool, task_cache, batch_size, prompt_length, workspace
|
||||
)
|
||||
|
||||
b = batch_size
|
||||
input_ids_buf = torch.zeros(b, 1, dtype=torch.long, device=self.device)
|
||||
@@ -256,8 +271,8 @@ class GenerationBenchmark:
|
||||
)
|
||||
position_ids_buf[:] = seq_len
|
||||
for tid in task_ids:
|
||||
pool.task_extend(tid, seq_len)
|
||||
kv_cache = pool.bind_tasks(task_ids, workspace, self.device)
|
||||
task_cache.task_extend(tid, seq_len)
|
||||
kv_cache = task_cache.bind(task_ids, workspace, self.device)
|
||||
|
||||
input_mask = torch.ge(
|
||||
position_ids_buf[:, None],
|
||||
@@ -313,15 +328,22 @@ class GenerationBenchmark:
|
||||
max_seq_len = prompt_length + 5 + gen_length * num_trials
|
||||
pool = self._make_pool(batch_size, max_seq_len)
|
||||
workspace = self._make_workspace(pool, self.config)
|
||||
task_ids = self._run_prefill(pool, batch_size, prompt_length, workspace)
|
||||
task_cache = self._make_task_cache(pool)
|
||||
task_ids = self._run_prefill(
|
||||
pool, task_cache, batch_size, prompt_length, workspace
|
||||
)
|
||||
|
||||
for i in range(5):
|
||||
self._run_decode_step(pool, task_ids, prompt_length + i, workspace)
|
||||
self._run_decode_step(
|
||||
pool, task_cache, task_ids, prompt_length + i, workspace
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
t0 = time.perf_counter()
|
||||
for i in range(gen_length * num_trials):
|
||||
self._run_decode_step(pool, task_ids, prompt_length + 5 + i, workspace)
|
||||
self._run_decode_step(
|
||||
pool, task_cache, task_ids, prompt_length + 5 + i, workspace
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
elapsed = time.perf_counter() - t0
|
||||
tokens = batch_size * gen_length * num_trials
|
||||
|
||||
@@ -7,11 +7,20 @@ seq_lens with padding mask), and end-to-end scheduler.run_batch.
|
||||
import torch
|
||||
|
||||
from astrai.extension import ATTN_BACKEND, attn_backend
|
||||
from astrai.inference.core.cache import PagePool
|
||||
from astrai.inference.core.cache import PagePool, TaskCacheManager
|
||||
from astrai.inference.core.workspace import InferenceWorkspace
|
||||
from tests.extension.conftest import D, skip_no_kernel
|
||||
|
||||
|
||||
def _mk_task_cache(pool: PagePool) -> TaskCacheManager:
|
||||
return TaskCacheManager(
|
||||
strategy=pool._strategy,
|
||||
req_pool=pool._req_pool,
|
||||
max_seq_len=pool.max_seq_len,
|
||||
pool=pool,
|
||||
)
|
||||
|
||||
|
||||
def _ws(pool: PagePool) -> InferenceWorkspace:
|
||||
return InferenceWorkspace(
|
||||
pool.max_batch_size,
|
||||
@@ -74,20 +83,21 @@ def test_prefill_with_kv_cache_matches_torch(cuda_model):
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
|
||||
task_cache = _mk_task_cache(cache)
|
||||
ws = _ws(cache)
|
||||
cache.task_alloc("t1", prompt_ids[0])
|
||||
cache.task_alloc("t2", prompt_ids[1])
|
||||
kv1 = cache.bind_tasks(["t1", "t2"], ws, start_pos=0)
|
||||
task_cache.task_alloc("t1", prompt_ids[0])
|
||||
task_cache.task_alloc("t2", prompt_ids[1])
|
||||
kv1 = task_cache.bind(["t1", "t2"], ws, start_pos=0)
|
||||
with torch.inference_mode():
|
||||
out_torch = model(
|
||||
input_ids, input_mask=input_mask, kv_cache=kv1, position_ids=position_ids
|
||||
)
|
||||
|
||||
cache.task_free("t1")
|
||||
cache.task_free("t2")
|
||||
cache.task_alloc("t1", prompt_ids[0])
|
||||
cache.task_alloc("t2", prompt_ids[1])
|
||||
kv2 = cache.bind_tasks(["t1", "t2"], ws, start_pos=0)
|
||||
task_cache.task_free("t1")
|
||||
task_cache.task_free("t2")
|
||||
task_cache.task_alloc("t1", prompt_ids[0])
|
||||
task_cache.task_alloc("t2", prompt_ids[1])
|
||||
kv2 = task_cache.bind(["t1", "t2"], ws, start_pos=0)
|
||||
with attn_backend(ATTN_BACKEND.CUDA):
|
||||
with torch.inference_mode():
|
||||
out_cuda = model(
|
||||
@@ -138,10 +148,11 @@ def test_decode_mixed_seq_lens_matches_torch(cuda_model):
|
||||
input_mask[i, : len(p)] = True
|
||||
position_ids[i, : len(p)] = torch.arange(len(p), device=device)
|
||||
|
||||
task_cache = _mk_task_cache(cache)
|
||||
ws = _ws(cache)
|
||||
cache.task_alloc("t1", prompt_ids[0])
|
||||
cache.task_alloc("t2", prompt_ids[1])
|
||||
kv = cache.bind_tasks(["t1", "t2"], ws, start_pos=0)
|
||||
task_cache.task_alloc("t1", prompt_ids[0])
|
||||
task_cache.task_alloc("t2", prompt_ids[1])
|
||||
kv = task_cache.bind(["t1", "t2"], ws, start_pos=0)
|
||||
with torch.inference_mode():
|
||||
model(input_ids, input_mask=input_mask, kv_cache=kv, position_ids=position_ids)
|
||||
|
||||
@@ -151,15 +162,15 @@ def test_decode_mixed_seq_lens_matches_torch(cuda_model):
|
||||
total_len = 9
|
||||
dec_mask = dec_pos[:, None, None] >= torch.arange(total_len, device=device)
|
||||
|
||||
cache.task_extend("t1", 8)
|
||||
cache.task_extend("t2", 6)
|
||||
kv_t = cache.bind_tasks(["t1", "t2"], ws)
|
||||
task_cache.task_extend("t1", 8)
|
||||
task_cache.task_extend("t2", 6)
|
||||
kv_t = task_cache.bind(["t1", "t2"], ws)
|
||||
with torch.inference_mode():
|
||||
out_torch = model(
|
||||
dec_ids, input_mask=dec_mask, kv_cache=kv_t, position_ids=dec_pos
|
||||
)
|
||||
|
||||
kv_c = cache.bind_tasks(["t1", "t2"], ws)
|
||||
kv_c = task_cache.bind(["t1", "t2"], ws)
|
||||
with attn_backend(ATTN_BACKEND.CUDA):
|
||||
with torch.inference_mode():
|
||||
out_cuda = model(
|
||||
|
||||
@@ -8,6 +8,7 @@ from astrai.inference import (
|
||||
PagePool,
|
||||
RadixCache,
|
||||
ReqToTokenPool,
|
||||
TaskCacheManager,
|
||||
page_hash,
|
||||
)
|
||||
from astrai.inference.core.workspace import InferenceWorkspace
|
||||
@@ -25,6 +26,15 @@ def _ws(pool: PagePool) -> InferenceWorkspace:
|
||||
)
|
||||
|
||||
|
||||
def _make_task_cache(pool: PagePool) -> TaskCacheManager:
|
||||
return TaskCacheManager(
|
||||
strategy=pool._strategy,
|
||||
req_pool=pool._req_pool,
|
||||
max_seq_len=pool.max_seq_len,
|
||||
pool=pool,
|
||||
)
|
||||
|
||||
|
||||
# ---- page_hash ----
|
||||
|
||||
|
||||
@@ -116,9 +126,9 @@ def test_prefix_cache_ignores_partial_last_page():
|
||||
def test_prefix_cache_on_evict_clears_mappings():
|
||||
prefix = RadixCache(64)
|
||||
prefix.record(0, list(range(64)), 0)
|
||||
assert 0 in prefix._page_to_hash
|
||||
assert prefix.has_page(0)
|
||||
prefix.evict(0)
|
||||
assert 0 not in prefix._page_to_hash
|
||||
assert not prefix.has_page(0)
|
||||
|
||||
|
||||
def test_prefix_cache_has_page():
|
||||
@@ -162,7 +172,8 @@ def test_prefix_cache_does_not_record_partial_page():
|
||||
|
||||
def test_page_pool_task_cacheable_ids_excludes_unmaterialized_tail():
|
||||
pool = _make_paged_pool_ps64()
|
||||
assert pool.task_cacheable_ids("missing", [1, 2], [3, 4]) == [1, 2, 3]
|
||||
task_cache = _make_task_cache(pool)
|
||||
assert task_cache.task_cacheable_ids("missing", [1, 2], [3, 4]) == [1, 2, 3]
|
||||
|
||||
|
||||
# ---- ReqToTokenPool ----
|
||||
@@ -243,31 +254,35 @@ def _make_contiguous_pool(**kwargs):
|
||||
|
||||
def test_page_pool_contiguous_task_alloc_free():
|
||||
pool = _make_contiguous_pool()
|
||||
assert pool.task_alloc("t1", [1, 2, 3])
|
||||
assert "t1" in pool._task_req
|
||||
pool.task_free("t1")
|
||||
assert "t1" not in pool._task_req
|
||||
task_cache = _make_task_cache(pool)
|
||||
assert task_cache.task_alloc("t1", [1, 2, 3])
|
||||
assert "t1" in task_cache._states
|
||||
task_cache.task_free("t1")
|
||||
assert "t1" not in task_cache._states
|
||||
|
||||
|
||||
def test_page_pool_contiguous_task_extend():
|
||||
pool = _make_contiguous_pool()
|
||||
pool.task_alloc("t1", [1, 2, 3])
|
||||
assert pool.task_extend("t1", 3)
|
||||
assert pool.task_extend("t1", 63)
|
||||
assert not pool.task_extend("t1", 64)
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", [1, 2, 3])
|
||||
assert task_cache.task_extend("t1", 3)
|
||||
assert task_cache.task_extend("t1", 63)
|
||||
assert not task_cache.task_extend("t1", 64)
|
||||
|
||||
|
||||
def test_page_pool_contiguous_task_cached():
|
||||
pool = _make_contiguous_pool()
|
||||
pool.task_alloc("t1", [1, 2, 3])
|
||||
assert pool.task_cached("t1") == 0
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", [1, 2, 3])
|
||||
assert task_cache.task_cached("t1") == 0
|
||||
|
||||
|
||||
def test_page_pool_contiguous_bind_tasks_prefill():
|
||||
pool = _make_contiguous_pool()
|
||||
pool.task_alloc("t1", list(range(10)))
|
||||
pool.task_alloc("t2", list(range(10)))
|
||||
kv = pool.bind_tasks(["t1", "t2"], _ws(pool), start_pos=0)
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(10)))
|
||||
task_cache.task_alloc("t2", list(range(10)))
|
||||
kv = task_cache.bind(["t1", "t2"], _ws(pool), start_pos=0)
|
||||
assert kv.out_cache_loc.shape == (2, 10)
|
||||
assert kv.seq_lens.tolist() == [10, 10]
|
||||
assert kv.req_pool_indices.shape == (2,)
|
||||
@@ -275,12 +290,13 @@ def test_page_pool_contiguous_bind_tasks_prefill():
|
||||
|
||||
def test_page_pool_contiguous_bind_tasks_decode():
|
||||
pool = _make_contiguous_pool()
|
||||
pool.task_alloc("t1", list(range(10)))
|
||||
pool.task_alloc("t2", list(range(8)))
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(10)))
|
||||
task_cache.task_alloc("t2", list(range(8)))
|
||||
# Simulate one decode extension so seq_lens advance to 11 and 9.
|
||||
assert pool.task_extend("t1", 10)
|
||||
assert pool.task_extend("t2", 8)
|
||||
kv = pool.bind_tasks(["t1", "t2"], _ws(pool))
|
||||
assert task_cache.task_extend("t1", 10)
|
||||
assert task_cache.task_extend("t2", 8)
|
||||
kv = task_cache.bind(["t1", "t2"], _ws(pool))
|
||||
assert kv.out_cache_loc.shape == (2, 1)
|
||||
assert kv.seq_lens.tolist() == [11, 9]
|
||||
|
||||
@@ -288,9 +304,10 @@ def test_page_pool_contiguous_bind_tasks_decode():
|
||||
def test_page_pool_contiguous_bind_roundtrip():
|
||||
"""Write KV via bind_tasks, then gather via req_to_token indexing."""
|
||||
pool = _make_contiguous_pool(n_layers=1, n_kv_heads=2, head_dim=4)
|
||||
pool.task_alloc("t1", list(range(4)))
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(4)))
|
||||
|
||||
kv = pool.bind_tasks(["t1"], _ws(pool), start_pos=0)
|
||||
kv = task_cache.bind(["t1"], _ws(pool), start_pos=0)
|
||||
k = torch.randn(1, 4, 2, 4)
|
||||
v = torch.randn(1, 4, 2, 4)
|
||||
kv.k_buffer[0, kv.out_cache_loc] = k
|
||||
@@ -324,35 +341,38 @@ def _make_paged_pool(**kwargs):
|
||||
|
||||
def test_page_pool_paged_task_alloc():
|
||||
pool = _make_paged_pool()
|
||||
assert pool.task_alloc("t1", list(range(10)))
|
||||
req_idx = pool._task_req["t1"]
|
||||
slots = pool._task_slots["t1"]
|
||||
assert len(slots) == 10
|
||||
assert pool._req_pool.req_to_token[req_idx, 0].item() == slots[0]
|
||||
task_cache = _make_task_cache(pool)
|
||||
assert task_cache.task_alloc("t1", list(range(10)))
|
||||
state = task_cache._states["t1"]
|
||||
assert len(state.pages) == 10
|
||||
assert pool._req_pool.req_to_token[state.req_idx, 0].item() == state.pages[0]
|
||||
|
||||
|
||||
def test_page_pool_paged_task_extend():
|
||||
pool = _make_paged_pool()
|
||||
pool.task_alloc("t1", list(range(4)))
|
||||
assert pool.task_extend("t1", 4)
|
||||
req_idx = pool._task_req["t1"]
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(4)))
|
||||
assert task_cache.task_extend("t1", 4)
|
||||
req_idx = task_cache._states["t1"].req_idx
|
||||
slot = pool._req_pool.req_to_token[req_idx, 4].item()
|
||||
assert slot >= 0
|
||||
|
||||
|
||||
def test_page_pool_paged_task_free_releases_slots():
|
||||
pool = _make_paged_pool(n_tokens=16)
|
||||
pool.task_alloc("t1", list(range(8)))
|
||||
pool.task_free("t1")
|
||||
assert "t1" not in pool._task_req
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(8)))
|
||||
task_cache.task_free("t1")
|
||||
assert "t1" not in task_cache._states
|
||||
assert len(pool._req_pool.free_slots) == 4
|
||||
|
||||
|
||||
def test_page_pool_paged_bind_roundtrip():
|
||||
pool = _make_paged_pool(n_layers=1, n_kv_heads=2, head_dim=4)
|
||||
pool.task_alloc("t1", list(range(4)))
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(4)))
|
||||
|
||||
kv = pool.bind_tasks(["t1"], _ws(pool), start_pos=0)
|
||||
kv = task_cache.bind(["t1"], _ws(pool), start_pos=0)
|
||||
k = torch.randn(1, 4, 2, 4)
|
||||
v = torch.randn(1, 4, 2, 4)
|
||||
kv.k_buffer[0, kv.out_cache_loc] = k
|
||||
@@ -384,26 +404,29 @@ def _make_paged_pool_ps64(**kwargs):
|
||||
|
||||
def test_page_pool_paged_ps64_task_alloc():
|
||||
pool = _make_paged_pool_ps64()
|
||||
task_cache = _make_task_cache(pool)
|
||||
prompt = list(range(200))
|
||||
assert pool.task_alloc("t1", prompt)
|
||||
assert pool.task_cached("t1") == 0
|
||||
assert task_cache.task_alloc("t1", prompt)
|
||||
assert task_cache.task_cached("t1") == 0
|
||||
n_pages = (200 + 63) // 64
|
||||
assert len(pool._task_pages["t1"]) == n_pages
|
||||
assert len(task_cache._states["t1"].pages) == n_pages
|
||||
|
||||
|
||||
def test_page_pool_paged_ps64_task_extend_crosses_page():
|
||||
pool = _make_paged_pool_ps64()
|
||||
pool.task_alloc("t1", list(range(64)))
|
||||
assert pool.task_extend("t1", 64)
|
||||
assert len(pool._task_pages["t1"]) >= 2
|
||||
task_cache = _make_task_cache(pool)
|
||||
task_cache.task_alloc("t1", list(range(64)))
|
||||
assert task_cache.task_extend("t1", 64)
|
||||
assert len(task_cache._states["t1"].pages) >= 2
|
||||
|
||||
|
||||
def test_page_pool_paged_ps64_bind_roundtrip():
|
||||
pool = _make_paged_pool_ps64(n_layers=1, n_kv_heads=2, head_dim=4)
|
||||
task_cache = _make_task_cache(pool)
|
||||
prompt = list(range(128))
|
||||
pool.task_alloc("t1", prompt)
|
||||
task_cache.task_alloc("t1", prompt)
|
||||
|
||||
kv = pool.bind_tasks(["t1"], _ws(pool), start_pos=0)
|
||||
kv = task_cache.bind(["t1"], _ws(pool), start_pos=0)
|
||||
k = torch.randn(1, 128, 2, 4)
|
||||
v = torch.randn(1, 128, 2, 4)
|
||||
kv.k_buffer[0, kv.out_cache_loc] = k
|
||||
|
||||
@@ -20,11 +20,12 @@ def test_task_default_status_is_pending():
|
||||
def test_task_next_pos():
|
||||
task = Task("id1", [1, 2, 3])
|
||||
task.input_tokens = 5
|
||||
task.mark_prefill_done()
|
||||
assert task.next_pos == 5
|
||||
task.output_ids.append(4)
|
||||
assert task.next_pos == 5
|
||||
task.output_ids.append(5)
|
||||
task.advance_kv()
|
||||
assert task.next_pos == 6
|
||||
task.advance_kv()
|
||||
assert task.next_pos == 7
|
||||
|
||||
|
||||
def test_task_is_finished_max_tokens():
|
||||
|
||||
Reference in New Issue
Block a user