Compare commits
6
Commits
v1.3.4
..
951df8155c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
951df8155c | ||
|
|
a58fab8d6e | ||
|
|
a3c8296135 | ||
|
|
c95ace41aa | ||
|
|
3da428e0e4 | ||
|
|
133a9de98f |
+44
-13
@@ -22,14 +22,16 @@ def page_hash(token_ids: List[int], page_idx: int, page_size: int) -> int:
|
|||||||
|
|
||||||
|
|
||||||
class PagedCache:
|
class PagedCache:
|
||||||
"""Paged KV cache with page-table-indirected read/write.
|
"""Paged KV cache with page-table-indirected read/write and persistent prefix caching.
|
||||||
|
|
||||||
Combines:
|
Combines:
|
||||||
- Page pool (ref-counted alloc/free via bitmask)
|
- Page pool (ref-counted alloc/free via bitmask)
|
||||||
- KV tensor storage (k_cache, v_cache)
|
- KV tensor storage (k_cache, v_cache)
|
||||||
- Prefix-cache hash lookup (page_content_hash -> physical_page_idx)
|
- Prefix-cache hash lookup (page_content_hash -> physical_page_idx)
|
||||||
|
- LRU eviction for persistent cross-batch prefix caching
|
||||||
|
|
||||||
Call :meth:`bind` to obtain a batch view for the attention layers.
|
Pages with recorded hashes persist after refcount reaches 0 (pinned).
|
||||||
|
They are evicted via LRU only when alloc() finds no free pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -57,6 +59,24 @@ class PagedCache:
|
|||||||
)
|
)
|
||||||
self._page_to_hash: Dict[int, int] = {}
|
self._page_to_hash: Dict[int, int] = {}
|
||||||
self._hash_to_page: Dict[int, int] = {}
|
self._hash_to_page: Dict[int, int] = {}
|
||||||
|
self._lru: List[int] = []
|
||||||
|
self._pin: List[bool] = [False] * n_pages
|
||||||
|
|
||||||
|
def _touch(self, idx: int) -> None:
|
||||||
|
if self._refs[idx] == 0 and idx in self._lru:
|
||||||
|
self._lru.remove(idx)
|
||||||
|
self._lru.append(idx)
|
||||||
|
|
||||||
|
def _evict_one(self) -> int:
|
||||||
|
while self._lru:
|
||||||
|
idx = self._lru.pop(0)
|
||||||
|
h = self._page_to_hash.pop(idx, None)
|
||||||
|
if h is not None:
|
||||||
|
self._hash_to_page.pop(h, None)
|
||||||
|
self._pin[idx] = False
|
||||||
|
self._refs[idx] = 1
|
||||||
|
return idx
|
||||||
|
return -1
|
||||||
|
|
||||||
def record_page(
|
def record_page(
|
||||||
self, page_idx: int, token_ids: List[int], logical_page_idx: int
|
self, page_idx: int, token_ids: List[int], logical_page_idx: int
|
||||||
@@ -67,6 +87,9 @@ class PagedCache:
|
|||||||
self._hash_to_page.pop(old_h, None)
|
self._hash_to_page.pop(old_h, None)
|
||||||
self._page_to_hash[page_idx] = h
|
self._page_to_hash[page_idx] = h
|
||||||
self._hash_to_page[h] = page_idx
|
self._hash_to_page[h] = page_idx
|
||||||
|
self._pin[page_idx] = True
|
||||||
|
if page_idx in self._lru:
|
||||||
|
self._lru.remove(page_idx)
|
||||||
|
|
||||||
def lookup_prefix(self, token_ids: List[int]) -> List[int]:
|
def lookup_prefix(self, token_ids: List[int]) -> List[int]:
|
||||||
full_pages = len(token_ids) // self.page_size
|
full_pages = len(token_ids) // self.page_size
|
||||||
@@ -76,20 +99,25 @@ class PagedCache:
|
|||||||
p = self._hash_to_page.get(h)
|
p = self._hash_to_page.get(h)
|
||||||
if p is None:
|
if p is None:
|
||||||
break
|
break
|
||||||
|
self._touch(p)
|
||||||
hits.append(p)
|
hits.append(p)
|
||||||
return hits
|
return hits
|
||||||
|
|
||||||
def inc_ref(self, idx: int) -> None:
|
def inc_ref(self, idx: int) -> None:
|
||||||
self._refs[idx] += 1
|
self._refs[idx] += 1
|
||||||
|
if self._refs[idx] == 1 and idx in self._lru:
|
||||||
|
self._lru.remove(idx)
|
||||||
|
|
||||||
def alloc(self) -> int:
|
def alloc(self) -> int:
|
||||||
|
if self._free_mask:
|
||||||
lsb = self._free_mask & -self._free_mask
|
lsb = self._free_mask & -self._free_mask
|
||||||
if lsb == 0:
|
|
||||||
return -1
|
|
||||||
idx = lsb.bit_length() - 1
|
idx = lsb.bit_length() - 1
|
||||||
self._free_mask ^= lsb
|
self._free_mask ^= lsb
|
||||||
self._refs[idx] = 1
|
self._refs[idx] = 1
|
||||||
|
if idx in self._lru:
|
||||||
|
self._lru.remove(idx)
|
||||||
return idx
|
return idx
|
||||||
|
return self._evict_one()
|
||||||
|
|
||||||
def alloc_n(self, n: int) -> List[int]:
|
def alloc_n(self, n: int) -> List[int]:
|
||||||
pages = [self.alloc() for _ in range(n)]
|
pages = [self.alloc() for _ in range(n)]
|
||||||
@@ -103,10 +131,15 @@ class PagedCache:
|
|||||||
def free(self, idx: int) -> None:
|
def free(self, idx: int) -> None:
|
||||||
self._refs[idx] -= 1
|
self._refs[idx] -= 1
|
||||||
if self._refs[idx] == 0:
|
if self._refs[idx] == 0:
|
||||||
|
h = self._page_to_hash.get(idx)
|
||||||
|
if h is not None and self._pin[idx]:
|
||||||
|
self._lru.append(idx)
|
||||||
|
else:
|
||||||
self._free_mask |= 1 << idx
|
self._free_mask |= 1 << idx
|
||||||
h = self._page_to_hash.pop(idx, None)
|
h = self._page_to_hash.pop(idx, None)
|
||||||
if h is not None:
|
if h is not None:
|
||||||
self._hash_to_page.pop(h, None)
|
self._hash_to_page.pop(h, None)
|
||||||
|
self._pin[idx] = False
|
||||||
|
|
||||||
def bind(self, page_table: Tensor, total_len: int = 0) -> "CacheView":
|
def bind(self, page_table: Tensor, total_len: int = 0) -> "CacheView":
|
||||||
return CacheView(self, page_table, total_len)
|
return CacheView(self, page_table, total_len)
|
||||||
@@ -137,15 +170,13 @@ class PagedCache:
|
|||||||
written += chunk
|
written += chunk
|
||||||
|
|
||||||
def gather(self, layer_id: int, page_table: Tensor) -> Tuple[Tensor, Tensor]:
|
def gather(self, layer_id: int, page_table: Tensor) -> Tuple[Tensor, Tensor]:
|
||||||
k_parts, v_parts = [], []
|
# page_table: [batch, max_pages] with -1 padding for tasks with fewer pages.
|
||||||
for pi in range(page_table.size(1)):
|
# clamp(min=0) maps -1 to page 0 (irrelevant data) — truncated by CacheView total_len.
|
||||||
phys_pages = page_table[:, pi]
|
safe = page_table.clamp(min=0)
|
||||||
if not (phys_pages >= 0).any():
|
k = self.k_cache[layer_id, safe]
|
||||||
break
|
v = self.v_cache[layer_id, safe]
|
||||||
k_parts.append(self.k_cache[layer_id, phys_pages])
|
k = k.flatten(1, 2)
|
||||||
v_parts.append(self.v_cache[layer_id, phys_pages])
|
v = v.flatten(1, 2)
|
||||||
k = torch.cat(k_parts, dim=1)
|
|
||||||
v = torch.cat(v_parts, dim=1)
|
|
||||||
return k, v
|
return k, v
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
-26
@@ -11,7 +11,7 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Union
|
from typing import Any, AsyncGenerator, Dict, Generator, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
@@ -126,7 +126,7 @@ class _Result:
|
|||||||
idx: Index of the generation task this token belongs to.
|
idx: Index of the generation task this token belongs to.
|
||||||
"""
|
"""
|
||||||
with self._cond:
|
with self._cond:
|
||||||
self.tokens.append(token)
|
self.tokens.append((idx, token))
|
||||||
if token is not STOP:
|
if token is not STOP:
|
||||||
self.results[idx] += token
|
self.results[idx] += token
|
||||||
else:
|
else:
|
||||||
@@ -136,11 +136,11 @@ class _Result:
|
|||||||
self._cond.notify_all()
|
self._cond.notify_all()
|
||||||
self._event.set()
|
self._event.set()
|
||||||
|
|
||||||
def pop_all(self) -> List[str]:
|
def pop_all(self) -> List[Tuple[int, str]]:
|
||||||
"""Returns and clears all accumulated tokens.
|
"""Returns and clears all accumulated (idx, token) pairs.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of token strings since the last call.
|
List of (index, token_string) tuples since the last call.
|
||||||
"""
|
"""
|
||||||
with self._cond:
|
with self._cond:
|
||||||
out = self.tokens.copy()
|
out = self.tokens.copy()
|
||||||
@@ -238,20 +238,22 @@ class InferenceEngine:
|
|||||||
temperature: float = 1.0,
|
temperature: float = 1.0,
|
||||||
top_p: float = 1.0,
|
top_p: float = 1.0,
|
||||||
top_k: int = 50,
|
top_k: int = 50,
|
||||||
) -> Union[Generator[str, None, None], str, List[str]]:
|
) -> Union[Generator, str, List[str]]:
|
||||||
"""Generates text from a prompt.
|
"""Generates text from a prompt.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: Single string or list of strings for batch generation.
|
prompt: Single string or list of strings for batch generation.
|
||||||
stream: If True, returns a generator yielding tokens one by one.
|
stream: If True, returns a generator yielding tokens.
|
||||||
max_tokens: Maximum number of tokens to generate.
|
max_tokens: Maximum number of tokens to generate.
|
||||||
temperature: Sampling temperature.
|
temperature: Sampling temperature.
|
||||||
top_p: Nucleus sampling probability threshold.
|
top_p: Nucleus sampling probability threshold.
|
||||||
top_k: Top-k sampling count (0 disables).
|
top_k: Top-k sampling count (0 disables).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Generator (stream=True), single string (non-stream, single prompt),
|
stream=False, single prompt: str
|
||||||
or list of strings (non-stream, batch prompts).
|
stream=False, batch: List[str]
|
||||||
|
stream=True, single prompt: Generator[str, None, None]
|
||||||
|
stream=True, batch: Generator[Tuple[int, str], None, None]
|
||||||
"""
|
"""
|
||||||
is_batch = isinstance(prompt, list)
|
is_batch = isinstance(prompt, list)
|
||||||
prompts = prompt if is_batch else [prompt]
|
prompts = prompt if is_batch else [prompt]
|
||||||
@@ -348,49 +350,60 @@ class InferenceEngine:
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
top_p: float,
|
top_p: float,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
) -> Generator[str, None, None]:
|
) -> Generator:
|
||||||
"""Internal streaming generator.
|
"""Internal streaming generator.
|
||||||
|
|
||||||
Polls the _Result accumulator in a loop, yielding tokens as they arrive.
|
Polls the _Result accumulator in a loop, yielding tokens as they arrive.
|
||||||
Cleans up the scheduler task on GeneratorExit.
|
Single prompt yields raw token strings; batch yields (idx, token) tuples.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompts: List of prompts (only first is used; batch not yet supported).
|
prompts: List of prompts.
|
||||||
is_batch: If True, raises NotImplementedError.
|
is_batch: If True, yields (idx, token) tuples; else yields raw tokens.
|
||||||
max_tokens: Maximum tokens to generate.
|
max_tokens: Maximum tokens to generate.
|
||||||
temperature: Sampling temperature.
|
temperature: Sampling temperature.
|
||||||
top_p: Nucleus sampling threshold.
|
top_p: Nucleus sampling threshold.
|
||||||
top_k: Top-k sampling count.
|
top_k: Top-k sampling count.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Decoded token strings.
|
Single prompt: decoded token strings.
|
||||||
|
Batch: (sequence_index, token_string) tuples.
|
||||||
"""
|
"""
|
||||||
if is_batch:
|
n = len(prompts)
|
||||||
raise NotImplementedError("Batch streaming not yet supported")
|
result = _Result(count=n)
|
||||||
|
task_ids = []
|
||||||
result = _Result()
|
|
||||||
|
|
||||||
|
for i, p in enumerate(prompts):
|
||||||
task_id = self.scheduler.add_task(
|
task_id = self.scheduler.add_task(
|
||||||
prompt=prompts[0],
|
prompt=p,
|
||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
top_p=top_p,
|
top_p=top_p,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
stream_callback=lambda tok: result.append(tok, 0),
|
stream_callback=lambda tok, idx=i: result.append(tok, idx),
|
||||||
)
|
)
|
||||||
|
task_ids.append(task_id)
|
||||||
|
|
||||||
|
remaining = n
|
||||||
|
finished = [False] * n
|
||||||
|
|
||||||
def gen():
|
def gen():
|
||||||
|
nonlocal remaining
|
||||||
try:
|
try:
|
||||||
while True:
|
while remaining > 0:
|
||||||
tokens = result.pop_all()
|
items = result.pop_all()
|
||||||
for token in tokens:
|
for idx, token in items:
|
||||||
if token is STOP:
|
if token is STOP:
|
||||||
return
|
if not finished[idx]:
|
||||||
yield token
|
finished[idx] = True
|
||||||
|
remaining -= 1
|
||||||
|
else:
|
||||||
|
yield (idx, token) if is_batch else token
|
||||||
|
if remaining > 0:
|
||||||
if not result.wait(timeout=0.05):
|
if not result.wait(timeout=0.05):
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self.scheduler.remove_task(task_id)
|
for tid in task_ids:
|
||||||
|
self.scheduler.remove_task(tid)
|
||||||
|
|
||||||
return gen()
|
return gen()
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,13 @@ class InferenceScheduler:
|
|||||||
if len(prompt_ids) > self.max_prompt_len:
|
if len(prompt_ids) > self.max_prompt_len:
|
||||||
prompt_ids = prompt_ids[-self.max_prompt_len :]
|
prompt_ids = prompt_ids[-self.max_prompt_len :]
|
||||||
|
|
||||||
|
if len(prompt_ids) >= self.max_seq_len:
|
||||||
|
if stream_callback:
|
||||||
|
stream_callback(STOP)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
max_tokens = min(max_tokens, self.max_seq_len - len(prompt_ids))
|
||||||
|
|
||||||
task = Task(
|
task = Task(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt_ids=prompt_ids,
|
prompt_ids=prompt_ids,
|
||||||
@@ -189,7 +196,10 @@ class InferenceScheduler:
|
|||||||
def _remove_finished_tasks(self) -> None:
|
def _remove_finished_tasks(self) -> None:
|
||||||
finished = []
|
finished = []
|
||||||
for task in self.active_tasks:
|
for task in self.active_tasks:
|
||||||
if task.is_finished(self.tokenizer.stop_ids):
|
if task.status == TaskStatus.ABORTED:
|
||||||
|
task.finish_time = time.time()
|
||||||
|
finished.append(task)
|
||||||
|
elif task.is_finished(self.tokenizer.stop_ids):
|
||||||
task.status = TaskStatus.FINISHED
|
task.status = TaskStatus.FINISHED
|
||||||
task.finish_time = time.time()
|
task.finish_time = time.time()
|
||||||
finished.append(task)
|
finished.append(task)
|
||||||
@@ -203,7 +213,9 @@ class InferenceScheduler:
|
|||||||
task._pages_freed = True
|
task._pages_freed = True
|
||||||
|
|
||||||
self.active_tasks = [
|
self.active_tasks = [
|
||||||
t for t in self.active_tasks if t.status != TaskStatus.FINISHED
|
t
|
||||||
|
for t in self.active_tasks
|
||||||
|
if t.status not in (TaskStatus.FINISHED, TaskStatus.ABORTED)
|
||||||
]
|
]
|
||||||
|
|
||||||
def _refill_active_batch(self) -> None:
|
def _refill_active_batch(self) -> None:
|
||||||
@@ -254,7 +266,9 @@ class InferenceScheduler:
|
|||||||
|
|
||||||
seq_len = prompt_len - start_pos
|
seq_len = prompt_len - start_pos
|
||||||
input_ids = torch.empty(batch_sz, seq_len, dtype=torch.long, device=self.device)
|
input_ids = torch.empty(batch_sz, seq_len, dtype=torch.long, device=self.device)
|
||||||
input_mask = torch.ones(batch_sz, seq_len, dtype=torch.bool, device=self.device)
|
input_mask = torch.ones(
|
||||||
|
batch_sz, prompt_len, dtype=torch.bool, device=self.device
|
||||||
|
)
|
||||||
|
|
||||||
for i, t in enumerate(tasks):
|
for i, t in enumerate(tasks):
|
||||||
input_ids[i] = torch.tensor(
|
input_ids[i] = torch.tensor(
|
||||||
@@ -280,10 +294,21 @@ class InferenceScheduler:
|
|||||||
return
|
return
|
||||||
|
|
||||||
tasks = sorted(tasks, key=lambda t: t.task_id)
|
tasks = sorted(tasks, key=lambda t: t.task_id)
|
||||||
batch_sz = len(tasks)
|
|
||||||
|
|
||||||
|
valid: List[Task] = []
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
self._maybe_alloc_page(t, start_pos)
|
if self._maybe_alloc_page(t, start_pos):
|
||||||
|
valid.append(t)
|
||||||
|
else:
|
||||||
|
t.status = TaskStatus.ABORTED
|
||||||
|
if t.stream_callback:
|
||||||
|
t.stream_callback(STOP)
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
return
|
||||||
|
|
||||||
|
tasks = valid
|
||||||
|
batch_sz = len(tasks)
|
||||||
|
|
||||||
input_ids = torch.tensor(
|
input_ids = torch.tensor(
|
||||||
[t.output_ids[-1] if t.output_ids else t.prompt_ids[-1] for t in tasks],
|
[t.output_ids[-1] if t.output_ids else t.prompt_ids[-1] for t in tasks],
|
||||||
@@ -334,14 +359,15 @@ class InferenceScheduler:
|
|||||||
rows = [t.page_table + [-1] * (max_pages - t.n_pages) for t in tasks]
|
rows = [t.page_table + [-1] * (max_pages - t.n_pages) for t in tasks]
|
||||||
return torch.tensor(rows, dtype=torch.long, device=self.device)
|
return torch.tensor(rows, dtype=torch.long, device=self.device)
|
||||||
|
|
||||||
def _maybe_alloc_page(self, task: Task, pos: int) -> None:
|
def _maybe_alloc_page(self, task: Task, pos: int) -> bool:
|
||||||
needed = self._n_pages_for(pos + 1)
|
needed = self._n_pages_for(pos + 1)
|
||||||
while task.n_pages < needed:
|
while task.n_pages < needed:
|
||||||
p = self.page_cache.alloc()
|
p = self.page_cache.alloc()
|
||||||
if p < 0:
|
if p < 0:
|
||||||
break
|
return False
|
||||||
task.page_table.append(p)
|
task.page_table.append(p)
|
||||||
task.n_pages += 1
|
task.n_pages += 1
|
||||||
|
return True
|
||||||
|
|
||||||
def _run_generation_loop(self) -> None:
|
def _run_generation_loop(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ def process_attention_mask(
|
|||||||
|
|
||||||
if seq_mask is None:
|
if seq_mask is None:
|
||||||
if start_pos != 0:
|
if start_pos != 0:
|
||||||
seq_mask = torch.ones((1, seq_len), dtype=torch.bool, device=device)
|
seq_mask = torch.ones(
|
||||||
|
(1, start_pos + seq_len), dtype=torch.bool, device=device
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user