[RaidxTree Refactor]: Support Unified HybridRadixTree V2 (#21206)
Co-authored-by: ispobock <ispobaoke@gmail.com> Co-authored-by: pansicheng <sicheng.pan.chn@gmail.com> Co-authored-by: yizhang2077 <1109276519@qq.com> Co-authored-by: xiezhq-hermann <xiezhq@stanford.edu>
This commit is contained in:
co-authored by
ispobock
pansicheng
yizhang2077
xiezhq-hermann
parent
5593539942
commit
bc59cc0f96
@@ -472,6 +472,9 @@ class Envs:
|
||||
SGLANG_MAMBA_CONV_DTYPE = EnvStr("bfloat16")
|
||||
SGLANG_MAMBA_SSM_DTYPE = EnvStr(None)
|
||||
|
||||
# Unified Radix Tree
|
||||
SGLANG_ENABLE_UNIFIED_RADIX_TREE = EnvBool(False)
|
||||
|
||||
# Breakable CUDA Graph
|
||||
SGLANG_USE_BREAKABLE_CUDA_GRAPH = EnvBool(False)
|
||||
|
||||
|
||||
@@ -835,6 +835,21 @@ class Scheduler(
|
||||
self.tp_worker.register_hicache_layer_transfer_counter(
|
||||
self.tree_cache.cache_controller.layer_done_counter
|
||||
)
|
||||
elif envs.SGLANG_ENABLE_UNIFIED_RADIX_TREE.get():
|
||||
from sglang.srt.mem_cache.unified_cache_components import (
|
||||
ComponentType,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedRadixCache,
|
||||
)
|
||||
|
||||
tree_components = [ComponentType.FULL]
|
||||
if self.is_hybrid_swa or self.is_hybrid_ssm:
|
||||
tree_components.append(
|
||||
ComponentType.SWA if self.is_hybrid_swa else ComponentType.MAMBA
|
||||
)
|
||||
params.tree_components = tuple(tree_components)
|
||||
self.tree_cache = UnifiedRadixCache(params)
|
||||
elif self.is_hybrid_swa:
|
||||
from sglang.srt.mem_cache.swa_radix_cache import SWARadixCache
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class MatchPrefixParams:
|
||||
class InsertParams:
|
||||
"""Unified parameters for insert across different cache types"""
|
||||
|
||||
key: RadixKey
|
||||
key: Optional[RadixKey] = None
|
||||
value: Optional[torch.Tensor] = None
|
||||
|
||||
# Mamba specific
|
||||
|
||||
@@ -8,6 +8,7 @@ import torch
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
|
||||
from sglang.srt.mem_cache.memory_pool import ReqToTokenPool
|
||||
from sglang.srt.mem_cache.unified_cache_components import ComponentType
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -39,3 +40,5 @@ class CacheInitParams:
|
||||
|
||||
# Time-to-live for cache entries in seconds. If None, TTL is disabled.
|
||||
cache_ttl_seconds: Optional[float] = None
|
||||
|
||||
tree_components: Optional[tuple[ComponentType, ...]] = None
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
# Unified Radix Cache
|
||||
|
||||
A component-based, pluggable prefix cache framework for SGLang that unifies Full-attention, Sliding-Window-Attention (SWA), and Mamba/SSM caching into a single radix tree.
|
||||
|
||||
## Design Goals
|
||||
|
||||
1. **Unified tree structure** — One radix tree manages all KV cache types instead of separate specialized implementations (`SWARadixCache`, `MambaRadixCache`, etc.).
|
||||
2. **Pluggable components** — Each attention/state type (Full, SWA, Mamba) is a `TreeComponent` that implements hook interfaces. Adding a new cache type only requires adding a new component.
|
||||
3. **Per-component resource isolation** — Each component has its own LRU list, lock reference counting, evictable/protected size tracking, and eviction driver.
|
||||
4. **Cascade eviction with priority** — When a component evicts a node, lower-or-equal-priority components on the same node are evicted together, maintaining cross-component consistency.
|
||||
5. **Zero special-casing in the main tree** — The tree operates purely on keys (logical). All physical resource management (allocation, freeing, copy-on-write) is handled by components through hooks.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────┐
|
||||
│ UnifiedRadixCache │
|
||||
│ (unified_radix_cache.py) │
|
||||
│ │
|
||||
│ root_node ──► UnifiedTreeNode (radix tree) │
|
||||
│ components ► {name → TreeComponent} │
|
||||
│ lru_lists ─► {name → UnifiedLRUList} │
|
||||
└──────────┬───────────┬───────────┬────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ Full │ │ SWA │ │ Mamba │
|
||||
│ Component │ │Component │ │ Component │
|
||||
└─────┬──────┘ └────┬─────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
└─────────────┼──────────────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│TreeComponent │
|
||||
│ (ABC) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Key Data Structures
|
||||
|
||||
**`UnifiedTreeNode`** — Each node stores per-component data independently:
|
||||
|
||||
```python
|
||||
node.component_data = {
|
||||
"full": ComponentData(value=Tensor|None, lock_ref=int, metadata={}),
|
||||
"swa": ComponentData(value=Tensor|None, lock_ref=int, metadata={}),
|
||||
"mamba": ComponentData(value=Tensor|None, lock_ref=int, metadata={}),
|
||||
}
|
||||
```
|
||||
|
||||
**`UnifiedLRUList`** — One doubly-linked list per component, threaded through the same tree nodes via `lru_prev[name]`/`lru_next[name]`. Supports O(1) insert/remove/promote and O(L) scan for eviction (L = locked nodes skipped).
|
||||
|
||||
**`ComponentData`** — Per-component data stored on each node:
|
||||
- `value: Tensor | None` — Device indices into the component's memory pool (`TokenToKVPool` for Full, `SWAKVPool` for SWA, `MambaPool` for Mamba). `None` means tombstone (data evicted but node structure retained).
|
||||
- `lock_ref: int` — Reference count of active requests using this node's component data. `lock_ref > 0` protects the node from eviction.
|
||||
- `metadata: dict` — Component-specific state (e.g., SWA stores `component_uuid` for window-lock boundary tracking).
|
||||
|
||||
---
|
||||
|
||||
## File Layout
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `../unified_radix_cache.py` | `UnifiedRadixCache`, `UnifiedTreeNode`, `UnifiedLRUList`, factory `create_unified_radix_cache` |
|
||||
| `tree_component.py` | `TreeComponent` ABC, `ComponentType`, `ComponentData`, `get_and_increase_time_counter`, `next_component_uuid` |
|
||||
| `full_component.py` | `FullComponent` — standard full-attention KV cache component |
|
||||
| `swa_component.py` | `SWAComponent` — sliding-window attention component with tombstone/window tracking |
|
||||
| `mamba_component.py` | `MambaComponent` — Mamba/SSM state component with copy-on-write |
|
||||
| `hybrid_cache_controller.py` | `HybridCacheController` — HiCache 3-tier storage controller (L1 GPU → L2 CPU → L3 Disk) |
|
||||
| `__init__.py` | Re-exports: `ComponentName`, `ComponentData`, `TreeComponent`, `FullComponent`, `SWAComponent`, `MambaComponent` |
|
||||
|
||||
---
|
||||
|
||||
## Public API Reference
|
||||
|
||||
All public APIs are on `UnifiedRadixCache`, which implements `BasePrefixCache`.
|
||||
|
||||
**Notation**: K = key length (tokens), D = matched path depth in tree (D ≤ K/P), P = page_size, C = number of components (≤ 3, treated as constant).
|
||||
|
||||
All tree traversal operations have two cost components: **O(K)** for data operations (key comparison, tensor clone/concat) + **O(D·C)** for component overhead (C hooks per node). Since D ≤ K/P and C is constant, overall **O(K)**.
|
||||
|
||||
### `match_prefix(params: MatchPrefixParams) → MatchResult`
|
||||
|
||||
Find the longest cached prefix for a token sequence.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | Walk the radix tree to find the longest prefix where **all** component validators pass |
|
||||
| **Inputs** | `params.key: RadixKey` — token IDs + optional extra key for namespace isolation |
|
||||
| **Output** | `MatchResult(device_indices, last_device_node, last_host_node, mamba_branching_seqlen, ...)` |
|
||||
| **Mutation** | Updates `last_access_time` on matched path; promotes matched nodes to MRU in all component LRU lists; may trigger `_split_node` if match ends mid-node |
|
||||
| **Complexity** | **O(K + D·C)** |
|
||||
|
||||
**Algorithm detail:**
|
||||
1. Calls `create_match_validator()` once per component — returns a stateful closure (e.g., SWA tracks accumulated window length)
|
||||
2. Walks tree edges via `key_match_fn`; at each node, calls all validator closures — the match boundary is only advanced when **all** validators return `True`
|
||||
3. If match ends mid-node, calls `_split_node` → triggers `redistribute_on_node_split()` per component
|
||||
4. Post-match (`_match_post_processor`):
|
||||
- Promotes matched path to MRU in each component's LRU via `node_has_component_data()` as filter
|
||||
- Updates `last_access_time` with decreasing timestamps up the path (parent < child)
|
||||
- Concatenates matched device indices via `torch.cat` (concat length ≤ K, subsumed by O(K))
|
||||
- Calls `finalize_match_result()` per component (Mamba performs copy-on-write: allocates new pool slot, copies SSM state)
|
||||
|
||||
---
|
||||
|
||||
### `insert(params: InsertParams) → InsertResult`
|
||||
|
||||
Insert a key-value pair into the tree.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | Insert token sequence + KV indices, reusing existing prefix and freeing duplicate KV slots |
|
||||
| **Inputs** | `params.key: RadixKey`, `params.value: Tensor` (KV pool indices), plus component-specific fields (`mamba_value`, `swa_evicted_seqlen`, `prev_prefix_len`) |
|
||||
| **Output** | `InsertResult(prefix_len, mamba_exist)` — `prefix_len` = length of reused prefix |
|
||||
| **Mutation** | Creates new leaf nodes; updates component data on overlapping nodes; frees duplicate KV indices; may split nodes; updates LRU lists and evictable sizes |
|
||||
| **Complexity** | **O(K + D·C)** |
|
||||
|
||||
**Algorithm detail** (`_insert_helper`):
|
||||
1. At each existing node, calls `_touch_node` → promotes to MRU via `node_has_component_data()`
|
||||
2. If key diverges mid-node, calls `_split_node` → `redistribute_on_node_split()` per component
|
||||
3. For each overlapping node, calls `update_component_on_insert_overlap()` per component — returns `consumed_from` index; the tree frees `value[dup_start:consumed_from]` as duplicate pool indices
|
||||
- Full: returns `prefix_len` (no consumption, default behavior)
|
||||
- SWA: checks if the overlapping node is a tombstone (SWA value = None) within the SWA window boundary (`swa_evicted_seqlen`):
|
||||
- If entirely within window: **recovers tombstone** — frees old `full_value`, clones `value_slice`, translates to SWA indices, inserts into SWA LRU (returns `0` = all consumed)
|
||||
- If partially within window: **splits node** at boundary, recovers SWA on the window portion (returns `start_idx`)
|
||||
- If entirely outside window: returns `prefix_len` (no consumption)
|
||||
- Mamba: returns `prefix_len` (no consumption, default behavior)
|
||||
4. Before creating a new leaf, checks `should_skip_leaf_creation()` per component — any veto aborts leaf creation and frees remaining value
|
||||
5. Creates leaf via `_add_new_node` (clones value tensor, inserts into Full LRU)
|
||||
6. Calls `commit_insert_component_data()` per component on the final target node (SWA may trigger a secondary split for window boundary; Mamba sets mamba pool indices and inserts into Mamba LRU)
|
||||
|
||||
---
|
||||
|
||||
### `evict(params: EvictParams) → EvictResult`
|
||||
|
||||
Free cached tokens to reclaim memory.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | Each component drives eviction from its own LRU list until its target is met |
|
||||
| **Inputs** | `params.num_tokens` (full), `params.swa_num_tokens` (SWA), `params.mamba_num` (Mamba) |
|
||||
| **Output** | `EvictResult(num_tokens_evicted, swa_num_tokens_evicted, mamba_num_evicted)` |
|
||||
| **Mutation** | Frees pool indices; removes nodes from LRU lists; deletes leaf nodes from tree; cascades to lower-priority components; walks up parent chain to delete tombstone ancestors |
|
||||
| **Complexity** | **O(E·H + L)** — E = nodes evicted, H = tombstone chain height, L = locked nodes skipped in LRU scan. |
|
||||
|
||||
**Algorithm detail:**
|
||||
1. Calls `drive_eviction()` for each component:
|
||||
- Full: scans Full LRU from tail, only evicts **leaf** nodes (`get_leaf_lru_no_lock` — **O(L)**); calls `evict_component()` to free pool indices
|
||||
- SWA: scans SWA LRU from tail; **internal** nodes are tombstoned (evict SWA data, keep node), **leaf** nodes are fully deleted; both trigger cascade
|
||||
- Mamba: scans Mamba LRU from tail; **internal** nodes are tombstoned, **leaf** nodes are fully deleted; both trigger cascade
|
||||
2. After each node eviction, calls `_cascade_evict`:
|
||||
- Queries `eviction_priority()` per component; evicts all with priority ≤ trigger's
|
||||
- Calls `evict_component()` + `node_has_component_data()` for cascaded components
|
||||
- For leaf: removes from parent, then `_iteratively_delete_tombstone_leaf` walks up **O(H)** ancestors
|
||||
|
||||
**Cascade eviction rules:**
|
||||
- **Leaf nodes**: all priorities = 0 → evicting any cascades to all (node deleted)
|
||||
- **Internal nodes**: Full(2) > SWA(1) > Mamba(0)
|
||||
- Evicting Mamba: no cascade
|
||||
- Evicting SWA: cascades to Mamba
|
||||
- Evicting Full: cascades to SWA + Mamba
|
||||
|
||||
---
|
||||
|
||||
### `inc_lock_ref(node: UnifiedTreeNode) → IncLockRefResult`
|
||||
|
||||
Lock a node to protect it (and its ancestors) from eviction.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | Called when a request begins using a cached prefix — prevents eviction of nodes it depends on |
|
||||
| **Inputs** | `node` — the last matched node (deepest) |
|
||||
| **Output** | `IncLockRefResult(swa_uuid_for_lock)` |
|
||||
| **Mutation** | Increments `lock_ref` per component along the path; moves tokens from evictable to protected size counters |
|
||||
| **Complexity** | **O(D)** — Full: node to root; SWA: up to window boundary O(min(D, W)); Mamba: O(1).|
|
||||
|
||||
**Algorithm detail:** Calls `acquire_component_lock()` for each component.
|
||||
|
||||
| Component | Strategy |
|
||||
|-----------|----------|
|
||||
| Full | **Path-lock**: walks from node to root, `lock_ref += 1` on every ancestor. On first lock (`lock_ref: 0→1`), moves tokens from `component_evictable_size_` to `component_protected_size_`. |
|
||||
| SWA | **Window-lock**: walks upward, accumulating SWA value lengths until `sliding_window_size` is filled. Records a `component_uuid` at the boundary node for `dec_lock_ref` to know where to stop. |
|
||||
| Mamba | **Single-node lock**: only `lock_ref += 1` on the node itself (mamba state is per-leaf, not per-path). |
|
||||
|
||||
---
|
||||
|
||||
### `dec_lock_ref(node, params?) → DecLockRefResult`
|
||||
|
||||
Unlock a previously locked node path.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | Called when a request finishes — releases eviction protection |
|
||||
| **Inputs** | `node`, optional `params.swa_uuid_for_lock` for SWA boundary detection |
|
||||
| **Output** | `DecLockRefResult()` |
|
||||
| **Mutation** | Decrements `lock_ref` per component; moves tokens from protected back to evictable when `lock_ref` reaches 0 |
|
||||
| **Complexity** | **O(D)** — symmetric to `inc_lock_ref` |
|
||||
|
||||
**Algorithm detail:** Calls `release_component_lock()` for each component. Full walks to root; SWA walks up until matching `component_uuid`; Mamba decrements single node.
|
||||
|
||||
---
|
||||
|
||||
### `cache_finished_req(req: Req, is_insert: bool = True)`
|
||||
|
||||
Cache a completed request's KV data into the tree.
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | After a request finishes, insert its token/KV data into the tree for future reuse |
|
||||
| **Inputs** | `req` — the finished request; `is_insert` — whether to insert (True) or just release locks (False) |
|
||||
| **Output** | `None` |
|
||||
| **Mutation** | Calls component hooks → `insert` → `dec_lock_ref` → component cleanup. Frees unaligned tail KV indices; frees non-inserted KV indices when `is_insert=False`. |
|
||||
| **Complexity** | **O(K + D·C)** — insert O(K + D·C) + lock release O(D). Simplifies to **O(K)**. |
|
||||
|
||||
**Algorithm detail:**
|
||||
1. `prepare_for_caching_req()` per component — sets component-specific insert params, returns effective cache length (SWA: sets `swa_evicted_seqlen`; Mamba: prepares `mamba_value` from ping-pong buffer, returns `mamba_last_track_seqlen` as truncation hint)
|
||||
2. Truncates if `effective_cache_len < len(token_ids)`: frees excess pool indices
|
||||
3. Converts token IDs (bigram if EAGLE), page-aligns keys, then calls `insert()`
|
||||
4. Frees unaligned tail KV indices beyond page boundary
|
||||
5. Calls `dec_lock_ref()` on the previous `req.last_node`
|
||||
6. `cleanup_after_caching_req()` per component (Mamba: frees forked mamba_value based on `mamba_exist`, handles ping-pong buffer cleanup)
|
||||
|
||||
---
|
||||
|
||||
### `cache_unfinished_req(req: Req, chunked=False)`
|
||||
|
||||
Cache an in-progress request's partial KV data (chunked prefill).
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| **Purpose** | During chunked prefill, insert partial results so the next chunk can match the prefix |
|
||||
| **Inputs** | `req` — the in-progress request |
|
||||
| **Output** | `None` |
|
||||
| **Mutation** | Inserts partial KV → re-matches prefix → updates `req.prefix_indices`, `req.cache_protected_len`, `req.last_node`; transfers lock from old node to new node |
|
||||
| **Complexity** | **O(K + D·C)** — two tree traversals: insert O(K + D·C) + re-match O(K + D·C) + lock transfer O(D). Simplifies to **O(K)**. |
|
||||
|
||||
**Algorithm detail:**
|
||||
1. `prepare_for_caching_req()` per component
|
||||
2. `insert()` — first tree traversal
|
||||
3. `match_prefix()` — **second** tree traversal to get updated indices
|
||||
4. Writes new prefix indices into `req_to_token_pool`
|
||||
5. `dec_lock_ref()` on old `req.last_node`
|
||||
6. `inc_lock_ref()` on new matched node
|
||||
7. Updates `req.prefix_indices`, `req.cache_protected_len`, `req.last_node`
|
||||
8. `cleanup_after_caching_req()` per component
|
||||
|
||||
---
|
||||
|
||||
## TreeComponent Hook Reference
|
||||
|
||||
Each component implements these hooks. See `tree_component.py` for the ABC and docstrings.
|
||||
|
||||
### Match Phase
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `create_match_validator()` | Return a per-match stateful predicate that decides whether a node is a valid match boundary. Full: always True. SWA: tracks accumulated window length, True when contiguous window ≥ `sliding_window_size`. Mamba: True iff node has mamba data. | `_match_prefix_helper` | *abstract* |
|
||||
| `finalize_match_result()` | Post-process the match result after prefix matching completes. Full/SWA: pass-through. Mamba: copy-on-write — allocates a new mamba pool slot, copies SSM state into the request pool, records `branching_seqlen`. | `_match_post_processor` | pass-through |
|
||||
|
||||
### Insert Phase
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `update_component_on_insert_overlap()` | Handle key overlap with an existing node during insert. Returns the index within `value_slice` from which this component consumed (took ownership of) pool slots. Full/Mamba: no consumption (`prefix_len`). SWA: may recover tombstoned nodes within the sliding window boundary. | `_insert_helper` | returns `prefix_len` |
|
||||
| `should_skip_leaf_creation()` | Veto leaf creation when the entire new leaf would be a tombstone for this component. SWA: vetoes if `swa_evicted_seqlen ≥ total_prefix_len + key_len`. | `_insert_helper` | `False` |
|
||||
| `commit_insert_component_data()` | Finalize component data on the target node after the insert walk completes. Full: no-op (handled by `_add_new_node`). SWA: checks window boundary, may split node — parent becomes tombstone, child gets SWA data. Mamba: sets mamba pool indices and inserts into Mamba LRU. | `_insert_helper` | no-op |
|
||||
|
||||
### Node Split
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `redistribute_on_node_split()` | Redistribute component data between new parent (prefix) and child (suffix) when a node is split. Full: copies `lock_ref` to parent. SWA: slices SWA value, copies `lock_ref` and `component_uuid`. Mamba: parent gets `None`/`lock_ref=0` (mamba stays on leaf). | `_split_node` | *abstract* |
|
||||
|
||||
### Eviction Phase
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `evict_component()` | Free this component's KV resources on a node being evicted. Internal nodes: free memory and tombstone (`value = None`). Leaf nodes: free memory, node will be deleted. Returns number of tokens freed. | `_evict_component_and_detach_lru` | *abstract* |
|
||||
| `eviction_priority()` | Return cascade eviction priority (higher = evicted later). Leaf: all 0. Internal: Full(2) > SWA(1) > Mamba(0). When evicting, all components with ≤ priority on the same node are cascade-evicted. | `_cascade_evict` | `0` |
|
||||
| `drive_eviction()` | Drive eviction from this component's LRU list until the target amount is freed. Full: leaf-only from Full LRU. SWA: both internal (tombstone) and leaf from SWA LRU. Mamba: both internal (tombstone) and leaf from Mamba LRU. | `evict` | *abstract* |
|
||||
|
||||
### Lock Phase
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `acquire_component_lock()` | Increment `lock_ref` to protect nodes from eviction; moves tokens from evictable to protected. Full: path-lock to root. SWA: window-lock with UUID boundary. Mamba: single-node lock. | `inc_lock_ref` | *abstract* |
|
||||
| `release_component_lock()` | Decrement `lock_ref` to un-protect nodes; moves tokens from protected to evictable when `lock_ref` → 0. Full: path-unlock to root. SWA: walks up to UUID boundary. Mamba: single-node unlock. | `dec_lock_ref` | *abstract* |
|
||||
|
||||
### Caching Phase
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `prepare_for_caching_req()` | Prepare component-specific data before insert, fill fields in `InsertParams`, return effective cache length. Full: no-op. SWA: sets `swa_evicted_seqlen`. Mamba: prepares `mamba_value` from ping-pong buffer, returns `mamba_last_track_seqlen`. | `cache_finished/unfinished_req` | returns `None` |
|
||||
| `cleanup_after_caching_req()` | Post-cache cleanup. Full/SWA: no-op. Mamba: frees forked `mamba_value` based on `mamba_exist`, handles ping-pong buffer `keep_idx`, resets `mamba_last_track_seqlen` on unfinished. | `cache_finished/unfinished_req` | no-op |
|
||||
|
||||
### Utility
|
||||
|
||||
| Hook | Purpose | Called By | Default |
|
||||
|------|---------|-----------|----------|
|
||||
| `node_has_component_data()` | Check if a node has this component's data. Used as filter for LRU operations and cascade checks. Full overrides to check `full_value` directly. | multiple | `value is not None` |
|
||||
|
||||
### Component Behavior Summary
|
||||
|
||||
| Behavior | FullComponent | SWAComponent | MambaComponent |
|
||||
|----------|--------------|-------------|----------------|
|
||||
| **Validator** | Always `True` | Tracks accumulated window; `True` when ≥ `sliding_window_size` | `True` iff node has mamba data |
|
||||
| **Lock strategy** | Path-lock (root → node) | Window-lock (up to window boundary, UUID-tagged) | Single-node lock |
|
||||
| **Internal eviction priority** | 2 (last) | 1 (middle) | 0 (first) |
|
||||
| **Split behavior** | Copy `lock_ref` to parent | Slice SWA value + copy UUID | Parent gets `None` (mamba stays on leaf) |
|
||||
| **Match finalize** | No-op | No-op | Copy-on-write: allocate new mamba slot, copy state |
|
||||
| **Drive eviction** | Full LRU (leaf-only) → cascade all | SWA LRU → tombstone internal, cascade leaf | Mamba LRU → tombstone internal, cascade leaf |
|
||||
|
||||
---
|
||||
|
||||
## Factory Function
|
||||
|
||||
```python
|
||||
def create_unified_radix_cache(
|
||||
params: CacheInitParams,
|
||||
component_names: Optional[tuple[ComponentName, ...]] = None,
|
||||
) -> UnifiedRadixCache
|
||||
```
|
||||
|
||||
Auto-detects component configuration from `params` if `component_names` is not specified:
|
||||
- `SWATokenToKVPoolAllocator` → `(SWA,)` → `UnifiedSWARadixCache`
|
||||
- `HybridReqToTokenPool` → `(MAMBA,)` → `UnifiedMambaRadixCache`
|
||||
- Explicit tuple → `UnifiedRadixCache` with specified components
|
||||
|
||||
Enable via `--enable-unified-radix-tree` server flag.
|
||||
@@ -0,0 +1,25 @@
|
||||
from sglang.srt.mem_cache.unified_cache_components.full_component import FullComponent
|
||||
from sglang.srt.mem_cache.unified_cache_components.mamba_component import MambaComponent
|
||||
from sglang.srt.mem_cache.unified_cache_components.swa_component import SWAComponent
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
|
||||
_NUM_COMPONENT_TYPES,
|
||||
BASE_COMPONENT_TYPE,
|
||||
ComponentData,
|
||||
ComponentType,
|
||||
TreeComponent,
|
||||
get_and_increase_time_counter,
|
||||
next_component_uuid,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BASE_COMPONENT_TYPE",
|
||||
"ComponentData",
|
||||
"ComponentType",
|
||||
"FullComponent",
|
||||
"MambaComponent",
|
||||
"SWAComponent",
|
||||
"TreeComponent",
|
||||
"_NUM_COMPONENT_TYPES",
|
||||
"next_component_uuid",
|
||||
"get_and_increase_time_counter",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictParams,
|
||||
IncLockRefResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
|
||||
ComponentType,
|
||||
TreeComponent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedTreeNode,
|
||||
)
|
||||
|
||||
|
||||
class FullComponent(TreeComponent):
|
||||
component_type = ComponentType.FULL
|
||||
|
||||
def __init__(self, cache, params):
|
||||
super().__init__(cache, params)
|
||||
allocator = cache.token_to_kv_pool_allocator
|
||||
# When SWA is present, only free full-attention KV here;
|
||||
# SWA KV will be freed by cascade via SWAComponent.evict_component.
|
||||
if ComponentType.SWA in cache.tree_components:
|
||||
self._free_full = allocator.full_attn_allocator.free
|
||||
else:
|
||||
self._free_full = allocator.free
|
||||
|
||||
def node_has_component_data(self, node: UnifiedTreeNode) -> bool:
|
||||
# Override so _for_each_component_lru includes Full in LRU operations
|
||||
return node.component_data[self.component_type].value is not None
|
||||
|
||||
def create_match_validator(self) -> Callable[[UnifiedTreeNode], bool]:
|
||||
return lambda node: True
|
||||
|
||||
def redistribute_on_node_split(
|
||||
self, new_parent: UnifiedTreeNode, child: UnifiedTreeNode
|
||||
):
|
||||
new_parent.component_data[self.component_type].lock_ref = child.component_data[
|
||||
self.component_type
|
||||
].lock_ref
|
||||
|
||||
def evict_component(self, node: UnifiedTreeNode, is_leaf: bool) -> int:
|
||||
cd = node.component_data[self.component_type]
|
||||
self._free_full(cd.value)
|
||||
freed = len(cd.value)
|
||||
self.cache.component_evictable_size_[self.component_type] -= freed
|
||||
return freed
|
||||
|
||||
def eviction_priority(self, is_leaf: bool) -> int:
|
||||
return 0 if is_leaf else 2
|
||||
|
||||
def drive_eviction(
|
||||
self, params: EvictParams, tracker: dict[ComponentType, int]
|
||||
) -> None:
|
||||
request = params.num_tokens
|
||||
lru = self.cache.lru_lists[self.component_type]
|
||||
while tracker[self.component_type] < request:
|
||||
x = lru.get_leaf_lru_no_lock()
|
||||
if x is None:
|
||||
break
|
||||
self.cache._evict_component_and_detach_lru(
|
||||
x, self, is_leaf=True, tracker=tracker
|
||||
)
|
||||
self.cache._cascade_evict(x, self, tracker)
|
||||
|
||||
def acquire_component_lock(
|
||||
self, node: UnifiedTreeNode, result: IncLockRefResult
|
||||
) -> IncLockRefResult:
|
||||
ct = self.component_type
|
||||
root = self.cache.root_node
|
||||
cur = node
|
||||
while cur != root:
|
||||
cd = cur.component_data[ct]
|
||||
if cd.lock_ref == 0:
|
||||
key_len = len(cd.value)
|
||||
self.cache.component_evictable_size_[ct] -= key_len
|
||||
self.cache.component_protected_size_[ct] += key_len
|
||||
cd.lock_ref += 1
|
||||
cur = cur.parent
|
||||
return result
|
||||
|
||||
def release_component_lock(
|
||||
self, node: UnifiedTreeNode, params: Optional[DecLockRefParams]
|
||||
) -> None:
|
||||
ct = self.component_type
|
||||
root = self.cache.root_node
|
||||
cur = node
|
||||
while cur != root:
|
||||
cd = cur.component_data[ct]
|
||||
assert cd.lock_ref > 0
|
||||
if cd.lock_ref == 1:
|
||||
key_len = len(cd.value)
|
||||
self.cache.component_evictable_size_[ct] += key_len
|
||||
self.cache.component_protected_size_[ct] -= key_len
|
||||
cd.lock_ref -= 1
|
||||
cur = cur.parent
|
||||
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictParams,
|
||||
IncLockRefResult,
|
||||
InsertParams,
|
||||
InsertResult,
|
||||
MatchPrefixParams,
|
||||
MatchResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
|
||||
ComponentType,
|
||||
TreeComponent,
|
||||
get_and_increase_time_counter,
|
||||
)
|
||||
from sglang.srt.server_args import get_global_server_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.schedule_batch import Req
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedRadixCache,
|
||||
UnifiedTreeNode,
|
||||
)
|
||||
|
||||
|
||||
class MambaComponent(TreeComponent):
|
||||
component_type = ComponentType.MAMBA
|
||||
|
||||
def __init__(self, cache: UnifiedRadixCache, params: CacheInitParams):
|
||||
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool
|
||||
|
||||
assert isinstance(
|
||||
cache.req_to_token_pool, HybridReqToTokenPool
|
||||
), f"MambaComponent requires HybridReqToTokenPool, got {type(cache.req_to_token_pool)}"
|
||||
if not params.enable_mamba_extra_buffer:
|
||||
assert (
|
||||
cache.page_size == 1
|
||||
), f"MambaComponent requires page_size=1 when mamba_extra_buffer is disabled, got {cache.page_size}"
|
||||
super().__init__(cache, params)
|
||||
self.enable_mamba_extra_buffer = params.enable_mamba_extra_buffer
|
||||
|
||||
def create_match_validator(self) -> Callable[[UnifiedTreeNode], bool]:
|
||||
ct = self.component_type
|
||||
return lambda node: node.component_data[ct].value is not None
|
||||
|
||||
def finalize_match_result(
|
||||
self,
|
||||
result: MatchResult,
|
||||
params: MatchPrefixParams,
|
||||
value_chunks: list[torch.Tensor],
|
||||
best_value_len: int,
|
||||
) -> MatchResult:
|
||||
cow_mamba = params.cow_mamba
|
||||
req = params.req
|
||||
last_node = result.last_device_node
|
||||
|
||||
if len(value_chunks) > best_value_len:
|
||||
chunk_size = get_global_server_args().mamba_cache_chunk_size
|
||||
aligned_seqlen = (
|
||||
sum(len(v) for v in value_chunks) // chunk_size
|
||||
) * chunk_size
|
||||
branching_seqlen = aligned_seqlen if aligned_seqlen > 0 else None
|
||||
else:
|
||||
branching_seqlen = None
|
||||
|
||||
mamba_value = last_node.component_data[self.component_type].value
|
||||
if cow_mamba and mamba_value is not None:
|
||||
assert req is not None
|
||||
if req.mamba_pool_idx is None:
|
||||
dst_index = self.cache.req_to_token_pool.mamba_pool.alloc(1)
|
||||
if dst_index is None:
|
||||
self.cache.inc_lock_ref(last_node)
|
||||
self.cache.evict(EvictParams(num_tokens=0, mamba_num=1))
|
||||
dst_index = self.cache.req_to_token_pool.mamba_pool.alloc(1)
|
||||
self.cache.dec_lock_ref(last_node)
|
||||
assert dst_index is not None, "Can not alloc mamba cache"
|
||||
self.cache.req_to_token_pool.mamba_pool.copy_from(
|
||||
mamba_value, dst_index
|
||||
)
|
||||
req.mamba_pool_idx = dst_index[0]
|
||||
else:
|
||||
dst_index = req.mamba_pool_idx.unsqueeze(0)
|
||||
self.cache.req_to_token_pool.mamba_pool.copy_from(
|
||||
mamba_value, dst_index
|
||||
)
|
||||
|
||||
return result._replace(mamba_branching_seqlen=branching_seqlen)
|
||||
|
||||
def commit_insert_component_data(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
is_new_leaf: bool,
|
||||
params: InsertParams,
|
||||
result: InsertResult,
|
||||
) -> None:
|
||||
assert params.mamba_value is not None
|
||||
if is_new_leaf:
|
||||
node.component_data[self.component_type].value = params.mamba_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(
|
||||
params.mamba_value
|
||||
)
|
||||
return
|
||||
if node.component_data[self.component_type].value is None:
|
||||
node.component_data[self.component_type].value = params.mamba_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(
|
||||
params.mamba_value
|
||||
)
|
||||
node.last_access_time = get_and_increase_time_counter()
|
||||
return
|
||||
self.cache.lru_lists[self.component_type].reset_node_mru(node)
|
||||
node.last_access_time = get_and_increase_time_counter()
|
||||
result.mamba_exist = True
|
||||
|
||||
def redistribute_on_node_split(
|
||||
self, new_parent: UnifiedTreeNode, child: UnifiedTreeNode
|
||||
):
|
||||
new_parent.component_data[self.component_type].value = None
|
||||
new_parent.component_data[self.component_type].lock_ref = 0
|
||||
|
||||
def evict_component(self, node: UnifiedTreeNode, is_leaf: bool) -> int:
|
||||
value = node.component_data[self.component_type].value
|
||||
self.cache.req_to_token_pool.mamba_pool.free(value)
|
||||
freed = len(value)
|
||||
self.cache.component_evictable_size_[self.component_type] -= freed
|
||||
if not is_leaf:
|
||||
node.component_data[self.component_type].value = None
|
||||
return freed
|
||||
|
||||
def drive_eviction(
|
||||
self, params: EvictParams, tracker: dict[ComponentType, int]
|
||||
) -> None:
|
||||
request = params.mamba_num
|
||||
lru = self.cache.lru_lists[self.component_type]
|
||||
x = lru.get_lru_no_lock()
|
||||
while (
|
||||
tracker[self.component_type] < request and x is not None and lru.in_list(x)
|
||||
):
|
||||
assert x.component_data[self.component_type].value is not None
|
||||
if len(x.children) > 0:
|
||||
x_next = lru.get_prev_no_lock(x)
|
||||
self.cache._evict_component_and_detach_lru(
|
||||
x, self, is_leaf=False, tracker=tracker
|
||||
)
|
||||
self.cache._cascade_evict(x, self, tracker)
|
||||
x = x_next
|
||||
else:
|
||||
self.cache._evict_component_and_detach_lru(
|
||||
x, self, is_leaf=True, tracker=tracker
|
||||
)
|
||||
self.cache._cascade_evict(x, self, tracker)
|
||||
x = lru.get_lru_no_lock()
|
||||
|
||||
def acquire_component_lock(
|
||||
self, node: UnifiedTreeNode, result: IncLockRefResult
|
||||
) -> IncLockRefResult:
|
||||
ct = self.component_type
|
||||
cd = node.component_data[ct]
|
||||
value = cd.value
|
||||
if value is not None:
|
||||
if cd.lock_ref == 0:
|
||||
vlen = len(value)
|
||||
self.cache.component_evictable_size_[ct] -= vlen
|
||||
self.cache.component_protected_size_[ct] += vlen
|
||||
cd.lock_ref += 1
|
||||
return result
|
||||
|
||||
def release_component_lock(
|
||||
self, node: UnifiedTreeNode, params: Optional[DecLockRefParams]
|
||||
) -> None:
|
||||
ct = self.component_type
|
||||
cd = node.component_data[ct]
|
||||
value = cd.value
|
||||
if value is not None:
|
||||
assert cd.lock_ref > 0
|
||||
if cd.lock_ref == 1:
|
||||
vlen = len(value)
|
||||
self.cache.component_evictable_size_[ct] += vlen
|
||||
self.cache.component_protected_size_[ct] -= vlen
|
||||
cd.lock_ref -= 1
|
||||
|
||||
def prepare_for_caching_req(
|
||||
self,
|
||||
req: Req,
|
||||
insert_params: InsertParams,
|
||||
token_ids_len: int,
|
||||
is_finished: bool,
|
||||
) -> Optional[int]:
|
||||
cache_len = (
|
||||
req.mamba_last_track_seqlen
|
||||
if self.enable_mamba_extra_buffer
|
||||
else token_ids_len
|
||||
)
|
||||
if is_finished:
|
||||
if cache_len is None:
|
||||
cache_len = 0
|
||||
if self.enable_mamba_extra_buffer:
|
||||
keep_idx = self.cache.req_to_token_pool.get_mamba_ping_pong_other_idx(
|
||||
req.mamba_next_track_idx
|
||||
)
|
||||
mamba_value = (
|
||||
req.mamba_ping_pong_track_buffer[keep_idx].unsqueeze(-1).clone()
|
||||
)
|
||||
else:
|
||||
mamba_value = req.mamba_pool_idx.unsqueeze(-1).clone()
|
||||
insert_params.mamba_value = mamba_value
|
||||
return cache_len
|
||||
else:
|
||||
if cache_len is None:
|
||||
return 0
|
||||
if self.enable_mamba_extra_buffer:
|
||||
keep_idx = self.cache.req_to_token_pool.get_mamba_ping_pong_other_idx(
|
||||
req.mamba_next_track_idx
|
||||
)
|
||||
mamba_value = (
|
||||
req.mamba_ping_pong_track_buffer[keep_idx].unsqueeze(-1).clone()
|
||||
)
|
||||
else:
|
||||
mamba_value = self.cache.req_to_token_pool.get_mamba_indices(
|
||||
req.req_pool_idx
|
||||
).unsqueeze(-1)
|
||||
mamba_value_forked = self.cache.req_to_token_pool.mamba_pool.fork_from(
|
||||
mamba_value
|
||||
)
|
||||
if mamba_value_forked is None:
|
||||
self.cache.evict(EvictParams(num_tokens=0, mamba_num=1))
|
||||
mamba_value_forked = self.cache.req_to_token_pool.mamba_pool.fork_from(
|
||||
mamba_value
|
||||
)
|
||||
assert mamba_value_forked is not None, "Can not alloc mamba cache"
|
||||
insert_params.mamba_value = mamba_value_forked
|
||||
return cache_len
|
||||
|
||||
def cleanup_after_caching_req(
|
||||
self,
|
||||
req: Req,
|
||||
is_finished: bool,
|
||||
insert_result: Optional[InsertResult] = None,
|
||||
insert_params: Optional[InsertParams] = None,
|
||||
) -> None:
|
||||
if is_finished:
|
||||
mamba_exist = (
|
||||
insert_result.mamba_exist if insert_result is not None else True
|
||||
)
|
||||
if self.enable_mamba_extra_buffer:
|
||||
keep_idx = self.cache.req_to_token_pool.get_mamba_ping_pong_other_idx(
|
||||
req.mamba_next_track_idx
|
||||
)
|
||||
else:
|
||||
keep_idx = None
|
||||
if mamba_exist:
|
||||
keep_idx = None
|
||||
free_mamba_cache = True if self.enable_mamba_extra_buffer else mamba_exist
|
||||
if free_mamba_cache:
|
||||
self.cache.req_to_token_pool.free_mamba_cache(
|
||||
req, mamba_ping_pong_track_buffer_to_keep=keep_idx
|
||||
)
|
||||
else:
|
||||
if insert_params.mamba_value is not None and (
|
||||
insert_result is None or insert_result.mamba_exist
|
||||
):
|
||||
self.cache.req_to_token_pool.mamba_pool.free(insert_params.mamba_value)
|
||||
req.mamba_last_track_seqlen = None
|
||||
@@ -0,0 +1,297 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Callable, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictParams,
|
||||
IncLockRefResult,
|
||||
InsertParams,
|
||||
InsertResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache_components.tree_component import (
|
||||
BASE_COMPONENT_TYPE,
|
||||
ComponentType,
|
||||
TreeComponent,
|
||||
next_component_uuid,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.schedule_batch import Req
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedRadixCache,
|
||||
UnifiedTreeNode,
|
||||
)
|
||||
|
||||
|
||||
class SWAComponent(TreeComponent):
|
||||
"""Sliding window attention component.
|
||||
|
||||
Each SWA node stores translated SWA pool indices as its component
|
||||
value, independent of the full attention indices on the same tree node.
|
||||
When SWA data is evicted from an internal node the node is tombstoned
|
||||
— its SWA component value becomes None while the full attention
|
||||
value stays intact.
|
||||
"""
|
||||
|
||||
def __init__(self, cache: UnifiedRadixCache, params: CacheInitParams):
|
||||
from sglang.srt.mem_cache.swa_memory_pool import SWATokenToKVPoolAllocator
|
||||
|
||||
assert isinstance(
|
||||
cache.token_to_kv_pool_allocator, SWATokenToKVPoolAllocator
|
||||
), f"SWAComponent requires SWATokenToKVPoolAllocator, got {type(cache.token_to_kv_pool_allocator)}"
|
||||
super().__init__(cache, params)
|
||||
self.sliding_window_size = params.sliding_window_size
|
||||
|
||||
component_type = ComponentType.SWA
|
||||
|
||||
def _translate_full_to_swa(self, full_indices: torch.Tensor) -> torch.Tensor:
|
||||
return self.cache.token_to_kv_pool_allocator.translate_loc_from_full_to_swa(
|
||||
full_indices
|
||||
)
|
||||
|
||||
def create_match_validator(self) -> Callable[[UnifiedTreeNode], bool]:
|
||||
sliding_window_size = self.sliding_window_size
|
||||
ct = self.component_type
|
||||
state = {"len": float("inf")}
|
||||
|
||||
def validator(node: UnifiedTreeNode) -> bool:
|
||||
if node.component_data[ct].value is None:
|
||||
state["len"] = 0
|
||||
return False
|
||||
state["len"] += len(node.key)
|
||||
return state["len"] >= sliding_window_size
|
||||
|
||||
return validator
|
||||
|
||||
def update_component_on_insert_overlap(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
prefix_len: int,
|
||||
total_prefix_len: int,
|
||||
value_slice: torch.Tensor,
|
||||
params: InsertParams,
|
||||
) -> int:
|
||||
if params.prev_prefix_len >= total_prefix_len + prefix_len:
|
||||
return prefix_len
|
||||
|
||||
is_tombstone = node.component_data[self.component_type].value is None
|
||||
if not is_tombstone:
|
||||
return prefix_len
|
||||
|
||||
swa_evicted_seqlen = params.swa_evicted_seqlen
|
||||
assert (
|
||||
node.component_data[self.component_type].lock_ref == 0
|
||||
), f"tombstone {self.component_type} lock_ref should be 0, node {node.id}"
|
||||
assert (
|
||||
swa_evicted_seqlen % self.cache.page_size == 0
|
||||
), f"{self.component_type}: swa_evicted_seqlen must be page-aligned, {swa_evicted_seqlen=}"
|
||||
|
||||
if swa_evicted_seqlen <= total_prefix_len:
|
||||
# Branch 1: entire value_slice is within SWA window — recover
|
||||
self.cache.token_to_kv_pool_allocator.free(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
node.component_data[BASE_COMPONENT_TYPE].value = value_slice.clone()
|
||||
swa_value = self._translate_full_to_swa(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
node.component_data[self.component_type].value = swa_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
|
||||
return 0
|
||||
elif swa_evicted_seqlen < total_prefix_len + prefix_len:
|
||||
# Branch 2: value_slice[start_idx:] is within SWA window — partial recover
|
||||
start_idx = swa_evicted_seqlen - total_prefix_len
|
||||
self.cache.token_to_kv_pool_allocator.free(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value[start_idx:]
|
||||
)
|
||||
self.cache._split_node(node.key, node, start_idx)
|
||||
node.component_data[BASE_COMPONENT_TYPE].value = value_slice[
|
||||
start_idx:
|
||||
].clone()
|
||||
swa_value = self._translate_full_to_swa(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
node.component_data[self.component_type].value = swa_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
|
||||
return start_idx
|
||||
else:
|
||||
# Branch 3: entire value_slice is outside SWA window — not consumed
|
||||
return prefix_len
|
||||
|
||||
def should_skip_leaf_creation(
|
||||
self, total_prefix_len: int, key_len: int, params: InsertParams
|
||||
) -> bool:
|
||||
return params.swa_evicted_seqlen >= total_prefix_len + key_len
|
||||
|
||||
def commit_insert_component_data(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
is_new_leaf: bool,
|
||||
params: InsertParams,
|
||||
result: InsertResult,
|
||||
) -> None:
|
||||
if not is_new_leaf:
|
||||
return
|
||||
|
||||
node_start = result.prefix_len
|
||||
split_pos = params.swa_evicted_seqlen - node_start
|
||||
|
||||
if split_pos <= 0:
|
||||
swa_value = self._translate_full_to_swa(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
node.component_data[self.component_type].value = swa_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
|
||||
elif split_pos < len(node.key):
|
||||
# Node straddles the SWA eviction boundary
|
||||
# Split into parent (tombstone, no SWA) and child (with SWA)
|
||||
# After _split_node, `node` becomes the child
|
||||
self.cache._split_node(node.key, node, split_pos)
|
||||
swa_value = self._translate_full_to_swa(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
node.component_data[self.component_type].value = swa_value
|
||||
self.cache.lru_lists[self.component_type].insert_mru(node)
|
||||
self.cache.component_evictable_size_[self.component_type] += len(swa_value)
|
||||
|
||||
def redistribute_on_node_split(
|
||||
self, new_parent: UnifiedTreeNode, child: UnifiedTreeNode
|
||||
):
|
||||
new_parent.component_data[self.component_type].lock_ref = child.component_data[
|
||||
self.component_type
|
||||
].lock_ref
|
||||
|
||||
child_swa_value = child.component_data[self.component_type].value
|
||||
if child_swa_value is not None:
|
||||
split_len = len(new_parent.key)
|
||||
new_parent.component_data[self.component_type].value = child_swa_value[
|
||||
:split_len
|
||||
].clone()
|
||||
child.component_data[self.component_type].value = child_swa_value[
|
||||
split_len:
|
||||
].clone()
|
||||
else:
|
||||
new_parent.component_data[self.component_type].value = None
|
||||
|
||||
# parent inherits the swa_uuid from child for swa lock ref
|
||||
new_parent.component_data[self.component_type].metadata["uuid"] = (
|
||||
child.component_data[self.component_type].metadata.get("uuid")
|
||||
)
|
||||
child.component_data[self.component_type].metadata.pop("uuid", None)
|
||||
|
||||
def evict_component(self, node: UnifiedTreeNode, is_leaf: bool) -> int:
|
||||
swa_value = node.component_data[self.component_type].value
|
||||
if swa_value is None:
|
||||
return 0
|
||||
# Direct swa_attn_allocator.free(swa_value) would double-free
|
||||
# free_swa(full_value) has the mapping guard to avoid double-free
|
||||
# TODO: decoupling full and swa free, need further discussion on mapping necessity
|
||||
self.cache.token_to_kv_pool_allocator.free_swa(
|
||||
node.component_data[BASE_COMPONENT_TYPE].value
|
||||
)
|
||||
freed = len(swa_value)
|
||||
self.cache.component_evictable_size_[self.component_type] -= freed
|
||||
if not is_leaf:
|
||||
node.component_data[self.component_type].value = None
|
||||
return freed
|
||||
|
||||
def eviction_priority(self, is_leaf: bool) -> int:
|
||||
return 0 if is_leaf else 1
|
||||
|
||||
def drive_eviction(
|
||||
self, params: EvictParams, tracker: dict[ComponentType, int]
|
||||
) -> None:
|
||||
request = params.swa_num_tokens
|
||||
lru = self.cache.lru_lists[self.component_type]
|
||||
x = lru.get_lru_no_lock()
|
||||
while (
|
||||
tracker[self.component_type] < request and x is not None and lru.in_list(x)
|
||||
):
|
||||
assert x.component_data[self.component_type].value is not None
|
||||
if len(x.children) > 0:
|
||||
x_next = lru.get_prev_no_lock(x)
|
||||
self.cache._evict_component_and_detach_lru(
|
||||
x, self, is_leaf=False, tracker=tracker
|
||||
)
|
||||
self.cache._cascade_evict(x, self, tracker)
|
||||
x = x_next
|
||||
else:
|
||||
self.cache._evict_component_and_detach_lru(
|
||||
x, self, is_leaf=True, tracker=tracker
|
||||
)
|
||||
self.cache._cascade_evict(x, self, tracker)
|
||||
x = lru.get_lru_no_lock()
|
||||
|
||||
def acquire_component_lock(
|
||||
self, node: UnifiedTreeNode, result: IncLockRefResult
|
||||
) -> IncLockRefResult:
|
||||
ct = self.component_type
|
||||
root = self.cache.root_node
|
||||
sliding_window_size = self.sliding_window_size
|
||||
swa_lock_size = 0
|
||||
swa_uuid_for_lock = None
|
||||
|
||||
cur = node
|
||||
while cur != root and swa_lock_size < sliding_window_size:
|
||||
assert (
|
||||
cur.component_data[ct].value is not None
|
||||
), f"acquire_component_lock({ct}) on tombstoned node {cur.id}"
|
||||
comp = cur.component_data[ct]
|
||||
if comp.lock_ref == 0:
|
||||
key_len = len(cur.key)
|
||||
self.cache.component_evictable_size_[ct] -= key_len
|
||||
self.cache.component_protected_size_[ct] += key_len
|
||||
comp.lock_ref += 1
|
||||
swa_lock_size += len(cur.key)
|
||||
if swa_lock_size >= sliding_window_size:
|
||||
if comp.metadata.get("uuid") is None:
|
||||
comp.metadata["uuid"] = next_component_uuid()
|
||||
swa_uuid_for_lock = comp.metadata["uuid"]
|
||||
cur = cur.parent
|
||||
|
||||
result.swa_uuid_for_lock = swa_uuid_for_lock
|
||||
return result
|
||||
|
||||
def release_component_lock(
|
||||
self, node: UnifiedTreeNode, params: Optional[DecLockRefParams]
|
||||
) -> None:
|
||||
ct = self.component_type
|
||||
root = self.cache.root_node
|
||||
swa_uuid_for_lock = params.swa_uuid_for_lock if params else None
|
||||
dec_swa = True
|
||||
|
||||
cur = node
|
||||
while cur != root and dec_swa:
|
||||
assert (
|
||||
cur.component_data[ct].value is not None
|
||||
), f"release_component_lock({ct}) on tombstoned node {cur.id}"
|
||||
comp = cur.component_data[ct]
|
||||
assert (
|
||||
comp.lock_ref > 0
|
||||
), f"release_component_lock({ct}) on node with lock_ref=0, node {cur.id}"
|
||||
if comp.lock_ref == 1:
|
||||
key_len = len(cur.key)
|
||||
self.cache.component_evictable_size_[ct] += key_len
|
||||
self.cache.component_protected_size_[ct] -= key_len
|
||||
comp.lock_ref -= 1
|
||||
if swa_uuid_for_lock and comp.metadata.get("uuid") == swa_uuid_for_lock:
|
||||
dec_swa = False
|
||||
cur = cur.parent
|
||||
|
||||
def prepare_for_caching_req(
|
||||
self,
|
||||
req: Req,
|
||||
insert_params: InsertParams,
|
||||
token_ids_len: int,
|
||||
is_finished: bool,
|
||||
) -> Optional[int]:
|
||||
if is_finished:
|
||||
insert_params.swa_evicted_seqlen = req.swa_evicted_seqlen
|
||||
return None
|
||||
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
import torch
|
||||
from numpy import float64
|
||||
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictParams,
|
||||
IncLockRefResult,
|
||||
InsertParams,
|
||||
InsertResult,
|
||||
MatchPrefixParams,
|
||||
MatchResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.schedule_batch import Req
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
from sglang.srt.mem_cache.unified_radix_cache import (
|
||||
UnifiedRadixCache,
|
||||
UnifiedTreeNode,
|
||||
)
|
||||
|
||||
|
||||
class ComponentType(int, Enum):
|
||||
"""Integer enum so that per-node list/tuple storage can be indexed directly."""
|
||||
|
||||
FULL = 0
|
||||
SWA = 1
|
||||
MAMBA = 2
|
||||
|
||||
def __str__(self) -> str: # keep human-readable logging
|
||||
return self.name.lower()
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return self == ComponentType.FULL
|
||||
|
||||
@property
|
||||
def is_swa(self) -> bool:
|
||||
return self == ComponentType.SWA
|
||||
|
||||
@property
|
||||
def is_mamba(self) -> bool:
|
||||
return self == ComponentType.MAMBA
|
||||
|
||||
|
||||
BASE_COMPONENT_TYPE = ComponentType.FULL
|
||||
_NUM_COMPONENT_TYPES = len(ComponentType)
|
||||
|
||||
_LAST_ACCESS_TIME_COUNTER_FLOAT = float64(1.0)
|
||||
_COMPONENT_UUID_COUNTER = 1
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ComponentData:
|
||||
value: Optional[torch.Tensor] = None
|
||||
lock_ref: int = 0
|
||||
metadata: dict[str, Any] = dataclasses.field(default_factory=dict)
|
||||
|
||||
|
||||
def get_and_increase_time_counter() -> float64:
|
||||
global _LAST_ACCESS_TIME_COUNTER_FLOAT
|
||||
ret = _LAST_ACCESS_TIME_COUNTER_FLOAT
|
||||
_LAST_ACCESS_TIME_COUNTER_FLOAT += 1.0
|
||||
return ret
|
||||
|
||||
|
||||
def next_component_uuid() -> int:
|
||||
global _COMPONENT_UUID_COUNTER
|
||||
_COMPONENT_UUID_COUNTER += 1
|
||||
return _COMPONENT_UUID_COUNTER
|
||||
|
||||
|
||||
class TreeComponent(ABC):
|
||||
def __init__(self, cache: UnifiedRadixCache, params: CacheInitParams):
|
||||
self.cache = cache
|
||||
|
||||
# Subclasses MUST set this as a class attribute (not @property)
|
||||
component_type: ComponentType
|
||||
|
||||
def node_has_component_data(self, node: UnifiedTreeNode) -> bool:
|
||||
return node.component_data[self.component_type].value is not None
|
||||
|
||||
def value_len(self, node: UnifiedTreeNode) -> int:
|
||||
value = node.component_data[self.component_type].value
|
||||
return len(value) if value is not None else 0
|
||||
|
||||
@abstractmethod
|
||||
def create_match_validator(self) -> Callable[[UnifiedTreeNode], bool]:
|
||||
"""Return a per-match stateful predicate that decides whether a node
|
||||
is a valid match boundary for this component.
|
||||
Called once per match_prefix; the returned closure may carry state.
|
||||
- Full: always True (every node is valid).
|
||||
- SWA: tracks accumulated length since last gap; returns True only
|
||||
when the contiguous window reaches swa_sliding_window_size.
|
||||
- Mamba: returns True iff the node has mamba component data."""
|
||||
...
|
||||
|
||||
def finalize_match_result(
|
||||
self,
|
||||
result: MatchResult,
|
||||
params: MatchPrefixParams,
|
||||
value_chunks: list[torch.Tensor],
|
||||
best_value_len: int,
|
||||
) -> MatchResult:
|
||||
"""Post-process the match result after prefix matching completes.
|
||||
- Full & SWA: pass through unchanged.
|
||||
- Mamba: performs copy-on-write — allocates a new mamba slot, copies
|
||||
the matched node's mamba state into the request pool, and records
|
||||
branching_seqlen in result."""
|
||||
return result
|
||||
|
||||
def update_component_on_insert_overlap(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
prefix_len: int,
|
||||
total_prefix_len: int,
|
||||
value_slice: torch.Tensor,
|
||||
params: InsertParams,
|
||||
) -> int:
|
||||
"""Called per-node when an insert's key overlaps an existing node.
|
||||
Returns the index within value_slice from which this component
|
||||
consumed (took ownership of) the underlying KV pool slots.
|
||||
Returns prefix_len if nothing was consumed (default).
|
||||
_insert_helper uses this to free only the non-consumed duplicate
|
||||
portion: value_slice[dup_start:consumed_from]."""
|
||||
return prefix_len
|
||||
|
||||
def should_skip_leaf_creation(
|
||||
self, total_prefix_len: int, key_len: int, params: InsertParams
|
||||
) -> bool:
|
||||
"""Return True to veto leaf creation when the entire new leaf would
|
||||
be a tombstone for this component."""
|
||||
return False
|
||||
|
||||
def commit_insert_component_data(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
is_new_leaf: bool,
|
||||
params: InsertParams,
|
||||
result: InsertResult,
|
||||
) -> None:
|
||||
"""Finalize component data on the target (leaf) node after the insert
|
||||
walk completes. Called once per insert.
|
||||
- Full: no-op (full data is handled by _add_new_node).
|
||||
- SWA: for new leaves, checks whether the node straddles the SWA
|
||||
eviction boundary (swa_evicted_seqlen). If so, splits the node
|
||||
via _split_node — the parent becomes a tombstone (no SWA) and the
|
||||
child (the deeper portion) receives SWA data. If the entire node
|
||||
is within the window, sets SWA directly. If entirely outside,
|
||||
leaves SWA as None (tombstone).
|
||||
- Mamba: sets the mamba component value from params, inserts into
|
||||
mamba LRU list, and increments evictable size. If the node already
|
||||
has mamba data, resets its LRU position instead."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def redistribute_on_node_split(
|
||||
self, new_parent: UnifiedTreeNode, child: UnifiedTreeNode
|
||||
):
|
||||
"""Redistribute component data between new_parent and child when a
|
||||
node is split. new_parent is the newly created prefix node.
|
||||
- Full: copies child's lock_ref to new_parent.
|
||||
- SWA: slices (or clones) the swa value for new_parent, copies
|
||||
lock_ref and component_uuid metadata, then syncs child's swa
|
||||
value with its (now-trimmed) full_value.
|
||||
- Mamba: sets new_parent's mamba value to None and lock_ref to 0
|
||||
(mamba data stays on the original leaf, not on prefix nodes)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def evict_component(self, node: UnifiedTreeNode, is_leaf: bool) -> int:
|
||||
"""Free this component's KV resources on a node being evicted.
|
||||
For internal (non-leaf) nodes: free memory and tombstone the value
|
||||
(set to None); the node structure is kept.
|
||||
For leaf nodes: free memory; the node will be deleted by caller.
|
||||
Returns the number of tokens/slots freed.
|
||||
- Full: frees full_value via token_to_kv_pool_allocator.
|
||||
- SWA: frees swa value via swa_token_to_kv_pool_allocator;
|
||||
only tombstones on internal nodes.
|
||||
- Mamba: frees mamba value via mamba_token_to_kv_pool_allocator;
|
||||
only tombstones on internal nodes."""
|
||||
...
|
||||
|
||||
def eviction_priority(self, is_leaf: bool) -> int:
|
||||
"""Eviction priority on this node type. Higher = evicted later.
|
||||
When a component is evicted, all other components with equal or
|
||||
lower priority on the same node are also cascade-evicted.
|
||||
|
||||
Leaf: all components equal (0) — evicting any cascades to all,
|
||||
because the node will be deleted.
|
||||
|
||||
Internal: full=2 > swa=1 > mamba=0.
|
||||
Why swa > mamba: SWA data on internal nodes is *path data* —
|
||||
the sliding window needs continuous SWA coverage along the path
|
||||
from root to the match boundary. E.g. A->B->C->D->E where C
|
||||
and E both have mamba and the window covers C->E: if C's mamba
|
||||
is evicted, C's SWA must stay so E remains reachable.
|
||||
Mamba data, by contrast, is only meaningful at the match
|
||||
boundary node; on internal nodes it
|
||||
contributes nothing to the path. So SWA is more valuable to
|
||||
keep and should be evicted later.
|
||||
|
||||
Cascade consequences:
|
||||
- Mamba evict internal: no cascade.
|
||||
- SWA evict internal: cascades to Mamba. SWA gone -> SWA
|
||||
validator fails -> mamba data is useless (match requires all
|
||||
validators to pass).
|
||||
- Full evict internal: cascades to SWA + Mamba."""
|
||||
return 0
|
||||
|
||||
@abstractmethod
|
||||
def drive_eviction(
|
||||
self, params: EvictParams, tracker: dict[ComponentType, int]
|
||||
) -> None:
|
||||
"""Drive eviction from this component's LRU list.
|
||||
Each component extracts its own request from params, walks its own
|
||||
LRU, evicts, and calls cache._cascade_evict for priority cascade.
|
||||
Updates the shared tracker with freed amounts for all components.
|
||||
- Full: walks leaf LRU, evicts full then cascades entire leaf.
|
||||
- Mamba: walks full LRU; tombstones internal nodes (with cascade
|
||||
to equal-priority components like swa), cascades leaves to all."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def acquire_component_lock(
|
||||
self, node: UnifiedTreeNode, result: IncLockRefResult
|
||||
) -> IncLockRefResult:
|
||||
"""Increment lock_ref for this component, protecting nodes from
|
||||
eviction. Updates evictable → protected size on first lock.
|
||||
- Full: path-lock — walks from node up to root, incrementing
|
||||
lock_ref on every ancestor.
|
||||
- SWA: path-lock — walks upward collecting swa values until the
|
||||
sliding window is filled; records a component_uuid at the
|
||||
boundary for release_component_lock to know where to stop.
|
||||
- Mamba: single-node lock — only increments lock_ref on the
|
||||
node itself (mamba state is per-leaf, not per-path)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def release_component_lock(
|
||||
self, node: UnifiedTreeNode, params: Optional[DecLockRefParams]
|
||||
) -> None:
|
||||
"""Decrement lock_ref for this component, un-protecting nodes.
|
||||
Updates protected → evictable size when lock_ref drops to 0.
|
||||
- Full: path-unlock — walks from node up to root, decrementing
|
||||
lock_ref on every ancestor.
|
||||
- SWA: path-unlock — walks upward, stopping at the node whose
|
||||
component_uuid matches the one recorded during acquire.
|
||||
- Mamba: single-node unlock — only decrements lock_ref on the
|
||||
node itself."""
|
||||
...
|
||||
|
||||
def prepare_for_caching_req(
|
||||
self,
|
||||
req: Req,
|
||||
insert_params: InsertParams,
|
||||
token_ids_len: int,
|
||||
is_finished: bool,
|
||||
) -> Optional[int]:
|
||||
"""Prepare component-specific data before insert, fill component
|
||||
fields in insert_params, return effective cache_len.
|
||||
Return None for no truncation opinion (use full length);
|
||||
return int >= 0 for effective cache length.
|
||||
- Full: no-op, returns None.
|
||||
- SWA: sets insert_params.swa_evicted_seqlen on finished; returns None.
|
||||
- Mamba: prepares mamba_value (finished from ping-pong buffer,
|
||||
unfinished fork from req); returns mamba_last_track_seqlen."""
|
||||
return None
|
||||
|
||||
def cleanup_after_caching_req(
|
||||
self,
|
||||
req: Req,
|
||||
is_finished: bool,
|
||||
insert_result: Optional[InsertResult] = None,
|
||||
insert_params: Optional[InsertParams] = None,
|
||||
) -> None:
|
||||
"""Post-cache cleanup for component-specific resources.
|
||||
|
||||
``is_finished`` — whether the request has finished generation.
|
||||
True means the request is complete and its resources can be released;
|
||||
``insert_result`` is None when insert was skipped (cache disabled
|
||||
or effective_cache_len <= 0); treat as "no insert happened".
|
||||
``insert_params`` is None only on the disabled path; on early-return
|
||||
paths it is still provided so components can free their resources."""
|
||||
pass
|
||||
@@ -0,0 +1,986 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
BasePrefixCache,
|
||||
DecLockRefParams,
|
||||
DecLockRefResult,
|
||||
EvictParams,
|
||||
EvictResult,
|
||||
IncLockRefResult,
|
||||
InsertParams,
|
||||
InsertResult,
|
||||
MatchPrefixParams,
|
||||
MatchResult,
|
||||
)
|
||||
from sglang.srt.mem_cache.radix_cache import (
|
||||
RadixKey,
|
||||
_key_match_page_size1,
|
||||
_key_match_paged,
|
||||
get_child_key,
|
||||
maybe_bigram_convert,
|
||||
page_align_keys,
|
||||
)
|
||||
from sglang.srt.mem_cache.unified_cache_components import (
|
||||
_NUM_COMPONENT_TYPES,
|
||||
BASE_COMPONENT_TYPE,
|
||||
ComponentData,
|
||||
ComponentType,
|
||||
FullComponent,
|
||||
MambaComponent,
|
||||
SWAComponent,
|
||||
TreeComponent,
|
||||
get_and_increase_time_counter,
|
||||
)
|
||||
from sglang.srt.mem_cache.utils import convert_to_bigram_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.managers.schedule_batch import Req
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
|
||||
|
||||
class UnifiedTreeNode:
|
||||
counter = 0
|
||||
|
||||
def __init__(self, tree_components: tuple[ComponentType, ...]):
|
||||
self.children = defaultdict(partial(UnifiedTreeNode, tree_components))
|
||||
self.parent: UnifiedTreeNode | None = None
|
||||
self.key: Optional[RadixKey] = None
|
||||
self.tree_components = tree_components
|
||||
# list indexed by ComponentType (int enum 0..N-1)
|
||||
self.component_data: list[ComponentData] = [
|
||||
ComponentData() for _ in range(_NUM_COMPONENT_TYPES)
|
||||
]
|
||||
self.last_access_time = get_and_increase_time_counter()
|
||||
self.host_value = None
|
||||
self.hit_count = 0
|
||||
self.lru_prev: list[UnifiedTreeNode | None] = [None] * _NUM_COMPONENT_TYPES
|
||||
self.lru_next: list[UnifiedTreeNode | None] = [None] * _NUM_COMPONENT_TYPES
|
||||
self.id = UnifiedTreeNode.counter
|
||||
UnifiedTreeNode.counter += 1
|
||||
|
||||
def component(self, component_type: ComponentType) -> ComponentData:
|
||||
return self.component_data[component_type]
|
||||
|
||||
def __lt__(self, other: UnifiedTreeNode):
|
||||
return self.last_access_time < other.last_access_time
|
||||
|
||||
|
||||
class UnifiedLRUList:
|
||||
def __init__(
|
||||
self, component_type: ComponentType, tree_components: tuple[ComponentType, ...]
|
||||
):
|
||||
self.component_type = component_type
|
||||
self.head = UnifiedTreeNode(tree_components)
|
||||
self.tail = UnifiedTreeNode(tree_components)
|
||||
self.head.lru_next[component_type] = self.tail
|
||||
self.tail.lru_prev[component_type] = self.head
|
||||
self.cache: dict[int, UnifiedTreeNode] = {}
|
||||
|
||||
def _add_node_after(self, prev_node: UnifiedTreeNode, new_node: UnifiedTreeNode):
|
||||
ct = self.component_type
|
||||
new_node.lru_prev[ct] = prev_node
|
||||
new_node.lru_next[ct] = prev_node.lru_next[ct]
|
||||
prev_node.lru_next[ct].lru_prev[ct] = new_node
|
||||
prev_node.lru_next[ct] = new_node
|
||||
|
||||
def _add_node(self, node: UnifiedTreeNode):
|
||||
self._add_node_after(self.head, node)
|
||||
|
||||
def _remove_node(self, node: UnifiedTreeNode):
|
||||
ct = self.component_type
|
||||
node.lru_prev[ct].lru_next[ct] = node.lru_next[ct]
|
||||
node.lru_next[ct].lru_prev[ct] = node.lru_prev[ct]
|
||||
|
||||
def insert_mru(self, node: UnifiedTreeNode):
|
||||
assert node.id not in self.cache
|
||||
self.cache[node.id] = node
|
||||
self._add_node(node)
|
||||
|
||||
def remove_node(self, node: UnifiedTreeNode):
|
||||
assert node.id in self.cache
|
||||
del self.cache[node.id]
|
||||
self._remove_node(node)
|
||||
|
||||
def reset_node_mru(self, node: UnifiedTreeNode):
|
||||
assert node.id in self.cache
|
||||
self._remove_node(node)
|
||||
self._add_node(node)
|
||||
|
||||
def reset_node_and_parents_mru(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
root_node: UnifiedTreeNode,
|
||||
should_include,
|
||||
):
|
||||
prev_node = self.head
|
||||
while node != root_node:
|
||||
if should_include(node):
|
||||
assert node.id in self.cache
|
||||
self._remove_node(node)
|
||||
self._add_node_after(prev_node, node)
|
||||
prev_node = node
|
||||
node = node.parent
|
||||
|
||||
def in_list(self, node: Optional[UnifiedTreeNode]):
|
||||
return node is not None and node.id in self.cache
|
||||
|
||||
def get_prev_no_lock(self, node: UnifiedTreeNode, check_id: bool = True):
|
||||
if check_id:
|
||||
assert node.id in self.cache
|
||||
ct = self.component_type
|
||||
x = node.lru_prev[ct]
|
||||
while x.component_data[ct].lock_ref > 0:
|
||||
x = x.lru_prev[ct]
|
||||
if x == self.head:
|
||||
return None
|
||||
return x
|
||||
|
||||
def get_prev_leaf_no_lock(self, node: UnifiedTreeNode, check_id: bool = True):
|
||||
if check_id:
|
||||
assert node.id in self.cache
|
||||
ct = self.component_type
|
||||
x = node.lru_prev[ct]
|
||||
while x.component_data[ct].lock_ref > 0 or len(x.children) > 0:
|
||||
x = x.lru_prev[ct]
|
||||
if x == self.head:
|
||||
return None
|
||||
return x
|
||||
|
||||
def get_lru_no_lock(self):
|
||||
return self.get_prev_no_lock(self.tail, check_id=False)
|
||||
|
||||
def get_leaf_lru_no_lock(self):
|
||||
return self.get_prev_leaf_no_lock(self.tail, check_id=False)
|
||||
|
||||
|
||||
COMPONENT_REGISTRY: dict[ComponentType, type[TreeComponent]] = {
|
||||
ComponentType.FULL: FullComponent,
|
||||
ComponentType.MAMBA: MambaComponent,
|
||||
ComponentType.SWA: SWAComponent,
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedRadixCache(BasePrefixCache):
|
||||
def __init__(
|
||||
self,
|
||||
params: CacheInitParams,
|
||||
):
|
||||
self.req_to_token_pool = params.req_to_token_pool
|
||||
self.token_to_kv_pool_allocator = params.token_to_kv_pool_allocator
|
||||
self.page_size = params.page_size
|
||||
self.disable = params.disable
|
||||
self.is_eagle = params.is_eagle
|
||||
|
||||
if self.token_to_kv_pool_allocator:
|
||||
self.device = self.token_to_kv_pool_allocator.device
|
||||
else:
|
||||
self.device = torch.device("cpu")
|
||||
|
||||
if params.enable_metrics:
|
||||
self.init_metrics_collector()
|
||||
|
||||
if self.page_size == 1:
|
||||
self.key_match_fn = _key_match_page_size1
|
||||
self.get_child_key_fn = get_child_key
|
||||
else:
|
||||
self.key_match_fn = partial(_key_match_paged, page_size=self.page_size)
|
||||
self.get_child_key_fn = partial(get_child_key, page_size=self.page_size)
|
||||
|
||||
assert params.tree_components is not None
|
||||
self.tree_components = tuple(params.tree_components)
|
||||
self.components: dict[ComponentType, TreeComponent] = {
|
||||
ct: COMPONENT_REGISTRY[ct](self, params) for ct in self.tree_components
|
||||
}
|
||||
self._components_tuple: tuple[TreeComponent, ...] = tuple(
|
||||
self.components.values()
|
||||
)
|
||||
if self.is_eagle:
|
||||
self.key_convert_fn = convert_to_bigram_key
|
||||
else:
|
||||
self.key_convert_fn = lambda key: key
|
||||
self.reset()
|
||||
logger.info(f"Init Unified RadixTree with components {self.tree_components}")
|
||||
|
||||
def reset(self) -> None:
|
||||
self.root_node = UnifiedTreeNode(self.tree_components)
|
||||
self.root_node.key = RadixKey([], None)
|
||||
self.root_node.component_data[BASE_COMPONENT_TYPE].value = []
|
||||
for ct in self.tree_components:
|
||||
self.root_node.component_data[ct].lock_ref = 1
|
||||
self.component_evictable_size_ = {ct: 0 for ct in self.tree_components}
|
||||
self.component_protected_size_ = {ct: 0 for ct in self.tree_components}
|
||||
self.lru_lists = {
|
||||
ct: UnifiedLRUList(ct, self.tree_components) for ct in self.tree_components
|
||||
}
|
||||
|
||||
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
|
||||
key = params.key
|
||||
key, _ = maybe_bigram_convert(self.is_eagle, key)
|
||||
if self.disable or len(key) == 0:
|
||||
return MatchResult(
|
||||
device_indices=torch.empty(
|
||||
(0,),
|
||||
dtype=torch.int64,
|
||||
device=self.device,
|
||||
),
|
||||
last_device_node=self.root_node,
|
||||
last_host_node=self.root_node,
|
||||
)
|
||||
if self.page_size != 1:
|
||||
page_aligned_len = len(key) // self.page_size * self.page_size
|
||||
key = key[:page_aligned_len]
|
||||
|
||||
value, last_node, best_value_len = self._match_prefix_helper(key)
|
||||
return self._match_post_processor(params, value, last_node, best_value_len)
|
||||
|
||||
def insert(self, params: InsertParams) -> InsertResult:
|
||||
if self.disable:
|
||||
return InsertResult(prefix_len=0)
|
||||
|
||||
key = params.key
|
||||
value = params.value
|
||||
if value is None:
|
||||
value = torch.tensor([x for x in key.token_ids], dtype=torch.int64)
|
||||
|
||||
key, value = maybe_bigram_convert(self.is_eagle, key, value)
|
||||
result = self._insert_helper(self.root_node, key, value, params)
|
||||
return result
|
||||
|
||||
def evict(self, params: EvictParams) -> EvictResult:
|
||||
if self.disable:
|
||||
return EvictResult()
|
||||
start_time = time.perf_counter()
|
||||
tracker = {ct: 0 for ct in self.tree_components}
|
||||
|
||||
for component in self._components_tuple:
|
||||
component.drive_eviction(params=params, tracker=tracker)
|
||||
|
||||
self.update_eviction_metrics(sum(tracker.values()), start_time)
|
||||
return EvictResult(
|
||||
num_tokens_evicted=tracker[BASE_COMPONENT_TYPE],
|
||||
swa_num_tokens_evicted=tracker.get(ComponentType.SWA, 0),
|
||||
mamba_num_evicted=tracker.get(ComponentType.MAMBA, 0),
|
||||
)
|
||||
|
||||
def inc_lock_ref(self, node: UnifiedTreeNode) -> IncLockRefResult:
|
||||
if self.disable:
|
||||
return IncLockRefResult()
|
||||
result = IncLockRefResult()
|
||||
for component in self._components_tuple:
|
||||
result = component.acquire_component_lock(node=node, result=result)
|
||||
return result
|
||||
|
||||
def dec_lock_ref(
|
||||
self, node: UnifiedTreeNode, params: Optional[DecLockRefParams] = None
|
||||
) -> DecLockRefResult:
|
||||
if self.disable:
|
||||
return DecLockRefResult()
|
||||
for component in self._components_tuple:
|
||||
component.release_component_lock(node=node, params=params)
|
||||
# TODO: delta is not aggregated from components; no caller uses it yet.
|
||||
return DecLockRefResult()
|
||||
|
||||
def cache_finished_req(self, req: Req, is_insert: bool = True) -> None:
|
||||
kv_committed_len = req.pop_committed_kv_cache()
|
||||
|
||||
if self.disable:
|
||||
kv_indices = self.req_to_token_pool.req_to_token[
|
||||
req.req_pool_idx, :kv_committed_len
|
||||
]
|
||||
self.token_to_kv_pool_allocator.free(kv_indices)
|
||||
for comp in self._components_tuple:
|
||||
comp.cleanup_after_caching_req(req, is_finished=True)
|
||||
return
|
||||
|
||||
token_ids = (req.origin_input_ids + req.output_ids)[:kv_committed_len]
|
||||
kv_indices = self.req_to_token_pool.req_to_token[
|
||||
req.req_pool_idx, :kv_committed_len
|
||||
]
|
||||
|
||||
result = None
|
||||
insert_params = None
|
||||
|
||||
if is_insert:
|
||||
insert_params = InsertParams(prev_prefix_len=req.cache_protected_len)
|
||||
|
||||
# components prepare insert data + return effective cache_len
|
||||
effective_cache_len = len(token_ids)
|
||||
for comp in self._components_tuple:
|
||||
cl = comp.prepare_for_caching_req(
|
||||
req=req,
|
||||
insert_params=insert_params,
|
||||
token_ids_len=len(token_ids),
|
||||
is_finished=True,
|
||||
)
|
||||
if cl is not None:
|
||||
effective_cache_len = min(effective_cache_len, cl)
|
||||
|
||||
# Truncate if needed
|
||||
if effective_cache_len < len(token_ids):
|
||||
free_start = max(effective_cache_len, req.cache_protected_len)
|
||||
self.token_to_kv_pool_allocator.free(kv_indices[free_start:])
|
||||
token_ids = token_ids[:effective_cache_len]
|
||||
kv_indices = kv_indices[:effective_cache_len]
|
||||
|
||||
# Key convert + page align
|
||||
keys = self.key_convert_fn(token_ids)
|
||||
keys = page_align_keys(keys, self.page_size)
|
||||
page_aligned_len = len(keys)
|
||||
values = kv_indices[:page_aligned_len].to(dtype=torch.int64, copy=True)
|
||||
radix_key = RadixKey(keys, req.extra_key, is_bigram=self.is_eagle)
|
||||
|
||||
insert_params.key = radix_key
|
||||
insert_params.value = values
|
||||
result = self.insert(insert_params)
|
||||
|
||||
# Free unaligned tail
|
||||
self.token_to_kv_pool_allocator.free(kv_indices[page_aligned_len:])
|
||||
else:
|
||||
self.token_to_kv_pool_allocator.free(kv_indices[req.cache_protected_len :])
|
||||
|
||||
self.dec_lock_ref(
|
||||
req.last_node,
|
||||
DecLockRefParams(swa_uuid_for_lock=getattr(req, "swa_uuid_for_lock", None)),
|
||||
)
|
||||
|
||||
# cleanup
|
||||
for comp in self._components_tuple:
|
||||
comp.cleanup_after_caching_req(
|
||||
req, is_finished=True, insert_result=result, insert_params=insert_params
|
||||
)
|
||||
|
||||
def cache_unfinished_req(self, req: Req, chunked=False) -> None:
|
||||
token_ids = req.fill_ids
|
||||
|
||||
if self.disable:
|
||||
kv_indices = self.req_to_token_pool.req_to_token[
|
||||
req.req_pool_idx, : len(token_ids)
|
||||
]
|
||||
req.prefix_indices = kv_indices
|
||||
return
|
||||
|
||||
kv_indices_orig = self.req_to_token_pool.req_to_token[
|
||||
req.req_pool_idx, : len(token_ids)
|
||||
]
|
||||
|
||||
# components prepare insert data + return effective cache_len
|
||||
insert_params = InsertParams(prev_prefix_len=req.cache_protected_len)
|
||||
effective_cache_len = len(token_ids)
|
||||
for comp in self._components_tuple:
|
||||
cl = comp.prepare_for_caching_req(
|
||||
req=req,
|
||||
insert_params=insert_params,
|
||||
token_ids_len=len(token_ids),
|
||||
is_finished=False,
|
||||
)
|
||||
if cl is not None:
|
||||
effective_cache_len = min(effective_cache_len, cl)
|
||||
|
||||
if effective_cache_len <= 0:
|
||||
req.prefix_indices = kv_indices_orig.to(dtype=torch.int64, copy=True)
|
||||
for comp in self._components_tuple:
|
||||
comp.cleanup_after_caching_req(
|
||||
req, is_finished=False, insert_params=insert_params
|
||||
)
|
||||
return
|
||||
|
||||
kv_indices = kv_indices_orig[:effective_cache_len]
|
||||
|
||||
# Key convert + page align
|
||||
keys = self.key_convert_fn(token_ids[:effective_cache_len])
|
||||
keys = page_align_keys(keys, self.page_size)
|
||||
page_aligned_len = len(keys)
|
||||
values = kv_indices[:page_aligned_len].to(dtype=torch.int64, copy=True)
|
||||
radix_key = RadixKey(keys, req.extra_key, is_bigram=self.is_eagle)
|
||||
|
||||
insert_params.key = radix_key
|
||||
insert_params.value = values
|
||||
result = self.insert(insert_params)
|
||||
|
||||
# Match prefix
|
||||
match_result = self.match_prefix(MatchPrefixParams(key=radix_key))
|
||||
new_indices = match_result.device_indices
|
||||
new_last_node = match_result.last_device_node
|
||||
new_prefix_len = result.prefix_len
|
||||
assert (
|
||||
req.cache_protected_len <= len(new_indices) + self.page_size - 1
|
||||
), f"{req.cache_protected_len=}, {len(new_indices)=}, {page_aligned_len=}"
|
||||
assert new_prefix_len <= len(
|
||||
new_indices
|
||||
), f"{new_prefix_len=}, {len(new_indices)=}"
|
||||
self.req_to_token_pool.write(
|
||||
(req.req_pool_idx, slice(req.cache_protected_len, len(new_indices))),
|
||||
new_indices[req.cache_protected_len :],
|
||||
)
|
||||
|
||||
self.dec_lock_ref(
|
||||
req.last_node,
|
||||
DecLockRefParams(swa_uuid_for_lock=getattr(req, "swa_uuid_for_lock", None)),
|
||||
)
|
||||
lock_result = self.inc_lock_ref(new_last_node)
|
||||
|
||||
# Update req fields
|
||||
if len(new_indices) < len(kv_indices_orig):
|
||||
req.prefix_indices = torch.cat(
|
||||
[new_indices, kv_indices_orig[len(new_indices) :]]
|
||||
)
|
||||
else:
|
||||
req.prefix_indices = new_indices
|
||||
req.cache_protected_len = len(new_indices)
|
||||
req.last_node = new_last_node
|
||||
req.swa_uuid_for_lock = lock_result.swa_uuid_for_lock
|
||||
|
||||
# cleanup
|
||||
for comp in self._components_tuple:
|
||||
comp.cleanup_after_caching_req(
|
||||
req,
|
||||
is_finished=False,
|
||||
insert_result=result,
|
||||
insert_params=insert_params,
|
||||
)
|
||||
|
||||
# ---- Internal Helpers ----
|
||||
|
||||
def _match_prefix_helper_readonly(
|
||||
self, key: RadixKey
|
||||
) -> tuple[list[torch.Tensor], UnifiedTreeNode, int]:
|
||||
"""Read-only version of _match_prefix_helper that does not split nodes.
|
||||
Only considers fully matched nodes, ignores partial matches.
|
||||
|
||||
Not used yet; reserved for future read-only match operations."""
|
||||
node = self.root_node
|
||||
child_key = self.get_child_key_fn(key)
|
||||
value: list[torch.Tensor] = []
|
||||
best_value_len = 0
|
||||
best_node = node
|
||||
validators = tuple(
|
||||
comp.create_match_validator() for comp in self._components_tuple
|
||||
)
|
||||
|
||||
def _update_best_if_valid(node):
|
||||
nonlocal best_value_len, best_node
|
||||
if all(v(node) for v in validators):
|
||||
best_value_len = len(value)
|
||||
best_node = node
|
||||
|
||||
while len(key) > 0 and child_key in node.children:
|
||||
child = node.children[child_key]
|
||||
prefix_len = self.key_match_fn(child.key, key)
|
||||
if prefix_len < len(child.key):
|
||||
# Read-only: do not split, ignore partial match and stop
|
||||
break
|
||||
value.append(child.component_data[BASE_COMPONENT_TYPE].value)
|
||||
node = child
|
||||
_update_best_if_valid(node)
|
||||
key = key[prefix_len:]
|
||||
if len(key):
|
||||
child_key = self.get_child_key_fn(key)
|
||||
return value, best_node, best_value_len
|
||||
|
||||
def _match_prefix_helper(
|
||||
self, key: RadixKey
|
||||
) -> tuple[list[torch.Tensor], UnifiedTreeNode, int]:
|
||||
node = self.root_node
|
||||
child_key = self.get_child_key_fn(key)
|
||||
value: list[torch.Tensor] = []
|
||||
best_value_len = 0
|
||||
best_node = node
|
||||
validators = tuple(
|
||||
comp.create_match_validator() for comp in self._components_tuple
|
||||
)
|
||||
|
||||
def _update_best_if_valid(node):
|
||||
nonlocal best_value_len, best_node
|
||||
if all(v(node) for v in validators):
|
||||
best_value_len = len(value)
|
||||
best_node = node
|
||||
|
||||
while len(key) > 0 and child_key in node.children:
|
||||
child = node.children[child_key]
|
||||
prefix_len = self.key_match_fn(child.key, key)
|
||||
if prefix_len < len(child.key):
|
||||
node = self._split_node(child.key, child, prefix_len)
|
||||
value.append(node.component_data[BASE_COMPONENT_TYPE].value)
|
||||
_update_best_if_valid(node)
|
||||
break
|
||||
value.append(child.component_data[BASE_COMPONENT_TYPE].value)
|
||||
node = child
|
||||
_update_best_if_valid(node)
|
||||
key = key[prefix_len:]
|
||||
if len(key):
|
||||
child_key = self.get_child_key_fn(key)
|
||||
return value, best_node, best_value_len
|
||||
|
||||
def _match_post_processor(
|
||||
self,
|
||||
params: MatchPrefixParams,
|
||||
value: list[torch.Tensor],
|
||||
last_node: UnifiedTreeNode,
|
||||
best_value_len: int,
|
||||
) -> MatchResult:
|
||||
node_update = last_node
|
||||
for comp in self._components_tuple:
|
||||
self.lru_lists[comp.component_type].reset_node_and_parents_mru(
|
||||
node_update, self.root_node, comp.node_has_component_data
|
||||
)
|
||||
cur_time = get_and_increase_time_counter()
|
||||
while node_update:
|
||||
node_update.last_access_time = cur_time
|
||||
cur_time -= 0.00001
|
||||
node_update = node_update.parent
|
||||
|
||||
if best_value_len > 0:
|
||||
device_indices = torch.cat(value[:best_value_len])
|
||||
else:
|
||||
device_indices = torch.empty((0,), dtype=torch.int64, device=self.device)
|
||||
result = MatchResult(
|
||||
device_indices=device_indices,
|
||||
last_device_node=last_node,
|
||||
last_host_node=last_node,
|
||||
)
|
||||
|
||||
for component in self._components_tuple:
|
||||
result = component.finalize_match_result(
|
||||
result=result,
|
||||
params=params,
|
||||
value_chunks=value,
|
||||
best_value_len=best_value_len,
|
||||
)
|
||||
return result
|
||||
|
||||
def _split_node(
|
||||
self, key: RadixKey, child: UnifiedTreeNode, split_len: int
|
||||
) -> UnifiedTreeNode:
|
||||
new_node = UnifiedTreeNode(self.tree_components)
|
||||
new_node.children = {self.get_child_key_fn(key[split_len:]): child}
|
||||
new_node.parent = child.parent
|
||||
new_node.key = child.key[:split_len]
|
||||
new_node.component_data[BASE_COMPONENT_TYPE].value = (
|
||||
child.component_data[BASE_COMPONENT_TYPE].value[:split_len].clone()
|
||||
)
|
||||
|
||||
self._for_each_component_lru(child, UnifiedLRUList.remove_node)
|
||||
|
||||
child.parent = new_node
|
||||
child.key = child.key[split_len:]
|
||||
child.component_data[BASE_COMPONENT_TYPE].value = (
|
||||
child.component_data[BASE_COMPONENT_TYPE].value[split_len:].clone()
|
||||
)
|
||||
|
||||
for component in self._components_tuple:
|
||||
component.redistribute_on_node_split(new_parent=new_node, child=child)
|
||||
new_node.parent.children[self.get_child_key_fn(key)] = new_node
|
||||
|
||||
self._for_each_component_lru(new_node, UnifiedLRUList.insert_mru)
|
||||
self._for_each_component_lru(child, UnifiedLRUList.insert_mru)
|
||||
child.last_access_time = get_and_increase_time_counter()
|
||||
return new_node
|
||||
|
||||
def _touch_node(self, node: UnifiedTreeNode):
|
||||
node.last_access_time = get_and_increase_time_counter()
|
||||
if node != self.root_node:
|
||||
self._for_each_component_lru(node, UnifiedLRUList.reset_node_mru)
|
||||
|
||||
def _add_new_node(
|
||||
self,
|
||||
parent: UnifiedTreeNode,
|
||||
key: RadixKey,
|
||||
value: torch.Tensor,
|
||||
) -> UnifiedTreeNode:
|
||||
new_node = UnifiedTreeNode(self.tree_components)
|
||||
new_node.parent = parent
|
||||
new_node.key = key
|
||||
new_node.component_data[BASE_COMPONENT_TYPE].value = value.clone()
|
||||
parent.children[self.get_child_key_fn(key)] = new_node
|
||||
self.lru_lists[BASE_COMPONENT_TYPE].insert_mru(new_node)
|
||||
self.component_evictable_size_[BASE_COMPONENT_TYPE] += len(value)
|
||||
return new_node
|
||||
|
||||
def _insert_helper(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
key: RadixKey,
|
||||
value: torch.Tensor,
|
||||
params: InsertParams,
|
||||
) -> InsertResult:
|
||||
self._touch_node(node)
|
||||
if len(key) == 0:
|
||||
return InsertResult(prefix_len=0, mamba_exist=True)
|
||||
|
||||
child_key = self.get_child_key_fn(key)
|
||||
total_prefix_length = 0
|
||||
while len(key) > 0 and child_key in node.children:
|
||||
node = node.children[child_key]
|
||||
self._touch_node(node)
|
||||
prefix_len = self.key_match_fn(node.key, key)
|
||||
if prefix_len < len(node.key):
|
||||
node = self._split_node(node.key, node, prefix_len)
|
||||
|
||||
value_slice = value[:prefix_len]
|
||||
consumed_from = prefix_len
|
||||
# Let each component claim ownership of overlapping KV slots
|
||||
for component in self._components_tuple:
|
||||
comp_consumed_from = component.update_component_on_insert_overlap(
|
||||
node=node,
|
||||
prefix_len=prefix_len,
|
||||
total_prefix_len=total_prefix_length,
|
||||
value_slice=value_slice,
|
||||
params=params,
|
||||
)
|
||||
consumed_from = min(consumed_from, comp_consumed_from)
|
||||
|
||||
dup_start = max(0, params.prev_prefix_len - total_prefix_length)
|
||||
if dup_start < consumed_from:
|
||||
self.token_to_kv_pool_allocator.free(
|
||||
value_slice[dup_start:consumed_from]
|
||||
)
|
||||
|
||||
total_prefix_length += prefix_len
|
||||
key = key[prefix_len:]
|
||||
value = value[prefix_len:]
|
||||
if len(key):
|
||||
child_key = self.get_child_key_fn(key)
|
||||
|
||||
is_new_leaf = False
|
||||
# Create new leaf for remaining suffix
|
||||
if len(key):
|
||||
if any(
|
||||
comp.should_skip_leaf_creation(
|
||||
total_prefix_len=total_prefix_length,
|
||||
key_len=len(key),
|
||||
params=params,
|
||||
)
|
||||
for comp in self._components_tuple
|
||||
):
|
||||
# TODO: When leaf creation is skipped, We should release all component
|
||||
# resources here or propagate a flag so that
|
||||
# cleanup_after_caching_req can free them properly.
|
||||
self.token_to_kv_pool_allocator.free(value)
|
||||
return InsertResult(prefix_len=total_prefix_length)
|
||||
target_node = self._add_new_node(node, key, value)
|
||||
is_new_leaf = True
|
||||
else:
|
||||
target_node = node
|
||||
|
||||
# Finalize: let each component attach its data to the target node.
|
||||
# e.g. Mamba attaches mamba_value to the leaf node
|
||||
result = InsertResult(prefix_len=total_prefix_length)
|
||||
for component in self._components_tuple:
|
||||
component.commit_insert_component_data(
|
||||
node=target_node,
|
||||
is_new_leaf=is_new_leaf,
|
||||
params=params,
|
||||
result=result,
|
||||
)
|
||||
return result
|
||||
|
||||
def _cascade_evict(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
trigger: TreeComponent,
|
||||
tracker: dict[ComponentType, int],
|
||||
):
|
||||
"""Cascade eviction from trigger to lower-or-equal priority components.
|
||||
|
||||
When a component evicts a node, all other components with equal or
|
||||
lower eviction_priority on the same node are also evicted.
|
||||
If the node is a leaf, it is removed from the tree and any
|
||||
resulting tombstone ancestors are cleaned up recursively."""
|
||||
is_leaf = len(node.children) == 0
|
||||
trigger_priority = trigger.eviction_priority(is_leaf)
|
||||
|
||||
for comp in self._components_tuple:
|
||||
if comp.eviction_priority(is_leaf) <= trigger_priority:
|
||||
if comp is not trigger and comp.node_has_component_data(node):
|
||||
assert node.component_data[comp.component_type].lock_ref == 0
|
||||
self._evict_component_and_detach_lru(
|
||||
node, comp, is_leaf=is_leaf, tracker=tracker
|
||||
)
|
||||
|
||||
if is_leaf:
|
||||
self._remove_leaf_from_parent(node)
|
||||
self._iteratively_delete_tombstone_leaf(node, tracker)
|
||||
|
||||
def _remove_leaf_from_parent(self, node: UnifiedTreeNode):
|
||||
key = self.get_child_key_fn(node.key)
|
||||
v = node.parent.children.pop(key, None)
|
||||
assert v == node
|
||||
|
||||
def _evict_component_and_detach_lru(
|
||||
self,
|
||||
node: UnifiedTreeNode,
|
||||
comp: TreeComponent,
|
||||
is_leaf: bool,
|
||||
tracker: dict[ComponentType, int],
|
||||
) -> int:
|
||||
freed = comp.evict_component(node, is_leaf=is_leaf)
|
||||
tracker[comp.component_type] += freed
|
||||
lru = self.lru_lists[comp.component_type]
|
||||
if lru.in_list(node):
|
||||
lru.remove_node(node)
|
||||
return freed
|
||||
|
||||
def _iteratively_delete_tombstone_leaf(
|
||||
self, deleted_node: UnifiedTreeNode, tracker: dict[ComponentType, int]
|
||||
):
|
||||
"""After a leaf is removed, walk up the parent chain and delete
|
||||
any ancestor that is leaf node and has lost any component data (tombstoned)."""
|
||||
cur = deleted_node.parent
|
||||
while cur != self.root_node and len(cur.children) == 0:
|
||||
has_tombstone = any(
|
||||
not comp.node_has_component_data(cur)
|
||||
for comp in self.components.values()
|
||||
)
|
||||
if not has_tombstone:
|
||||
break
|
||||
|
||||
if any(
|
||||
cur.component_data[comp.component_type].lock_ref > 0
|
||||
for comp in self.components.values()
|
||||
if comp.node_has_component_data(cur)
|
||||
):
|
||||
break
|
||||
|
||||
for comp in self.components.values():
|
||||
if comp.node_has_component_data(cur):
|
||||
self._evict_component_and_detach_lru(
|
||||
cur, comp, is_leaf=True, tracker=tracker
|
||||
)
|
||||
self._remove_leaf_from_parent(cur)
|
||||
cur = cur.parent
|
||||
|
||||
def _for_each_component_lru(self, node: UnifiedTreeNode, lru_op):
|
||||
for ct in self.tree_components:
|
||||
if node.component_data[ct].value is not None:
|
||||
lru_op(self.lru_lists[ct], node)
|
||||
|
||||
# ---- Query / Inspection APIs ----
|
||||
# These APIs exist for compatibility with other RadixTree implementations.
|
||||
# TODO: simplify and consolidate in a future refactor.
|
||||
|
||||
@property
|
||||
def sliding_window_size(self):
|
||||
swa = self.components.get(ComponentType.SWA)
|
||||
return swa.sliding_window_size if swa else None
|
||||
|
||||
def supports_swa(self) -> bool:
|
||||
return ComponentType.SWA in self.components
|
||||
|
||||
def supports_mamba(self) -> bool:
|
||||
return ComponentType.MAMBA in self.components
|
||||
|
||||
def evictable_size(self) -> int:
|
||||
return self.component_evictable_size_.get(BASE_COMPONENT_TYPE, 0)
|
||||
|
||||
def protected_size(self) -> int:
|
||||
return self.component_protected_size_.get(BASE_COMPONENT_TYPE, 0)
|
||||
|
||||
def full_evictable_size(self) -> int:
|
||||
return self.evictable_size()
|
||||
|
||||
def full_protected_size(self) -> int:
|
||||
return self.protected_size()
|
||||
|
||||
def swa_evictable_size(self) -> int:
|
||||
return self.component_evictable_size_.get(ComponentType.SWA, 0)
|
||||
|
||||
def mamba_evictable_size(self) -> int:
|
||||
return self.component_evictable_size_.get(ComponentType.MAMBA, 0)
|
||||
|
||||
def swa_protected_size(self) -> int:
|
||||
return self.component_protected_size_.get(ComponentType.SWA, 0)
|
||||
|
||||
def mamba_protected_size(self) -> int:
|
||||
return self.component_protected_size_.get(ComponentType.MAMBA, 0)
|
||||
|
||||
def total_size(self):
|
||||
total_size = 0
|
||||
total_aux_size = 0
|
||||
stack = [self.root_node]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
total_size += len(node.component_data[BASE_COMPONENT_TYPE].value)
|
||||
for ct in self.tree_components:
|
||||
if ct == BASE_COMPONENT_TYPE:
|
||||
continue
|
||||
value = node.component_data[ct].value
|
||||
if value is not None:
|
||||
total_aux_size += len(value)
|
||||
for child in node.children.values():
|
||||
stack.append(child)
|
||||
return total_size, total_aux_size
|
||||
|
||||
def all_values_flatten(self) -> torch.Tensor:
|
||||
values = []
|
||||
|
||||
def _dfs(node: UnifiedTreeNode):
|
||||
for child in node.children.values():
|
||||
values.append(child.component_data[BASE_COMPONENT_TYPE].value)
|
||||
_dfs(child)
|
||||
|
||||
_dfs(self.root_node)
|
||||
if values:
|
||||
return torch.cat(values)
|
||||
return torch.tensor([], dtype=torch.int64, device=self.device)
|
||||
|
||||
def _all_component_values_flatten(
|
||||
self, component_type: ComponentType
|
||||
) -> torch.Tensor:
|
||||
if component_type not in self.components:
|
||||
return torch.tensor([], dtype=torch.int64, device=self.device)
|
||||
|
||||
values = []
|
||||
|
||||
def _dfs(node: UnifiedTreeNode):
|
||||
value = node.component_data[component_type].value
|
||||
if value is not None:
|
||||
values.append(value)
|
||||
for child in node.children.values():
|
||||
_dfs(child)
|
||||
|
||||
_dfs(self.root_node)
|
||||
if values:
|
||||
return torch.cat(values)
|
||||
return torch.tensor([], dtype=torch.int64, device=self.device)
|
||||
|
||||
def all_mamba_values_flatten(self) -> torch.Tensor:
|
||||
return self._all_component_values_flatten(ComponentType.MAMBA)
|
||||
|
||||
def all_swa_values_flatten(self) -> torch.Tensor:
|
||||
return self._all_component_values_flatten(ComponentType.SWA)
|
||||
|
||||
def available_and_evictable_str(self) -> str:
|
||||
if self.supports_swa():
|
||||
full_available_size = self.token_to_kv_pool_allocator.full_available_size()
|
||||
else:
|
||||
full_available_size = self.token_to_kv_pool_allocator.available_size()
|
||||
full_evictable = self.component_evictable_size_[BASE_COMPONENT_TYPE]
|
||||
lines = [
|
||||
f"Available full tokens: {full_available_size + full_evictable} "
|
||||
f"(full_available_size={full_available_size} + full_evictable_size_={full_evictable})"
|
||||
]
|
||||
for ct in self.tree_components:
|
||||
if ct == BASE_COMPONENT_TYPE:
|
||||
continue
|
||||
if ct.is_swa:
|
||||
available_size = self.token_to_kv_pool_allocator.swa_available_size()
|
||||
elif ct.is_mamba:
|
||||
available_size = self.req_to_token_pool.mamba_pool.available_size()
|
||||
else:
|
||||
continue
|
||||
|
||||
lines.append(
|
||||
f"Available {ct}: {available_size + self.component_evictable_size_[ct]} "
|
||||
f"(available_size={available_size} + component_evictable_size_={self.component_evictable_size_[ct]})"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def _collect_all_nodes(self) -> list[UnifiedTreeNode]:
|
||||
nodes = []
|
||||
stack = [self.root_node]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
nodes.append(node)
|
||||
stack.extend(node.children.values())
|
||||
return nodes
|
||||
|
||||
def sanity_check(self):
|
||||
"""Thorough sanity check: verify LRU membership, lock state, linked-list
|
||||
integrity, and evictable sizes for every component.
|
||||
Expensive — use only in tests or idle checks."""
|
||||
try:
|
||||
# 1. Collect all nodes from tree
|
||||
all_nodes = self._collect_all_nodes()
|
||||
|
||||
for ct in self.tree_components:
|
||||
# 2. Basic size invariants
|
||||
assert (
|
||||
self.component_evictable_size_[ct] >= 0
|
||||
), f"component_evictable_size_[{ct}] = {self.component_evictable_size_[ct]} < 0"
|
||||
assert (
|
||||
self.component_protected_size_[ct] >= 0
|
||||
), f"component_protected_size_[{ct}] = {self.component_protected_size_[ct]} < 0"
|
||||
|
||||
# 3. Verify LRU membership: tree nodes with data == LRU cache entries
|
||||
lru = self.lru_lists[ct]
|
||||
tree_ids = {
|
||||
n.id
|
||||
for n in all_nodes
|
||||
if n != self.root_node and n.component_data[ct].value is not None
|
||||
}
|
||||
lru_ids = set(lru.cache.keys())
|
||||
assert tree_ids == lru_ids, (
|
||||
f"[{ct}] LRU membership mismatch: "
|
||||
f"in_tree_not_lru={tree_ids - lru_ids}, "
|
||||
f"in_lru_not_tree={lru_ids - tree_ids}"
|
||||
)
|
||||
|
||||
# 4. Walk LRU doubly-linked list: verify structural integrity
|
||||
# and that all nodes are unlocked (idle check)
|
||||
visited = set()
|
||||
x = lru.head.lru_next[ct]
|
||||
prev = lru.head
|
||||
while x != lru.tail:
|
||||
assert (
|
||||
x.lru_prev[ct] == prev
|
||||
), f"[{ct}] broken prev link at node {x.id}"
|
||||
assert (
|
||||
x.id in lru.cache
|
||||
), f"[{ct}] node {x.id} in linked list but not in cache dict"
|
||||
assert x.id not in visited, f"[{ct}] cycle detected at node {x.id}"
|
||||
assert x.component_data[ct].lock_ref == 0, (
|
||||
f"[{ct}] node {x.id} should not be locked when idle, "
|
||||
f"lock_ref={x.component_data[ct].lock_ref}"
|
||||
)
|
||||
visited.add(x.id)
|
||||
prev = x
|
||||
x = x.lru_next[ct]
|
||||
assert len(visited) == len(lru.cache), (
|
||||
f"[{ct}] linked list has {len(visited)} nodes, "
|
||||
f"cache dict has {len(lru.cache)}"
|
||||
)
|
||||
|
||||
# 5. Verify evictable size by walking unlocked LRU nodes
|
||||
recomputed = 0
|
||||
x = lru.get_lru_no_lock()
|
||||
while lru.in_list(x):
|
||||
v = x.component_data[ct].value
|
||||
recomputed += len(v) if v is not None else 0
|
||||
x = lru.get_prev_no_lock(x)
|
||||
assert self.component_evictable_size_[ct] == recomputed, (
|
||||
f"[{ct}] evictable_size_={self.component_evictable_size_[ct]} "
|
||||
f"!= recomputed={recomputed}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unified RadixTree sanity check failed: {e}")
|
||||
self.pretty_print()
|
||||
raise
|
||||
|
||||
def pretty_print(self) -> None:
|
||||
stack = [(self.root_node, 0)]
|
||||
while stack:
|
||||
node, indent = stack.pop()
|
||||
component_str = " ".join(
|
||||
f"{ct}={'yes' if node.component_data[ct].value is not None else 'no'}"
|
||||
for ct in self.tree_components
|
||||
)
|
||||
print(
|
||||
" " * indent,
|
||||
f"[{node.id}]",
|
||||
len(node.key),
|
||||
f"full_lock={node.component_data[BASE_COMPONENT_TYPE].lock_ref}",
|
||||
component_str,
|
||||
)
|
||||
for child in node.children.values():
|
||||
stack.append((child, indent + 2))
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Enhanced multi-turn KL divergence test helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from sglang.test.kl_test_utils import (
|
||||
_extract_output_logprobs,
|
||||
_flush_cache,
|
||||
_generate,
|
||||
_get_input_logprobs,
|
||||
compare_kl_divergence,
|
||||
get_input_ids,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Cache assertion callbacks
|
||||
"default_prefill_cache_assert",
|
||||
"default_decode_cache_assert",
|
||||
"make_mamba_prefill_assert",
|
||||
"make_mamba_decode_assert",
|
||||
# Enhanced test helpers
|
||||
"test_input_output_logprobs_match_helper",
|
||||
"test_input_output_logprobs_match_prefill_cache_hit_helper",
|
||||
"test_input_output_logprobs_match_decode_cache_hit_helper",
|
||||
# Internal helpers (for custom inline tests)
|
||||
"_replay_and_compare_kl",
|
||||
# Re-exports from kl_test_utils
|
||||
"get_input_ids",
|
||||
"_generate",
|
||||
"_flush_cache",
|
||||
"_extract_output_logprobs",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cache assertion callbacks
|
||||
# =============================================================================
|
||||
# Prefill signature: (result, prefix_len, label) -> None
|
||||
# Decode signature: (result, history_len, output_len, label) -> None
|
||||
|
||||
|
||||
def default_prefill_cache_assert(result: dict, prefix_len: int, label: str):
|
||||
"""Standard radix cache: cached_tokens == prefix_len."""
|
||||
actual = result["meta_info"]["cached_tokens"]
|
||||
assert (
|
||||
actual == prefix_len
|
||||
), f"{label}: expected cached_tokens={prefix_len}, got {actual}"
|
||||
|
||||
|
||||
def default_decode_cache_assert(
|
||||
result: dict, history_len: int, output_len: int, label: str
|
||||
):
|
||||
"""Standard radix cache: cached_tokens == history_len + output_len."""
|
||||
expected = history_len + output_len
|
||||
actual = result["meta_info"]["cached_tokens"]
|
||||
assert (
|
||||
actual == expected
|
||||
), f"{label}: expected cached_tokens={expected}, got {actual}"
|
||||
|
||||
|
||||
def make_mamba_prefill_assert(chunk_size: int = 64) -> Callable:
|
||||
"""Mamba: cached_tokens in [rounded_down - chunk_size, rounded_down]."""
|
||||
|
||||
def _check(result: dict, prefix_len: int, label: str):
|
||||
actual = result["meta_info"]["cached_tokens"]
|
||||
upper = (prefix_len // chunk_size) * chunk_size
|
||||
lower = max(0, upper - chunk_size)
|
||||
assert (
|
||||
lower <= actual <= upper
|
||||
), f"{label}: expected cached_tokens in [{lower}, {upper}], got {actual}"
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
def make_mamba_decode_assert(track_interval: int = 16) -> Callable:
|
||||
"""Mamba: cached_tokens = floor((history+output-1)/interval)*interval."""
|
||||
|
||||
def _check(result: dict, history_len: int, output_len: int, label: str):
|
||||
actual = result["meta_info"]["cached_tokens"]
|
||||
if output_len <= 0:
|
||||
expected = history_len
|
||||
else:
|
||||
expected = (
|
||||
(history_len + output_len - 1) // track_interval
|
||||
) * track_interval
|
||||
assert (
|
||||
actual == expected
|
||||
), f"{label}: expected cached_tokens={expected}, got {actual}"
|
||||
|
||||
return _check
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Internal helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _replay_and_compare_kl(
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
kl_threshold: float,
|
||||
replay_input_ids: list[list[int]],
|
||||
output_logprobs: list[list[float]],
|
||||
label: str,
|
||||
batch_size: int = 1,
|
||||
):
|
||||
"""Flush cache, run replay prefill in batches, compare KL divergence."""
|
||||
all_input_logprobs = []
|
||||
for start in range(0, len(replay_input_ids), batch_size):
|
||||
end = start + batch_size
|
||||
all_input_logprobs.extend(
|
||||
_get_input_logprobs(
|
||||
base_url,
|
||||
replay_input_ids[start:end],
|
||||
output_logprobs[start:end],
|
||||
)
|
||||
)
|
||||
acc = {model_name: {"kl_div": kl_threshold}}
|
||||
compare_kl_divergence(all_input_logprobs, output_logprobs, acc, model_name, label)
|
||||
|
||||
|
||||
def _interleave_order(n: int, branches_per_group: int) -> list[int] | None:
|
||||
"""Build interleaved submission order for branch stress testing.
|
||||
|
||||
Given n items grouped into groups of branches_per_group, returns indices
|
||||
that interleave branches across groups: [g0b0, g1b0, ..., g0b1, g1b1, ...].
|
||||
|
||||
Returns None if no interleaving is needed.
|
||||
"""
|
||||
if branches_per_group <= 0 or branches_per_group >= n:
|
||||
return None
|
||||
num_groups = n // branches_per_group
|
||||
order = [
|
||||
g * branches_per_group + b
|
||||
for b in range(branches_per_group)
|
||||
for g in range(num_groups)
|
||||
]
|
||||
# Append remainder indices not covered by complete groups
|
||||
for i in range(num_groups * branches_per_group, n):
|
||||
order.append(i)
|
||||
return order
|
||||
|
||||
|
||||
def _generate_maybe_interleaved(base_url, inputs, max_new_tokens, order=None):
|
||||
"""Generate with optional interleaved submission order.
|
||||
|
||||
Submits inputs reordered by ``order``, then maps results back to the
|
||||
original order so the caller always sees results[i] corresponds to
|
||||
inputs[i].
|
||||
"""
|
||||
if order is None:
|
||||
return _generate(base_url, inputs, max_new_tokens, return_logprob=True)
|
||||
ordered = [inputs[i] for i in order]
|
||||
results = _generate(base_url, ordered, max_new_tokens, return_logprob=True)
|
||||
unordered = [None] * len(results)
|
||||
for idx, orig in enumerate(order):
|
||||
unordered[orig] = results[idx]
|
||||
return unordered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper 1: test_input_output_logprobs_match_helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_input_output_logprobs_match_helper(
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
kl_threshold: float,
|
||||
input_ids: list[list[int]],
|
||||
*,
|
||||
label: str = "logprobs_match",
|
||||
max_new_tokens: int = 256,
|
||||
# --- Multi-turn ---
|
||||
# turn_suffixes[t][i] = suffix tokens for sample i at turn t+1
|
||||
turn_suffixes: list[list[list[int]]] | None = None,
|
||||
# --- Cache assertion (for turns > 0) ---
|
||||
assert_decode_cached_tokens: Callable | None = None,
|
||||
replay_batch_size: int = 1,
|
||||
):
|
||||
"""Verify decode logprobs match prefill replay.
|
||||
|
||||
Single-turn (turn_suffixes=None):
|
||||
flush -> generate(input_ids) -> replay -> KL
|
||||
|
||||
Multi-turn (turn_suffixes provided):
|
||||
flush -> generate turn 0 ->
|
||||
for t in range(len(turn_suffixes)):
|
||||
input = accumulated + output + suffix[t] -> generate ->
|
||||
assert_decode_cached_tokens (optional) ->
|
||||
replay last turn -> KL
|
||||
|
||||
Multi-branch: caller passes input_ids where multiple entries share
|
||||
a prefix.
|
||||
"""
|
||||
n = len(input_ids)
|
||||
num_turns = 1 + (len(turn_suffixes) if turn_suffixes else 0)
|
||||
print(f"[{label}] {n} samples, {num_turns} turns, max_new_tokens={max_new_tokens}")
|
||||
|
||||
_flush_cache(base_url)
|
||||
|
||||
current_input = list(input_ids)
|
||||
last_outputs = None
|
||||
prev_input_lens = [0] * n
|
||||
prev_output_lens = [0] * n
|
||||
|
||||
for turn in range(num_turns):
|
||||
if turn > 0:
|
||||
suffixes = turn_suffixes[turn - 1]
|
||||
current_input = [
|
||||
current_input[i] + last_outputs[i] + suffixes[i] for i in range(n)
|
||||
]
|
||||
|
||||
results = _generate(
|
||||
base_url, current_input, max_new_tokens, return_logprob=True
|
||||
)
|
||||
assert len(results) == n
|
||||
|
||||
if turn > 0 and assert_decode_cached_tokens:
|
||||
for i, result in enumerate(results):
|
||||
assert_decode_cached_tokens(
|
||||
result,
|
||||
prev_input_lens[i],
|
||||
prev_output_lens[i],
|
||||
f"{label}[turn{turn}][{i}]",
|
||||
)
|
||||
|
||||
last_outputs = [r["output_ids"] for r in results]
|
||||
prev_input_lens = [len(current_input[i]) for i in range(n)]
|
||||
prev_output_lens = [len(last_outputs[i]) for i in range(n)]
|
||||
|
||||
# Replay last turn
|
||||
replay_ids = [current_input[i] + results[i]["output_ids"] for i in range(n)]
|
||||
output_lps = [_extract_output_logprobs(r) for r in results]
|
||||
|
||||
_replay_and_compare_kl(
|
||||
base_url,
|
||||
model_name,
|
||||
kl_threshold,
|
||||
replay_ids,
|
||||
output_lps,
|
||||
label=label,
|
||||
batch_size=replay_batch_size,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper 2: test_input_output_logprobs_match_prefill_cache_hit_helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_input_output_logprobs_match_prefill_cache_hit_helper(
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
kl_threshold: float,
|
||||
input_ids: list[list[int]] | None = None,
|
||||
*,
|
||||
# --- Multi-branch: explicit prefix/full split ---
|
||||
prefix_input_ids: list[list[int]] | None = None,
|
||||
full_input_ids: list[list[int]] | None = None,
|
||||
label: str = "prefill_cache_hit",
|
||||
max_new_tokens: int = 256,
|
||||
# --- Multi-turn: additional turns after the cache-hit generation ---
|
||||
turn_suffixes: list[list[list[int]]] | None = None,
|
||||
# --- Cache assertions ---
|
||||
assert_prefill_cached_tokens: Callable | None = None, # turn 0
|
||||
assert_decode_cached_tokens: Callable | None = None, # turns > 0
|
||||
# --- Interleaving for branch stress ---
|
||||
branches_per_group: int = 0,
|
||||
replay_batch_size: int = 1,
|
||||
):
|
||||
"""Verify logprobs when prefill cache is hit.
|
||||
|
||||
Original (input_ids only, backward compat):
|
||||
flush -> seed(input_ids) -> generate(input_ids, cache hit) -> replay -> KL
|
||||
|
||||
Multi-branch (prefix_input_ids + full_input_ids):
|
||||
flush -> seed(prefixes) -> generate(fulls, prefix cache hit) ->
|
||||
assert_prefill_cached_tokens -> replay -> KL
|
||||
|
||||
Multi-turn (+ turn_suffixes):
|
||||
... after prefill cache-hit turn, additional turns:
|
||||
input = accumulated + output + suffix -> generate ->
|
||||
assert_decode_cached_tokens -> replay last turn -> KL
|
||||
|
||||
Interleaving (branches_per_group > 0):
|
||||
Reorders submission for decode-cache-hit turns to interleave branches
|
||||
across groups, stressing the radix tree with competing branches.
|
||||
"""
|
||||
# Resolve inputs: backward compat with input_ids-only
|
||||
if input_ids is not None and prefix_input_ids is None:
|
||||
prefix_input_ids = input_ids
|
||||
full_input_ids = input_ids
|
||||
assert prefix_input_ids is not None and full_input_ids is not None
|
||||
assert len(prefix_input_ids) == len(full_input_ids)
|
||||
|
||||
if assert_prefill_cached_tokens is None:
|
||||
assert_prefill_cached_tokens = default_prefill_cache_assert
|
||||
|
||||
n = len(full_input_ids)
|
||||
num_turns = 1 + (len(turn_suffixes) if turn_suffixes else 0)
|
||||
order = _interleave_order(n, branches_per_group)
|
||||
print(f"[{label}] {n} samples, {num_turns} turns, max_new_tokens={max_new_tokens}")
|
||||
|
||||
# Seed cache with prefixes
|
||||
_flush_cache(base_url)
|
||||
_generate(base_url, prefix_input_ids, max_new_tokens=0)
|
||||
|
||||
# Turn 0: prefill cache hit (NOT interleaved, matching original behavior)
|
||||
results = _generate(base_url, full_input_ids, max_new_tokens, return_logprob=True)
|
||||
assert len(results) == n
|
||||
|
||||
for i, result in enumerate(results):
|
||||
assert_prefill_cached_tokens(
|
||||
result, len(prefix_input_ids[i]), f"{label}[turn0][{i}]"
|
||||
)
|
||||
|
||||
current_input = list(full_input_ids)
|
||||
last_outputs = [r["output_ids"] for r in results]
|
||||
prev_input_lens = [len(full_input_ids[i]) for i in range(n)]
|
||||
prev_output_lens = [len(last_outputs[i]) for i in range(n)]
|
||||
|
||||
# Additional turns: decode cache hits (interleaved if order is set)
|
||||
if turn_suffixes:
|
||||
if assert_decode_cached_tokens is None:
|
||||
assert_decode_cached_tokens = default_decode_cache_assert
|
||||
|
||||
for t, suffixes in enumerate(turn_suffixes):
|
||||
current_input = [
|
||||
current_input[i] + last_outputs[i] + suffixes[i] for i in range(n)
|
||||
]
|
||||
results = _generate_maybe_interleaved(
|
||||
base_url, current_input, max_new_tokens, order
|
||||
)
|
||||
assert len(results) == n
|
||||
|
||||
for i, result in enumerate(results):
|
||||
assert_decode_cached_tokens(
|
||||
result,
|
||||
prev_input_lens[i],
|
||||
prev_output_lens[i],
|
||||
f"{label}[turn{t + 1}][{i}]",
|
||||
)
|
||||
|
||||
last_outputs = [r["output_ids"] for r in results]
|
||||
prev_input_lens = [len(current_input[i]) for i in range(n)]
|
||||
prev_output_lens = [len(last_outputs[i]) for i in range(n)]
|
||||
|
||||
# Replay last turn
|
||||
replay_ids = [current_input[i] + results[i]["output_ids"] for i in range(n)]
|
||||
output_lps = [_extract_output_logprobs(r) for r in results]
|
||||
|
||||
_replay_and_compare_kl(
|
||||
base_url,
|
||||
model_name,
|
||||
kl_threshold,
|
||||
replay_ids,
|
||||
output_lps,
|
||||
label=label,
|
||||
batch_size=replay_batch_size,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper 3: test_input_output_logprobs_match_decode_cache_hit_helper
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_input_output_logprobs_match_decode_cache_hit_helper(
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
kl_threshold: float,
|
||||
first_turn_input_ids: list[list[int]],
|
||||
*,
|
||||
# --- Multi-turn ---
|
||||
# turn_suffixes[t][i] = suffix for sample i at turn t+2
|
||||
turn_suffixes: list[list[list[int]]],
|
||||
label: str = "decode_cache_hit",
|
||||
max_new_tokens: int = 256,
|
||||
# --- Cache assertion ---
|
||||
assert_decode_cached_tokens: Callable | None = None,
|
||||
# --- Interleaving ---
|
||||
branches_per_group: int = 0,
|
||||
replay_batch_size: int = 1,
|
||||
):
|
||||
"""Verify logprobs when decode cache is hit.
|
||||
|
||||
2-turn (turn_suffixes has 1 entry):
|
||||
flush -> generate turn 1 ->
|
||||
turn 2: input = turn1 + output + suffix -> generate ->
|
||||
assert_decode_cached_tokens -> replay -> KL
|
||||
|
||||
Multi-turn (turn_suffixes has N entries):
|
||||
flush -> generate turn 1 ->
|
||||
for each turn t: input = accumulated + output + suffix[t] -> generate ->
|
||||
assert_decode_cached_tokens -> replay last turn -> KL
|
||||
|
||||
Multi-branch: caller duplicates first_turn_input_ids entries and provides
|
||||
different suffixes per branch. Use branches_per_group for interleaved
|
||||
submission to stress the radix tree.
|
||||
"""
|
||||
assert (
|
||||
len(turn_suffixes) >= 1
|
||||
), "turn_suffixes must have at least 1 entry (for turn 2)"
|
||||
if assert_decode_cached_tokens is None:
|
||||
assert_decode_cached_tokens = default_decode_cache_assert
|
||||
|
||||
n = len(first_turn_input_ids)
|
||||
num_turns = 1 + len(turn_suffixes)
|
||||
order = _interleave_order(n, branches_per_group)
|
||||
print(f"[{label}] {n} samples, {num_turns} turns, max_new_tokens={max_new_tokens}")
|
||||
|
||||
# Turn 1: populate cache, no assertion, no interleaving
|
||||
_flush_cache(base_url)
|
||||
results = _generate(
|
||||
base_url, first_turn_input_ids, max_new_tokens, return_logprob=True
|
||||
)
|
||||
assert len(results) == n
|
||||
|
||||
current_input = list(first_turn_input_ids)
|
||||
last_outputs = [r["output_ids"] for r in results]
|
||||
prev_input_lens = [len(first_turn_input_ids[i]) for i in range(n)]
|
||||
prev_output_lens = [len(last_outputs[i]) for i in range(n)]
|
||||
|
||||
# Turns 2..N: decode cache hits (interleaved if order is set)
|
||||
for t, suffixes in enumerate(turn_suffixes):
|
||||
current_input = [
|
||||
current_input[i] + last_outputs[i] + suffixes[i] for i in range(n)
|
||||
]
|
||||
results = _generate_maybe_interleaved(
|
||||
base_url, current_input, max_new_tokens, order
|
||||
)
|
||||
assert len(results) == n
|
||||
|
||||
for i, result in enumerate(results):
|
||||
assert_decode_cached_tokens(
|
||||
result,
|
||||
prev_input_lens[i],
|
||||
prev_output_lens[i],
|
||||
f"{label}[turn{t + 1}][{i}]",
|
||||
)
|
||||
|
||||
last_outputs = [r["output_ids"] for r in results]
|
||||
prev_input_lens = [len(current_input[i]) for i in range(n)]
|
||||
prev_output_lens = [len(last_outputs[i]) for i in range(n)]
|
||||
|
||||
# Replay last turn
|
||||
replay_ids = [current_input[i] + results[i]["output_ids"] for i in range(n)]
|
||||
output_lps = [_extract_output_logprobs(r) for r in results]
|
||||
|
||||
_replay_and_compare_kl(
|
||||
base_url,
|
||||
model_name,
|
||||
kl_threshold,
|
||||
replay_ids,
|
||||
output_lps,
|
||||
label=label,
|
||||
batch_size=replay_batch_size,
|
||||
)
|
||||
Reference in New Issue
Block a user