[3/N] HiSparse: host-resident sparse-MLA decode hot-buffering (#53781)
Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Signed-off-by: Lucas Wilkinson <lwilkinson@neuralmagic.com> Signed-off-by: Matthew Bonanni <matthew@neuralmagic.com> Signed-off-by: Nicolò Lucchesi <nicolo.lucchesi@mistral.ai> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Lucas Wilkinson <lwilkinson@neuralmagic.com> Co-authored-by: Nicolò Lucchesi <nicolo.lucchesi@mistral.ai> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com>
This commit is contained in:
co-authored by
OpenAI Codex
Lucas Wilkinson
Lucas Wilkinson
Nicolò Lucchesi
OpenAI Codex
Claude Opus 5
Lucas Wilkinson
parent
120ec4ebd2
commit
d43bb2f37f
@@ -476,6 +476,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
|
||||
|
||||
if(VLLM_GPU_LANG STREQUAL "CUDA")
|
||||
list(APPEND VLLM_STABLE_EXT_SRC
|
||||
"csrc/libtorch_stable/hisparse_kernels.cu"
|
||||
"csrc/libtorch_stable/attention/dcp_utils/dcp_direct_a2a_lse_reduce.cu"
|
||||
"csrc/libtorch_stable/attention/dcp_utils/dcp_direct_kv_gather.cu"
|
||||
"csrc/libtorch_stable/attention/dcp_utils/dcp_direct_q_gather.cu")
|
||||
|
||||
@@ -1458,7 +1458,11 @@ __global__ void cp_gather_and_upconvert_fp8_kv_cache_page(
|
||||
const int32_t block_size, const int32_t total_tokens,
|
||||
const int64_t block_table_stride, const int64_t cache_block_stride,
|
||||
const int64_t cache_entry_stride, const int64_t dst_entry_stride,
|
||||
const int32_t* __restrict__ seq_starts) {
|
||||
const int32_t* __restrict__ seq_starts,
|
||||
const uint8_t* __restrict__ host_cache,
|
||||
const int32_t* __restrict__ host_row_ids,
|
||||
const int32_t* __restrict__ device_row_ids,
|
||||
const int64_t host_entry_stride) {
|
||||
constexpr int32_t warps_per_cta = 16;
|
||||
__shared__ GatherPageTask page;
|
||||
__shared__ int32_t physical_block;
|
||||
@@ -1484,8 +1488,22 @@ __global__ void cp_gather_and_upconvert_fp8_kv_cache_page(
|
||||
page_token < page.page_token_end; page_token += warps_per_cta) {
|
||||
const int32_t output_token =
|
||||
page.output_token_begin + page_token - page.page_token_begin;
|
||||
const uint8_t* token_ptr = src_cache + physical_block * cache_block_stride +
|
||||
page_token * cache_entry_stride;
|
||||
const int32_t source_row = physical_block * block_size + page_token;
|
||||
const uint8_t* token_ptr;
|
||||
if (device_row_ids == nullptr) {
|
||||
token_ptr = src_cache + physical_block * cache_block_stride +
|
||||
page_token * cache_entry_stride;
|
||||
} else {
|
||||
const int32_t device_row = device_row_ids[source_row];
|
||||
if (device_row >= 0) {
|
||||
token_ptr = src_cache + (device_row / block_size) * cache_block_stride +
|
||||
(device_row % block_size) * cache_entry_stride;
|
||||
} else {
|
||||
token_ptr =
|
||||
host_cache +
|
||||
static_cast<int64_t>(host_row_ids[source_row]) * host_entry_stride;
|
||||
}
|
||||
}
|
||||
__nv_bfloat16* dst_ptr = dst + output_token * dst_entry_stride;
|
||||
gather_and_upconvert_fp8_token(token_ptr, dst_ptr, lane_id);
|
||||
}
|
||||
@@ -1694,7 +1712,10 @@ void cp_gather_and_upconvert_fp8_kv_cache(
|
||||
torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
|
||||
torch::stable::Tensor const& workspace_starts, // [BATCH]
|
||||
int64_t batch_size,
|
||||
std::optional<torch::stable::Tensor> seq_starts = std::nullopt) {
|
||||
std::optional<torch::stable::Tensor> seq_starts = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> host_cache = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> host_row_ids = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> device_row_ids = std::nullopt) {
|
||||
torch::stable::accelerator::DeviceGuard device_guard(
|
||||
src_cache.get_device_index());
|
||||
const cudaStream_t stream = get_current_cuda_stream();
|
||||
@@ -1714,6 +1735,12 @@ void cp_gather_and_upconvert_fp8_kv_cache(
|
||||
"seq_starts must be int32");
|
||||
}
|
||||
|
||||
const bool has_host_rows = host_cache.has_value();
|
||||
STD_TORCH_CHECK(
|
||||
has_host_rows == host_row_ids.has_value() &&
|
||||
has_host_rows == device_row_ids.has_value(),
|
||||
"host_cache, host_row_ids, and device_row_ids must be provided together");
|
||||
|
||||
STD_TORCH_CHECK(src_cache.device() == dst.device(),
|
||||
"src_cache and dst must be on the same device");
|
||||
STD_TORCH_CHECK(src_cache.device() == block_table.device(),
|
||||
@@ -1724,6 +1751,43 @@ void cp_gather_and_upconvert_fp8_kv_cache(
|
||||
STD_TORCH_CHECK(src_cache.device() == seq_starts.value().device(),
|
||||
"src_cache and seq_starts must be on the same device");
|
||||
}
|
||||
const uint8_t* host_cache_ptr = nullptr;
|
||||
const int32_t* host_row_ids_ptr = nullptr;
|
||||
const int32_t* device_row_ids_ptr = nullptr;
|
||||
int64_t host_entry_stride = 0;
|
||||
if (has_host_rows) {
|
||||
auto const& host = host_cache.value();
|
||||
auto const& host_rows = host_row_ids.value();
|
||||
auto const& device_rows = device_row_ids.value();
|
||||
STD_TORCH_CHECK(
|
||||
host.device().is_cpu() &&
|
||||
host.scalar_type() == torch::headeronly::ScalarType::Byte &&
|
||||
host.dim() == 2 && host.is_contiguous() &&
|
||||
host.size(1) == src_cache.size(2),
|
||||
"host_cache must be a contiguous uint8 CPU row matrix matching "
|
||||
"src_cache");
|
||||
cudaPointerAttributes attributes{};
|
||||
const cudaError_t pointer_status =
|
||||
cudaPointerGetAttributes(&attributes, host.const_data_ptr());
|
||||
if (pointer_status != cudaSuccess) {
|
||||
cudaGetLastError();
|
||||
}
|
||||
STD_TORCH_CHECK(
|
||||
pointer_status == cudaSuccess && attributes.type == cudaMemoryTypeHost,
|
||||
"host_cache must be CUDA-accessible pinned memory");
|
||||
STD_TORCH_CHECK(
|
||||
host_rows.is_cuda() && device_rows.is_cuda() &&
|
||||
host_rows.scalar_type() == torch::headeronly::ScalarType::Int &&
|
||||
device_rows.scalar_type() == torch::headeronly::ScalarType::Int &&
|
||||
host_rows.is_contiguous() && device_rows.is_contiguous() &&
|
||||
host_rows.numel() == device_rows.numel() && host_rows.dim() <= 2 &&
|
||||
device_rows.dim() <= 2,
|
||||
"host/device row maps must be matching contiguous CUDA int32 tensors");
|
||||
host_cache_ptr = reinterpret_cast<const uint8_t*>(host.const_data_ptr());
|
||||
host_row_ids_ptr = host_rows.const_data_ptr<int32_t>();
|
||||
device_row_ids_ptr = device_rows.const_data_ptr<int32_t>();
|
||||
host_entry_stride = host.stride(0);
|
||||
}
|
||||
auto dtype = src_cache.scalar_type();
|
||||
STD_TORCH_CHECK(
|
||||
dtype == torch::headeronly::ScalarType::Byte || // uint8
|
||||
@@ -1766,7 +1830,8 @@ void cp_gather_and_upconvert_fp8_kv_cache(
|
||||
workspace_starts.const_data_ptr<int32_t>(),
|
||||
static_cast<int32_t>(batch_size), block_size, total_tokens,
|
||||
block_table_stride, cache_block_stride, cache_entry_stride,
|
||||
dst_entry_stride, seq_starts_ptr);
|
||||
dst_entry_stride, seq_starts_ptr, host_cache_ptr, host_row_ids_ptr,
|
||||
device_row_ids_ptr, host_entry_stride);
|
||||
}
|
||||
|
||||
void cp_gather_and_upconvert_nvfp4_kv_cache(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -661,6 +661,54 @@ void concat_and_cache_mla_grouped(
|
||||
std::optional<torch::stable::Tensor> kv_scales,
|
||||
const std::string& kv_cache_dtype);
|
||||
|
||||
#ifndef USE_ROCM
|
||||
// HiSparse kernels use raw PTX in the row copy; CUDA-only.
|
||||
void hisparse_resolve_residency(
|
||||
torch::stable::Tensor const& host_cache, torch::stable::Tensor& hot_cache,
|
||||
torch::stable::Tensor const& hot_block_table,
|
||||
torch::stable::Tensor const& global_indices,
|
||||
torch::stable::Tensor& hot_indices,
|
||||
torch::stable::Tensor& device_global_indices,
|
||||
torch::stable::Tensor& lru_slots,
|
||||
std::optional<torch::stable::Tensor> const& request_state_indices,
|
||||
int64_t region_stride,
|
||||
std::optional<torch::stable::Tensor> const& miss_mask,
|
||||
std::optional<torch::stable::Tensor> const& attention_indices,
|
||||
int64_t attention_block_stride,
|
||||
std::optional<torch::stable::Tensor> const& request_ids,
|
||||
std::optional<torch::stable::Tensor> const& source_block_table,
|
||||
int64_t source_block_size,
|
||||
std::optional<torch::stable::Tensor> const& resolved_global_indices,
|
||||
std::optional<torch::stable::Tensor> const& valid_counts,
|
||||
std::optional<torch::stable::Tensor> const& swap_host_physical_rows,
|
||||
std::optional<torch::stable::Tensor> const& swap_device_physical_rows,
|
||||
std::optional<torch::stable::Tensor> const& swap_counts,
|
||||
std::optional<torch::stable::Tensor> const& resident_block_table,
|
||||
int64_t resident_block_size, int64_t resident_null_block);
|
||||
|
||||
void hisparse_invalidate_written_slots(
|
||||
torch::stable::Tensor& device_global_indices,
|
||||
torch::stable::Tensor const& request_state_indices,
|
||||
torch::stable::Tensor const& req_id_per_token,
|
||||
torch::stable::Tensor const& written_slots);
|
||||
|
||||
void hisparse_gather_plan(
|
||||
torch::stable::Tensor const& host_cache, torch::stable::Tensor& hot_cache,
|
||||
torch::stable::Tensor const& global_indices,
|
||||
torch::stable::Tensor const& hot_indices,
|
||||
torch::stable::Tensor const& miss_mask,
|
||||
std::optional<torch::stable::Tensor> const& request_state_indices,
|
||||
std::optional<torch::stable::Tensor> const& attention_indices,
|
||||
int64_t attention_block_stride);
|
||||
|
||||
void hisparse_gather_compact(torch::stable::Tensor const& host_cache,
|
||||
torch::stable::Tensor& hot_cache,
|
||||
torch::stable::Tensor const& miss_global_indices,
|
||||
torch::stable::Tensor const& miss_hot_indices,
|
||||
torch::stable::Tensor const& miss_counts);
|
||||
|
||||
#endif // !USE_ROCM
|
||||
|
||||
// NOTE: k_pe and kv_c order is flipped compared to concat_and_cache_mla
|
||||
void concat_and_cache_mla_rope_fused(
|
||||
torch::stable::Tensor& positions, torch::stable::Tensor& q_pe,
|
||||
@@ -704,7 +752,10 @@ void cp_gather_and_upconvert_fp8_kv_cache(
|
||||
torch::stable::Tensor const& block_table, // [BATCH, BLOCK_INDICES]
|
||||
torch::stable::Tensor const& workspace_starts, // [BATCH]
|
||||
int64_t batch_size,
|
||||
std::optional<torch::stable::Tensor> seq_starts = std::nullopt);
|
||||
std::optional<torch::stable::Tensor> seq_starts = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> host_cache = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> host_row_ids = std::nullopt,
|
||||
std::optional<torch::stable::Tensor> device_row_ids = std::nullopt);
|
||||
|
||||
// Gather and upconvert an nvfp4_ds_mla KV cache to a BF16 workspace
|
||||
void cp_gather_and_upconvert_nvfp4_kv_cache(
|
||||
|
||||
@@ -987,6 +987,57 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C_cache_ops, ops) {
|
||||
" Tensor? kv_scales=None,"
|
||||
" str kv_cache_dtype='auto') -> ()");
|
||||
|
||||
#ifndef USE_ROCM
|
||||
ops.def(
|
||||
"hisparse_resolve_residency(Tensor host_cache,"
|
||||
" Tensor! hot_cache,"
|
||||
" Tensor hot_block_table,"
|
||||
" Tensor global_indices,"
|
||||
" Tensor! hot_indices,"
|
||||
" Tensor! device_global_indices,"
|
||||
" Tensor! lru_slots,"
|
||||
" Tensor? request_state_indices,"
|
||||
" int region_stride,"
|
||||
" Tensor(a!)? miss_mask=None,"
|
||||
" Tensor(b!)? attention_indices=None,"
|
||||
" int attention_block_stride=0,"
|
||||
" Tensor? request_ids=None,"
|
||||
" Tensor? source_block_table=None,"
|
||||
" int source_block_size=0,"
|
||||
" Tensor(c!)? resolved_global_indices=None,"
|
||||
" Tensor(d!)? valid_counts=None,"
|
||||
" Tensor(e!)? swap_host_physical_rows=None,"
|
||||
" Tensor(f!)? swap_device_physical_rows=None,"
|
||||
" Tensor(g!)? swap_counts=None,"
|
||||
" Tensor? resident_block_table=None,"
|
||||
" int resident_block_size=0,"
|
||||
" int resident_null_block=0) -> ()");
|
||||
|
||||
ops.def(
|
||||
"hisparse_invalidate_written_slots(Tensor! device_global_indices,"
|
||||
" Tensor request_state_indices,"
|
||||
" Tensor req_id_per_token,"
|
||||
" Tensor written_slots) -> ()");
|
||||
|
||||
ops.def(
|
||||
"hisparse_gather_plan(Tensor host_cache,"
|
||||
" Tensor! hot_cache,"
|
||||
" Tensor global_indices,"
|
||||
" Tensor hot_indices,"
|
||||
" Tensor miss_mask,"
|
||||
" Tensor? request_state_indices,"
|
||||
" Tensor(a!)? attention_indices=None,"
|
||||
" int attention_block_stride=0) -> ()");
|
||||
|
||||
ops.def(
|
||||
"hisparse_gather_compact(Tensor host_cache,"
|
||||
" Tensor! hot_cache,"
|
||||
" Tensor miss_global_indices,"
|
||||
" Tensor miss_hot_indices,"
|
||||
" Tensor miss_counts) -> ()");
|
||||
|
||||
#endif // !USE_ROCM
|
||||
|
||||
// Rotate Q and K, then write to kv cache for MLA
|
||||
ops.def(
|
||||
"concat_and_cache_mla_rope_fused("
|
||||
@@ -1023,7 +1074,8 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C_cache_ops, ops) {
|
||||
ops.def(
|
||||
"cp_gather_and_upconvert_fp8_kv_cache(Tensor src_cache, Tensor! dst, "
|
||||
"Tensor block_table, Tensor workspace_starts, int batch_size, Tensor? "
|
||||
"seq_starts) -> ()");
|
||||
"seq_starts, Tensor? host_cache=None, Tensor? host_row_ids=None, Tensor? "
|
||||
"device_row_ids=None) -> ()");
|
||||
|
||||
ops.def(
|
||||
"cp_gather_and_upconvert_nvfp4_kv_cache(Tensor src_cache, Tensor! dst, "
|
||||
@@ -1091,6 +1143,15 @@ STABLE_TORCH_LIBRARY_IMPL(_C_cache_ops, CUDA, ops) {
|
||||
ops.impl("concat_and_cache_mla", TORCH_BOX(&concat_and_cache_mla));
|
||||
ops.impl("concat_and_cache_mla_grouped",
|
||||
TORCH_BOX(&concat_and_cache_mla_grouped));
|
||||
|
||||
#ifndef USE_ROCM
|
||||
ops.impl("hisparse_resolve_residency",
|
||||
TORCH_BOX(&hisparse_resolve_residency));
|
||||
ops.impl("hisparse_invalidate_written_slots",
|
||||
TORCH_BOX(&hisparse_invalidate_written_slots));
|
||||
ops.impl("hisparse_gather_plan", TORCH_BOX(&hisparse_gather_plan));
|
||||
ops.impl("hisparse_gather_compact", TORCH_BOX(&hisparse_gather_compact));
|
||||
#endif // !USE_ROCM
|
||||
ops.impl("concat_and_cache_mla_rope_fused",
|
||||
TORCH_BOX(&concat_and_cache_mla_rope_fused));
|
||||
ops.impl("convert_fp8", TORCH_BOX(&convert_fp8));
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# HiSparse local KV offload architecture
|
||||
|
||||
Status: experimental
|
||||
|
||||
## The short version
|
||||
|
||||
There are three different jobs:
|
||||
|
||||
1. The normal KV cache system manages GPU block pools and tables.
|
||||
2. `HiSparseCoordinator` manages logical host blocks, source-prefix identity, and
|
||||
host/GPU residency transitions.
|
||||
3. `HiSparseConnector` carries residency work between scheduler and worker;
|
||||
`HiSparseWorker` coordinates transfers, while per-cache
|
||||
`HiSparseRuntime` objects own host/hot views and GPU replacement state.
|
||||
|
||||
Neither worker-side object allocates or frees logical blocks. HMA provides the
|
||||
GPU allocation shared by the resident and hot groups; it does not manage CPU
|
||||
memory or KV identity.
|
||||
|
||||
```text
|
||||
request ────► HiSparseCoordinator ── source blocks + residency policy
|
||||
│
|
||||
├──► KV cache manager ── resident/hot GPU leases (HMA)
|
||||
│
|
||||
└──► HiSparseConnector ── host bytes + copies + GPU LRU
|
||||
```
|
||||
|
||||
This is a local KV connector. When P/D or another offload connector is also
|
||||
configured, `MultiConnector` composes it with `HiSparseConnector`.
|
||||
|
||||
`host_pool_gib` is configured on `HiSparseConnector` and is the usable
|
||||
host-cache capacity per data-parallel replica, not a node-wide memory budget.
|
||||
Tensor-parallel ranks hold replicated views of that logical cache. Those views
|
||||
may use private per-rank backing or one shared
|
||||
physical allocation without changing the configured capacity. Physical host
|
||||
memory consumption is therefore topology- and implementation-dependent. The
|
||||
realized capacity may be slightly smaller because the budget is rounded down
|
||||
to complete host blocks.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Thing | Owner | What “owner” means |
|
||||
| --- | --- | --- |
|
||||
| HiSparse source and prefix identity | `HiSparseCoordinator` | maps tokens to logical host blocks |
|
||||
| Resident GPU block leases | normal KV cache manager | allocates and frees HMA blocks |
|
||||
| Resident block tables | normal KV cache manager | tells attention where resident pages are |
|
||||
| Residency transitions | `HiSparseCoordinator` | plans spill-before-free transactions |
|
||||
| Logical host block allocation | `HiSparseCoordinator` | owns the separate CPU block pool and its lifecycle |
|
||||
| Pinned host-pool lifecycle | `HiSparseWorker` | worker-wide backing and teardown |
|
||||
| Per-cache host view and hot contents | `HiSparseRuntime` | binds host/hot storage and fills cache-manager-provided hot leases |
|
||||
| Hot row map and LRU | `HiSparseRuntime` | resolves hits and chooses victims on GPU |
|
||||
| Resident-cache route | `HiSparseCacheHandle` | exposes resident or host/hot resolution to attention |
|
||||
| Sparse attention | attention backend | consumes a device cache and physical row IDs |
|
||||
| HMA | allocator | provides GPU capacity; owns no KV meaning |
|
||||
|
||||
The key distinction is logical allocation versus contents. `HiSparseCoordinator`
|
||||
owns host block IDs and their request/prefix associations. `HiSparseWorker` and
|
||||
its per-cache runtimes own the corresponding bytes. The normal cache manager
|
||||
sees only device pools.
|
||||
The source group has `block_pool_id=None`; device-pool consumers must narrow it
|
||||
before indexing, so host ownership cannot masquerade as a numeric GPU pool.
|
||||
|
||||
## Code boundary
|
||||
|
||||
```text
|
||||
scheduler process worker process
|
||||
|
||||
HiSparseConnector HiSparseConnector
|
||||
└─ HiSparseCoordinator └─ HiSparseWorker
|
||||
│ │
|
||||
│ connector metadata ├─ host bytes
|
||||
│ - page transfers ├─ copy scheduling
|
||||
│ - block-table replacements └─ per-layer hot state
|
||||
└───────────────────────────────────────────────►│
|
||||
◄──────── connector worker metadata ─────────────┘
|
||||
enqueued and completed transfer IDs
|
||||
```
|
||||
|
||||
The command travels in `kv_connector_metadata`; transfer updates return in
|
||||
`KVConnectorOutput.kv_connector_worker_meta`. The model runner does not
|
||||
interpret page transfers. Enqueue acknowledgements let the scheduler release
|
||||
source leases in stream order; completion acknowledgements publish the copied
|
||||
host pages.
|
||||
|
||||
## Resident device pages
|
||||
|
||||
Resident pages are intentionally outside `HiSparseRuntime`.
|
||||
|
||||
KV-cache initialization binds cache-manager allocations to the attention-facing
|
||||
`HiSparseCacheHandle` before constructing `HiSparseWorker`. That same
|
||||
handle's runtime retains the resident source index needed by a transfer plan.
|
||||
There is no second resident object or registration wrapper.
|
||||
|
||||
```text
|
||||
KV cache setup
|
||||
│
|
||||
├─ bind resident allocation ──► HiSparseCacheHandle
|
||||
│ cache + block table + slot mapping
|
||||
│
|
||||
├─ bind host/hot allocation ──► HiSparseRuntime
|
||||
│ host + hot + GPU LRU
|
||||
│
|
||||
└─ register cache handles ────► HiSparseWorker
|
||||
step-level transfers
|
||||
|
||||
HiSparseWorker registers the same HiSparseCacheHandle objects directly
|
||||
```
|
||||
|
||||
Attention construction links each layer to the most recent layer that actually
|
||||
owns an indexer. This releases a follower's duplicate LRU tensors before GPU
|
||||
memory profiling. Cache binding only attaches storage; it does not infer
|
||||
semantic groups from the physical packed-tensor order. The construction cursor
|
||||
is discarded with the worker's pinned state.
|
||||
|
||||
Every HiSparse decode batch uses the same fused resolver. It checks resident
|
||||
pages first, then hot rows, then pinned host memory. A resident hit exits inside
|
||||
the kernel before hot-LRU lookup or host copying; there is no framework-level
|
||||
residency route or separate CUDA graph. No CPU decision is added to the decode
|
||||
path. The resolver consumes the existing graph-stable request mapping from
|
||||
attention metadata; neither the worker nor individual cache handles keep a
|
||||
duplicate mapping.
|
||||
|
||||
Speculative decoding resolves and consumes each verification step in order.
|
||||
Each step receives distinct replayable plan rows while sharing the request's
|
||||
hot-cache state, so a later step cannot reuse a hot row before an earlier step
|
||||
has consumed it.
|
||||
|
||||
## P/D import target
|
||||
|
||||
The decoder chooses the landing target once per request from the normal cache
|
||||
admission calculation. If the complete imported prefix fits the device pools,
|
||||
NIXL transfers it directly into resident GPU pages. Otherwise, if the fixed
|
||||
host-backed GPU footprint and host source blocks fit, the request imports into
|
||||
the host tier. There is no context-length threshold or other heuristic, and a
|
||||
request waiting for capacity retains its choice across admission retries.
|
||||
|
||||
A host import reads through a bounded decoder-GPU staging pool before copying
|
||||
into registered host memory. Pages needed immediately are mirrored into their
|
||||
resident destinations during that copy. Both landing targets then use the same
|
||||
fused decode resolver described above.
|
||||
|
||||
## Indexer KV offloading
|
||||
|
||||
HiSparse does not keep a private CPU copy of indexer KV. The indexer remains a
|
||||
normal prefix-cacheable GPU cache group. If `OffloadingConnector` is configured
|
||||
with HiSparse, it stores and restores that group through the generic KV
|
||||
offloading path; HiSparse continues to own only the sparse MLA host tier.
|
||||
|
||||
The two prefix sources can have different hit lengths. When the HiSparse host
|
||||
prefix extends beyond the GPU-resident indexer prefix, the scheduler asks
|
||||
`OffloadingConnector` to restore only the missing indexer suffix, capped at the
|
||||
host prefix boundary. If that suffix is unavailable, all groups fall back to
|
||||
the shorter prefix they share. NIXL P/D transfers continue to place indexer KV
|
||||
directly in its GPU group.
|
||||
|
||||
## Spill transaction
|
||||
|
||||
A resident block cannot be reused until its contents have been handed to the
|
||||
worker.
|
||||
|
||||
```text
|
||||
HiSparseCoordinator HiSparseWorker
|
||||
│ │
|
||||
│ pin source and destination leases │
|
||||
│── SparseKVPageTransfer ─────────────►│
|
||||
│ │ enqueue GPU-to-host copy
|
||||
│◄── enqueued transfer ID ────────────│
|
||||
│ replace resident table entry │
|
||||
│ release resident lease to HMA │
|
||||
│ │ copy reaches its event
|
||||
│◄── completed transfer ID ───────────│
|
||||
│ mark host page valid │
|
||||
│ release destination host lease │
|
||||
```
|
||||
|
||||
“Enqueued” means the copy has entered the worker stream. Stream ordering makes
|
||||
it safe to reuse the resident GPU block for later work, but the host page is
|
||||
not yet published. “Completed” means the worker has observed the copy's event;
|
||||
only then does the coordinator publish the host page for prefix reuse and
|
||||
release its destination lease. A host-write event separately protects direct
|
||||
CPU readers from writes already queued on the accelerator.
|
||||
|
||||
The worker transfer contains only its transfer ID and physical copy
|
||||
coordinates. Request identity and logical page state remain in the scheduler.
|
||||
|
||||
## Hot lookup and LRU
|
||||
|
||||
The NVIDIA CUDA path keeps replacement entirely on the accelerator:
|
||||
|
||||
```text
|
||||
top-K logical positions
|
||||
│
|
||||
▼
|
||||
resident page? ── yes ──► resident physical row
|
||||
│ no
|
||||
▼
|
||||
hot row? ──────── yes ──► existing hot physical row + update GPU LRU
|
||||
│ no
|
||||
▼
|
||||
choose GPU LRU victim ──► copy pinned host row ──► hot physical row
|
||||
```
|
||||
|
||||
ROCm is not currently supported because the fused HiSparse cache operations are
|
||||
implemented only by CUDA kernels. A future platform-specific worker may provide
|
||||
the same command, output, and cache-resolution boundaries.
|
||||
|
||||
## Main classes
|
||||
|
||||
| Class | Inherits / implements | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| `HiSparseCoordinator` | plain scheduler component | host allocation, source prefixes, resident leases, and spill state machine |
|
||||
| `HiSparseConnector` | `KVConnectorBase_V1`, `SupportsHMA` | scheduler/worker metadata and lifecycle boundary |
|
||||
| `HiSparseResidentManager` | `SingleTypeKVCacheManager` | normal block-pool bookkeeping with host-backed holes |
|
||||
| `PagedCacheView` | immutable data object | shared resident/hot HMA tensor binding |
|
||||
| `HiSparseWorker` | connector-owned worker component | worker-wide transfer scheduling and host-pool lifecycle |
|
||||
| `HiSparseRuntime` | plain worker-owned component | per-cache host/hot tensors, GPU LRU, and fused resolution |
|
||||
| `HiSparseCacheHandle` | plain attention component | resident view and fused cache resolution |
|
||||
| `SparseKVOffloadCommand` | dataclass | opaque scheduler-to-worker work |
|
||||
|
||||
## Performance invariants
|
||||
|
||||
- Resident hits bypass hot-LRU lookup and host copies inside the fused resolver.
|
||||
- Hot lookup, victim selection, and LRU updates stay on the GPU.
|
||||
- A hot miss still copies directly from registered pinned host memory.
|
||||
- Top-K resolution stays inside the attention invocation and remains graph
|
||||
capturable.
|
||||
- Compatible layers still share one miss plan.
|
||||
- Index-sharing followers release their private LRU state before memory sizing.
|
||||
- Indexer KV is untouched by HiSparse unless a generic KV offloader is configured.
|
||||
- Resident and hot leases can still share one packed HMA allocation.
|
||||
- No device scalar readback or CPU/device metadata round trip is added.
|
||||
- The abstraction wraps the fused kernel; it does not add another kernel
|
||||
launch.
|
||||
- When HiSparse is disabled, the scheduler does not construct an offload
|
||||
command or empty update table.
|
||||
|
||||
## What remains platform-specific
|
||||
|
||||
The command/result and attention-layer boundaries can be shared. The host
|
||||
allocator, copy implementation, hot layout, and replacement policy should stay
|
||||
platform-specific. NVIDIA uses the current accelerator LRU and fused host/hot
|
||||
kernel. ROCm is not currently supported; AMD or other accelerator backends can
|
||||
implement their own worker without forcing NVIDIA's policy into the shared
|
||||
boundary.
|
||||
@@ -364,6 +364,12 @@ def main() -> None:
|
||||
type=int,
|
||||
help="Maximum number of concurrent requests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--request-timeout-seconds",
|
||||
type=float,
|
||||
default=600,
|
||||
help="Timeout for each request, including time waiting for a connection",
|
||||
)
|
||||
parser.add_argument("--save-results", type=str, help="Save results to JSON file")
|
||||
|
||||
args = parser.parse_args()
|
||||
@@ -377,6 +383,7 @@ def main() -> None:
|
||||
temperature=args.temperature,
|
||||
seed=args.seed,
|
||||
max_concurrency=args.max_concurrency,
|
||||
request_timeout_seconds=args.request_timeout_seconds,
|
||||
)
|
||||
|
||||
# Print results to terminal
|
||||
|
||||
@@ -217,7 +217,8 @@ def test_flashinfer_mla_dense_decode_unified_slot_view():
|
||||
assert (out_ref - out_view).abs().max().item() == 0.0
|
||||
|
||||
|
||||
def test_flashmla_fp8_sparse_decode_unified_slot_view():
|
||||
@pytest.mark.parametrize("next_n", [1, 2, 4])
|
||||
def test_flashmla_fp8_sparse_decode_unified_slot_view(next_n: int):
|
||||
"""FlashMLA fp8 sparse decode (DeepSeek V3.2/V4 DSA path) must read a
|
||||
unified-slot block-major view bit-identically to a contiguous fp8_ds_mla
|
||||
cache, with finite nonzero output."""
|
||||
@@ -240,7 +241,10 @@ def test_flashmla_fp8_sparse_decode_unified_slot_view():
|
||||
n_layers = 3
|
||||
layer = 1
|
||||
|
||||
q = torch.randn(batch, 1, h_q, head_dim, device=dev, dtype=torch.bfloat16) * 0.1
|
||||
q = (
|
||||
torch.randn(batch, next_n, h_q, head_dim, device=dev, dtype=torch.bfloat16)
|
||||
* 0.1
|
||||
)
|
||||
|
||||
# Structurally valid fp8 ds_mla payload: 512B fp8 + 16B f32 scales + 128B
|
||||
# bf16 rope (random bytes corrupt the scale region and yield NaNs).
|
||||
@@ -273,13 +277,13 @@ def test_flashmla_fp8_sparse_decode_unified_slot_view():
|
||||
|
||||
# Sparse indices: each batch uses its own disjoint blocks.
|
||||
blocks_per_batch = num_blocks // batch
|
||||
idx = torch.full((batch, 1, topk), -1, device=dev, dtype=torch.int32)
|
||||
idx = torch.full((batch, next_n, topk), -1, device=dev, dtype=torch.int32)
|
||||
for b in range(batch):
|
||||
slots: list[int] = []
|
||||
for blk in range(b * blocks_per_batch, (b + 1) * blocks_per_batch):
|
||||
slots.extend(blk * page + off for off in range(page))
|
||||
slots_t = torch.tensor(slots[:topk], device=dev, dtype=torch.int32)
|
||||
idx[b, 0, : slots_t.numel()] = slots_t
|
||||
idx[b, :, : slots_t.numel()] = slots_t
|
||||
|
||||
def run(kc):
|
||||
meta, num_splits = fm.get_mla_metadata()
|
||||
|
||||
@@ -316,6 +316,48 @@ def test_cp_gather_fp8_shuffled_blocks():
|
||||
assert torch.equal(dst[:, NOPE_DIM:], expected[:, NOPE_DIM:])
|
||||
|
||||
|
||||
def test_cp_gather_fp8_mixes_resident_and_pinned_host_rows():
|
||||
seq_lens = [7, 5]
|
||||
block_size = 4
|
||||
(
|
||||
cache,
|
||||
block_table,
|
||||
workspace_starts,
|
||||
num_reqs,
|
||||
total_tokens,
|
||||
expected,
|
||||
) = _build_test_case(seq_lens, block_size)
|
||||
|
||||
device_cache = cache.clone()
|
||||
host_cache = cache.cpu().pin_memory().view(-1, ENTRY_BYTES)
|
||||
device_rows = torch.arange(host_cache.shape[0], dtype=torch.int32, device="cuda")
|
||||
use_device = device_rows % 2 == 0
|
||||
host_rows = device_rows.clone()
|
||||
device_rows.masked_fill_(~use_device, -1)
|
||||
|
||||
host_cache[use_device.cpu()] = 0
|
||||
device_cache.view(-1, ENTRY_BYTES)[~use_device] = 0
|
||||
dst = torch.empty(
|
||||
total_tokens, NOPE_DIM + ROPE_DIM, dtype=torch.bfloat16, device="cuda"
|
||||
)
|
||||
|
||||
ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
device_cache,
|
||||
dst,
|
||||
block_table,
|
||||
workspace_starts,
|
||||
num_reqs,
|
||||
host_cache=host_cache,
|
||||
host_row_ids=host_rows,
|
||||
device_row_ids=device_rows,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(
|
||||
dst[:, :NOPE_DIM], expected[:, :NOPE_DIM], atol=1e-3, rtol=1e-2
|
||||
)
|
||||
assert torch.equal(dst[:, NOPE_DIM:], expected[:, NOPE_DIM:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gather_seq_lens,seq_starts",
|
||||
[
|
||||
|
||||
@@ -234,6 +234,76 @@ def test_fused_norm_rope(num_tokens: int, index_interleave: bool, mla_dtype: str
|
||||
assert (topk == -1).all(), "topk buffer not cleared on indexer layer"
|
||||
|
||||
|
||||
def test_fused_norm_rope_packed_indexer_block_stride():
|
||||
"""Indexer writes use their HMA block stride and independent slot map."""
|
||||
torch.manual_seed(7)
|
||||
dev = "cuda"
|
||||
num_tokens = block_size = 2
|
||||
pos = torch.arange(num_tokens, device=dev, dtype=torch.int64)
|
||||
q_c = torch.randn(num_tokens, Q_LORA, device=dev, dtype=torch.bfloat16)
|
||||
kv_c = torch.randn(num_tokens, KV_LORA, device=dev, dtype=torch.bfloat16)
|
||||
k_pe = torch.randn(num_tokens, ROPE_DIM, device=dev, dtype=torch.bfloat16)
|
||||
qw = torch.randn(Q_LORA, device=dev, dtype=torch.bfloat16)
|
||||
kvw = torch.randn(KV_LORA, device=dev, dtype=torch.bfloat16)
|
||||
ik = torch.randn(num_tokens, INDEX_HEAD_DIM, device=dev, dtype=torch.bfloat16)
|
||||
ikw = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
|
||||
ikb = torch.randn(INDEX_HEAD_DIM, device=dev, dtype=torch.float32)
|
||||
cos_sin = make_cos_sin(32, ROPE_DIM, dev)
|
||||
|
||||
idx_row = INDEX_HEAD_DIM + INDEX_HEAD_DIM // 128 * 4
|
||||
packed_block_stride = block_size * idx_row + 64
|
||||
backing = torch.zeros(2 * packed_block_stride, device=dev, dtype=torch.uint8)
|
||||
idx_cache = torch.as_strided(
|
||||
backing,
|
||||
(2, block_size, idx_row),
|
||||
(packed_block_stride, idx_row, 1),
|
||||
)
|
||||
mla_cache = torch.zeros(
|
||||
1,
|
||||
block_size,
|
||||
KV_LORA + ROPE_DIM,
|
||||
device=dev,
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
topk = torch.zeros(num_tokens, 8, device=dev, dtype=torch.int32)
|
||||
|
||||
K.fused_norm_rope(
|
||||
pos,
|
||||
q_c,
|
||||
qw,
|
||||
EPS,
|
||||
kv_c,
|
||||
kvw,
|
||||
EPS,
|
||||
k_pe,
|
||||
cos_sin,
|
||||
ik,
|
||||
ikw,
|
||||
ikb,
|
||||
EPS,
|
||||
cos_sin,
|
||||
topk,
|
||||
slot_mapping=torch.arange(num_tokens, device=dev, dtype=torch.int64),
|
||||
indexer_slot_mapping=torch.arange(
|
||||
block_size, block_size + num_tokens, device=dev, dtype=torch.int64
|
||||
),
|
||||
indexer_k_cache=idx_cache,
|
||||
mla_kv_cache=mla_cache,
|
||||
has_indexer=True,
|
||||
)
|
||||
|
||||
ik_ref = rope(layer_norm(ik, ikw, ikb), pos, cos_sin, interleave=False)
|
||||
q_ref, s_ref = ue8m0_quant(ik_ref)
|
||||
packed = idx_cache[1].reshape(-1)
|
||||
values = (
|
||||
packed[: block_size * INDEX_HEAD_DIM].view(FP8).view(block_size, INDEX_HEAD_DIM)
|
||||
)
|
||||
scales = packed[block_size * INDEX_HEAD_DIM :].view(torch.float32)
|
||||
assert_fp8(values, q_ref, "packed indexer-K fp8")
|
||||
torch.testing.assert_close(scales, s_ref, rtol=0, atol=0)
|
||||
assert (backing[block_size * idx_row : packed_block_stride] == 0).all()
|
||||
|
||||
|
||||
def test_fused_norm_rope_materializes_qk_without_local_cache_slots():
|
||||
"""DCP non-owner ranks still need valid query shards for query AllGather."""
|
||||
torch.manual_seed(7)
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
import torch
|
||||
|
||||
import vllm.model_executor.layers.sparse_attn_indexer as sparse_indexer
|
||||
import vllm.models.deepseek_v32.attention as deepseek_attention
|
||||
from vllm.config import CUDAGraphMode
|
||||
from vllm.models.deepseek_v32 import attention as deepseek_v32_attention
|
||||
from vllm.models.deepseek_v32.attention import DeepseekV32Attention
|
||||
@@ -16,6 +17,51 @@ INDEXER_LAYER = "model.layers.0.self_attn.indexer.k_cache"
|
||||
MLA_LAYER = "model.layers.0.self_attn.attn"
|
||||
|
||||
|
||||
def test_sparse_attention_refreshes_batch_state_inside_eager_segment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
metadata = SimpleNamespace(num_actual_tokens=1)
|
||||
|
||||
class BatchStateRefreshed(Exception):
|
||||
pass
|
||||
|
||||
def refresh_batch_state(attn_metadata):
|
||||
assert attn_metadata is metadata
|
||||
raise BatchStateRefreshed
|
||||
|
||||
layer = SimpleNamespace(
|
||||
indexer=None,
|
||||
skip_topk=True,
|
||||
layer_name=MLA_LAYER,
|
||||
impl=SimpleNamespace(
|
||||
prepare_for_batch=refresh_batch_state,
|
||||
record_logical_topk_ready=lambda: None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
deepseek_attention,
|
||||
"get_attention_context",
|
||||
lambda layer_name: (metadata, None, torch.empty(1), None),
|
||||
)
|
||||
|
||||
with pytest.raises(BatchStateRefreshed):
|
||||
DeepseekV32Attention._sparse_indexer_and_attn(
|
||||
layer,
|
||||
torch.empty(1, dtype=torch.long),
|
||||
torch.empty(1, 1),
|
||||
torch.empty(1, 1, 1),
|
||||
torch.empty(1, 1, 1),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
torch.empty(1, 1, 1),
|
||||
torch.empty(1, 1, 1),
|
||||
torch.empty(1, 1),
|
||||
)
|
||||
|
||||
|
||||
def make_indexer_metadata(
|
||||
*,
|
||||
num_decodes: int = 0,
|
||||
@@ -246,6 +292,10 @@ def test_deepseek_v32_dispatches_selected_mha(
|
||||
_fp8_query=fp8_query,
|
||||
_use_sparse_mha=lambda _: True,
|
||||
rotary_emb=lambda _positions, q: (q + 1, None),
|
||||
impl=SimpleNamespace(
|
||||
record_logical_topk_ready=lambda: None,
|
||||
prepare_for_batch=lambda _: None,
|
||||
),
|
||||
forward_impl=record_forward_impl,
|
||||
)
|
||||
q_nope = torch.randn(2, 1, 2)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from inspect import signature
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
@@ -76,8 +77,16 @@ def test_flashinfer_autotune_token_counts_are_bounded_and_deduplicated():
|
||||
assert token_counts == (32,)
|
||||
|
||||
|
||||
def test_flashinfer_autotune_uses_token_buckets_for_each_dummy_run():
|
||||
@pytest.mark.parametrize("skip_attn", [False, True])
|
||||
def test_flashinfer_autotune_uses_token_buckets_for_each_dummy_run(skip_attn):
|
||||
from vllm.v1.worker.gpu.model_runner import GPUModelRunner as V2Runner
|
||||
from vllm.v1.worker.gpu_model_runner import GPUModelRunner as V1Runner
|
||||
|
||||
runner = _make_runner([])
|
||||
dummy_run_signature = signature((V2Runner if skip_attn else V1Runner)._dummy_run)
|
||||
runner._dummy_run.side_effect = lambda **kwargs: dummy_run_signature.bind(
|
||||
None, **kwargs
|
||||
)
|
||||
max_buckets = (1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 8192)
|
||||
deferred_buckets = (1, 2, 4, 8, 16, 32, 64, 128)
|
||||
|
||||
@@ -93,7 +102,7 @@ def test_flashinfer_autotune_uses_token_buckets_for_each_dummy_run():
|
||||
) as get_buckets,
|
||||
patch("vllm.utils.flashinfer.autotune") as autotune,
|
||||
):
|
||||
_run_flashinfer_autotune_dummy_runs(runner)
|
||||
_run_flashinfer_autotune_dummy_runs(runner, skip_attn=skip_attn)
|
||||
|
||||
assert get_buckets.call_args_list == [call(8192), call(128)]
|
||||
assert autotune.call_args_list == [
|
||||
@@ -106,11 +115,13 @@ def test_flashinfer_autotune_uses_token_buckets_for_each_dummy_run():
|
||||
skip_eplb=True,
|
||||
is_profile=True,
|
||||
randomize_inputs=True,
|
||||
**({"skip_attn": True} if skip_attn else {}),
|
||||
),
|
||||
call(
|
||||
num_tokens=128,
|
||||
skip_eplb=True,
|
||||
is_profile=True,
|
||||
randomize_inputs=True,
|
||||
**({"skip_attn": True} if skip_attn else {}),
|
||||
),
|
||||
]
|
||||
|
||||
+66
-2
@@ -19,10 +19,12 @@ import vllm.config.vllm as vllm_config_module
|
||||
import vllm.envs as envs
|
||||
from vllm.compilation.backends import VllmBackend
|
||||
from vllm.config import (
|
||||
AttentionConfig,
|
||||
CacheConfig,
|
||||
CompilationConfig,
|
||||
DeviceConfig,
|
||||
EngramConfig,
|
||||
HiSparseConfig,
|
||||
KernelConfig,
|
||||
KVTransferConfig,
|
||||
ModelConfig,
|
||||
@@ -279,6 +281,62 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected):
|
||||
assert envs.VLLM_USE_V2_MODEL_RUNNER is expected
|
||||
|
||||
|
||||
def test_hisparse_requires_v2_model_runner():
|
||||
config = object.__new__(VllmConfig)
|
||||
config.attention_config = AttentionConfig(hisparse_config=HiSparseConfig())
|
||||
|
||||
with patch.object(envs, "VLLM_USE_V2_MODEL_RUNNER", None):
|
||||
assert config.use_v2_model_runner
|
||||
with (
|
||||
patch.object(envs, "VLLM_USE_V2_MODEL_RUNNER", False),
|
||||
pytest.raises(ValueError, match="requires Model Runner V2"),
|
||||
):
|
||||
_ = config.use_v2_model_runner
|
||||
|
||||
|
||||
def test_hisparse_rejects_decode_context_parallelism(monkeypatch):
|
||||
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
|
||||
monkeypatch.setattr(current_platform, "device_count", lambda: 2)
|
||||
with pytest.raises(ValueError, match="decode context parallelism"):
|
||||
VllmConfig(
|
||||
attention_config=AttentionConfig(hisparse_config=HiSparseConfig()),
|
||||
parallel_config=ParallelConfig(
|
||||
tensor_parallel_size=2,
|
||||
decode_context_parallel_size=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_rejects_pipeline_parallelism(monkeypatch):
|
||||
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
|
||||
monkeypatch.setattr(current_platform, "device_count", lambda: 2)
|
||||
with pytest.raises(ValueError, match="pipeline parallelism"):
|
||||
VllmConfig(
|
||||
attention_config=AttentionConfig(hisparse_config=HiSparseConfig()),
|
||||
parallel_config=ParallelConfig(pipeline_parallel_size=2),
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_rejects_disabled_hybrid_kv_cache_manager(monkeypatch):
|
||||
monkeypatch.setattr(current_platform, "is_cuda", lambda: True)
|
||||
monkeypatch.setattr("vllm.config.vllm.HAS_TRITON", True)
|
||||
with pytest.raises(ValueError, match="requires the hybrid KV cache manager"):
|
||||
VllmConfig(
|
||||
attention_config=AttentionConfig(hisparse_config=HiSparseConfig()),
|
||||
scheduler_config=SchedulerConfig(
|
||||
max_model_len=2048,
|
||||
is_encoder_decoder=False,
|
||||
disable_hybrid_kv_cache_manager=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_rejects_non_cuda(monkeypatch):
|
||||
monkeypatch.setattr(current_platform, "is_cuda", lambda: False)
|
||||
with pytest.raises(ValueError, match="requires NVIDIA CUDA"):
|
||||
VllmConfig(attention_config=AttentionConfig(hisparse_config=HiSparseConfig()))
|
||||
|
||||
|
||||
def test_rocm_keeps_compiled_deepseek_defaults(monkeypatch):
|
||||
"""ROCm keeps the DSA models (DeepSeek V3.2/V4, GLM-5.2) on their compiled
|
||||
MRV1 paths and off breakable cudagraphs by default."""
|
||||
@@ -305,7 +363,8 @@ def test_rocm_keeps_compiled_deepseek_defaults(monkeypatch):
|
||||
# (warning_once args must be hashable for its lru_cache).
|
||||
monkeypatch.delenv("VLLM_USE_V2_MODEL_RUNNER", raising=False)
|
||||
config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(architectures=["DeepseekV32ForCausalLM"])
|
||||
model_config=SimpleNamespace(architectures=["DeepseekV32ForCausalLM"]),
|
||||
attention_config=AttentionConfig(),
|
||||
)
|
||||
assert VllmConfig.use_v2_model_runner.fget(config) is False
|
||||
finally:
|
||||
@@ -385,6 +444,7 @@ def test_dsa_models_default_to_mrv2_and_breakable_cudagraph(
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
model_config=model_config,
|
||||
attention_config=AttentionConfig(),
|
||||
speculative_config=SimpleNamespace(method="mtp") if with_mtp else None,
|
||||
parallel_config=SimpleNamespace(prefill_context_parallel_size=1),
|
||||
compilation_config=CompilationConfig(
|
||||
@@ -897,7 +957,10 @@ def test_models_default_to_v2_model_runner(model_config, expected, monkeypatch):
|
||||
monkeypatch.delenv("VLLM_USE_V2_MODEL_RUNNER", raising=False)
|
||||
monkeypatch.setattr(vllm_config_module, "HAS_TRITON", True)
|
||||
monkeypatch.setattr(current_platform, "is_rocm", lambda: False)
|
||||
config = SimpleNamespace(model_config=model_config)
|
||||
config = SimpleNamespace(
|
||||
model_config=model_config,
|
||||
attention_config=AttentionConfig(),
|
||||
)
|
||||
config._get_v2_model_runner_unsupported_features = lambda: []
|
||||
|
||||
assert VllmConfig.use_v2_model_runner.fget(config) is expected
|
||||
@@ -2787,6 +2850,7 @@ def test_draft_sample_method_gumbel_is_rejected():
|
||||
def _watermarked_vllm_config() -> VllmConfig:
|
||||
config = object.__new__(VllmConfig)
|
||||
config.watermark_config = WatermarkConfig(key=42)
|
||||
config.attention_config = AttentionConfig()
|
||||
config.speculative_config = None
|
||||
return config
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for per-KV-group attention backend selection (backend_per_kind)."""
|
||||
"""Tests for attention configuration and per-KV-group backend selection."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.config.attention import AttentionConfig
|
||||
from vllm.config.attention import AttentionConfig, HiSparseConfig
|
||||
from vllm.model_executor.layers.attention.attention import (
|
||||
_largest_kernel_block_within,
|
||||
)
|
||||
from vllm.v1.attention.backend import AttentionType, MultipleOf
|
||||
from vllm.v1.attention.backends.registry import AttentionBackendEnum
|
||||
from vllm.v1.attention.selector import get_attn_spec_kind
|
||||
from vllm.v1.hisparse.runtime import ResolvedHiSparseConfig
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpecKind
|
||||
|
||||
|
||||
@@ -86,3 +89,52 @@ def test_backend_per_kind_rejects_unknown_kind():
|
||||
|
||||
def test_backend_per_kind_defaults_empty():
|
||||
assert AttentionConfig().backend_per_kind == {}
|
||||
|
||||
|
||||
def test_hisparse_device_buffer_size_boundaries():
|
||||
vllm_config = SimpleNamespace(
|
||||
attention_config=AttentionConfig(hisparse_config=HiSparseConfig()),
|
||||
speculative_config=None,
|
||||
)
|
||||
resolved = ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
assert resolved is not None
|
||||
assert resolved.device_buffer_size == 256
|
||||
|
||||
vllm_config.attention_config.hisparse_config = HiSparseConfig(
|
||||
device_buffer_size=127
|
||||
)
|
||||
with pytest.raises(ValueError, match="expected at least 128"):
|
||||
ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
|
||||
vllm_config.attention_config.hisparse_config = HiSparseConfig(
|
||||
device_buffer_size=32768
|
||||
)
|
||||
resolved = ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
assert resolved is not None
|
||||
assert resolved.device_buffer_size == 32768
|
||||
|
||||
vllm_config.attention_config.hisparse_config = HiSparseConfig(
|
||||
device_buffer_size=32769
|
||||
)
|
||||
with pytest.raises(ValueError, match="int16 slot-index limit"):
|
||||
ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
|
||||
|
||||
def test_hisparse_device_buffer_covers_speculative_window():
|
||||
vllm_config = SimpleNamespace(
|
||||
attention_config=AttentionConfig(hisparse_config=HiSparseConfig()),
|
||||
speculative_config=SimpleNamespace(
|
||||
num_speculative_tokens=3,
|
||||
parallel_drafting=False,
|
||||
),
|
||||
)
|
||||
|
||||
resolved = ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
assert resolved is not None
|
||||
assert resolved.device_buffer_size == 5 * 128
|
||||
|
||||
vllm_config.attention_config.hisparse_config = HiSparseConfig(
|
||||
device_buffer_size=4 * 128 - 1
|
||||
)
|
||||
with pytest.raises(ValueError, match="expected at least 512"):
|
||||
ResolvedHiSparseConfig.from_vllm_config(vllm_config, model_top_k=128)
|
||||
|
||||
@@ -200,6 +200,7 @@ def test_mla_kv_cache_spec_uses_layer_cache_dtype(
|
||||
layer = SimpleNamespace(
|
||||
kv_cache_dtype=cache_dtype,
|
||||
head_size=576,
|
||||
indexer=None,
|
||||
non_causal_multi_token_decode=False,
|
||||
sliding_window=None,
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ def build_chunked_context(
|
||||
def test_sparse_context_lengths_do_not_force_gpu_sync(monkeypatch):
|
||||
"""Sparse MLA must pin computed context lengths before the H2D copy."""
|
||||
builder = SimpleNamespace(
|
||||
_context_lens_cpu=torch.empty(2, dtype=torch.int32, pin_memory=True),
|
||||
chunked_prefill_workspace=torch.empty((2048, 1)),
|
||||
chunked_prefill_workspace_size=1024,
|
||||
kv_cache_spec=SimpleNamespace(block_size=BLOCK_SIZE),
|
||||
@@ -113,6 +114,7 @@ def test_chunks_gather_every_context_row_exactly_once(context_lens, workspace_si
|
||||
query_lens = [4] * len(context_lens)
|
||||
metadata = build_chunked_context(context_lens, query_lens, workspace_size)
|
||||
assert metadata is not None
|
||||
assert metadata.context_lens.tolist() == context_lens
|
||||
|
||||
gathered: dict[int, list[tuple[int, int]]] = {}
|
||||
previous_request_start = -1
|
||||
@@ -253,6 +255,7 @@ def test_dcp_chunks_fit_the_per_rank_row_budget():
|
||||
dcp_local_block_size=interleave,
|
||||
)
|
||||
assert metadata is not None
|
||||
assert metadata.context_lens.tolist() == context_lens
|
||||
virtual_block_size = interleave * dcp_world_size
|
||||
|
||||
local_cursor: dict[int, int] = {}
|
||||
@@ -330,3 +333,23 @@ def test_dcp_reorg_uses_each_chunks_local_starts():
|
||||
)
|
||||
|
||||
torch.testing.assert_close(reorganized, torch.cat(expected))
|
||||
|
||||
|
||||
def test_chunked_context_metadata_owns_context_lengths():
|
||||
"""Reusing CPU scratch must not overwrite an earlier batch's context lengths."""
|
||||
context_lens = torch.tensor([16, 32], dtype=torch.int32)
|
||||
metadata = build_mla_chunked_context_metadata(
|
||||
context_lens_cpu=context_lens,
|
||||
prefill_query_start_loc_cpu=torch.tensor([0, 4, 8], dtype=torch.int32),
|
||||
chunked_prefill_workspace=torch.empty((64, 1)),
|
||||
chunked_prefill_workspace_size=64,
|
||||
block_size=BLOCK_SIZE,
|
||||
align_chunk_to_block=True,
|
||||
device=torch.device("cpu"),
|
||||
dcp_world_size=1,
|
||||
dcp_local_block_size=1,
|
||||
dcp_virtual_block_size=1,
|
||||
)
|
||||
context_lens.zero_()
|
||||
assert metadata is not None
|
||||
assert metadata.context_lens.tolist() == [16, 32]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,9 @@ def test_connector_without_divergent_hit_support_uses_common_lookup():
|
||||
manager = MagicMock()
|
||||
manager.get_computed_blocks.return_value = (common_blocks, 0, 0)
|
||||
scheduler = SimpleNamespace(
|
||||
connector=SimpleNamespace(supports_divergent_local_hybrid_hits=False),
|
||||
connector=SimpleNamespace(
|
||||
supports_divergent_local_hybrid_hits=False,
|
||||
),
|
||||
kv_cache_manager=manager,
|
||||
)
|
||||
|
||||
@@ -61,7 +63,9 @@ def test_capable_connector_uses_divergent_partial_hit_lookup():
|
||||
True,
|
||||
)
|
||||
scheduler = SimpleNamespace(
|
||||
connector=SimpleNamespace(supports_divergent_local_hybrid_hits=True),
|
||||
connector=SimpleNamespace(
|
||||
supports_divergent_local_hybrid_hits=True,
|
||||
),
|
||||
kv_cache_manager=manager,
|
||||
)
|
||||
|
||||
@@ -922,71 +926,6 @@ def test_connector_finish_registers_partial_tail_before_cleanup():
|
||||
scheduler.kv_cache_manager.remove_skipped_blocks.assert_called_once()
|
||||
|
||||
|
||||
def test_block_pool_touch_pins_released_cow_target():
|
||||
"""The connector can rescue an offered CoW target after its step-scoped
|
||||
retention is released by using the bound BlockPool's touch method."""
|
||||
hash_block_size = 2
|
||||
block_size = 2 * hash_block_size
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=24,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["full"],
|
||||
FullAttentionSpec(
|
||||
block_size=hash_block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=1,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
),
|
||||
KVCacheGroupSpec(
|
||||
["mamba"],
|
||||
MambaSpec(
|
||||
block_size=block_size,
|
||||
shapes=(1, 1),
|
||||
dtypes=(torch.float32,),
|
||||
mamba_cache_mode="align",
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
manager = make_kv_cache_manager(
|
||||
kv_cache_config=kv_cache_config,
|
||||
max_model_len=8192,
|
||||
enable_caching=True,
|
||||
hash_block_size=hash_block_size,
|
||||
)
|
||||
req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
|
||||
computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
|
||||
assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
|
||||
req0.num_computed_tokens = 6
|
||||
req0.append_output_token_ids([3])
|
||||
assert manager.allocate_slots(req0, 1) is not None
|
||||
|
||||
# Retention released before the drain (defer_block_free=False ordering).
|
||||
_copies, retained = manager.take_kv_cache_block_copies()
|
||||
manager.block_pool.free_blocks(retained)
|
||||
|
||||
offloads = drain_boundary_state_offloads(manager)
|
||||
((_group_id, block_id, boundary_tokens),) = offloads["0"]
|
||||
assert boundary_tokens == 6
|
||||
cow_block = manager.block_pool.blocks[block_id]
|
||||
assert cow_block.ref_cnt == 0
|
||||
assert block_id in _free_block_ids(manager)
|
||||
|
||||
manager.block_pool.touch([cow_block])
|
||||
assert cow_block.ref_cnt == 1
|
||||
assert block_id not in _free_block_ids(manager)
|
||||
|
||||
# The connector-pinned block is out of the free queue: draining every free block
|
||||
# neither trips the allocator's ref_cnt assert nor hands it out.
|
||||
new_blocks = manager.block_pool.get_new_blocks(
|
||||
manager.block_pool.get_num_free_blocks()
|
||||
)
|
||||
assert block_id not in {b.block_id for b in new_blocks}
|
||||
|
||||
|
||||
def test_boundary_state_offload_dropped_when_request_freed_before_drain():
|
||||
"""A hand-off recorded in the same scheduling pass as the request's death
|
||||
must not be drained: its release hook has already run, so draining would
|
||||
|
||||
@@ -13,7 +13,7 @@ from vllm.v1.request import RequestStatus
|
||||
from vllm.v1.structured_output import StructuredOutputGrammar
|
||||
from vllm.v1.utils import ConstantList
|
||||
|
||||
from .utils import create_requests, create_scheduler
|
||||
from .utils import create_requests, create_scheduler, mock_kv
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
@@ -145,6 +145,32 @@ def test_abort():
|
||||
assert req.num_output_tokens == abort_order_copy.index(i)
|
||||
|
||||
|
||||
def test_connector_metadata_precedes_async_placeholder_advance(monkeypatch):
|
||||
"""Mirroring must see earlier unresolved outputs, excluding the current step."""
|
||||
scheduler = create_scheduler(
|
||||
async_scheduling=True,
|
||||
use_kv_connector=mock_kv(matched_tokens=0, is_async=False),
|
||||
)
|
||||
(request,) = create_requests(num_requests=1, num_tokens=4, max_tokens=4)
|
||||
scheduler.add_request(request)
|
||||
|
||||
assert scheduler.connector is not None
|
||||
build_connector_meta = scheduler.connector.build_connector_meta
|
||||
observed_placeholders = []
|
||||
|
||||
def record_placeholders(scheduler_output):
|
||||
observed_placeholders.append(request.num_output_placeholders)
|
||||
return build_connector_meta(scheduler_output)
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler.connector, "build_connector_meta", record_placeholders
|
||||
)
|
||||
scheduler.schedule()
|
||||
scheduler.schedule()
|
||||
|
||||
assert observed_placeholders == [0, 1]
|
||||
|
||||
|
||||
def test_preempt():
|
||||
scheduler = create_scheduler(async_scheduling=True)
|
||||
requests = create_requests(num_requests=10, max_tokens=20)
|
||||
|
||||
@@ -75,6 +75,7 @@ def _mock_vllm_config(layout: str | None):
|
||||
config.cache_config = CacheConfig()
|
||||
config.cache_config.num_gpu_blocks_override = None
|
||||
config.cache_config.kv_cache_layout = layout
|
||||
config.attention_config.hisparse_config = None
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from vllm.config import (
|
||||
SchedulerConfig,
|
||||
VllmConfig,
|
||||
)
|
||||
from vllm.config.attention import HiSparseConfig
|
||||
from vllm.config.kv_events import KVEventsConfig
|
||||
from vllm.lora.request import LoRARequest
|
||||
from vllm.multimodal.inputs import (
|
||||
@@ -52,10 +53,16 @@ from vllm.v1.core.kv_cache_utils import (
|
||||
make_block_hash_with_group_id,
|
||||
tensor_data,
|
||||
)
|
||||
from vllm.v1.hisparse.layout import (
|
||||
create_hisparse_layout,
|
||||
get_hisparse_gpu_memory_usage,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
ChunkedLocalAttentionSpec,
|
||||
FullAttentionSpec,
|
||||
HiddenStateCacheSpec,
|
||||
HiSparseHotSpec,
|
||||
HiSparseResidentSpec,
|
||||
KpoolTailSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
@@ -68,18 +75,139 @@ from vllm.v1.kv_cache_interface import (
|
||||
SinkFullAttentionSpec,
|
||||
SlidingWindowMLASpec,
|
||||
SlidingWindowSpec,
|
||||
SparseCacheRole,
|
||||
UniformTypeKVCacheSpecs,
|
||||
get_kv_cache_spec_kind,
|
||||
get_kv_cache_spec_sliding_window,
|
||||
is_full_attention_spec,
|
||||
iter_layer_specs,
|
||||
)
|
||||
from vllm.v1.kv_cache_layout import KVCacheLayout
|
||||
from vllm.v1.metrics.stats import CachingMetrics, PrefixCacheStats
|
||||
from vllm.v1.request import Request
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gpu_block_size", [32, 64])
|
||||
def test_hisparse_hma_uses_resolved_gpu_block_size(monkeypatch, gpu_block_size):
|
||||
specs = {
|
||||
"model.layers.0.self_attn": MLAAttentionSpec(
|
||||
block_size=gpu_block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=576,
|
||||
dtype=torch.bfloat16,
|
||||
is_index_group_leader=True,
|
||||
),
|
||||
"model.layers.0.self_attn.indexer": MLAAttentionSpec(
|
||||
block_size=gpu_block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=128,
|
||||
dtype=torch.bfloat16,
|
||||
cache_role=SparseCacheRole.INDEXER,
|
||||
),
|
||||
}
|
||||
group_spec = UniformTypeKVCacheSpecs.from_specs(specs)
|
||||
assert group_spec is not None
|
||||
group = KVCacheGroupSpec(list(specs), group_spec)
|
||||
config = SimpleNamespace(
|
||||
attention_config=SimpleNamespace(hisparse_config=HiSparseConfig()),
|
||||
model_config=SimpleNamespace(
|
||||
hf_config=SimpleNamespace(index_topk=128),
|
||||
max_model_len=gpu_block_size,
|
||||
),
|
||||
parallel_config=SimpleNamespace(decode_context_parallel_size=1),
|
||||
cache_config=SimpleNamespace(
|
||||
num_gpu_blocks_override=7,
|
||||
prefix_cache_retention_interval=None,
|
||||
get_resolved_kv_cache_layout=lambda: KVCacheLayout.BLHNC,
|
||||
),
|
||||
)
|
||||
indexer_spec = specs["model.layers.0.self_attn.indexer"]
|
||||
assert get_hisparse_gpu_memory_usage(config, [group]) == (
|
||||
indexer_spec.max_memory_usage_bytes(config)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(kv_cache_utils, "get_hisparse_host_pool_bytes", lambda _: 2**30)
|
||||
cache_config = kv_cache_utils.get_kv_cache_config_from_groups(
|
||||
config, [group], available_memory=2**30
|
||||
)
|
||||
assert cache_config.num_blocks == 7
|
||||
assert cache_config.hisparse_host_num_blocks is not None
|
||||
assert cache_config.hisparse_host_num_blocks > 7
|
||||
|
||||
host_group, indexer_group, *auxiliary_groups = cache_config.kv_cache_groups
|
||||
assert host_group.host_resident
|
||||
assert not any(group.host_resident for group in [indexer_group, *auxiliary_groups])
|
||||
host_layers = set(host_group.layer_names)
|
||||
for tensor in cache_config.kv_cache_tensors:
|
||||
assert all(
|
||||
(name in host_layers) == tensor.host_resident for name in tensor.layers
|
||||
)
|
||||
host_tensor = next(t for t in cache_config.kv_cache_tensors if t.host_resident)
|
||||
host_spec = host_group.kv_cache_spec.kv_cache_specs[host_tensor.layers[0]]
|
||||
assert host_tensor.block_stride == host_spec.page_size_bytes
|
||||
assert host_tensor.layer_stride == (
|
||||
host_spec.page_size_bytes * cache_config.hisparse_host_num_blocks
|
||||
)
|
||||
assert host_group.kv_cache_spec.block_size == gpu_block_size
|
||||
assert indexer_group.kv_cache_spec.block_size == gpu_block_size
|
||||
host_specs = host_group.kv_cache_spec.kv_cache_specs
|
||||
gpu_indexer_specs = indexer_group.kv_cache_spec.kv_cache_specs
|
||||
assert set(host_specs) == {"model.layers.0.self_attn"}
|
||||
assert set(gpu_indexer_specs) == {"model.layers.0.self_attn.indexer"}
|
||||
assert indexer_group.kv_cache_spec.prefix_cacheable
|
||||
assert host_group.enable_kv_transfer
|
||||
auxiliary_specs = [group.kv_cache_spec for group in auxiliary_groups]
|
||||
assert any(isinstance(spec, HiSparseResidentSpec) for spec in auxiliary_specs)
|
||||
assert any(isinstance(spec, HiSparseHotSpec) for spec in auxiliary_specs)
|
||||
assert all(
|
||||
spec.block_size == gpu_block_size
|
||||
for spec in auxiliary_specs
|
||||
if isinstance(spec, (HiSparseResidentSpec, HiSparseHotSpec))
|
||||
)
|
||||
scheduler_block_size, hash_block_size = kv_cache_utils.resolve_kv_cache_block_sizes(
|
||||
cache_config,
|
||||
SimpleNamespace(
|
||||
cache_config=SimpleNamespace(
|
||||
block_size=16,
|
||||
enable_prefix_caching=True,
|
||||
prefix_match_unit=None,
|
||||
),
|
||||
parallel_config=SimpleNamespace(decode_context_parallel_size=1),
|
||||
kv_transfer_config=object(),
|
||||
),
|
||||
)
|
||||
assert scheduler_block_size == hash_block_size == gpu_block_size
|
||||
|
||||
|
||||
def test_hisparse_rejects_deepseek_v4():
|
||||
full_specs = {
|
||||
"model.layers.0.attn": MLAAttentionSpec(
|
||||
block_size=256,
|
||||
num_kv_heads=1,
|
||||
head_size=512,
|
||||
dtype=torch.uint8,
|
||||
model_version="deepseek_v4",
|
||||
)
|
||||
}
|
||||
full_uniform = UniformTypeKVCacheSpecs.from_specs(full_specs)
|
||||
assert full_uniform is not None
|
||||
group = KVCacheGroupSpec(list(full_specs), full_uniform)
|
||||
config = SimpleNamespace(
|
||||
attention_config=SimpleNamespace(hisparse_config=HiSparseConfig()),
|
||||
model_config=SimpleNamespace(hf_config=SimpleNamespace(index_topk=512)),
|
||||
cache_config=SimpleNamespace(num_gpu_blocks_override=7),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not support DeepSeek V4"):
|
||||
create_hisparse_layout(
|
||||
config,
|
||||
[group],
|
||||
host_budget=2**30,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_init_hash_fn(request):
|
||||
hash_fn: Callable
|
||||
@@ -3848,6 +3976,22 @@ def test_iter_layer_specs_returns_group_members():
|
||||
assert list(iter_layer_specs(wrapped)) == [full, mla]
|
||||
|
||||
|
||||
def test_wrapped_mamba_group_requires_block_zeroing():
|
||||
mamba = MambaSpec(
|
||||
block_size=4,
|
||||
shapes=((4, 1),),
|
||||
dtypes=(torch.float32,),
|
||||
)
|
||||
wrapped = UniformTypeKVCacheSpecs(block_size=4, kv_cache_specs={"mamba": mamba})
|
||||
config = KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=[KVCacheGroupSpec(["mamba"], wrapped)],
|
||||
)
|
||||
|
||||
assert config.needs_kv_cache_zeroing
|
||||
|
||||
|
||||
def _spec_decode_grouping_config(method="dspark", model_type=None):
|
||||
"""Grouping config with an EAGLE-family speculative method enabled."""
|
||||
return SimpleNamespace(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,12 @@ from vllm.config import (
|
||||
SpeculativeConfig,
|
||||
VllmConfig,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
|
||||
HiSparseConnector,
|
||||
HiSparseConnectorScheduler,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import MultiConnector
|
||||
from vllm.multimodal.inputs import (
|
||||
MultiModalFeatureSpec,
|
||||
MultiModalKwargsItem,
|
||||
@@ -34,6 +39,7 @@ from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
|
||||
from vllm.v1.core.sched.scheduler import Scheduler
|
||||
from vllm.v1.core.single_type_kv_cache_manager import register_all_kvcache_specs
|
||||
from vllm.v1.engine import FinishReason
|
||||
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheConfig,
|
||||
@@ -148,6 +154,30 @@ def test_get_num_unfinished_requests():
|
||||
assert scheduler.get_num_unfinished_requests() == len(requests) - i - 1
|
||||
|
||||
|
||||
def _bind_hisparse_connector(scheduler):
|
||||
connector = object.__new__(HiSparseConnector)
|
||||
connector.connector_scheduler = HiSparseConnectorScheduler(async_speculative=False)
|
||||
from tests.v1.core.test_prefix_caching import make_hisparse_kv_cache_manager
|
||||
|
||||
coordinator = get_hisparse_coordinator(make_hisparse_kv_cache_manager(16, 16))
|
||||
connector.connector_scheduler.bind_coordinator(coordinator)
|
||||
composite = object.__new__(MultiConnector)
|
||||
composite._connectors = (connector,)
|
||||
scheduler.connector = composite
|
||||
return coordinator
|
||||
|
||||
|
||||
def test_pending_hisparse_spill_keeps_scheduler_alive():
|
||||
"""A final host spill must complete after the last request finishes."""
|
||||
scheduler = create_scheduler()
|
||||
coordinator = _bind_hisparse_connector(scheduler)
|
||||
pending = coordinator.pending_spills
|
||||
pending[0] = Mock()
|
||||
assert scheduler.has_requests()
|
||||
pending.clear()
|
||||
assert not scheduler.has_requests()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"enable_prefix_caching, prompt_logprobs",
|
||||
[
|
||||
@@ -1181,6 +1211,45 @@ def test_preempt_during_execution():
|
||||
assert requests[1].output_token_ids[0] == 42
|
||||
|
||||
|
||||
def test_pending_hisparse_reclamation_defers_preemption(monkeypatch):
|
||||
"""Pending reclamation must stall rather than preempt its owning request."""
|
||||
scheduler = create_scheduler(enable_prefix_caching=False)
|
||||
request = create_requests(num_requests=1, num_tokens=16, block_size=16)[0]
|
||||
scheduler.add_request(request)
|
||||
scheduler_output = scheduler.schedule()
|
||||
scheduler.update_from_output(
|
||||
scheduler_output,
|
||||
ModelRunnerOutput(
|
||||
req_ids=[request.request_id],
|
||||
req_id_to_index={request.request_id: 0},
|
||||
sampled_token_ids=[[0]],
|
||||
logprobs=None,
|
||||
prompt_logprobs_dict={},
|
||||
pooler_output=[],
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
scheduler.kv_cache_manager, "allocate_slots", Mock(return_value=None)
|
||||
)
|
||||
coordinator = _bind_hisparse_connector(scheduler)
|
||||
monkeypatch.setattr(
|
||||
scheduler.connector, "build_connector_meta", Mock(return_value=None)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
coordinator,
|
||||
"has_pending_reclamation",
|
||||
Mock(return_value=True),
|
||||
)
|
||||
|
||||
deferred_output = scheduler.schedule()
|
||||
|
||||
assert not deferred_output.num_scheduled_tokens
|
||||
assert scheduler.running == [request]
|
||||
assert request.status == RequestStatus.RUNNING
|
||||
assert request.num_preemptions == 0
|
||||
|
||||
|
||||
def test_prefix_cache_query_not_inflated_by_connector_defer():
|
||||
"""The GPU prefix-cache query is recorded at admission, so a request the
|
||||
connector defers several times is counted once, not once per retry."""
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import prometheus_client
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.conftest import VllmRunner
|
||||
from tests.utils import fork_new_process_for_each_test
|
||||
from vllm.config import AttentionConfig, HiSparseConfig, KVTransferConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
|
||||
HiSparseConnector,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
|
||||
HiSparseConnectorWorker,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import (
|
||||
MultiConnector,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
|
||||
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
|
||||
|
||||
MODEL = "deepseek-ai/DeepSeek-V3.2"
|
||||
|
||||
HF_OVERRIDES = {
|
||||
"num_hidden_layers": 8,
|
||||
"hidden_size": 256,
|
||||
"intermediate_size": 512,
|
||||
"num_attention_heads": 8,
|
||||
"num_key_value_heads": 1,
|
||||
"n_routed_experts": 8,
|
||||
"num_experts_per_tok": 2,
|
||||
"index_topk": 128,
|
||||
}
|
||||
|
||||
|
||||
def _shrink_config(config):
|
||||
overrides = HF_OVERRIDES.copy()
|
||||
if any("MTP" in architecture for architecture in config.architectures):
|
||||
overrides.pop("num_hidden_layers")
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def _num_hisparse_spills(runner: VllmRunner) -> int:
|
||||
client = runner.llm.llm_engine.engine_core
|
||||
coordinator = get_hisparse_coordinator(
|
||||
client.engine_core.scheduler.kv_cache_manager
|
||||
)
|
||||
return coordinator.next_spill_id
|
||||
|
||||
|
||||
def _offload_load_bytes() -> float:
|
||||
return sum(
|
||||
sample.value
|
||||
for metric in prometheus_client.REGISTRY.collect()
|
||||
if metric.name == "vllm:kv_offload_load_bytes"
|
||||
for sample in metric.samples
|
||||
if sample.name == "vllm:kv_offload_load_bytes_total"
|
||||
)
|
||||
|
||||
|
||||
def _get_hisparse_worker(runner: VllmRunner) -> HiSparseConnectorWorker:
|
||||
engine_core = runner.llm.llm_engine.engine_core.engine_core
|
||||
model_runner = engine_core.model_executor.driver_worker.worker.model_runner
|
||||
connector = model_runner.kv_connector.kv_connector
|
||||
if isinstance(connector, MultiConnector):
|
||||
connector = next(
|
||||
child
|
||||
for child in connector.sub_connectors
|
||||
if isinstance(child, HiSparseConnector)
|
||||
)
|
||||
assert isinstance(connector, HiSparseConnector)
|
||||
assert connector.connector_worker is not None
|
||||
return connector.connector_worker
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not current_platform.is_cuda(), reason="HiSparse requires NVIDIA CUDA"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"with_offloading", [False, True], ids=["standalone", "offload"]
|
||||
)
|
||||
@fork_new_process_for_each_test
|
||||
def test_hisparse_spill_and_prefix_restore(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
vllm_runner: type[VllmRunner],
|
||||
with_offloading: bool,
|
||||
):
|
||||
"""Spilled prefixes restore and FULL-graph decode writes reach host KV.
|
||||
|
||||
A regression omitted attention metadata before FULL graph replay, so decode
|
||||
completed normally while its newly written KV rows were never copied to host.
|
||||
"""
|
||||
capability = current_platform.get_device_capability()
|
||||
if capability is None or capability.major < 9:
|
||||
pytest.skip("Sparse MLA requires Hopper or newer")
|
||||
|
||||
monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
|
||||
monkeypatch.setenv("VLLM_DEEP_GEMM_WARMUP", "skip")
|
||||
|
||||
target = [1000 + i % 64 for i in range(257)]
|
||||
pressure = [
|
||||
[2000 + request_idx * 128 + i % 64 for i in range(257)]
|
||||
for request_idx in range(4)
|
||||
]
|
||||
hisparse_connector = {
|
||||
"kv_connector": "HiSparseConnector",
|
||||
"kv_role": "kv_both",
|
||||
"kv_connector_extra_config": {"host_pool_gib": 1},
|
||||
}
|
||||
kv_transfer_config = KVTransferConfig(**hisparse_connector)
|
||||
if with_offloading:
|
||||
kv_transfer_config = KVTransferConfig(
|
||||
kv_connector="MultiConnector",
|
||||
kv_role="kv_both",
|
||||
kv_connector_extra_config={
|
||||
"connectors": [
|
||||
hisparse_connector,
|
||||
{
|
||||
"kv_connector": "OffloadingConnector",
|
||||
"kv_role": "kv_both",
|
||||
"kv_connector_extra_config": {"cpu_bytes_to_use": 1 << 30},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
with vllm_runner(
|
||||
MODEL,
|
||||
load_format="dummy",
|
||||
hf_overrides=_shrink_config,
|
||||
attention_config=AttentionConfig(
|
||||
hisparse_config=HiSparseConfig(
|
||||
device_buffer_size=512,
|
||||
)
|
||||
),
|
||||
kv_transfer_config=kv_transfer_config,
|
||||
block_size=64,
|
||||
max_model_len=320,
|
||||
max_num_batched_tokens=1024,
|
||||
max_num_seqs=4,
|
||||
num_gpu_blocks_override=128,
|
||||
disable_log_stats=False,
|
||||
enable_chunked_prefill=True,
|
||||
enable_prefix_caching=True,
|
||||
speculative_config={"method": "mtp", "num_speculative_tokens": 3},
|
||||
compilation_config={
|
||||
"cudagraph_mode": "FULL_AND_PIECEWISE",
|
||||
"cudagraph_capture_sizes": [1, 4],
|
||||
},
|
||||
) as runner:
|
||||
worker = _get_hisparse_worker(runner)
|
||||
engine_core = runner.llm.llm_engine.engine_core.engine_core
|
||||
model_runner = engine_core.model_executor.driver_worker.worker.model_runner
|
||||
full_graph_calls = 0
|
||||
full_replay_pending = False
|
||||
original_run_fullgraph = model_runner.cudagraph_manager.run_fullgraph
|
||||
|
||||
def record_fullgraph(batch_desc):
|
||||
nonlocal full_graph_calls, full_replay_pending
|
||||
full_graph_calls += 1
|
||||
full_replay_pending = True
|
||||
return original_run_fullgraph(batch_desc)
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_runner.cudagraph_manager, "run_fullgraph", record_fullgraph
|
||||
)
|
||||
|
||||
original_finish_forward = worker.finish_forward
|
||||
verified_full_replay_rows = 0
|
||||
copied_nonzero_kv = False
|
||||
|
||||
def finish_forward():
|
||||
nonlocal full_replay_pending, verified_full_replay_rows
|
||||
nonlocal copied_nonzero_kv
|
||||
try:
|
||||
original_finish_forward()
|
||||
if not full_replay_pending:
|
||||
return
|
||||
# Synchronize only the test's inspection, not finish_forward itself.
|
||||
with gpu_sync_allowed():
|
||||
torch.accelerator.synchronize()
|
||||
assert worker._row_mirrors
|
||||
for layer_index, cache in enumerate(worker.cache_handles):
|
||||
source_index = cache.runtime.resident_source_index
|
||||
for mirror in worker._row_mirrors:
|
||||
source_row = mirror.source_starts[source_index]
|
||||
destination_row = mirror.destination_start
|
||||
num_rows = mirror.num_rows
|
||||
source_block, source_offset = divmod(
|
||||
source_row, worker.kernel_block_size
|
||||
)
|
||||
with gpu_sync_allowed():
|
||||
gpu_rows = worker.resident_caches[layer_index][
|
||||
source_block, source_offset : source_offset + num_rows
|
||||
].cpu()
|
||||
host_rows = worker.host_caches[layer_index][
|
||||
destination_row : destination_row + num_rows
|
||||
]
|
||||
assert torch.equal(host_rows, gpu_rows)
|
||||
copied_nonzero_kv |= bool(torch.count_nonzero(gpu_rows).item())
|
||||
verified_full_replay_rows += num_rows
|
||||
finally:
|
||||
full_replay_pending = False
|
||||
|
||||
monkeypatch.setattr(worker, "finish_forward", finish_forward)
|
||||
|
||||
expected = runner.generate_greedy([target], max_tokens=8)
|
||||
|
||||
assert full_graph_calls > 0
|
||||
assert verified_full_replay_rows > 0
|
||||
assert copied_nonzero_kv
|
||||
|
||||
runner.generate_greedy(pressure, max_tokens=8)
|
||||
|
||||
assert _num_hisparse_spills(runner) > 0
|
||||
load_bytes = _offload_load_bytes()
|
||||
actual = runner.generate_greedy([target], max_tokens=8)
|
||||
runner.generate_greedy([[42]], max_tokens=1)
|
||||
|
||||
assert actual == expected
|
||||
if with_offloading:
|
||||
assert _offload_load_bytes() > load_bytes
|
||||
@@ -21,6 +21,7 @@ from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
HiddenStateCacheSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupRole,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheSpec,
|
||||
KVCacheTensor,
|
||||
@@ -336,6 +337,36 @@ def test_worker_kv_bytes_preserves_tensor_layout(packed: bool):
|
||||
assert offloading_config.cache.blocks_per_chunk == 2
|
||||
|
||||
|
||||
def test_hisparse_offloads_only_indexer_group():
|
||||
source = KVCacheGroupSpec(
|
||||
["source"],
|
||||
_full_attention_spec(),
|
||||
host_resident=True,
|
||||
)
|
||||
indexer = KVCacheGroupSpec(
|
||||
["indexer"],
|
||||
_full_attention_spec(),
|
||||
role=KVCacheGroupRole.HISPARSE_INDEXER,
|
||||
)
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=[source, indexer],
|
||||
hisparse_host_num_blocks=4,
|
||||
)
|
||||
config = _make_vllm_config()
|
||||
|
||||
offloading_config = build_offloading_config(config, kv_cache_config)
|
||||
scheduler_config = SchedulerOffloadConfig.from_spec(
|
||||
MockOffloadingSpec(offloading_config), config, kv_cache_config
|
||||
)
|
||||
|
||||
assert [
|
||||
(group.group_id, group.layer_names) for group in offloading_config.groups
|
||||
] == [(1, ("indexer",))]
|
||||
assert [group.group_idx for group in scheduler_config.kv_group_configs] == [1]
|
||||
|
||||
|
||||
def test_zero_blocks_skips_tensor_layout_validation():
|
||||
kv_cache_config = _make_sizing_kv_cache_config(packed=False)
|
||||
kv_cache_config.num_blocks = 0
|
||||
@@ -792,7 +823,6 @@ def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
|
||||
False,
|
||||
id="uniform-uncertifiable-inner",
|
||||
),
|
||||
pytest.param([], False, id="attention-free"),
|
||||
],
|
||||
)
|
||||
def test_canonical_layout_gate(kv_cache_groups, certified):
|
||||
|
||||
@@ -98,6 +98,14 @@ def test_swa_offload_window_covers_unaligned_hit(boundary, eagle, left_state):
|
||||
)
|
||||
manager = CPUOffloadingManager(num_chunks=100)
|
||||
spec = SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
groups=tuple(
|
||||
SimpleNamespace(
|
||||
group_id=i, tokens_per_block=group.kv_cache_spec.block_size
|
||||
)
|
||||
for i, group in enumerate(groups)
|
||||
)
|
||||
),
|
||||
tokens_per_block=(256, 64, 8),
|
||||
tokens_per_hash=8,
|
||||
blocks_per_chunk=4,
|
||||
@@ -303,7 +311,24 @@ def test_aligned_boundary_store_uses_exact_source_with_partial_tail():
|
||||
assert src_spec.block_indices == [0, 0]
|
||||
|
||||
|
||||
def test_aligned_boundary_store_flushes_before_cow_destination_reuse():
|
||||
def test_aligned_boundary_store_maps_sparse_group_id_to_dense_transfer_slot():
|
||||
scheduler = _make_partial_tail_scheduler()
|
||||
indexer_config = scheduler.config.kv_group_configs[1]
|
||||
scheduler.config = scheduler.config._replace(kv_group_configs=(indexer_config,))
|
||||
_make_partial_tail_request(scheduler)
|
||||
scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
|
||||
jobs = scheduler._build_aligned_boundary_store_jobs({"req": [(1, 98, 16)]})
|
||||
|
||||
[job] = jobs.values()
|
||||
assert job.src_spec.group_sizes == [1]
|
||||
assert job.src_spec.block_indices == [0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cow_reuse", [False, True])
|
||||
def test_aligned_boundary_store_flushes_before_block_reuse(cow_reuse):
|
||||
scheduler = _make_partial_tail_scheduler()
|
||||
_make_partial_tail_request(scheduler)
|
||||
scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
@@ -320,7 +345,11 @@ def test_aligned_boundary_store_flushes_before_cow_destination_reuse():
|
||||
[job_id] = meta.store_jobs
|
||||
|
||||
output = SchedulerOutput.make_empty()
|
||||
output.kv_cache_block_copies = [KVCacheBlockCopy(98, 99)]
|
||||
if cow_reuse:
|
||||
output.kv_cache_block_copies = [KVCacheBlockCopy(98, 99)]
|
||||
else:
|
||||
output.scheduled_cached_reqs.req_ids = ["req"]
|
||||
output.scheduled_cached_reqs.new_block_ids = [([], [99])]
|
||||
meta = scheduler.build_connector_meta(output)
|
||||
|
||||
assert meta.jobs_to_flush == {job_id}
|
||||
@@ -384,6 +413,20 @@ def test_partial_lookup_returns_exact_boundary_and_group_load_keys():
|
||||
assert req_status.partial_tail_boundary is None
|
||||
|
||||
|
||||
def test_lookup_cap_stops_at_authoritative_prefix_boundary():
|
||||
scheduler = _make_partial_tail_scheduler()
|
||||
request = _make_partial_tail_request(scheduler)
|
||||
request.skip_reading_prefix_cache = False
|
||||
scheduler.manager.lookup.return_value = LookupResult.HIT
|
||||
|
||||
tokens, load_async = scheduler.get_num_new_matched_tokens(
|
||||
request, 0, max_num_new_tokens=20
|
||||
)
|
||||
|
||||
assert (tokens, load_async) == (20, True)
|
||||
assert scheduler._req_status["req"].partial_tail_boundary == 20
|
||||
|
||||
|
||||
def test_recurrent_group_unhashed_block_does_not_truncate_load_boundary():
|
||||
"""A recurrent group may hold unhashed blocks inside the computed prefix.
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.example_connector import (
|
||||
ExampleConnector,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
|
||||
HiSparseConnector,
|
||||
HiSparseConnectorMetadata,
|
||||
HiSparseConnectorScheduler,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
|
||||
HiSparseConnectorWorker,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import MultiConnector
|
||||
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
|
||||
from vllm.v1.hisparse.runtime import HiSparseCacheHandle
|
||||
from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nested", [False, True])
|
||||
def test_cache_manager_binding_preserves_hisparse_and_legacy_pool_hooks(nested):
|
||||
"""Composite binding must reach HiSparse and existing pool-only connectors."""
|
||||
hisparse = object.__new__(HiSparseConnector)
|
||||
hisparse._role = KVConnectorRole.SCHEDULER
|
||||
hisparse.connector_scheduler = HiSparseConnectorScheduler(async_speculative=False)
|
||||
legacy = object.__new__(ExampleConnector)
|
||||
legacy.bind_gpu_block_pool = MagicMock()
|
||||
connector = object.__new__(MultiConnector)
|
||||
connector._connectors = [hisparse, legacy]
|
||||
if nested:
|
||||
parent = object.__new__(MultiConnector)
|
||||
parent._connectors = [connector]
|
||||
connector = parent
|
||||
from tests.v1.core.test_prefix_caching import make_hisparse_kv_cache_manager
|
||||
|
||||
manager = make_hisparse_kv_cache_manager(16, 16)
|
||||
|
||||
connector.bind_kv_cache_manager(manager)
|
||||
|
||||
assert hisparse.connector_scheduler.coordinator is get_hisparse_coordinator(manager)
|
||||
legacy.bind_gpu_block_pool.assert_called_once_with(manager.block_pool)
|
||||
|
||||
|
||||
def test_hisparse_requires_block_outermost_device_layout():
|
||||
assert HiSparseConnector.get_required_kvcache_layout(MagicMock()) == "BLHNC"
|
||||
|
||||
|
||||
def test_no_forward_enqueues_deferred_hisparse_transfers():
|
||||
"""A zero-token step must still enqueue deferred post-forward transfers."""
|
||||
connector = object.__new__(ActiveKVConnector)
|
||||
connector._disabled = False
|
||||
connector.pre_forward = MagicMock()
|
||||
connector.finish_forward = MagicMock()
|
||||
connector.post_forward = MagicMock(return_value=None)
|
||||
|
||||
scheduler_output = SimpleNamespace(finished_req_ids=set())
|
||||
connector.no_forward(scheduler_output)
|
||||
|
||||
connector.pre_forward.assert_called_once_with(scheduler_output)
|
||||
connector.finish_forward.assert_called_once_with()
|
||||
|
||||
|
||||
def test_full_graph_step_prepares_host_mirror_outside_model():
|
||||
"""Graph replay must restore host-mirror state cleared at step start."""
|
||||
runtime = SimpleNamespace(
|
||||
is_group_leader=True,
|
||||
eager_host_mirror=True,
|
||||
begin_forward=MagicMock(),
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
handle = HiSparseCacheHandle(runtime)
|
||||
handle.mirror_slot_mapping = torch.tensor([4, 5])
|
||||
worker = object.__new__(HiSparseConnectorWorker)
|
||||
worker.cache_layer_names = ["layer"]
|
||||
worker.cache_handles = [handle]
|
||||
worker._group_leaders = (("layer", handle),)
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker.is_host_writer = True
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
worker.start_step = MagicMock(
|
||||
side_effect=lambda *_args, **_kwargs: worker._clear_forward_mirror_state()
|
||||
)
|
||||
|
||||
connector = object.__new__(HiSparseConnector)
|
||||
connector.connector_worker = worker
|
||||
connector._get_connector_metadata = MagicMock(
|
||||
return_value=HiSparseConnectorMetadata(None, (), (), {}, True)
|
||||
)
|
||||
req_id_per_token = torch.tensor([0, 1], dtype=torch.int32)
|
||||
attn_metadata = SimpleNamespace(
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=2,
|
||||
num_reqs=2,
|
||||
max_query_len=1,
|
||||
req_id_per_token=req_id_per_token,
|
||||
)
|
||||
|
||||
connector.start_load_kv(
|
||||
SimpleNamespace(),
|
||||
request_state_indices=None,
|
||||
request_ids=[],
|
||||
attn_metadata={"layer": attn_metadata},
|
||||
)
|
||||
|
||||
worker._enqueue_host_mirror()
|
||||
|
||||
worker._enqueue_row_dma.assert_called_once_with((0,), ready_event=None)
|
||||
runtime.invalidate_written_slots.assert_called_once()
|
||||
@@ -3297,7 +3297,9 @@ def _make_bare_worker(
|
||||
# path; everything flows through the coordinator).
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
)
|
||||
|
||||
worker.disk_offload_buffer_budget_bytes = None
|
||||
@@ -3311,6 +3313,18 @@ def _make_bare_worker(
|
||||
block_size=block_size, num_kv_heads=8, head_size=64, dtype=None
|
||||
)
|
||||
group = KVCacheGroupSpec(["layer0", "__cross_layer__"], spec)
|
||||
worker._kv_cache_config = KVCacheConfig(
|
||||
num_blocks=num_gpu_blocks,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=0,
|
||||
layers=["layer0", "__cross_layer__"],
|
||||
layer_stride=0,
|
||||
block_stride=0,
|
||||
)
|
||||
],
|
||||
kv_cache_groups=[group],
|
||||
)
|
||||
worker._kv_cache_groups = [group]
|
||||
worker.pcp_size = 1
|
||||
worker.dcp_size = 1
|
||||
@@ -4059,6 +4073,77 @@ def test_register_kv_caches_shared_storage(layout: KVCacheLayout):
|
||||
worker.store.register_buffer.assert_called_once_with(raw.data_ptr(), raw.nbytes)
|
||||
|
||||
|
||||
def test_register_kv_caches_uses_transfer_group_memory_domain():
|
||||
"""Derived GPU caches must not change host-source transfer addresses."""
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
KVCacheConfig,
|
||||
KVCacheGroupRole,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
)
|
||||
|
||||
host_num_blocks = 4
|
||||
gpu_num_blocks = 2
|
||||
page_size = 32
|
||||
source_spec = FullAttentionSpec(
|
||||
block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float16
|
||||
)
|
||||
indexer_spec = FullAttentionSpec(
|
||||
block_size=16, num_kv_heads=1, head_size=1, dtype=torch.float16
|
||||
)
|
||||
source_group = KVCacheGroupSpec(
|
||||
["source"],
|
||||
source_spec,
|
||||
host_resident=True,
|
||||
)
|
||||
indexer_group = KVCacheGroupSpec(
|
||||
["indexer"],
|
||||
indexer_spec,
|
||||
enable_kv_transfer=False,
|
||||
role=KVCacheGroupRole.HISPARSE_INDEXER,
|
||||
)
|
||||
config = KVCacheConfig(
|
||||
num_blocks=gpu_num_blocks,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=host_num_blocks * page_size,
|
||||
layers=["source"],
|
||||
layer_stride=host_num_blocks * page_size,
|
||||
block_stride=page_size,
|
||||
host_resident=True,
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=gpu_num_blocks * page_size,
|
||||
layers=["indexer"],
|
||||
layer_stride=gpu_num_blocks * page_size,
|
||||
block_stride=page_size,
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[source_group, indexer_group],
|
||||
hisparse_host_num_blocks=host_num_blocks,
|
||||
)
|
||||
worker = _make_bare_worker(num_gpu_blocks=gpu_num_blocks)
|
||||
worker._kv_cache_config = config
|
||||
worker._kv_cache_groups = list(config.transfer_groups)
|
||||
worker.token_dbs = [
|
||||
ChunkedTokenDatabase(KeyMetadata("test-model", 0, 0, 0, 0), block_size=16)
|
||||
]
|
||||
source = torch.zeros(host_num_blocks, page_size, dtype=torch.uint8)
|
||||
indexer = torch.zeros(gpu_num_blocks, page_size, dtype=torch.uint8)
|
||||
|
||||
_register_with_mocked_threads(
|
||||
worker,
|
||||
{"source": source, "indexer": indexer},
|
||||
)
|
||||
|
||||
db = worker.token_dbs[0]
|
||||
assert db.kv_caches_base_addr == [source.data_ptr()]
|
||||
assert db.block_len == [page_size]
|
||||
worker.store.register_buffer.assert_called_once_with(
|
||||
source.data_ptr(), source.nbytes
|
||||
)
|
||||
|
||||
|
||||
def test_register_kv_caches_separate_head_groups():
|
||||
# LHBNC gives each head group its own region; the K/V split doubles heads.
|
||||
layout = KVCacheLayout.LHBNC
|
||||
|
||||
@@ -304,10 +304,10 @@ def test_multi_example_connector_consistency():
|
||||
events = get_connector_events()
|
||||
storage1_scheduler_events = _ignore_event_collection(events["storage1-SCHEDULER"])
|
||||
storage2_scheduler_events = _ignore_event_collection(events["storage2-SCHEDULER"])
|
||||
# Initial events bind the block pool, query completion counts, and exchange
|
||||
# Initial events bind the cache manager, query completion counts, and exchange
|
||||
# handshake metadata before the request is enqueued.
|
||||
assert storage1_scheduler_events[:7] == [
|
||||
"bind_gpu_block_pool",
|
||||
"bind_kv_cache_manager",
|
||||
"get_finished_count",
|
||||
"set_xfer_handshake_metadata_pp_aware",
|
||||
"on_new_request",
|
||||
@@ -315,8 +315,8 @@ def test_multi_example_connector_consistency():
|
||||
"update_state_after_alloc num_blocks=[7] 0",
|
||||
"build_connector_meta",
|
||||
]
|
||||
# First three events are from initialization. During generate(), layer hooks
|
||||
# run before the deferred load is started after the forward pass.
|
||||
# First three events are from initialization. Layer hooks run before the
|
||||
# deferred load starts after the forward pass.
|
||||
expected_worker_prefix = [
|
||||
"register_kv_caches",
|
||||
"set_host_xfer_buffer_ops",
|
||||
@@ -328,12 +328,12 @@ def test_multi_example_connector_consistency():
|
||||
]
|
||||
for connector_name in ("storage1-WORKER", "storage2-WORKER"):
|
||||
worker_events = events[connector_name]
|
||||
assert worker_events[:7] == expected_worker_prefix
|
||||
assert worker_events[: len(expected_worker_prefix)] == expected_worker_prefix
|
||||
assert worker_events.index("start_load_kv") > worker_events.index(
|
||||
"save_kv_layer"
|
||||
)
|
||||
assert storage2_scheduler_events[:7] == [
|
||||
"bind_gpu_block_pool",
|
||||
"bind_kv_cache_manager",
|
||||
"get_finished_count",
|
||||
"set_xfer_handshake_metadata_pp_aware",
|
||||
"on_new_request",
|
||||
|
||||
@@ -29,7 +29,10 @@ from vllm.distributed.kv_transfer.kv_connector.utils import (
|
||||
get_current_attn_backend,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1 import nixl
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorRole,
|
||||
KVConnectorTransferResults,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.multi_connector import (
|
||||
MultiKVConnectorStats,
|
||||
@@ -518,6 +521,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
|
||||
slot_size_bytes = 4096
|
||||
self.slot_size_per_layer = [slot_size_bytes]
|
||||
self.block_len_per_layer = [slot_size_bytes * self.block_size]
|
||||
self.num_blocks = self.kv_cache_config.num_blocks
|
||||
self.num_regions = 1
|
||||
self.block_stride_per_layer = list(self.block_len_per_layer)
|
||||
self.region_num_blocks = [self.num_blocks]
|
||||
@@ -635,6 +639,12 @@ class TestNixlHandshake:
|
||||
if pcp_rank > 0:
|
||||
assert connector.get_finished(set()) == (set(), set())
|
||||
|
||||
worker.get_transfer_results = MagicMock(
|
||||
return_value=KVConnectorTransferResults(finished_sending={"sent"})
|
||||
)
|
||||
results = connector.get_transfer_results(set())
|
||||
assert results.finished_sending == ({"sent"} if pcp_rank == 0 else set())
|
||||
|
||||
@patch(
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
|
||||
FakeNixlWrapper,
|
||||
@@ -1195,6 +1205,7 @@ class TestNixlHandshake:
|
||||
ssm_sizes=(0, 0),
|
||||
attn_backend_name=worker.backend_name,
|
||||
physical_blocks_per_logical_kv_block=1,
|
||||
region_num_blocks=None,
|
||||
)
|
||||
|
||||
assert worker._build_fa_remote(plan, meta, block_size_ratio=1).tolist() == [
|
||||
@@ -1435,9 +1446,9 @@ def test_kv_connector_stats(default_vllm_config, dist_init):
|
||||
metadata = NixlConnectorMetadata()
|
||||
metadata.add_new_req_to_recv(
|
||||
request_id=request_id,
|
||||
local_block_ids=([1, 2, 3],),
|
||||
local_block_ids=([0],),
|
||||
kv_transfer_params={
|
||||
"remote_block_ids": ([4, 5, 6],),
|
||||
"remote_block_ids": ([0],),
|
||||
"remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
|
||||
"remote_request_id": f"prefill-{request_id}",
|
||||
"remote_host": "localhost",
|
||||
@@ -1482,6 +1493,10 @@ def test_kv_connector_stats(default_vllm_config, dist_init):
|
||||
assert stats_after_reset is None
|
||||
|
||||
|
||||
@patch(
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
|
||||
FakeNixlWrapper,
|
||||
)
|
||||
def test_reqs_to_send_deadline_rebased_to_worker_clock(default_vllm_config, dist_init):
|
||||
"""reqs_to_send deadlines are stamped with the scheduler process's
|
||||
perf_counter, whose epoch differs across processes and (by boot-time
|
||||
@@ -1906,9 +1921,10 @@ def test_mixed_memory_local_descriptors_split_by_memory_type():
|
||||
assert handle == 22
|
||||
assert worker._dram_src_handles_by_block_size[worker.block_size] == 11
|
||||
assert [block[2] for block in blocks] == [0, 0, 3, 3]
|
||||
assert [
|
||||
memory_types = [
|
||||
call.args[1] for call in worker.nixl_wrapper.get_xfer_descs.call_args_list
|
||||
] == ["DRAM", "VRAM"]
|
||||
]
|
||||
assert memory_types == ["DRAM", "VRAM"]
|
||||
|
||||
|
||||
def test_mixed_memory_read_notifies_after_both_transfers_finish():
|
||||
@@ -1919,6 +1935,7 @@ def test_mixed_memory_read_notifies_after_both_transfers_finish():
|
||||
worker._recving_metadata = {"request": MagicMock()}
|
||||
worker._recving_transfers = defaultdict(list)
|
||||
worker._pending_recv_notifs = {}
|
||||
worker._failed_inflight_recvs = set()
|
||||
worker.xfer_stats = MagicMock()
|
||||
worker.nixl_wrapper = MagicMock()
|
||||
worker.nixl_wrapper.make_prepped_xfer.side_effect = [101, 102]
|
||||
@@ -1959,6 +1976,7 @@ def test_mixed_memory_read_failure_does_not_notify_producer():
|
||||
worker._recving_metadata = {"request": MagicMock()}
|
||||
worker._recving_transfers = {"request": [101, 102]}
|
||||
worker._pending_recv_notifs = {"request": [("prefill", b"request:1")]}
|
||||
worker._failed_inflight_recvs = set()
|
||||
worker._is_hma_required = True
|
||||
worker._failed_recv_reqs = MagicMock()
|
||||
worker._log_failure = MagicMock() # type: ignore[method-assign]
|
||||
@@ -2871,9 +2889,11 @@ def test_handshake_failure_returns_finished(default_vllm_config, dist_init):
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
|
||||
FailingNixlWrapper,
|
||||
)
|
||||
def test_transfer_setup_failure_returns_finished(default_vllm_config, dist_init):
|
||||
"""Test that transfer setup failures mark blocks invalid
|
||||
and return via get_finished."""
|
||||
@pytest.mark.parametrize("is_hma", [False, True])
|
||||
def test_transfer_setup_failure_returns_finished(
|
||||
default_vllm_config, dist_init, is_hma
|
||||
):
|
||||
"""Setup failures report the request; only non-HMA reports block IDs."""
|
||||
vllm_config = create_vllm_config()
|
||||
|
||||
connector = NixlConnector(
|
||||
@@ -2883,6 +2903,7 @@ def test_transfer_setup_failure_returns_finished(default_vllm_config, dist_init)
|
||||
vllm_config, connector.engine_id, hand_shake_latency=0
|
||||
)
|
||||
connector.connector_worker.nixl_wrapper.fail_transfer_setup = True
|
||||
connector.connector_worker._is_hma_required = is_hma
|
||||
|
||||
request_id = "test_transfer_fail"
|
||||
metadata = NixlConnectorMetadata()
|
||||
@@ -2912,13 +2933,106 @@ def test_transfer_setup_failure_returns_finished(default_vllm_config, dist_init)
|
||||
time.sleep(0.1)
|
||||
connector.start_load_kv(dummy_ctx)
|
||||
|
||||
# check that blocks were marked invalid
|
||||
results = connector.get_transfer_results(finished_req_ids=set())
|
||||
assert request_id in results.finished_recving
|
||||
assert results.failed_recving == {request_id}
|
||||
invalid_blocks = connector.get_block_ids_with_load_errors()
|
||||
assert invalid_blocks == {7, 8, 9}
|
||||
assert invalid_blocks == (set() if is_hma else {7, 8, 9})
|
||||
|
||||
# ensure request appears in get_finished
|
||||
|
||||
class _ScriptedXferWrapper(FakeNixlWrapper):
|
||||
"""Scripts per-handle xfer states; forbids releasing in-flight handles."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# handle -> successive states; the last one repeats.
|
||||
self.xfer_states: dict[int, list[str]] = {}
|
||||
self.released: list[int] = []
|
||||
|
||||
def check_xfer_state(self, handle: int) -> str:
|
||||
states = self.xfer_states[handle]
|
||||
return states.pop(0) if len(states) > 1 else states[0]
|
||||
|
||||
def release_xfer_handle(self, handle: int) -> None:
|
||||
# A posted-but-unfinished transfer cannot be aborted: releasing it
|
||||
# leaves the RDMA READ armed. Production code must never do this.
|
||||
assert self.xfer_states.get(handle, ["DONE"])[0] != "PROC", (
|
||||
f"released in-flight handle {handle}"
|
||||
)
|
||||
self.released.append(handle)
|
||||
|
||||
|
||||
def _make_split_read_connector(vllm_config, request_id, states):
|
||||
"""Seed a request with scripted in-flight xfer handles + recv metadata."""
|
||||
connector = NixlConnector(
|
||||
vllm_config, KVConnectorRole.WORKER, make_kv_cache_config(block_size=16)
|
||||
)
|
||||
connector.connector_worker = FakeNixlConnectorWorker(
|
||||
vllm_config, connector.engine_id, hand_shake_latency=0
|
||||
)
|
||||
worker = connector.connector_worker
|
||||
wrapper = _ScriptedXferWrapper("agent")
|
||||
worker.nixl_wrapper = wrapper
|
||||
metadata = NixlConnectorMetadata()
|
||||
metadata.add_new_req_to_recv(
|
||||
request_id=request_id,
|
||||
local_block_ids=([7, 8, 9],),
|
||||
kv_transfer_params={
|
||||
"remote_block_ids": ([10, 11, 12],),
|
||||
"remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
|
||||
"remote_request_id": f"prefill-{request_id}",
|
||||
"remote_host": "localhost",
|
||||
"remote_port": 1234,
|
||||
"remote_tp_size": 1,
|
||||
},
|
||||
)
|
||||
worker._recving_metadata[request_id] = metadata.reqs_to_recv[request_id]
|
||||
wrapper.xfer_states = dict(states)
|
||||
worker._recving_transfers[request_id] = list(states)
|
||||
return connector, worker, wrapper
|
||||
|
||||
|
||||
@patch(
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
|
||||
FakeNixlWrapper,
|
||||
)
|
||||
def test_split_read_failure_defers_report_until_last_handle(
|
||||
default_vllm_config, dist_init
|
||||
):
|
||||
"""One half of a split (mixed DRAM/VRAM) read failing must not report the
|
||||
request — nor invalidate its blocks — while the sibling xfer is still in
|
||||
flight: a posted READ cannot be aborted and would DMA into blocks the
|
||||
scheduler could free and reuse. The report happens exactly once, when the
|
||||
last handle is terminal."""
|
||||
request_id = "split_read_partial_failure"
|
||||
err_handle, live_handle = 11, 22
|
||||
connector, worker, wrapper = _make_split_read_connector(
|
||||
create_vllm_config(),
|
||||
request_id,
|
||||
{err_handle: ["ERR"], live_handle: ["PROC", "PROC", "DONE"]},
|
||||
)
|
||||
|
||||
# Poll 1: one half fails; the sibling is in flight -> nothing reported.
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
assert request_id in done_recving
|
||||
assert done_recving == set()
|
||||
assert connector.get_block_ids_with_load_errors() == set()
|
||||
assert request_id in worker._recving_metadata
|
||||
assert wrapper.released == [err_handle]
|
||||
|
||||
# Poll 2: sibling still in flight.
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
assert done_recving == set()
|
||||
|
||||
# Poll 3: sibling terminal -> reported exactly once, blocks invalidated.
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
assert done_recving == {request_id}
|
||||
assert connector.get_block_ids_with_load_errors() == {7, 8, 9}
|
||||
assert request_id not in worker._recving_metadata
|
||||
assert wrapper.released == [err_handle, live_handle]
|
||||
|
||||
# Poll 4: nothing left; no double report.
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
assert done_recving == set()
|
||||
|
||||
|
||||
@patch(
|
||||
@@ -2938,7 +3052,7 @@ def test_failed_request_skips_kv_postprocessing(
|
||||
default_vllm_config, dist_init, failure_mode
|
||||
):
|
||||
"""Test that failed requests skip KV sync and post-processing in
|
||||
get_finished().
|
||||
get_transfer_results().
|
||||
|
||||
This is the core safety behavior: when a KV transfer fails at any stage,
|
||||
the request must still appear in done_recving (so the scheduler can apply
|
||||
@@ -3023,11 +3137,10 @@ def test_failed_request_skips_kv_postprocessing(
|
||||
patch.object(worker, "sync_recved_kv_to_device") as mock_sync,
|
||||
patch.object(worker, "post_process_device_kv_on_receive") as mock_postprocess,
|
||||
):
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
results = connector.get_transfer_results(finished_req_ids=set())
|
||||
|
||||
# The failed request must appear in done_recving so the scheduler
|
||||
# can handle it (e.g., trigger recompute via kv_load_failure_policy).
|
||||
assert request_id in done_recving
|
||||
assert request_id in results.finished_recving
|
||||
assert results.failed_recving == {request_id}
|
||||
|
||||
# Critical: KV sync and post-processing must NOT have been called
|
||||
# since no valid KV data was received for the failed request.
|
||||
@@ -3049,19 +3162,7 @@ def test_failed_request_skips_kv_postprocessing(
|
||||
def test_handles_failing_in_separate_polls_do_not_kill_the_engine(
|
||||
default_vllm_config, dist_init
|
||||
):
|
||||
"""A request whose handles fail in different polls must not crash the engine.
|
||||
|
||||
One transfer handle is created per remote rank, and when a peer goes away
|
||||
they do not all fail in the same poll: one errors while another is still
|
||||
PROC. The first failure reports the request via _failed_recv_reqs and
|
||||
get_finished() pops its metadata; when the remaining handles fail in a
|
||||
later poll, the request must be cleaned up without being reported again.
|
||||
|
||||
Reporting twice kills the EngineCore: the scheduler's assert in
|
||||
_update_from_kv_xfer_finished only expects a finished recv for a request
|
||||
still waiting for KVs, having moved this one out of
|
||||
WAITING_FOR_REMOTE_KVS to recompute locally after the first report.
|
||||
"""
|
||||
"""Failures across polls report once, after the last writer is terminal."""
|
||||
vllm_config = create_vllm_config()
|
||||
connector = NixlConnector(
|
||||
vllm_config, KVConnectorRole.WORKER, make_kv_cache_config(block_size=16)
|
||||
@@ -3107,20 +3208,21 @@ def test_handles_failing_in_separate_polls_do_not_kill_the_engine(
|
||||
):
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
|
||||
assert request_id in done_recving
|
||||
assert request_id not in worker._recving_metadata
|
||||
assert request_id not in done_recving
|
||||
assert request_id in worker._recving_metadata
|
||||
assert worker._recving_transfers[request_id] == [second]
|
||||
assert connector.get_block_ids_with_load_errors() == set()
|
||||
|
||||
# Poll 2: the second handle fails too. The request was already reported,
|
||||
# so it must not be reported a second time: the scheduler has since moved
|
||||
# it out of WAITING_FOR_REMOTE_KVS to recompute locally, and asserts on a
|
||||
# finished recv for a request in that state. Its remaining handles are
|
||||
# still released and the transfer entry removed.
|
||||
# Only the last terminal handle permits the scheduler to reuse the blocks.
|
||||
with patch.object(worker.nixl_wrapper, "check_xfer_state", return_value="ERR"):
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
|
||||
assert request_id not in done_recving
|
||||
assert done_recving == {request_id}
|
||||
assert request_id not in worker._recving_transfers
|
||||
assert request_id not in worker._recving_metadata
|
||||
assert connector.get_block_ids_with_load_errors() == {1, 2, 3}
|
||||
_, done_recving = connector.get_finished(finished_req_ids=set())
|
||||
assert done_recving == set()
|
||||
|
||||
|
||||
def _set_test_speculative_config(
|
||||
|
||||
@@ -5,16 +5,31 @@
|
||||
import gc
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.v1.attention.utils import MockMambaBuilder
|
||||
from vllm import LLM, SamplingParams
|
||||
from vllm.config import KVTransferConfig
|
||||
from vllm.config import KVTransferConfig, set_current_vllm_config
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
NixlAgentMetadata,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
)
|
||||
from vllm.v1.core.single_type_kv_cache_manager import (
|
||||
FullAttentionManager,
|
||||
SlidingWindowManager,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
MLAAttentionSpec,
|
||||
)
|
||||
|
||||
from .utils import (
|
||||
create_request,
|
||||
@@ -272,26 +287,24 @@ def test_logical_to_kernel_block_ids_with_hma():
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@pytest.mark.parametrize(
|
||||
"is_rocm,has_mamba,use_host_buffer,done_recving,failed_recving,expected_syncs",
|
||||
"is_rocm,has_mamba,use_host_buffer,done_recving,expected_syncs",
|
||||
[
|
||||
(True, True, False, {"req"}, set(), 1),
|
||||
(False, True, False, {"req"}, set(), 0),
|
||||
(True, False, False, {"req"}, set(), 0),
|
||||
(True, True, True, {"req"}, set(), 0),
|
||||
(True, True, False, set(), set(), 0),
|
||||
(True, True, False, {"req"}, {"req"}, 0),
|
||||
(True, True, False, {"req"}, 1),
|
||||
(False, False, False, {"req"}, 0),
|
||||
(False, True, False, {"req"}, 0),
|
||||
(True, True, True, {"req"}, 0),
|
||||
(True, True, False, set(), 0),
|
||||
],
|
||||
)
|
||||
def test_sync_device_after_mamba_recv_gates(
|
||||
def test_sync_device_after_direct_recv_gates(
|
||||
monkeypatch,
|
||||
is_rocm,
|
||||
has_mamba,
|
||||
use_host_buffer,
|
||||
done_recving,
|
||||
failed_recving,
|
||||
expected_syncs,
|
||||
):
|
||||
"""Only direct-GPU Mamba receives on ROCm need a device fence."""
|
||||
"""Only backends requiring visibility fences synchronize direct receives."""
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
@@ -309,7 +322,7 @@ def test_sync_device_after_mamba_recv_gates(
|
||||
lambda: sync_calls.append(True),
|
||||
)
|
||||
|
||||
worker._sync_device_after_mamba_recv(done_recving, failed_recving)
|
||||
worker._sync_device_after_direct_recv(done_recving)
|
||||
|
||||
assert len(sync_calls) == expected_syncs
|
||||
|
||||
@@ -399,6 +412,8 @@ def test_read_blocks_for_req_expands_remote_ids(
|
||||
)
|
||||
|
||||
remote_engine_id = "remote-engine"
|
||||
worker.region_group_ids = list(range(len(resolved_types)))
|
||||
worker.dst_region_group_ids = {remote_engine_id: list(worker.region_group_ids)}
|
||||
|
||||
worker.transfer_topo = MagicMock()
|
||||
# tp_ratio not exercised (all_source_ranks is empty so no reads run),
|
||||
@@ -888,6 +903,45 @@ def _make_mock_worker_for_desc_ids(
|
||||
return worker
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_shared_nixl_region_broadcasts_every_group_block_table():
|
||||
"""A shared region must receive every group's block table exactly once."""
|
||||
worker = _make_mock_worker_for_desc_ids(
|
||||
num_regions=3,
|
||||
has_mamba=False,
|
||||
group_spec_types=(FullAttentionSpec,) * 3,
|
||||
block_len_per_layer=[100] * 3,
|
||||
)
|
||||
worker.region_group_ids = [0, bw._SHARED_REGION_GROUP_ID, 2]
|
||||
worker._uses_region_group_mapping = True
|
||||
|
||||
result = worker._compute_desc_ids(
|
||||
block_ids=([1], [2], [3]),
|
||||
dst_num_blocks=10,
|
||||
block_size_ratio=None,
|
||||
physical_blocks_per_logical=1,
|
||||
region_num_blocks=[10, 10, 10],
|
||||
)
|
||||
|
||||
assert result.tolist() == [1, 11, 12, 13, 23]
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_nixl_region_sort_preserves_pipeline_layer_order():
|
||||
"""Numeric layer order prevents PP peers from pairing different regions."""
|
||||
names = [
|
||||
"model.layers.10.self_attn",
|
||||
"model.layers.2.self_attn",
|
||||
"model.layers.1.self_attn",
|
||||
]
|
||||
|
||||
assert sorted(names, key=bw._region_sort_key) == [
|
||||
"model.layers.1.self_attn",
|
||||
"model.layers.2.self_attn",
|
||||
"model.layers.10.self_attn",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_get_block_descs_ids_hybrid_ssm():
|
||||
"""Test _compute_desc_ids uses per-group strides for hybrid
|
||||
@@ -1097,10 +1151,7 @@ def test_post_process_zeroes_untransferred_tail():
|
||||
worker.device_kv_caches = {"attn.0": attn_cache, "mamba.0": mamba_cache}
|
||||
fa_group = MagicMock(layer_names=["attn.0"])
|
||||
ssm_group = MagicMock(layer_names=["mamba.0"])
|
||||
worker.kv_cache_config = MagicMock(
|
||||
kv_cache_groups=[fa_group, ssm_group],
|
||||
transfer_groups=[fa_group, ssm_group],
|
||||
)
|
||||
worker.kv_cache_config = MagicMock(transfer_groups=[fa_group, ssm_group])
|
||||
# The cached property filters mamba layers out of the permuted caches.
|
||||
attn_caches = NixlConnectorWorker._attention_kv_caches.func(worker)
|
||||
assert len(attn_caches) == 1 and attn_caches[0] is attn_cache
|
||||
@@ -1188,6 +1239,11 @@ def _make_fake_kv_cache_manager():
|
||||
|
||||
manager = object.__new__(KVCacheManager)
|
||||
manager.coordinator = MagicMock()
|
||||
manager.kv_cache_config = MagicMock()
|
||||
manager.kv_cache_config.kv_cache_groups = (
|
||||
MagicMock(host_resident=False),
|
||||
MagicMock(host_resident=False),
|
||||
)
|
||||
manager.coordinator.single_type_managers = (
|
||||
_FakeSingleTypeManager(True, 16, [10, 11, 12, 13, 14, 15]), # attention
|
||||
_FakeSingleTypeManager(False, 16, [20, 21, 22, 23, 24, 25]), # mamba
|
||||
@@ -1723,16 +1779,6 @@ def test_exchange_clipped_blocks_ssm_positional_states():
|
||||
assert clipped == ([1, 2, 3], [0, 5, 6, 7])
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_exchange_clipped_blocks_excludes_nontransfer_groups():
|
||||
"""Scheduler block tables must match the worker's registered regions."""
|
||||
sched = make_nixl_scheduler()
|
||||
sched.kv_cache_config = make_kv_cache_config(block_size=16, swa_enabled=True)
|
||||
sched.kv_cache_config.kv_cache_groups[1].enable_kv_transfer = False
|
||||
|
||||
assert sched.get_exchange_clipped_blocks(([1, 2], [9])) == ([1, 2],)
|
||||
|
||||
|
||||
# ── Hybrid MLA+SSM (KimiLinear-shaped KDA+MLA) tests ─────────────────────
|
||||
|
||||
|
||||
@@ -1786,6 +1832,95 @@ def _make_hybrid_mla_kv_cache_config(num_blocks: int = 4):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@pytest.mark.parametrize("kernel_block_size", [16, 8])
|
||||
def test_nixl_keeps_device_block_count_with_hisparse_host_pool(kernel_block_size):
|
||||
host_num_blocks = 4
|
||||
gpu_num_blocks = 9
|
||||
spec = MLAAttentionSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=8,
|
||||
dtype=torch.float16,
|
||||
)
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=gpu_num_blocks,
|
||||
hisparse_host_num_blocks=host_num_blocks,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=host_num_blocks * spec.page_size_bytes,
|
||||
layers=["mla.host"],
|
||||
layer_stride=host_num_blocks * spec.page_size_bytes,
|
||||
block_stride=spec.page_size_bytes,
|
||||
host_resident=True,
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=gpu_num_blocks * spec.page_size_bytes,
|
||||
layers=["mla.device"],
|
||||
layer_stride=gpu_num_blocks * spec.page_size_bytes,
|
||||
block_stride=spec.page_size_bytes,
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["mla.host"],
|
||||
spec,
|
||||
host_resident=True,
|
||||
),
|
||||
KVCacheGroupSpec(["mla.device"], spec),
|
||||
],
|
||||
)
|
||||
vllm_config = create_vllm_config(block_size=16)
|
||||
vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
|
||||
fake_backend = MagicMock()
|
||||
fake_backend.get_supported_kernel_block_sizes.return_value = [kernel_block_size]
|
||||
fake_backend.get_name.return_value = "FLASHMLA"
|
||||
fake_backend.full_cls_name.return_value = "fake.FLASHMLA"
|
||||
fake_backend.get_kv_cache_shape.return_value = (1, kernel_block_size, 1, 1)
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.device_type = "cuda"
|
||||
fake_platform.get_nixl_memory_type.return_value = "VRAM"
|
||||
physical_per_logical = spec.block_size // kernel_block_size
|
||||
physical_page_bytes = spec.page_size_bytes // physical_per_logical
|
||||
host_cache = torch.zeros(
|
||||
host_num_blocks * physical_per_logical, physical_page_bytes, dtype=torch.uint8
|
||||
)
|
||||
device_cache = torch.zeros(
|
||||
gpu_num_blocks * physical_per_logical, physical_page_bytes, dtype=torch.uint8
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(bw, "NixlWrapper"),
|
||||
patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
|
||||
patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
|
||||
patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
|
||||
patch.object(bw, "current_platform", fake_platform),
|
||||
set_current_vllm_config(vllm_config),
|
||||
):
|
||||
worker = NixlConnectorWorker(vllm_config, "test-engine", kv_cache_config)
|
||||
worker.use_mla = True
|
||||
worker.nixl_wrapper.get_agent_metadata.return_value = b"metadata"
|
||||
worker.register_kv_caches({"mla.host": host_cache, "mla.device": device_cache})
|
||||
|
||||
assert worker.num_blocks == gpu_num_blocks * physical_per_logical
|
||||
assert dict(zip(worker.region_group_ids, worker.region_num_blocks)) == {
|
||||
0: host_num_blocks * physical_per_logical,
|
||||
1: gpu_num_blocks * physical_per_logical,
|
||||
}
|
||||
caches = {"mla.host": host_cache, "mla.device": device_cache}
|
||||
expected_addrs = [
|
||||
caches[name].data_ptr() + block * stride
|
||||
for name, stride, count in zip(
|
||||
worker.region_names,
|
||||
worker.block_stride_per_layer,
|
||||
worker.region_num_blocks,
|
||||
strict=True,
|
||||
)
|
||||
for block in range(count)
|
||||
]
|
||||
assert worker.src_blocks_data[:, 0].tolist() == expected_addrs
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
|
||||
"""Hybrid MLA+KDA registration: HMA tensors shared by both layer types
|
||||
@@ -1803,6 +1938,9 @@ def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
|
||||
kv_cache_config = _make_hybrid_mla_kv_cache_config()
|
||||
unified_page = kv_cache_config.kv_cache_groups[0].kv_cache_spec.page_size_bytes
|
||||
vllm_config = create_vllm_config(block_size=12)
|
||||
# The engine rewrites this global value to the smallest scheduler-group
|
||||
# block size. NIXL must retain the transferable groups' 12-token geometry.
|
||||
vllm_config.cache_config.block_size = 4
|
||||
# kv_buffer_device defaults to the *real* platform's device type, which on
|
||||
# a CPU-only test host would make this a host-buffer worker: host xfer
|
||||
# buffers are per-layer, so the HMA shared tensors would not be
|
||||
@@ -1833,7 +1971,13 @@ def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
|
||||
worker.use_mla = True # opt-125m test config is not MLA; force the flag
|
||||
worker.nixl_wrapper.get_agent_metadata.return_value = b"fake-agent-metadata"
|
||||
|
||||
tensors = [torch.zeros(4 * unified_page, dtype=torch.uint8) for _ in range(2)]
|
||||
kernel_page = unified_page // 3
|
||||
block_stride = 2 * kernel_page
|
||||
backing = torch.zeros(12, block_stride, dtype=torch.uint8)
|
||||
tensors = [
|
||||
backing[:, :kernel_page],
|
||||
backing[:, kernel_page:],
|
||||
]
|
||||
# KDA layer first per tensor: exercises the dual-purpose flag merge.
|
||||
worker.register_kv_caches(
|
||||
{
|
||||
@@ -1849,6 +1993,8 @@ def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
|
||||
# 12-token logical blocks over the 4-token MLA kernel block.
|
||||
assert worker._physical_blocks_per_logical_kv_block == 3
|
||||
assert worker.block_size == 4 and worker.num_blocks == 12
|
||||
assert worker.region_group_ids == [bw._SHARED_REGION_GROUP_ID] * 2
|
||||
assert worker.block_stride_per_layer == [block_stride, block_stride]
|
||||
# Both shared tensors are dual-purpose: their FA view is MLA even though
|
||||
# a KDA layer registered them first.
|
||||
assert worker._region_is_mla == [True, True]
|
||||
@@ -1861,8 +2007,20 @@ def test_register_kv_caches_hybrid_mla_dual_purpose_regions():
|
||||
# Mamba descs: 2 regions x (3 conv sub-projections + 1 ssm) x 4 blocks.
|
||||
assert worker.src_blocks_data.shape == (24 + 32, 3)
|
||||
fa_descs = worker.src_blocks_data[:24]
|
||||
assert fa_descs[1][0] - fa_descs[0][0] == unified_page // 3
|
||||
expected_addrs = [
|
||||
tensor.data_ptr() + block * block_stride
|
||||
for tensor in tensors
|
||||
for block in range(12)
|
||||
]
|
||||
assert fa_descs[:, 0].tolist() == expected_addrs
|
||||
assert all(size == unified_page // 3 for size in fa_descs[:, 1])
|
||||
worker.nixl_wrapper.register_memory.assert_called_once()
|
||||
metadata = msgspec.msgpack.decode(
|
||||
worker.xfer_handshake_metadata.agent_metadata_bytes,
|
||||
type=NixlAgentMetadata,
|
||||
)
|
||||
assert metadata.block_strides == [block_stride, block_stride]
|
||||
assert metadata.region_num_blocks == [12, 12]
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@@ -1909,6 +2067,8 @@ def test_push_write_hybrid_mla_replicates_attention():
|
||||
worker.dst_xfer_side_handles = {engine_id: {0: 100, 1: 101}}
|
||||
worker.src_xfer_handles_by_tp_ratio = {(-2, 4): [200, 201]}
|
||||
worker.src_xfer_handles_by_block_size = {4: 300}
|
||||
worker.region_group_ids = [0, 1]
|
||||
worker.dst_region_group_ids = {engine_id: [0, 1]}
|
||||
worker._sending_transfers = defaultdict(list)
|
||||
worker._sending_transfers_lock = threading.Lock()
|
||||
worker.kv_cache_config = _make_hybrid_mla_kv_cache_config()
|
||||
|
||||
@@ -186,11 +186,16 @@ def test_overlaid_transfer_groups_share_region_geometry():
|
||||
worker.host_buffer_kv_cache_layout = "NHD"
|
||||
worker._physical_blocks_per_logical_kv_block = 1
|
||||
worker._logical_num_blocks = num_blocks
|
||||
worker.region_mem_types = []
|
||||
worker.region_group_ids = []
|
||||
worker.region_mem_types = []
|
||||
worker._mixed_mem_types = False
|
||||
worker.region_names = []
|
||||
worker.region_num_blocks = []
|
||||
worker._mixed_mem_types = False
|
||||
worker._desc_is_dram_by_block_size = {}
|
||||
worker._desc_pos_by_block_size = {}
|
||||
worker._dram_src_handles_by_block_size = {}
|
||||
worker._region_is_mla = []
|
||||
worker.block_len_per_layer = []
|
||||
worker.block_stride_per_layer = []
|
||||
@@ -304,8 +309,6 @@ def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blo
|
||||
# would not be deduplicated. Pin it to the faked device type.
|
||||
vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_backend = MagicMock()
|
||||
fake_backend.get_supported_kernel_block_sizes.return_value = [kernel_block_size]
|
||||
fake_backend.get_name.return_value = "FLASHMLA"
|
||||
@@ -552,6 +555,16 @@ def _make_remote_meta(
|
||||
)
|
||||
|
||||
|
||||
def _register_remote_agents(worker, metadata, tp_size):
|
||||
"""Mirror the async handshake callback that publishes prepared agents."""
|
||||
worker._remote_agents[metadata.engine_id] = {
|
||||
(0, rank): worker.add_remote_agent(
|
||||
metadata, remote_tp_rank=rank, remote_tp_size=tp_size
|
||||
)
|
||||
for rank in range(tp_size)
|
||||
}
|
||||
|
||||
|
||||
def _owned_byte_ranges(worker, group_logical_ids):
|
||||
"""Byte ranges owned by a request: for each HMA region tensor, every
|
||||
logical block id of every group maps to one unified page."""
|
||||
@@ -609,8 +622,7 @@ def test_hetero_ppl_multi_read_writes_stay_within_request_blocks():
|
||||
remote_num_logical=12,
|
||||
remote_ssm_sizes=(24, 32),
|
||||
)
|
||||
for rank in (0, 1):
|
||||
worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=2)
|
||||
_register_remote_agents(worker, meta_r, 2)
|
||||
|
||||
# Request B: 17 matched tokens. Local: 2 logical blocks (24 tok
|
||||
# capacity); remote: 16 prefilled tokens -> 2 remote logical blocks.
|
||||
@@ -626,7 +638,7 @@ def test_hetero_ppl_multi_read_writes_stay_within_request_blocks():
|
||||
"remote_block_ids": remote_ids,
|
||||
"remote_engine_id": "remote-engine",
|
||||
"remote_request_id": "prefill-req-b",
|
||||
"remote_host": "localhost",
|
||||
"remote_host": "remote-host",
|
||||
"remote_port": 1234,
|
||||
"tp_size": 2,
|
||||
},
|
||||
@@ -709,8 +721,7 @@ def _run_hetero_case(
|
||||
remote_num_logical=max(2 * n_remote + 4, 8),
|
||||
remote_ssm_sizes=(48 // tp_size, 64 // tp_size),
|
||||
)
|
||||
for rank in range(tp_size):
|
||||
worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=tp_size)
|
||||
_register_remote_agents(worker, meta_r, tp_size)
|
||||
|
||||
# Sparse ids so neighbors exist between the request's blocks.
|
||||
local_attn = [2 * i + 1 for i in range(n_local)]
|
||||
@@ -726,7 +737,7 @@ def _run_hetero_case(
|
||||
"remote_block_ids": remote_ids,
|
||||
"remote_engine_id": "remote-engine",
|
||||
"remote_request_id": "prefill-req-b",
|
||||
"remote_host": "localhost",
|
||||
"remote_host": "remote-host",
|
||||
"remote_port": 1234,
|
||||
"tp_size": tp_size,
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ requiring a real NIXL agent or network:
|
||||
* The worker matches D registrations against P finished blocks (both
|
||||
scenario directions) and forwards non-PUSH_REG NIXL notifs to the main
|
||||
thread's ``_get_new_notifs``.
|
||||
* ``get_finished`` enqueues evictions for the writer.
|
||||
* ``get_transfer_results`` enqueues evictions for the writer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,6 +32,9 @@ from unittest.mock import MagicMock, patch
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorTransferResults,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
PUSH_REG_NOTIF_PREFIX,
|
||||
NixlAgentMetadata,
|
||||
@@ -347,6 +350,8 @@ class _StubWriterWorker(NixlPushConnectorWorker):
|
||||
|
||||
# Base worker fields touched by start_load_kv / _get_new_notifs.
|
||||
w._recving_metadata = {}
|
||||
w._pending_recv_notifs = {}
|
||||
w._failed_inflight_recvs = set()
|
||||
w._recving_transfers = defaultdict(list)
|
||||
w._reqs_to_process = set()
|
||||
w._reqs_to_send = {}
|
||||
@@ -742,21 +747,19 @@ class TestPushWriterNotifs:
|
||||
assert notified == set()
|
||||
assert request_id in w._recving_transfers
|
||||
|
||||
def test_get_finished_evicts_completed_state(self):
|
||||
"""``get_finished`` should enqueue evictions and wake the writer."""
|
||||
def test_get_transfer_results_evicts_completed_state(self):
|
||||
"""Transfer completion should enqueue evictions and wake the writer."""
|
||||
w = _StubWriterWorker.fresh()
|
||||
|
||||
# Stub the base ``get_finished`` to return one done_sending entry.
|
||||
# Patch via the MRO's parent class.
|
||||
with patch.object(
|
||||
NixlPushConnectorWorker.__mro__[1],
|
||||
"get_finished",
|
||||
return_value=({"req-done"}, set()),
|
||||
"get_transfer_results",
|
||||
return_value=KVConnectorTransferResults(finished_sending={"req-done"}),
|
||||
):
|
||||
done_sending, done_recving = w.get_finished()
|
||||
results = w.get_transfer_results()
|
||||
|
||||
assert "req-done" in done_sending
|
||||
assert done_recving == set()
|
||||
assert results.finished_sending == {"req-done"}
|
||||
assert results.finished_recving == set()
|
||||
# Eviction enqueued for the writer.
|
||||
evicted = []
|
||||
while True:
|
||||
@@ -928,19 +931,21 @@ class TestPushWriterNegative:
|
||||
assert len(w._pending_d_registrations) == 1
|
||||
assert w.start_push_calls == []
|
||||
|
||||
def test_get_finished_enqueues_eviction_for_each_done_request(self):
|
||||
"""``get_finished`` must enqueue an eviction for every request
|
||||
def test_get_transfer_results_enqueues_each_completed_request(self):
|
||||
"""``get_transfer_results`` must enqueue every completed request
|
||||
in ``done_sending`` so the writer can drop stale matching state.
|
||||
Unlike the happy-path test, this verifies the *cardinality*: N
|
||||
completed requests -> N evictions, in order."""
|
||||
w = _StubWriterWorker.fresh()
|
||||
with patch.object(
|
||||
NixlPushConnectorWorker.__mro__[1],
|
||||
"get_finished",
|
||||
return_value=({"req-1", "req-2", "req-3"}, set()),
|
||||
"get_transfer_results",
|
||||
return_value=KVConnectorTransferResults(
|
||||
finished_sending={"req-1", "req-2", "req-3"}
|
||||
),
|
||||
):
|
||||
done_sending, _ = w.get_finished()
|
||||
assert done_sending == {"req-1", "req-2", "req-3"}
|
||||
results = w.get_transfer_results()
|
||||
assert results.finished_sending == {"req-1", "req-2", "req-3"}
|
||||
|
||||
evicted: list[str] = []
|
||||
while True:
|
||||
@@ -950,23 +955,47 @@ class TestPushWriterNegative:
|
||||
break
|
||||
assert sorted(evicted) == ["req-1", "req-2", "req-3"]
|
||||
|
||||
def test_get_finished_with_no_completions_does_not_enqueue_eviction(self):
|
||||
def test_empty_transfer_results_do_not_enqueue_eviction(self):
|
||||
"""If there's nothing newly done, no eviction should be enqueued.
|
||||
The wake event IS still set because ``get_finished`` always wakes
|
||||
the writer to drain notifs."""
|
||||
w = _StubWriterWorker.fresh()
|
||||
with patch.object(
|
||||
NixlPushConnectorWorker.__mro__[1],
|
||||
"get_finished",
|
||||
return_value=(set(), set()),
|
||||
"get_transfer_results",
|
||||
return_value=KVConnectorTransferResults(),
|
||||
):
|
||||
done_sending, done_recving = w.get_finished()
|
||||
assert done_sending == set()
|
||||
assert done_recving == set()
|
||||
results = w.get_transfer_results()
|
||||
assert results.finished_sending == set()
|
||||
assert results.finished_recving == set()
|
||||
assert w._evict_finished_inbox.qsize() == 0
|
||||
# Wake set so the writer drains NIXL notifs even when idle.
|
||||
assert w._push_writer_wake.is_set()
|
||||
|
||||
def test_failed_push_transfer_finishes_sending(self):
|
||||
w = _StubWriterWorker.fresh()
|
||||
w.nixl_wrapper = MagicMock()
|
||||
w.nixl_wrapper.check_xfer_state.return_value = "ERR"
|
||||
w.xfer_stats = MagicMock()
|
||||
w._log_failure = MagicMock()
|
||||
w._sending_transfers["req-failed"] = [17]
|
||||
w._reqs_to_send["req-failed"] = time.perf_counter() + 30
|
||||
w._reqs_to_process.add("req-failed")
|
||||
|
||||
with patch.object(
|
||||
NixlPushConnectorWorker.__mro__[1],
|
||||
"get_transfer_results",
|
||||
return_value=KVConnectorTransferResults(),
|
||||
):
|
||||
results = w.get_transfer_results()
|
||||
|
||||
assert results.finished_sending == {"req-failed"}
|
||||
assert results.finished_recving == set()
|
||||
assert "req-failed" not in w._sending_transfers
|
||||
assert "req-failed" not in w._reqs_to_send
|
||||
assert "req-failed" not in w._reqs_to_process
|
||||
w.nixl_wrapper.release_xfer_handle.assert_called_once_with(17)
|
||||
|
||||
def test_get_new_notifs_unknown_request_is_logged_and_skipped(self, caplog):
|
||||
"""A completion notif for a request the worker doesn't know
|
||||
about should be logged but not crash."""
|
||||
@@ -1160,6 +1189,7 @@ class TestPushWriterMlaReplication:
|
||||
)
|
||||
}
|
||||
w._logical_to_kernel_block_ids = lambda block_ids, ratio: block_ids
|
||||
w.dst_region_group_ids[engine_id] = [0]
|
||||
w.dst_xfer_side_handles = {engine_id: {r: 1000 + r for r in d_ranks}}
|
||||
w.src_xfer_handles_by_block_size = {16: 2000}
|
||||
w._remote_agents = {engine_id: {(0, r): f"agent-{r}" for r in d_ranks}}
|
||||
@@ -1240,6 +1270,8 @@ class TestPushWriterMlaReplication:
|
||||
)
|
||||
}
|
||||
w._logical_to_kernel_block_ids = lambda block_ids, ratio: block_ids
|
||||
w.region_group_ids = [0, 1, 2, 3]
|
||||
w.dst_region_group_ids[engine_id] = [0, 1, 2, 3]
|
||||
w.dst_xfer_side_handles = {engine_id: {0: 1000, 1: 1001}}
|
||||
w.src_xfer_handles_by_tp_ratio = {(-2, 16): [2000, 2001]}
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@ class DummyModelRunnerOutput(ModelRunnerOutput):
|
||||
finished_sending: set[str] | None = None,
|
||||
finished_recving: set[str] | None = None,
|
||||
invalid_block_ids: set[int] | None = None,
|
||||
failed_recving: set[str] | None = None,
|
||||
expected_finished_count: int = 0,
|
||||
):
|
||||
self.kv_connector_output = KVConnectorOutput(
|
||||
finished_sending=finished_sending,
|
||||
finished_recving=finished_recving,
|
||||
invalid_block_ids=invalid_block_ids or set(),
|
||||
failed_recving=failed_recving or set(),
|
||||
expected_finished_count=expected_finished_count,
|
||||
)
|
||||
|
||||
@@ -73,7 +75,9 @@ def test_aggregate_workers_output():
|
||||
|
||||
output1 = DummyModelRunnerOutput(invalid_block_ids={3, 4})
|
||||
output2 = DummyModelRunnerOutput(
|
||||
finished_recving={"req2"}, invalid_block_ids={4, 5}
|
||||
finished_recving={"req2"},
|
||||
invalid_block_ids={4, 5},
|
||||
failed_recving={"req3"},
|
||||
)
|
||||
|
||||
aggregated = aggregator.aggregate([output1, output2])
|
||||
@@ -83,6 +87,12 @@ def test_aggregate_workers_output():
|
||||
assert aggregated.finished_sending is None
|
||||
assert aggregated.finished_recving == {"req2"}
|
||||
assert aggregated.invalid_block_ids == {3, 4, 5}
|
||||
assert not aggregated.failed_recving
|
||||
|
||||
output1 = DummyModelRunnerOutput(finished_recving={"req3"})
|
||||
output2 = DummyModelRunnerOutput(finished_recving={"req3"})
|
||||
aggregated = aggregator.aggregate([output1, output2])
|
||||
assert aggregated.kv_connector_output.failed_recving == {"req3"}
|
||||
|
||||
|
||||
def test_aggregate_workers_output_with_expected_finished_count():
|
||||
|
||||
@@ -539,6 +539,7 @@ def make_nixl_scheduler(
|
||||
sched._heartbeat_interval = kv_lease_duration // 6
|
||||
# Fields touched by build_connector_meta / request_finished:
|
||||
sched._reqs_need_recv = {}
|
||||
sched._hisparse_host_blocks_to_recv = {}
|
||||
sched._reqs_need_send = {}
|
||||
sched._reqs_in_batch = set()
|
||||
sched._reqs_not_processed = set()
|
||||
|
||||
@@ -62,7 +62,7 @@ def _make_offloading_config(
|
||||
normalized_extra_config["cpu_bytes_to_use"] = cpu_bytes_to_use
|
||||
|
||||
if groups is None:
|
||||
groups = (OffloadingGroupConfig(16, ("layer",)),)
|
||||
groups = (OffloadingGroupConfig(16, ("layer",), 0),)
|
||||
|
||||
return OffloadingConfig(
|
||||
groups=groups,
|
||||
@@ -493,8 +493,8 @@ def test_offloading_spec_has_replicated_layout_default():
|
||||
|
||||
def test_offloading_spec_uses_normalized_chunk_geometry():
|
||||
groups = (
|
||||
OffloadingGroupConfig(12, ("full_layer",)),
|
||||
OffloadingGroupConfig(16, ("mla_layer",)),
|
||||
OffloadingGroupConfig(12, ("full_layer",), 0),
|
||||
OffloadingGroupConfig(16, ("mla_layer",), 1),
|
||||
)
|
||||
spec = _create_spec(
|
||||
groups=groups,
|
||||
|
||||
@@ -26,8 +26,11 @@ def make_mapper_from_offloading_spec(**kwargs) -> FileMapper:
|
||||
OffloadingGroupConfig(
|
||||
tokens_per_block=tokens_per_block,
|
||||
layer_names=(layer_name,),
|
||||
group_id=group_id,
|
||||
)
|
||||
for group_id, (tokens_per_block, layer_name) in enumerate(
|
||||
kwargs.get("groups", ())
|
||||
)
|
||||
for tokens_per_block, layer_name in kwargs.get("groups", ())
|
||||
),
|
||||
worker_kv_bytes_per_block=0,
|
||||
enable_kv_cache_events=False,
|
||||
|
||||
@@ -400,6 +400,8 @@ def test_register_mixed_page_sizes_in_one_cache_group(monkeypatch):
|
||||
vllm_config.cache_config = CacheConfig()
|
||||
vllm_config.cache_config.kv_cache_layout = layout.name
|
||||
vllm_config.cache_config.num_gpu_blocks_override = None
|
||||
vllm_config.attention_config.hisparse_config = None
|
||||
vllm_config.kv_transfer_config = None
|
||||
|
||||
pages = [spec.page_size_bytes for spec in specs.values()]
|
||||
num_blocks = 4
|
||||
@@ -458,6 +460,8 @@ def test_register_mixed_page_sizes_odd_block_counts(monkeypatch, rank_blocks):
|
||||
vllm_config.cache_config = CacheConfig()
|
||||
vllm_config.cache_config.kv_cache_layout = layout.name
|
||||
vllm_config.cache_config.num_gpu_blocks_override = None
|
||||
vllm_config.attention_config.hisparse_config = None
|
||||
vllm_config.kv_transfer_config = None
|
||||
|
||||
pages = [spec.page_size_bytes for spec in specs.values()]
|
||||
kv_cache_config = get_kv_cache_config_from_groups(
|
||||
@@ -507,6 +511,8 @@ def test_mixed_page_byte_placement_is_dcp_invariant():
|
||||
vllm_config.cache_config.kv_cache_layout = KVCacheLayout.LBNHC.name
|
||||
vllm_config.cache_config.num_gpu_blocks_override = None
|
||||
vllm_config.parallel_config.decode_context_parallel_size = dcp_size
|
||||
vllm_config.attention_config.hisparse_config = None
|
||||
vllm_config.kv_transfer_config = None
|
||||
|
||||
page_bytes = sum(spec.page_size_bytes for spec in specs.values())
|
||||
config = get_kv_cache_config_from_groups(vllm_config, [group], page_bytes * 4)
|
||||
|
||||
@@ -16,6 +16,7 @@ from vllm.v1.core.single_type_kv_cache_manager import (
|
||||
ChunkedLocalAttentionManager,
|
||||
CrossAttentionManager,
|
||||
FullAttentionManager,
|
||||
HiSparseSourceManager,
|
||||
MambaManager,
|
||||
SingleTypeKVCacheManager,
|
||||
SinkFullAttentionManager,
|
||||
@@ -27,6 +28,7 @@ from vllm.v1.kv_cache_interface import (
|
||||
CrossAttentionSpec,
|
||||
FullAttentionSpec,
|
||||
HiddenStateCacheSpec,
|
||||
KVCacheGroupRole,
|
||||
KVCacheSpec,
|
||||
KVCacheSpecKind,
|
||||
MambaSpec,
|
||||
@@ -176,6 +178,19 @@ class TestKVCacheSpecRegistry:
|
||||
is spec_uniform_base_map[spec_cls]
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("role", list(KVCacheGroupRole))
|
||||
def test_mla_manager_selection_by_role(self, role):
|
||||
"""Only the source role overrides ordinary MLA manager selection."""
|
||||
expected = (
|
||||
HiSparseSourceManager
|
||||
if role == KVCacheGroupRole.HISPARSE_SOURCE
|
||||
else FullAttentionManager
|
||||
)
|
||||
assert (
|
||||
KVCacheSpecRegistry.get_manager_class(make_spec(MLAAttentionSpec), role)
|
||||
is expected
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("spec_cls", list(spec_manager_map))
|
||||
def test_custom_spec_register(self, spec_cls):
|
||||
"""A decorated custom spec resolves to the declared manager."""
|
||||
|
||||
@@ -13,15 +13,18 @@ import pytest
|
||||
import torch
|
||||
|
||||
from tests.v1.attention.utils import dense_kv_cache_views
|
||||
from vllm.v1.attention.backend import AttentionCGSupport
|
||||
from vllm.v1.attention.backend import AttentionBackend, AttentionCGSupport, MultipleOf
|
||||
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
|
||||
from vllm.v1.hisparse.binding import allocate_hisparse_kv_caches
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
HiSparseResidentSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheLayout,
|
||||
KVCacheTensor,
|
||||
MLAAttentionSpec,
|
||||
SparseCacheRole,
|
||||
compute_layout_strides,
|
||||
)
|
||||
from vllm.v1.worker.gpu import attn_utils
|
||||
@@ -36,6 +39,60 @@ from vllm.v1.worker.utils import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled", "block_size", "main_sizes", "indexer_sizes", "expected"),
|
||||
[
|
||||
(True, 256, [64], [64], 64),
|
||||
(True, 64, [32, 64], [16, 32], 32),
|
||||
(True, 64, [MultipleOf(16)], [32], 32),
|
||||
(True, 64, [64], [32], None),
|
||||
(False, 256, [64], [64], 256),
|
||||
],
|
||||
)
|
||||
def test_get_kv_cache_spec_resolves_hisparse_block_size(
|
||||
monkeypatch, enabled, block_size, main_sizes, indexer_sizes, expected
|
||||
):
|
||||
"""Resolve shared MLA geometry before planning; leave other specs alone."""
|
||||
specs = {
|
||||
"main": MLAAttentionSpec(
|
||||
block_size=block_size, num_kv_heads=1, head_size=576, dtype=torch.bfloat16
|
||||
),
|
||||
"indexer": MLAAttentionSpec(
|
||||
block_size=block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=128,
|
||||
dtype=torch.bfloat16,
|
||||
cache_role=SparseCacheRole.INDEXER,
|
||||
),
|
||||
"dense": FullAttentionSpec(
|
||||
block_size=block_size, num_kv_heads=1, head_size=128, dtype=torch.bfloat16
|
||||
),
|
||||
}
|
||||
layers = {}
|
||||
for name, sizes in zip(specs, [main_sizes, indexer_sizes, [block_size]]):
|
||||
backend = SimpleNamespace(
|
||||
customize_spec=AttentionBackend.customize_spec,
|
||||
get_supported_kernel_block_sizes=lambda sizes=sizes: sizes,
|
||||
)
|
||||
layers[name] = SimpleNamespace(
|
||||
get_kv_cache_spec=lambda _, spec=specs[name]: spec,
|
||||
get_attn_backend=lambda backend=backend: backend,
|
||||
)
|
||||
monkeypatch.setattr(attn_utils, "get_layers_from_vllm_config", lambda *_: layers)
|
||||
config = SimpleNamespace(
|
||||
attention_config=SimpleNamespace(hisparse_config=object() if enabled else None)
|
||||
)
|
||||
if expected is None:
|
||||
with pytest.raises(ValueError, match="supported by every sparse"):
|
||||
attn_utils.get_kv_cache_spec(config)
|
||||
return
|
||||
|
||||
resolved = attn_utils.get_kv_cache_spec(config)
|
||||
assert resolved["main"].block_size == resolved["indexer"].block_size == expected
|
||||
assert resolved["dense"] is specs["dense"]
|
||||
assert all(spec.block_size == block_size for spec in specs.values())
|
||||
|
||||
|
||||
class _FakeMetadataBuilder:
|
||||
def __init__(self, support: AttentionCGSupport):
|
||||
self.support = support
|
||||
@@ -390,3 +447,71 @@ def test_copy_kv_cache_blocks_with_virtual_block_splitting(
|
||||
torch.testing.assert_close(
|
||||
cache[dst_start + physical_idx], expected[layer_idx][physical_idx]
|
||||
)
|
||||
|
||||
|
||||
def test_allocate_hisparse_kv_caches_host_pool_and_view_less_specs():
|
||||
"""Host tensors get their own backing; view-less specs keep the raw one."""
|
||||
spec = FullAttentionSpec(
|
||||
block_size=2, num_kv_heads=1, head_size=4, dtype=torch.float32
|
||||
)
|
||||
page = spec.page_size_bytes
|
||||
resident_spec = HiSparseResidentSpec(block_size=2, page_size=page)
|
||||
device_size = 4 * page
|
||||
config = KVCacheConfig(
|
||||
num_blocks=4,
|
||||
hisparse_host_num_blocks=3,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=3 * page,
|
||||
layers=["source"],
|
||||
layer_stride=3 * page,
|
||||
block_stride=page,
|
||||
host_resident=True,
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=device_size,
|
||||
layers=["indexer"],
|
||||
layer_stride=device_size,
|
||||
block_stride=page,
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=device_size,
|
||||
layers=["resident"],
|
||||
layer_stride=device_size,
|
||||
block_stride=page,
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(["source"], spec, host_resident=True),
|
||||
KVCacheGroupSpec(["indexer"], spec),
|
||||
KVCacheGroupSpec(["resident"], resident_spec),
|
||||
],
|
||||
)
|
||||
host_buffers: list[torch.Tensor] = []
|
||||
|
||||
def host_allocator(size: int) -> torch.Tensor:
|
||||
host_buffers.append(torch.zeros(size, dtype=torch.int8))
|
||||
return host_buffers[-1]
|
||||
|
||||
caches = allocate_hisparse_kv_caches(
|
||||
config,
|
||||
torch.device("cpu"),
|
||||
KVCacheLayout.LBHNC,
|
||||
[2, 2, 2],
|
||||
SimpleNamespace(allocate=host_allocator),
|
||||
)
|
||||
assert len(config.kv_cache_tensors) == 3
|
||||
|
||||
assert [buf.numel() for buf in host_buffers] == [3 * page]
|
||||
assert caches["source"].shape[0] == 3
|
||||
assert (
|
||||
caches["source"].untyped_storage().data_ptr()
|
||||
== host_buffers[0].untyped_storage().data_ptr()
|
||||
)
|
||||
assert caches["indexer"].shape[0] == 4
|
||||
backing = caches["resident"]
|
||||
assert backing.dtype == torch.int8 and backing.numel() >= device_size
|
||||
assert (
|
||||
backing.untyped_storage().data_ptr()
|
||||
== caches["indexer"].untyped_storage().data_ptr()
|
||||
)
|
||||
|
||||
@@ -134,6 +134,7 @@ def test_overlaid_zeroer_dedups_segments_with_max_span():
|
||||
vllm_config.cache_config = CacheConfig()
|
||||
vllm_config.cache_config.num_gpu_blocks_override = None
|
||||
vllm_config.cache_config.kv_cache_layout = "BLHNC"
|
||||
vllm_config.attention_config.hisparse_config = None
|
||||
config = get_kv_cache_config_from_groups(vllm_config, groups, 8 * 1024 * 1024)
|
||||
views = allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout.BLHNC, None)
|
||||
buf_ptr = views["g1.big"].data_ptr()
|
||||
|
||||
@@ -351,6 +351,8 @@ def test_multi_step_decode_replays_captured_graph_as_expected(
|
||||
speculator = object.__new__(_TestSpeculator)
|
||||
speculator.num_speculative_steps = 4
|
||||
speculator.current_draft_step = torch.tensor(0)
|
||||
speculator.slot_mapping_observer = None
|
||||
speculator.host_mirror_forward_observer = None
|
||||
speculator.input_buffers = SimpleNamespace(
|
||||
positions=torch.arange(2),
|
||||
query_start_loc=torch.arange(3),
|
||||
|
||||
@@ -5,9 +5,11 @@ from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm.v1.worker.gpu.kv_connector as kv_connector_module
|
||||
from vllm.config import KVTransferConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorTransferResults
|
||||
from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector
|
||||
|
||||
|
||||
@@ -18,9 +20,9 @@ def _make_connector(
|
||||
backend = Mock()
|
||||
backend.handle_preemptions.side_effect = lambda _: events.append("handle")
|
||||
backend.bind_connector_metadata.side_effect = lambda _: events.append("bind")
|
||||
backend.start_load_kv.side_effect = lambda _: events.append("start")
|
||||
backend.start_load_kv.side_effect = lambda *_args, **_kwargs: events.append("start")
|
||||
backend.wait_for_save.side_effect = lambda: events.append("wait")
|
||||
backend.get_finished.side_effect = lambda _: (set(), set())
|
||||
backend.get_transfer_results.return_value = KVConnectorTransferResults()
|
||||
backend.get_block_ids_with_load_errors.return_value = set()
|
||||
backend.get_kv_connector_stats.return_value = None
|
||||
backend.get_kv_connector_kv_cache_events.return_value = None
|
||||
@@ -61,7 +63,15 @@ def test_load_start_phase(
|
||||
connector = _make_connector(monkeypatch, events)
|
||||
output = _scheduler_output(has_sync_kv_loads)
|
||||
|
||||
connector.pre_forward(output) # type: ignore[arg-type]
|
||||
request_indices = torch.tensor([3, 1])
|
||||
request_ids = ["first", "second"]
|
||||
attn_metadata = {"layer": object()}
|
||||
connector.pre_forward( # type: ignore[arg-type]
|
||||
output,
|
||||
request_state_indices=request_indices,
|
||||
request_ids=request_ids,
|
||||
attn_metadata=attn_metadata,
|
||||
)
|
||||
assert events == (
|
||||
["handle", "bind", "start"] if has_sync_kv_loads else ["handle", "bind"]
|
||||
)
|
||||
@@ -69,6 +79,16 @@ def test_load_start_phase(
|
||||
connector.post_forward(set())
|
||||
assert events == ["handle", "bind", "start", "wait", "clear"]
|
||||
|
||||
kwargs = connector.kv_connector.start_load_kv.call_args.kwargs
|
||||
assert kwargs["request_state_indices"] is request_indices
|
||||
assert kwargs["request_ids"] is request_ids
|
||||
assert kwargs["attn_metadata"] is attn_metadata
|
||||
|
||||
# A subsequent step without a forward must not reuse the prior batch.
|
||||
connector.no_forward(_scheduler_output(False)) # type: ignore[arg-type]
|
||||
assert connector.kv_connector.start_load_kv.call_count == 2
|
||||
assert connector.kv_connector.start_load_kv.call_args.kwargs == {}
|
||||
|
||||
|
||||
def test_no_forward_starts_deferred_load_once(monkeypatch: pytest.MonkeyPatch):
|
||||
events: list[str] = []
|
||||
|
||||
@@ -312,6 +312,10 @@ def test_select_common_block_size_uses_largest_shared_int():
|
||||
assert selected_size == 64
|
||||
|
||||
|
||||
def test_select_common_block_size_without_active_backends_uses_manager_size():
|
||||
assert select_common_block_size(256, []) == 256
|
||||
|
||||
|
||||
def test_select_common_block_size_accepts_rocm_sparse_block_size_16(monkeypatch):
|
||||
monkeypatch.setattr(current_platform, "is_rocm", lambda: True)
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ def _make_capture_runner(captured: bool) -> GPUModelRunner:
|
||||
runner.attn_groups = None
|
||||
runner.kv_cache_config = None
|
||||
runner.use_aux_hidden_state_outputs = False
|
||||
runner.kv_connector = model_runner_module.NO_OP_KV_CONNECTOR
|
||||
return runner
|
||||
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ def _make_runner(
|
||||
return SimpleNamespace(
|
||||
num_speculative_steps=num_spec_steps,
|
||||
adaptive_verification=None,
|
||||
rejection_sampler=None,
|
||||
decode_query_len=num_spec_steps + 1,
|
||||
is_pooling_model=False,
|
||||
is_encoder_decoder=False,
|
||||
|
||||
@@ -5,8 +5,10 @@ from contextlib import AbstractContextManager
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm.v1.hisparse.binding as hisparse_binding
|
||||
import vllm.v1.worker.gpu.attn_utils as attn_utils
|
||||
import vllm.v1.worker.gpu_model_runner as gpu_model_runner
|
||||
from vllm.v1.worker.gpu_worker import Worker
|
||||
@@ -26,7 +28,8 @@ class _AllocationScope(AbstractContextManager):
|
||||
self.active = False
|
||||
|
||||
|
||||
def test_mrv2_kv_pool_only_wraps_backing_allocation(monkeypatch) -> None:
|
||||
@pytest.mark.parametrize("hisparse", [False, True])
|
||||
def test_mrv2_kv_pool_only_wraps_backing_allocation(monkeypatch, hisparse) -> None:
|
||||
scope = _AllocationScope()
|
||||
kv_caches = {"layer": torch.empty(0)}
|
||||
|
||||
@@ -38,23 +41,48 @@ def test_mrv2_kv_pool_only_wraps_backing_allocation(monkeypatch) -> None:
|
||||
assert not scope.active
|
||||
|
||||
monkeypatch.setattr(attn_utils, "allocate_kv_cache", allocate)
|
||||
monkeypatch.setattr(attn_utils, "bind_kv_cache", bind)
|
||||
monkeypatch.setattr(attn_utils, "bind_kv_cache_to_layers", bind)
|
||||
monkeypatch.setattr(attn_utils, "get_shared_kv_cache_layers", lambda config: {})
|
||||
|
||||
hisparse_bindings = []
|
||||
if hisparse:
|
||||
host_pool = SimpleNamespace()
|
||||
|
||||
def bind_hisparse(**kwargs):
|
||||
hisparse_bindings.append(kwargs)
|
||||
assert scope.active
|
||||
assert kwargs["kv_caches"] is kv_caches
|
||||
assert kwargs["host_pool"] is host_pool
|
||||
return [
|
||||
SimpleNamespace(
|
||||
view=SimpleNamespace(cache=torch.empty(1, 1, 1), block_size=1),
|
||||
runtime=SimpleNamespace(),
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(hisparse_binding, "HiSparseHostPool", lambda: host_pool)
|
||||
monkeypatch.setattr(hisparse_binding, "allocate_hisparse_kv_caches", allocate)
|
||||
monkeypatch.setattr(hisparse_binding, "bind_hisparse_kv_caches", bind_hisparse)
|
||||
|
||||
config = SimpleNamespace(
|
||||
attention_config=SimpleNamespace(
|
||||
hisparse_config=object() if hisparse else None
|
||||
),
|
||||
scheduler_config=SimpleNamespace(max_num_seqs=1, max_num_batched_tokens=1),
|
||||
cache_config=SimpleNamespace(get_resolved_kv_cache_layout=lambda: None),
|
||||
model_config=SimpleNamespace(hf_config=SimpleNamespace(model_type="test")),
|
||||
)
|
||||
result = attn_utils.init_kv_cache(
|
||||
[],
|
||||
{},
|
||||
SimpleNamespace(kv_cache_groups=[]),
|
||||
torch.device("cpu"),
|
||||
[],
|
||||
config,
|
||||
kv_cache_allocation_context=scope,
|
||||
block_tables=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert len(hisparse_bindings) == int(hisparse)
|
||||
assert result is kv_caches
|
||||
assert not scope.active
|
||||
|
||||
|
||||
@@ -35,10 +35,15 @@ def test_mixed_warmup_skipped_for_single_seq(max_num_reqs):
|
||||
def test_kernel_warmup_restores_uncalibrated_adaptive_manager(monkeypatch, fail_warmup):
|
||||
"""Startup must warm fixed drafts before calibration and retain its manager."""
|
||||
manager = SimpleNamespace(cost_tables=None)
|
||||
runner = SimpleNamespace(adaptive_verification=manager)
|
||||
rejection_sampler = SimpleNamespace(enable_adaptive_verification=True)
|
||||
runner = SimpleNamespace(
|
||||
adaptive_verification=manager,
|
||||
rejection_sampler=rejection_sampler,
|
||||
)
|
||||
|
||||
def run_steps(model_runner, execute, sample):
|
||||
assert model_runner.adaptive_verification is None
|
||||
assert not model_runner.rejection_sampler.enable_adaptive_verification
|
||||
if fail_warmup:
|
||||
raise RuntimeError("warmup failed")
|
||||
|
||||
@@ -50,3 +55,4 @@ def test_kernel_warmup_restores_uncalibrated_adaptive_manager(monkeypatch, fail_
|
||||
warmup.warmup_kernels(runner, _fail, _fail)
|
||||
assert runner.adaptive_verification is manager
|
||||
assert manager.cost_tables is None
|
||||
assert rejection_sampler.enable_adaptive_verification
|
||||
|
||||
@@ -1,13 +1,897 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections import deque
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm.v1.attention.backends.mla.index_group as index_group_module
|
||||
import vllm.v1.hisparse.runtime as hisparse_runtime_module
|
||||
from vllm.config import CUDAGraphMode
|
||||
from vllm.config.mamba import MambaBackendEnum, MambaConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse import (
|
||||
worker as hisparse_worker_module,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
|
||||
HiSparseConnectorWorker,
|
||||
_flatten_row_mirrors,
|
||||
_select_written_row_mirrors,
|
||||
_SlotMappingStaging,
|
||||
)
|
||||
from vllm.model_executor.layers.mamba.mamba_mixer2 import MambaMixer2
|
||||
from vllm.v1.worker.utils import bind_kv_cache
|
||||
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
|
||||
from vllm.v1.hisparse.types import SparseKVPageTransfer, SparseKVRowMirror
|
||||
from vllm.v1.worker.utils import (
|
||||
bind_kv_cache,
|
||||
bind_kv_cache_to_layers,
|
||||
copy_kv_cache_blocks_inplace,
|
||||
)
|
||||
|
||||
|
||||
def _make_hisparse_worker() -> HiSparseConnectorWorker:
|
||||
worker = object.__new__(HiSparseConnectorWorker)
|
||||
worker.refines_row_mirrors = False
|
||||
worker._slot_mapping_staging = None
|
||||
worker._row_mirror_num_rows = 0
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._pending_dma_descriptors = deque()
|
||||
worker._dma_free_descriptors = []
|
||||
worker.host_write_events = (MagicMock(), MagicMock())
|
||||
worker.host_write_event = worker.host_write_events[1]
|
||||
worker._next_host_write_event = 0
|
||||
worker.dma_stream = None
|
||||
worker.shared_host_region = None
|
||||
worker._completed_host_copy_dst_ids = []
|
||||
worker._slot_mapping_staging = None
|
||||
return worker
|
||||
|
||||
|
||||
def test_hisparse_row_mirrors_follow_runner_request_order():
|
||||
first = SparseKVRowMirror((1,), 10, 1)
|
||||
second = SparseKVRowMirror((2,), 20, 1)
|
||||
row_mirrors = {"first": (first,), "second": (second,)}
|
||||
|
||||
assert _flatten_row_mirrors(row_mirrors, ["second", "first"]) == (
|
||||
second,
|
||||
first,
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_gpu_slots_select_written_rows_from_async_envelope():
|
||||
"""Rejected speculative rows must never become durable host KV."""
|
||||
candidates = (
|
||||
SparseKVRowMirror((100, 500), 1000, 4),
|
||||
SparseKVRowMirror((20, 300), 2000, 4),
|
||||
)
|
||||
source_slots = np.array([102, 103, -1, 21, 22, 23], dtype=np.int64)
|
||||
|
||||
assert _select_written_row_mirrors(candidates, source_slots, 0) == (
|
||||
SparseKVRowMirror((102, 502), 1002, 2),
|
||||
SparseKVRowMirror((21, 301), 2001, 3),
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_written_rows_preserve_scheduler_page_boundaries():
|
||||
candidates = (
|
||||
SparseKVRowMirror((0,), 100, 4),
|
||||
SparseKVRowMirror((4,), 104, 4),
|
||||
)
|
||||
|
||||
assert _select_written_row_mirrors(
|
||||
candidates, np.array([2, 3, 4, 5], dtype=np.int64), 0
|
||||
) == (
|
||||
SparseKVRowMirror((2,), 102, 2),
|
||||
SparseKVRowMirror((4,), 104, 2),
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_written_rows_without_host_destinations_are_skipped():
|
||||
assert (
|
||||
_select_written_row_mirrors((), np.array([100, 101], dtype=np.int64), 0) == ()
|
||||
)
|
||||
|
||||
|
||||
def test_hisparse_appends_reference_slots_within_a_mirror_phase(monkeypatch):
|
||||
"""Separate context and query writes must share one mirror phase."""
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.refines_row_mirrors = True
|
||||
state = _SlotMappingStaging(
|
||||
MagicMock(), MagicMock(), torch.empty(4, dtype=torch.int64)
|
||||
)
|
||||
worker._slot_mapping_staging = state
|
||||
handle = SimpleNamespace(runtime=SimpleNamespace(resident_source_index=1))
|
||||
worker.cache_layer_names = ["layer"]
|
||||
worker.cache_handles = [handle]
|
||||
worker._layer_mirror_callbacks = (MagicMock(),)
|
||||
worker._row_mirror_num_rows = 1
|
||||
worker._submitted_mirror_layers = set()
|
||||
main_stream = MagicMock()
|
||||
monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: main_stream)
|
||||
monkeypatch.setattr(torch.cuda, "stream", lambda stream: nullcontext())
|
||||
|
||||
handle.slot_mapping = torch.tensor([7, 8], dtype=torch.int64)
|
||||
worker._stage_row_mirror_mapping(2)
|
||||
handle.slot_mapping = torch.tensor([9, 10], dtype=torch.int64)
|
||||
worker._stage_row_mirror_mapping(2)
|
||||
|
||||
torch.testing.assert_close(state.slots, torch.tensor([7, 8, 9, 10]))
|
||||
assert state.stream.wait_stream.call_args_list == [call(main_stream)] * 2
|
||||
assert state.event.record.call_args_list == [call(state.stream)] * 2
|
||||
assert state.num_tokens == 4
|
||||
assert state.source_index == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "expected_calls"),
|
||||
[(CUDAGraphMode.PIECEWISE, 1), (CUDAGraphMode.FULL, 0)],
|
||||
)
|
||||
def test_hisparse_submits_layer_mirror_at_replayed_attention_boundary(
|
||||
monkeypatch, mode, expected_calls
|
||||
):
|
||||
"""Piecewise replay must pipeline DMA; FULL replay uses the batch fallback."""
|
||||
handle = object.__new__(hisparse_runtime_module.HiSparseCacheHandle)
|
||||
handle.dummy_batch = False
|
||||
handle.decode_batch = False
|
||||
handle.submit_layer_mirror = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_runtime_module,
|
||||
"get_forward_context",
|
||||
lambda: SimpleNamespace(cudagraph_runtime_mode=mode),
|
||||
)
|
||||
|
||||
handle.finish_kv_update()
|
||||
|
||||
assert handle.submit_layer_mirror.call_count == expected_calls
|
||||
|
||||
|
||||
def test_copy_cpu_kv_cache_logical_blocks_ignores_storage_padding():
|
||||
backing = torch.full((10, 2, 3), -1, dtype=torch.float32)
|
||||
cache = backing[1:9]
|
||||
cache[2:4] = 7
|
||||
cache[6:8] = 11
|
||||
|
||||
copy_kv_cache_blocks_inplace(
|
||||
[cache],
|
||||
num_blocks=4,
|
||||
kv_cache_block_copies=[
|
||||
KVCacheBlockCopy(1, 0),
|
||||
KVCacheBlockCopy(3, 2),
|
||||
],
|
||||
)
|
||||
|
||||
torch.testing.assert_close(cache[0:2], torch.full_like(cache[0:2], 7))
|
||||
torch.testing.assert_close(cache[4:6], torch.full_like(cache[4:6], 11))
|
||||
assert (backing[0] == -1).all()
|
||||
assert (backing[9] == -1).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared,rank", [(False, 0), (True, 0), (True, 1)])
|
||||
@pytest.mark.parametrize("has_copies", [False, True])
|
||||
def test_hisparse_host_copy_waits_for_writes_on_writer_only(
|
||||
monkeypatch, shared, rank, has_copies
|
||||
):
|
||||
"""Host CoW must read completed writes; shared non-writers only barrier."""
|
||||
worker = _make_hisparse_worker()
|
||||
cache = torch.zeros((2, 1, 1))
|
||||
worker.host_caches = [cache]
|
||||
worker.host_num_blocks = 2
|
||||
worker.shared_host_region = object() if shared else None
|
||||
event = MagicMock()
|
||||
event.synchronize.side_effect = lambda: cache[0].fill_(7)
|
||||
barrier = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "get_tensor_model_parallel_rank", lambda: rank
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "get_tp_group", lambda: SimpleNamespace(barrier=barrier)
|
||||
)
|
||||
copies = [KVCacheBlockCopy(0, 1)] if has_copies else []
|
||||
|
||||
worker._copy_host_blocks(copies, event)
|
||||
|
||||
writes = has_copies and (not shared or rank == 0)
|
||||
assert cache[1].item() == (7 if writes else 0)
|
||||
assert event.synchronize.call_count == int(writes)
|
||||
assert barrier.call_count == int(shared and has_copies)
|
||||
assert worker._completed_host_copy_dst_ids == ([1] if has_copies else [])
|
||||
|
||||
|
||||
def test_hisparse_worker_updates_request_state_mapping_in_place(monkeypatch):
|
||||
worker = _make_hisparse_worker()
|
||||
worker.request_state_indices = torch.arange(4, dtype=torch.int32)
|
||||
worker._pending_invalid_block_ids = [5]
|
||||
invalidations = []
|
||||
worker.invalidate_blocks = lambda blocks, states: invalidations.append(
|
||||
(blocks.copy(), states.clone())
|
||||
)
|
||||
original_ptr = worker.request_state_indices.data_ptr()
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
|
||||
worker.set_request_state_indices(torch.tensor([3, 1], dtype=torch.int32))
|
||||
|
||||
assert worker.request_state_indices.data_ptr() == original_ptr
|
||||
assert worker.request_state_indices.tolist() == [3, 1, -1, -1]
|
||||
assert len(invalidations) == 1
|
||||
assert invalidations[0][0] == [5]
|
||||
torch.testing.assert_close(
|
||||
invalidations[0][1], torch.tensor([3, 1], dtype=torch.int32)
|
||||
)
|
||||
assert worker._pending_invalid_block_ids == []
|
||||
|
||||
|
||||
def test_hisparse_pre_forward_transfer_builds_page_descriptors():
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.kernel_block_size = 2
|
||||
source = torch.empty((3, 2, 4), dtype=torch.uint8)
|
||||
destination = torch.empty((6, 4), dtype=torch.uint8)
|
||||
worker.resident_caches = (source,)
|
||||
worker.host_caches = (destination,)
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(runtime=SimpleNamespace(resident_source_index=0))
|
||||
]
|
||||
worker._dma_free_descriptors = []
|
||||
worker._submit_dma_descriptors = MagicMock()
|
||||
|
||||
worker._enqueue_transfers([SparseKVPageTransfer(7, 2, (1,), after_forward=False)])
|
||||
|
||||
descriptors, count = worker._submit_dma_descriptors.call_args.args
|
||||
assert count == 1
|
||||
assert descriptors.src[:count].tolist() == [
|
||||
source.data_ptr() + source.stride(0) * source.element_size()
|
||||
]
|
||||
assert descriptors.dst[:count].tolist() == [destination.data_ptr() + 4 * 4]
|
||||
assert descriptors.sizes[:count].tolist() == [2 * 4]
|
||||
assert worker._submit_dma_descriptors.call_args.kwargs["transfer_ids"] == (7,)
|
||||
|
||||
|
||||
def test_hisparse_eager_mirror_records_transfer_without_page_copy():
|
||||
worker = _make_hisparse_worker()
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(runtime=SimpleNamespace(eager_host_mirror=True))
|
||||
]
|
||||
worker._record_transfer_completion = MagicMock()
|
||||
worker._enqueue_transfers = MagicMock()
|
||||
transfers = [SparseKVPageTransfer(7, 2, (1,), after_forward=False)]
|
||||
|
||||
worker._submit_transfers(transfers)
|
||||
|
||||
worker._record_transfer_completion.assert_called_once_with(transfers)
|
||||
worker._enqueue_transfers.assert_not_called()
|
||||
|
||||
|
||||
def test_hisparse_lazy_mirror_copies_transfer_pages():
|
||||
worker = _make_hisparse_worker()
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(runtime=SimpleNamespace(eager_host_mirror=False))
|
||||
]
|
||||
worker._record_transfer_completion = MagicMock()
|
||||
worker._enqueue_transfers = MagicMock()
|
||||
transfers = [SparseKVPageTransfer(7, 2, (1,), after_forward=False)]
|
||||
|
||||
worker._submit_transfers(transfers)
|
||||
|
||||
worker._enqueue_transfers.assert_called_once_with(transfers)
|
||||
worker._record_transfer_completion.assert_not_called()
|
||||
|
||||
|
||||
def test_hisparse_dma_row_mirror_builds_descriptors(monkeypatch):
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.kernel_block_size = 2
|
||||
source = torch.empty((3, 2, 4), dtype=torch.uint8)
|
||||
destination = torch.empty((6, 4), dtype=torch.uint8)
|
||||
worker.resident_caches = (source,)
|
||||
worker.host_caches = (destination,)
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(
|
||||
runtime=SimpleNamespace(resident_source_index=0), decode_batch=True
|
||||
)
|
||||
]
|
||||
worker.hot_backing = SimpleNamespace(device=torch.device("cuda:0"))
|
||||
worker.dma_stream = MagicMock()
|
||||
worker.host_write_event = MagicMock()
|
||||
worker._dma_free_descriptors = []
|
||||
worker._pending_dma_descriptors = deque()
|
||||
worker._pending_transfer_events = deque()
|
||||
worker._enqueued_transfer_ids = []
|
||||
worker._dma_submitted = False
|
||||
current_stream = MagicMock()
|
||||
event = MagicMock()
|
||||
swap_blocks_batch = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "current_stream", lambda: current_stream
|
||||
)
|
||||
monkeypatch.setattr(torch.cuda, "stream", lambda stream: nullcontext())
|
||||
monkeypatch.setattr(torch, "Event", lambda: event)
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module.ops, "swap_blocks_batch", swap_blocks_batch
|
||||
)
|
||||
|
||||
worker._set_row_mirrors((SparseKVRowMirror((3,), 4, 1),))
|
||||
worker._enqueue_row_dma(range(1))
|
||||
|
||||
src_ptrs, dst_ptrs, sizes = swap_blocks_batch.call_args.args
|
||||
assert src_ptrs.tolist() == [source.data_ptr() + 3 * 4]
|
||||
assert dst_ptrs.tolist() == [destination.data_ptr() + 4 * 4]
|
||||
assert sizes.tolist() == [4]
|
||||
worker.dma_stream.wait_stream.assert_called_once_with(current_stream)
|
||||
worker.host_write_event.record.assert_called_once_with(worker.dma_stream)
|
||||
event.record.assert_called_once_with(worker.dma_stream)
|
||||
assert worker._dma_submitted
|
||||
|
||||
|
||||
def test_hisparse_row_dma_uses_resident_spans():
|
||||
worker = _make_hisparse_worker()
|
||||
source = torch.empty((2, 2, 4), dtype=torch.uint8)
|
||||
destination = torch.empty((12, 4), dtype=torch.uint8)
|
||||
worker.is_host_writer = True
|
||||
worker.kernel_block_size = 2
|
||||
worker.resident_caches = (source,)
|
||||
worker.host_caches = (destination,)
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(
|
||||
runtime=SimpleNamespace(resident_source_index=0), decode_batch=True
|
||||
)
|
||||
]
|
||||
worker._set_row_mirrors(
|
||||
(
|
||||
SparseKVRowMirror((0,), 4, 2),
|
||||
SparseKVRowMirror((2,), 9, 1),
|
||||
)
|
||||
)
|
||||
worker._dma_free_descriptors = []
|
||||
worker._submit_dma_descriptors = MagicMock()
|
||||
|
||||
worker._enqueue_row_dma(range(1))
|
||||
|
||||
descriptors, count = worker._submit_dma_descriptors.call_args.args
|
||||
assert count == 2
|
||||
assert descriptors.src[:count].tolist() == [
|
||||
source.data_ptr(),
|
||||
source.data_ptr() + 2 * 4,
|
||||
]
|
||||
assert descriptors.dst[:count].tolist() == [
|
||||
destination.data_ptr() + 4 * 4,
|
||||
destination.data_ptr() + 9 * 4,
|
||||
]
|
||||
assert descriptors.sizes[:count].tolist() == [2 * 4, 4]
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_mirrors_all_layers_once(monkeypatch):
|
||||
dst_slots = torch.tensor([7, 8, 9], dtype=torch.int64)
|
||||
req_ids = torch.tensor([0, 1, 2], dtype=torch.int32)
|
||||
leader = SimpleNamespace(
|
||||
eager_host_mirror=True,
|
||||
is_group_leader=True,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
follower = SimpleNamespace(
|
||||
eager_host_mirror=True,
|
||||
is_group_leader=False,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
handles = [
|
||||
SimpleNamespace(
|
||||
runtime=runtime,
|
||||
decode_batch=True,
|
||||
host_mirror_required=True,
|
||||
num_actual_tokens=3,
|
||||
num_decode_tokens=2,
|
||||
req_id_per_token=req_ids,
|
||||
mirror_slot_mapping=dst_slots,
|
||||
)
|
||||
for runtime in (leader, follower)
|
||||
]
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = handles
|
||||
worker.hot_backing = SimpleNamespace(device=torch.device("cuda:0"))
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0, 0), 7, 3),))
|
||||
worker._dma_submitted = False
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._post_forward_transfers = []
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
worker.host_write_event = MagicMock()
|
||||
worker._forward_ready_event = MagicMock()
|
||||
current_stream = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "current_stream", lambda: current_stream
|
||||
)
|
||||
|
||||
worker.finish_forward()
|
||||
|
||||
worker._forward_ready_event.record.assert_called_once_with()
|
||||
worker._enqueue_row_dma.assert_called_once_with(
|
||||
(0, 1), ready_event=worker._forward_ready_event
|
||||
)
|
||||
leader.invalidate_written_slots.assert_called_once()
|
||||
torch.testing.assert_close(
|
||||
leader.invalidate_written_slots.call_args.args[0], dst_slots
|
||||
)
|
||||
follower.invalidate_written_slots.assert_not_called()
|
||||
worker.host_write_event.record.assert_called_once_with(current_stream)
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_does_not_repeat_per_layer_mirrors():
|
||||
slots = torch.tensor([7, 8], dtype=torch.int64)
|
||||
runtime = SimpleNamespace(
|
||||
eager_host_mirror=False,
|
||||
is_group_leader=False,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
handles = [
|
||||
SimpleNamespace(
|
||||
runtime=runtime,
|
||||
decode_batch=False,
|
||||
host_mirror_required=True,
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=0,
|
||||
req_id_per_token=torch.empty(0, dtype=torch.int32),
|
||||
mirror_slot_mapping=slots,
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = handles
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0, 0), 7, 2),))
|
||||
worker._per_layer_mirrored = {0, 1}
|
||||
worker._submitted_mirror_layers = {0, 1}
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
|
||||
worker._enqueue_host_mirror()
|
||||
|
||||
worker._enqueue_row_dma.assert_not_called()
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_submits_lazy_post_forward_transfer(monkeypatch):
|
||||
runtime = SimpleNamespace(eager_host_mirror=False)
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = [SimpleNamespace(runtime=runtime, num_actual_tokens=0)]
|
||||
transfer = SparseKVPageTransfer(7, 2, (1,), after_forward=True)
|
||||
worker._post_forward_transfers = [transfer]
|
||||
worker._forward_ready_event = MagicMock()
|
||||
worker._enqueue_host_mirror = MagicMock()
|
||||
worker._submit_transfers = MagicMock()
|
||||
worker._dma_submitted = False
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._finish_mirror_phase = MagicMock()
|
||||
worker._release_completed_dma_descriptors = MagicMock()
|
||||
stream = MagicMock()
|
||||
monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: stream)
|
||||
|
||||
worker.finish_forward()
|
||||
|
||||
worker._finish_mirror_phase.assert_called_once_with(worker._forward_ready_event)
|
||||
worker._submit_transfers.assert_called_once_with([transfer])
|
||||
worker.host_write_event.record.assert_called_once_with(stream)
|
||||
|
||||
|
||||
def test_hisparse_prefill_mirrors_source_groups_and_flushes_partial_group():
|
||||
slots = torch.tensor([7, 8], dtype=torch.int64)
|
||||
source_indices = [0, 0, 1, 1, 1, 1, 2]
|
||||
handles = [
|
||||
SimpleNamespace(
|
||||
runtime=SimpleNamespace(
|
||||
eager_host_mirror=False,
|
||||
is_group_leader=False,
|
||||
resident_source_index=source_index,
|
||||
),
|
||||
decode_batch=False,
|
||||
host_mirror_required=layer_index < 5,
|
||||
num_actual_tokens=2 if layer_index < 5 else 0,
|
||||
num_decode_tokens=0,
|
||||
req_id_per_token=torch.empty(0, dtype=torch.int32),
|
||||
mirror_slot_mapping=slots,
|
||||
)
|
||||
for layer_index, source_index in enumerate(source_indices)
|
||||
]
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = handles
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0, 0, 0), 7, 2),))
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._layer_ready_events = tuple(MagicMock() for _ in handles)
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
|
||||
for layer_index in range(5):
|
||||
worker._enqueue_layer_mirror(layer_index)
|
||||
|
||||
worker._enqueue_row_dma.assert_called_once_with(
|
||||
(0, 1), ready_event=worker._layer_ready_events[1]
|
||||
)
|
||||
worker._enqueue_host_mirror(ready_event=worker._layer_ready_events[-1])
|
||||
assert worker._enqueue_row_dma.call_args_list[1].args == ((2, 3, 4),)
|
||||
assert worker._enqueue_row_dma.call_args_list[1].kwargs == {
|
||||
"ready_event": worker._layer_ready_events[-1]
|
||||
}
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_rejects_partial_per_layer_mirror():
|
||||
slots = torch.tensor([7, 8], dtype=torch.int64)
|
||||
runtime = SimpleNamespace(
|
||||
eager_host_mirror=False,
|
||||
is_group_leader=False,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
handles = [
|
||||
SimpleNamespace(
|
||||
runtime=runtime,
|
||||
decode_batch=False,
|
||||
host_mirror_required=True,
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=0,
|
||||
req_id_per_token=torch.empty(0, dtype=torch.int32),
|
||||
mirror_slot_mapping=slots,
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = handles
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0, 0), 7, 2),))
|
||||
worker._per_layer_mirrored = {0}
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match="did not mirror every active layer"):
|
||||
worker._enqueue_host_mirror()
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_orders_next_forward_after_dma(monkeypatch):
|
||||
current_stream = MagicMock()
|
||||
worker = _make_hisparse_worker()
|
||||
worker.hot_backing = SimpleNamespace(device=torch.device("cuda:0"))
|
||||
worker.is_host_writer = True
|
||||
worker._dma_submitted = True
|
||||
worker.host_write_event = MagicMock()
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(
|
||||
runtime=SimpleNamespace(eager_host_mirror=True), num_actual_tokens=0
|
||||
)
|
||||
]
|
||||
worker._post_forward_transfers = []
|
||||
worker._pending_dma_descriptors = deque()
|
||||
worker._dma_free_descriptors = []
|
||||
worker._forward_ready_event = MagicMock()
|
||||
worker._finish_mirror_phase = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "current_stream", lambda: current_stream
|
||||
)
|
||||
|
||||
worker.finish_forward()
|
||||
|
||||
current_stream.wait_event.assert_called_once_with(worker.host_write_event)
|
||||
assert not worker._dma_submitted
|
||||
|
||||
|
||||
def test_hisparse_finish_forward_mirrors_standalone_mtp_cache(monkeypatch):
|
||||
dst_slots = torch.tensor([7, 8], dtype=torch.int64)
|
||||
runtime = SimpleNamespace(
|
||||
eager_host_mirror=True,
|
||||
is_group_leader=False,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
inactive = SimpleNamespace(
|
||||
runtime=runtime,
|
||||
decode_batch=False,
|
||||
host_mirror_required=False,
|
||||
num_actual_tokens=0,
|
||||
num_decode_tokens=0,
|
||||
req_id_per_token=None,
|
||||
mirror_slot_mapping=dst_slots,
|
||||
)
|
||||
mtp = SimpleNamespace(
|
||||
runtime=runtime,
|
||||
decode_batch=True,
|
||||
host_mirror_required=True,
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=2,
|
||||
req_id_per_token=torch.tensor([0, 1], dtype=torch.int32),
|
||||
mirror_slot_mapping=dst_slots,
|
||||
)
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.cache_handles = [inactive, mtp]
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0,), 7, 2),))
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
|
||||
worker._enqueue_host_mirror()
|
||||
|
||||
worker._enqueue_row_dma.assert_called_once_with((1,), ready_event=None)
|
||||
|
||||
|
||||
def test_hisparse_shared_host_reader_skips_mirror(monkeypatch):
|
||||
"""A non-writer TP rank must not mirror rows into the shared host pool."""
|
||||
dst_slots = torch.tensor([7, 8], dtype=torch.int64)
|
||||
leader = SimpleNamespace(
|
||||
eager_host_mirror=True,
|
||||
is_group_leader=True,
|
||||
invalidate_written_slots=MagicMock(),
|
||||
)
|
||||
handle = SimpleNamespace(
|
||||
runtime=leader,
|
||||
decode_batch=True,
|
||||
host_mirror_required=True,
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=1,
|
||||
req_id_per_token=torch.tensor([0], dtype=torch.int32),
|
||||
mirror_slot_mapping=dst_slots,
|
||||
)
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = False
|
||||
worker.cache_handles = [handle]
|
||||
worker._set_row_mirrors((SparseKVRowMirror((0,), 7, 2),))
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._enqueue_row_dma = MagicMock()
|
||||
|
||||
worker._enqueue_host_mirror()
|
||||
|
||||
worker._enqueue_row_dma.assert_not_called()
|
||||
leader.invalidate_written_slots.assert_called_once()
|
||||
|
||||
|
||||
def test_hisparse_shared_host_reader_skips_transfer_completion():
|
||||
"""A non-writer TP rank must not acknowledge host writes."""
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = False
|
||||
worker.kernel_block_size = 1
|
||||
worker._enqueued_transfer_ids = []
|
||||
worker._pending_transfer_events = []
|
||||
|
||||
worker._record_transfer_completion([SparseKVPageTransfer(1, 2, (3,), True)])
|
||||
|
||||
assert worker._enqueued_transfer_ids == []
|
||||
assert worker._pending_transfer_events == []
|
||||
|
||||
|
||||
def test_hisparse_writer_records_transfer_completion_after_dma(monkeypatch):
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.dma_stream = MagicMock()
|
||||
worker._enqueued_transfer_ids = []
|
||||
worker._pending_transfer_events = deque()
|
||||
event = MagicMock()
|
||||
monkeypatch.setattr(torch, "Event", lambda: event)
|
||||
|
||||
worker._record_transfer_completion(
|
||||
[
|
||||
SparseKVPageTransfer(3, 2, (1,), False),
|
||||
SparseKVPageTransfer(7, 4, (5,), True),
|
||||
]
|
||||
)
|
||||
|
||||
event.record.assert_called_once_with(worker.dma_stream)
|
||||
assert worker._enqueued_transfer_ids == [3, 7]
|
||||
assert worker._pending_transfer_events == deque([(event, (3, 7))])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_host_writer", [False, True])
|
||||
def test_hisparse_step_waits_for_previous_host_write(monkeypatch, is_host_writer):
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = is_host_writer
|
||||
worker.hot_backing = SimpleNamespace(device=torch.device("cuda:1"))
|
||||
host_write_events = (MagicMock(), MagicMock())
|
||||
worker.host_write_events = host_write_events
|
||||
worker.host_write_event = host_write_events[1]
|
||||
worker._next_host_write_event = 0
|
||||
worker.host_caches = ()
|
||||
worker.host_num_blocks = 1
|
||||
worker._post_forward_transfers = []
|
||||
worker._pending_invalid_block_ids = []
|
||||
worker.cache_handles = [
|
||||
SimpleNamespace(
|
||||
runtime=SimpleNamespace(eager_host_mirror=True),
|
||||
decode_batch=False,
|
||||
host_mirror_required=False,
|
||||
num_actual_tokens=0,
|
||||
num_decode_tokens=0,
|
||||
req_id_per_token=None,
|
||||
)
|
||||
]
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._layer_mirror_callbacks = ()
|
||||
stream = MagicMock()
|
||||
monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: stream)
|
||||
|
||||
worker.start_step(
|
||||
SimpleNamespace(
|
||||
host_block_copies=[],
|
||||
command=None,
|
||||
source_block_ids=[],
|
||||
row_mirrors={},
|
||||
all_context_pages_resident=True,
|
||||
row_mirrors_from_resident=False,
|
||||
),
|
||||
None,
|
||||
)
|
||||
worker.start_step(
|
||||
SimpleNamespace(
|
||||
host_block_copies=[],
|
||||
command=None,
|
||||
source_block_ids=[],
|
||||
row_mirrors={},
|
||||
all_context_pages_resident=True,
|
||||
row_mirrors_from_resident=False,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert stream.wait_event.call_args_list == [
|
||||
call(host_write_events[1]),
|
||||
call(host_write_events[0]),
|
||||
]
|
||||
assert worker.host_write_event is host_write_events[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tp_rank, expected_copies", [(0, 1), (1, 0)])
|
||||
def test_hisparse_shared_host_block_copy_has_one_writer(
|
||||
monkeypatch, tp_rank, expected_copies
|
||||
):
|
||||
worker = _make_hisparse_worker()
|
||||
worker.shared_host_region = object()
|
||||
worker.host_caches = (torch.empty(4, 1),)
|
||||
worker.host_num_blocks = 4
|
||||
previous_event = MagicMock()
|
||||
copies = (KVCacheBlockCopy(0, 1),)
|
||||
copy_blocks = MagicMock()
|
||||
tp_group = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "get_tensor_model_parallel_rank", lambda: tp_rank
|
||||
)
|
||||
monkeypatch.setattr(hisparse_worker_module, "get_tp_group", lambda: tp_group)
|
||||
monkeypatch.setattr(
|
||||
hisparse_worker_module, "copy_kv_cache_blocks_inplace", copy_blocks
|
||||
)
|
||||
|
||||
worker._copy_host_blocks(copies, previous_event)
|
||||
|
||||
assert copy_blocks.call_count == expected_copies
|
||||
tp_group.barrier.assert_called_once_with()
|
||||
assert worker.take_completed_host_copies() == [1]
|
||||
assert worker.take_completed_host_copies() == []
|
||||
|
||||
|
||||
def test_hisparse_empty_step_does_not_replay_stale_host_mirror(monkeypatch):
|
||||
handle = SimpleNamespace(
|
||||
runtime=SimpleNamespace(eager_host_mirror=True),
|
||||
decode_batch=True,
|
||||
num_actual_tokens=2,
|
||||
num_decode_tokens=2,
|
||||
req_id_per_token=torch.tensor([0, 1]),
|
||||
mirror_slot_mapping=torch.tensor([4, 5]),
|
||||
)
|
||||
worker = _make_hisparse_worker()
|
||||
worker.is_host_writer = True
|
||||
worker.hot_backing = SimpleNamespace(device=torch.device("cpu"))
|
||||
worker.host_write_event = MagicMock()
|
||||
worker._forward_ready_event = MagicMock()
|
||||
worker.host_caches = ()
|
||||
worker.host_num_blocks = 1
|
||||
worker.cache_handles = [handle]
|
||||
worker._per_layer_mirrored = set()
|
||||
worker._submitted_mirror_layers = set()
|
||||
worker._layer_mirror_callbacks = (MagicMock(),)
|
||||
worker._post_forward_transfers = []
|
||||
worker._pending_invalid_block_ids = []
|
||||
worker._enqueue_host_mirror = MagicMock(wraps=worker._enqueue_host_mirror)
|
||||
stream = MagicMock()
|
||||
monkeypatch.setattr(hisparse_worker_module, "current_stream", lambda: stream)
|
||||
|
||||
worker.start_step(
|
||||
SimpleNamespace(
|
||||
host_block_copies=[],
|
||||
command=None,
|
||||
source_block_ids=[],
|
||||
row_mirrors={},
|
||||
all_context_pages_resident=True,
|
||||
row_mirrors_from_resident=False,
|
||||
),
|
||||
None,
|
||||
)
|
||||
worker.finish_forward()
|
||||
|
||||
worker._enqueue_host_mirror.assert_called_once_with(worker._forward_ready_event)
|
||||
assert handle.num_actual_tokens == 0
|
||||
torch.testing.assert_close(handle.mirror_slot_mapping, torch.tensor([4, 5]))
|
||||
|
||||
|
||||
def test_hisparse_cache_handles_join_index_groups_during_construction(monkeypatch):
|
||||
"""Followers must not allocate duplicate runtime state before profiling."""
|
||||
config = SimpleNamespace(
|
||||
scheduler_config=SimpleNamespace(
|
||||
max_num_seqs=2,
|
||||
max_num_batched_tokens=2,
|
||||
async_scheduling=False,
|
||||
),
|
||||
speculative_config=None,
|
||||
kv_transfer_config=None,
|
||||
)
|
||||
resolved = hisparse_runtime_module.ResolvedHiSparseConfig(
|
||||
top_k=4,
|
||||
device_buffer_size=8,
|
||||
)
|
||||
monkeypatch.setattr(hisparse_runtime_module, "_has_hisparse_ops", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
hisparse_runtime_module.ResolvedHiSparseConfig,
|
||||
"from_vllm_config",
|
||||
classmethod(lambda cls, vllm_config, model_top_k: resolved),
|
||||
)
|
||||
shared_states: list[object] = []
|
||||
streams: list[object] = []
|
||||
|
||||
def create_shared_state(_device, _max_rows, _top_k):
|
||||
shared_states.append(object())
|
||||
return shared_states[-1]
|
||||
|
||||
def create_stream(_device):
|
||||
streams.append(object())
|
||||
return streams[-1]
|
||||
|
||||
monkeypatch.setattr(
|
||||
hisparse_runtime_module, "_create_shared_topk_state", create_shared_state
|
||||
)
|
||||
monkeypatch.setattr(hisparse_runtime_module, "_create_copy_stream", create_stream)
|
||||
monkeypatch.setattr(index_group_module, "_create_side_stream", lambda _: object())
|
||||
monkeypatch.setattr(index_group_module, "_create_event", lambda: object())
|
||||
index_group_builder = index_group_module.SparseMLAIndexGroupBuilder(
|
||||
torch.empty((2, 4), dtype=torch.int32)
|
||||
)
|
||||
|
||||
def make_cache_handle(is_leader: bool):
|
||||
index_group, _ = index_group_builder.register_layer(is_leader)
|
||||
cache_handle = hisparse_runtime_module.create_hisparse_cache_handle(
|
||||
config,
|
||||
model_top_k=4,
|
||||
is_index_group_leader=is_leader,
|
||||
row_width=8,
|
||||
kv_dtype=torch.float32,
|
||||
index_group=index_group,
|
||||
device="cpu",
|
||||
)
|
||||
assert cache_handle is not None
|
||||
return cache_handle
|
||||
|
||||
first_leader = make_cache_handle(True)
|
||||
first_index_group = index_group_builder.current_group
|
||||
first_follower = make_cache_handle(False)
|
||||
second_leader = make_cache_handle(True)
|
||||
second_index_group = index_group_builder.current_group
|
||||
second_follower = make_cache_handle(False)
|
||||
|
||||
assert first_index_group is not None
|
||||
assert second_index_group is not None
|
||||
assert first_follower.runtime.index_group is first_leader.runtime.index_group
|
||||
assert second_follower.runtime.index_group is second_leader.runtime.index_group
|
||||
assert first_leader.runtime.index_group is not second_leader.runtime.index_group
|
||||
assert first_leader.runtime.index_group.copy_stream is first_index_group.side_stream
|
||||
assert (
|
||||
first_leader.runtime.index_group.logical_topk_ready
|
||||
is first_index_group.logical_topk_ready
|
||||
)
|
||||
assert (
|
||||
second_leader.runtime.index_group.copy_stream is second_index_group.side_stream
|
||||
)
|
||||
assert (
|
||||
second_leader.runtime.index_group.logical_topk_ready
|
||||
is second_index_group.logical_topk_ready
|
||||
)
|
||||
assert len(shared_states) == 2
|
||||
assert streams == []
|
||||
|
||||
|
||||
class _TestReplaySSMMixer(MambaMixer2):
|
||||
@@ -30,7 +914,8 @@ def _packed_replayssm_cache(num_blocks: int) -> torch.Tensor:
|
||||
return torch.full((num_blocks, 1, 1, 80), 0, dtype=torch.int8)
|
||||
|
||||
|
||||
def test_bind_kv_cache_shares_replayssm_trackers_by_cache_group():
|
||||
@pytest.mark.parametrize("layers_only", [False, True])
|
||||
def test_bind_kv_cache_shares_replayssm_trackers_by_cache_group(layers_only):
|
||||
mixers = [_TestReplaySSMMixer() for _ in range(3)]
|
||||
layer_names = [f"layers.{i}.mixer" for i in range(3)]
|
||||
ctx = dict(zip(layer_names, mixers))
|
||||
@@ -45,7 +930,10 @@ def test_bind_kv_cache_shares_replayssm_trackers_by_cache_group():
|
||||
SimpleNamespace(layer_names=[layer_names[1]]),
|
||||
]
|
||||
|
||||
bind_kv_cache(kv_cache, ctx, [], kv_cache_groups=kv_cache_groups)
|
||||
if layers_only:
|
||||
bind_kv_cache_to_layers(kv_cache, ctx, kv_cache_groups=kv_cache_groups)
|
||||
else:
|
||||
bind_kv_cache(kv_cache, ctx, [], kv_cache_groups=kv_cache_groups)
|
||||
|
||||
assert (
|
||||
mixers[0]._replayssm_ring_start.data_ptr()
|
||||
|
||||
+15
-1
@@ -3008,6 +3008,9 @@ def cp_gather_and_upconvert_fp8_kv_cache(
|
||||
workspace_starts: torch.Tensor,
|
||||
batch_size: int,
|
||||
seq_starts: torch.Tensor | None = None,
|
||||
host_cache: torch.Tensor | None = None,
|
||||
host_row_ids: torch.Tensor | None = None,
|
||||
device_row_ids: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
"""Gather and upconvert FP8 KV cache to BF16 workspace.
|
||||
|
||||
@@ -3018,9 +3021,20 @@ def cp_gather_and_upconvert_fp8_kv_cache(
|
||||
workspace_starts: Workspace start offsets [num_reqs]
|
||||
batch_size: Number of requests
|
||||
seq_starts: Optional source sequence offsets [num_reqs]
|
||||
host_cache: Optional pinned host rows used for non-resident entries
|
||||
host_row_ids: Host source row for each remapped cache row
|
||||
device_row_ids: Device source row, or -1, for each remapped cache row
|
||||
"""
|
||||
torch.ops._C_cache_ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
src_cache, dst, block_table, workspace_starts, batch_size, seq_starts
|
||||
src_cache,
|
||||
dst,
|
||||
block_table,
|
||||
workspace_starts,
|
||||
batch_size,
|
||||
seq_starts,
|
||||
host_cache,
|
||||
host_row_ids,
|
||||
device_row_ids,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,10 @@ def fused_rope_unified_mla_kv_cache_update_impl(
|
||||
layer_name: LayerNameType,
|
||||
) -> torch.Tensor:
|
||||
layer_name = _resolve_layer_name(layer_name)
|
||||
attn_metadata, _, kv_cache, layer_slot_mapping = get_attention_context(layer_name)
|
||||
attn_metadata, attn_layer, kv_cache, layer_slot_mapping = get_attention_context(
|
||||
layer_name
|
||||
)
|
||||
attn_layer.prepare_kv_cache_update(attn_metadata)
|
||||
if layer_slot_mapping is not None:
|
||||
ops.concat_and_cache_mla_rope_fused(
|
||||
positions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.config.attention import AttentionConfig
|
||||
from vllm.config.attention import AttentionConfig, HiSparseConfig
|
||||
from vllm.config.cache import CacheConfig
|
||||
from vllm.config.compilation import (
|
||||
CompilationConfig,
|
||||
@@ -68,6 +68,7 @@ from vllm.config.weight_transfer import WeightTransferConfig
|
||||
__all__ = [
|
||||
# From vllm.config.attention
|
||||
"AttentionConfig",
|
||||
"HiSparseConfig",
|
||||
# From vllm.config.cache
|
||||
"CacheConfig",
|
||||
# From vllm.config.compilation
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from dataclasses import field
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from vllm.config.utils import config
|
||||
from vllm.v1.attention.backends.mla.prefill.registry import MLAPrefillBackendEnum
|
||||
@@ -14,6 +14,25 @@ IndexerKVDType = Literal["auto", "bf16", "fp8", "mxfp4", "nvfp4"]
|
||||
MiniMaxM3MSADecodeBackend = Literal["triton", "cutlass"]
|
||||
|
||||
|
||||
@config
|
||||
class HiSparseConfig:
|
||||
"""Configuration for HiSparse sparse-MLA KV offloading."""
|
||||
|
||||
device_buffer_size: int | None = Field(default=None, gt=0)
|
||||
"""Total per-request GPU hot-buffer rows, including the newest-token slot.
|
||||
|
||||
Defaults to one top-k per decode query plus one top-k of LRU slack. The
|
||||
physical allocation is rounded up to the GPU cache block size selected
|
||||
from the active backends.
|
||||
"""
|
||||
|
||||
eager_host_mirror: bool = True
|
||||
"""Mirror decode-written KV rows to the host pool during the forward so
|
||||
page spills complete without moving data. When disabled, decode rows stay
|
||||
resident-only and evicted pages are copied to host at spill time. Prefill
|
||||
rows are always mirrored during the forward."""
|
||||
|
||||
|
||||
@config
|
||||
class AttentionConfig:
|
||||
"""Configuration for attention mechanisms in vLLM."""
|
||||
@@ -67,6 +86,10 @@ class AttentionConfig:
|
||||
indexer). Quantized formats (fp8, mxfp4, nvfp4) require indexer kernel
|
||||
support in the backend."""
|
||||
|
||||
hisparse_config: HiSparseConfig | None = None
|
||||
"""HiSparse host-resident KV configuration. Setting this enables experimental
|
||||
Model Runner V2-only HiSparse sparse-MLA decode hot-buffering."""
|
||||
|
||||
use_non_causal: bool = False
|
||||
"""Whether to use non-causal (bidirectional) attention."""
|
||||
|
||||
|
||||
@@ -13,6 +13,40 @@ KVConsumer = Literal["kv_consumer", "kv_both"]
|
||||
KVRole = Literal[KVProducer, KVConsumer]
|
||||
|
||||
|
||||
def hisparse_host_pool_gib(
|
||||
kv_transfer_config: "KVTransferConfig | None",
|
||||
) -> float | None:
|
||||
if kv_transfer_config is None:
|
||||
return None
|
||||
if kv_transfer_config.kv_connector == "MultiConnector":
|
||||
connectors = kv_transfer_config.kv_connector_extra_config.get("connectors", [])
|
||||
else:
|
||||
connectors = [
|
||||
{
|
||||
"kv_connector": kv_transfer_config.kv_connector,
|
||||
"kv_connector_extra_config": (
|
||||
kv_transfer_config.kv_connector_extra_config
|
||||
),
|
||||
}
|
||||
]
|
||||
entries = [
|
||||
connector.get("kv_connector_extra_config", {})
|
||||
for connector in connectors
|
||||
if connector.get("kv_connector") == "HiSparseConnector"
|
||||
]
|
||||
if len(entries) > 1:
|
||||
raise ValueError("Only one HiSparseConnector may be configured")
|
||||
if not entries:
|
||||
return None
|
||||
host_pool_gib = entries[0].get("host_pool_gib")
|
||||
if host_pool_gib is None:
|
||||
raise ValueError("HiSparseConnector requires host_pool_gib")
|
||||
host_pool_gib = float(host_pool_gib)
|
||||
if host_pool_gib <= 0:
|
||||
raise ValueError("HiSparseConnector host_pool_gib must be positive")
|
||||
return host_pool_gib
|
||||
|
||||
|
||||
def kv_buffer_device_default_factory() -> str:
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
|
||||
@@ -1919,6 +1919,13 @@ class SpeculativeConfig:
|
||||
def uses_draft_model(self) -> bool:
|
||||
return self.method == "draft_model"
|
||||
|
||||
def uses_draft_kv_cache(self) -> bool:
|
||||
return (
|
||||
self.use_eagle()
|
||||
or self.uses_draft_model()
|
||||
or self.uses_extract_hidden_states()
|
||||
)
|
||||
|
||||
def uses_extract_hidden_states(self) -> bool:
|
||||
return self.method == "extract_hidden_states"
|
||||
|
||||
|
||||
@@ -666,6 +666,14 @@ class VllmConfig:
|
||||
|
||||
@property
|
||||
def use_v2_model_runner(self) -> bool:
|
||||
if self.attention_config.hisparse_config is not None:
|
||||
if envs.VLLM_USE_V2_MODEL_RUNNER is False:
|
||||
raise ValueError(
|
||||
"HiSparse requires Model Runner V2; remove "
|
||||
"VLLM_USE_V2_MODEL_RUNNER=0."
|
||||
)
|
||||
return True
|
||||
|
||||
if getattr(self, "watermark_config", None) is not None:
|
||||
if envs.VLLM_USE_V2_MODEL_RUNNER is False:
|
||||
logger.info_once(
|
||||
@@ -1559,6 +1567,39 @@ class VllmConfig:
|
||||
self._maybe_disable_dynamic_sd_for_data_parallel()
|
||||
self._maybe_override_dynamic_sd_cudagraph_mode()
|
||||
|
||||
if self.attention_config.hisparse_config is not None:
|
||||
if not current_platform.is_cuda():
|
||||
raise ValueError("HiSparse currently requires NVIDIA CUDA.")
|
||||
if self.parallel_config.pipeline_parallel_size > 1:
|
||||
raise ValueError("HiSparse does not support pipeline parallelism.")
|
||||
if self.parallel_config.decode_context_parallel_size > 1:
|
||||
raise ValueError(
|
||||
"HiSparse does not support decode context parallelism."
|
||||
)
|
||||
if self.model_config is not None and not hasattr(
|
||||
self.model_config.hf_config, "index_topk"
|
||||
):
|
||||
raise ValueError(
|
||||
"HiSparse is only supported for DSA models with index_topk."
|
||||
)
|
||||
if self.kv_transfer_config is not None and (
|
||||
self.kv_transfer_config.kv_connector
|
||||
not in (
|
||||
None,
|
||||
"NixlConnector",
|
||||
"MooncakeStoreConnector",
|
||||
"MultiConnector",
|
||||
)
|
||||
):
|
||||
logger.warning(
|
||||
"HiSparse host-resident KV is configured with connector "
|
||||
"%s. NixlConnector (GPU-staged host imports) and "
|
||||
"MooncakeStoreConnector (shared-store offload) are the "
|
||||
"validated paths; other connectors are treated as "
|
||||
"debug/fallback paths.",
|
||||
self.kv_transfer_config.kv_connector,
|
||||
)
|
||||
|
||||
self._normalize_piecewise_cudagraph_mode(
|
||||
breakable_cudagraph_enabled=breakable_cudagraph_enabled
|
||||
)
|
||||
@@ -1937,6 +1978,16 @@ class VllmConfig:
|
||||
# Default to enable HMA if not explicitly disabled by user or logic above.
|
||||
self.scheduler_config.disable_hybrid_kv_cache_manager = False
|
||||
|
||||
if (
|
||||
self.attention_config.hisparse_config is not None
|
||||
and self.scheduler_config.disable_hybrid_kv_cache_manager
|
||||
):
|
||||
raise ValueError(
|
||||
"HiSparse requires the hybrid KV cache manager; remove "
|
||||
"--disable-hybrid-kv-cache-manager or use connectors that "
|
||||
"support HMA."
|
||||
)
|
||||
|
||||
if self.compilation_config.debug_dump_path:
|
||||
self.compilation_config.debug_dump_path = (
|
||||
self.compilation_config.debug_dump_path.absolute().expanduser()
|
||||
|
||||
@@ -197,6 +197,12 @@ KVConnectorFactory.register_connector(
|
||||
"MultiConnector",
|
||||
)
|
||||
|
||||
KVConnectorFactory.register_connector(
|
||||
"HiSparseConnector",
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector",
|
||||
"HiSparseConnector",
|
||||
)
|
||||
|
||||
KVConnectorFactory.register_connector(
|
||||
"MoRIIOConnector",
|
||||
"vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector",
|
||||
|
||||
@@ -60,6 +60,7 @@ class KVOutputAggregator:
|
||||
# [req_id -> n_remaining_workers]
|
||||
self._recv_remaining_count = dict[str, int]()
|
||||
self._send_remaining_count = dict[str, int]()
|
||||
self._failed_recving_pending = set[str]()
|
||||
self._expected_finished_count = expected_finished_count
|
||||
|
||||
@classmethod
|
||||
@@ -156,6 +157,10 @@ class KVOutputAggregator:
|
||||
combined_kv_cache_events.increment_workers(1)
|
||||
|
||||
invalid_block_ids |= kv_output.invalid_block_ids
|
||||
self._failed_recving_pending |= kv_output.failed_recving
|
||||
|
||||
failed_recving = self._failed_recving_pending & finished_recving
|
||||
self._failed_recving_pending -= failed_recving
|
||||
|
||||
# select output of the worker specified by output_rank
|
||||
output = outputs[output_rank]
|
||||
@@ -168,6 +173,7 @@ class KVOutputAggregator:
|
||||
kv_cache_events=combined_kv_cache_events or None,
|
||||
kv_connector_worker_meta=aggregated_kv_connector_worker_meta or None,
|
||||
invalid_block_ids=invalid_block_ids,
|
||||
failed_recving=failed_recving,
|
||||
expected_finished_count=self._expected_finished_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ The class provides the following primitives:
|
||||
save_kv_layer() - starts saving KV for layer i (maybe async)
|
||||
wait_for_save() - blocks until all saves are done
|
||||
|
||||
get_finished() - called with ids of finished requests, returns
|
||||
ids of requests that have completed async sending/recving.
|
||||
get_transfer_results() - returns async send/receive completions and
|
||||
receive failures in one snapshot.
|
||||
build_connector_worker_meta() - builds metadata to be sent
|
||||
back to the scheduler-side connector
|
||||
"""
|
||||
@@ -43,6 +43,7 @@ The class provides the following primitives:
|
||||
import enum
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import torch
|
||||
@@ -63,7 +64,7 @@ if TYPE_CHECKING:
|
||||
)
|
||||
from vllm.forward_context import ForwardContext
|
||||
from vllm.v1.core.block_pool import BlockPool
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
from vllm.v1.request import Request
|
||||
|
||||
@@ -82,6 +83,19 @@ CopyBlocksOp = Callable[
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KVConnectorTransferResults:
|
||||
"""Asynchronous transfer completions from one worker snapshot.
|
||||
|
||||
Failed receives also appear in ``finished_recving`` so the scheduler can
|
||||
release the request from its transfer wait state.
|
||||
"""
|
||||
|
||||
finished_sending: set[str] = field(default_factory=set)
|
||||
finished_recving: set[str] = field(default_factory=set)
|
||||
failed_recving: set[str] = field(default_factory=set)
|
||||
|
||||
|
||||
class SupportsHMA(ABC):
|
||||
"""
|
||||
The class that indicates the corresponding connector supports hybrid memory
|
||||
@@ -211,6 +225,7 @@ class KVConnectorBase_V1(ABC):
|
||||
else:
|
||||
raise ValueError("kv_transfer_config must be set for KVConnectorBase_V1")
|
||||
self._kv_cache_config = kv_cache_config
|
||||
self._kv_cache_manager: KVCacheManager | None = None
|
||||
self._role = role
|
||||
|
||||
@property
|
||||
@@ -278,6 +293,15 @@ class KVConnectorBase_V1(ABC):
|
||||
"""
|
||||
return
|
||||
|
||||
# TODO(NickLucche): group model-runner lifecycle hooks in the interface.
|
||||
def finish_forward(self) -> None:
|
||||
"""Notify the connector that the model no longer reads this step's KV."""
|
||||
return
|
||||
|
||||
def reset_capture_state(self) -> None:
|
||||
"""Reset worker state mutated while capturing CUDA graphs."""
|
||||
return
|
||||
|
||||
def handle_preemptions(self, kv_connector_metadata: KVConnectorMetadata):
|
||||
"""
|
||||
Handle preempted requests or evicted blocks BEFORE they are overwritten.
|
||||
@@ -368,6 +392,16 @@ class KVConnectorBase_V1(ABC):
|
||||
"""
|
||||
return None, None
|
||||
|
||||
def get_transfer_results(
|
||||
self, finished_req_ids: set[str]
|
||||
) -> KVConnectorTransferResults:
|
||||
"""Return completed sends, receives, and receive failures together."""
|
||||
finished_sending, finished_recving = self.get_finished(finished_req_ids)
|
||||
return KVConnectorTransferResults(
|
||||
finished_sending=set(finished_sending or ()),
|
||||
finished_recving=set(finished_recving or ()),
|
||||
)
|
||||
|
||||
def get_block_ids_with_load_errors(self) -> set[int]:
|
||||
"""
|
||||
Get the set of block IDs that failed to load.
|
||||
@@ -380,9 +414,9 @@ class KVConnectorBase_V1(ABC):
|
||||
- Applies to both sync- and async-loading requests.
|
||||
- Async loading: failed blocks may be reported in any forward pass
|
||||
up to and including the pass where the request ID is returned by
|
||||
`get_finished()`. Even if failures occur, the request must still
|
||||
be reported via `get_finished()`, and the failed block IDs must
|
||||
appear here no later than that same pass.
|
||||
`get_transfer_results()`. Even if failures occur, the request
|
||||
must still be reported as finished receiving, and the failed
|
||||
block IDs must appear here no later than that same pass.
|
||||
- Sync loading: failed blocks should be reported in the forward
|
||||
pass in which they are detected.
|
||||
"""
|
||||
@@ -436,6 +470,11 @@ class KVConnectorBase_V1(ABC):
|
||||
# Scheduler-side methods
|
||||
# ==============================
|
||||
|
||||
def bind_kv_cache_manager(self, kv_cache_manager: "KVCacheManager") -> None:
|
||||
"""Bind the scheduler's cache manager after it has been constructed."""
|
||||
self._kv_cache_manager = kv_cache_manager
|
||||
self.bind_gpu_block_pool(kv_cache_manager.block_pool)
|
||||
|
||||
def bind_gpu_block_pool(self, gpu_block_pool: "BlockPool") -> None:
|
||||
"""
|
||||
Bind the GPU block pool to the connector for per-GPU block status tracking.
|
||||
@@ -583,6 +622,10 @@ class KVConnectorBase_V1(ABC):
|
||||
"""
|
||||
return ()
|
||||
|
||||
def has_pending_block_frees(self) -> bool:
|
||||
"""Whether pending transfers can release blocks instead of preemption."""
|
||||
return False
|
||||
|
||||
def has_pending_push_work(self) -> bool:
|
||||
"""Return True if the connector has push-mode work that requires
|
||||
the engine main loop to keep stepping (e.g. a P-side request whose
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -0,0 +1,329 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Connector boundary for HiSparse scheduler and worker state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorBase_V1,
|
||||
KVConnectorMetadata,
|
||||
KVConnectorRole,
|
||||
KVConnectorWorkerMetadata,
|
||||
SupportsHMA,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.worker import (
|
||||
HiSparseConnectorWorker,
|
||||
)
|
||||
from vllm.v1.attention.backend import AttentionMetadata
|
||||
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
|
||||
from vllm.v1.hisparse.types import SparseKVOffloadCommand, SparseKVRowMirror
|
||||
from vllm.v1.outputs import KVConnectorOutput
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.forward_context import ForwardContext
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager
|
||||
from vllm.v1.hisparse.coordinator import HiSparseCoordinator
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
from vllm.v1.request import Request
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiSparseConnectorMetadata(KVConnectorMetadata):
|
||||
command: SparseKVOffloadCommand | None
|
||||
host_block_copies: tuple[KVCacheBlockCopy, ...]
|
||||
source_block_ids: tuple[int, ...]
|
||||
row_mirrors: dict[str, tuple[SparseKVRowMirror, ...]]
|
||||
all_context_pages_resident: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiSparseConnectorWorkerMetadata(KVConnectorWorkerMetadata):
|
||||
enqueued_transfer_counts: dict[int, int]
|
||||
completed_transfer_counts: dict[int, int]
|
||||
completed_host_copy_dst_ids: tuple[int, ...] = ()
|
||||
|
||||
def aggregate(self, other: KVConnectorWorkerMetadata) -> KVConnectorWorkerMetadata:
|
||||
assert isinstance(other, HiSparseConnectorWorkerMetadata)
|
||||
|
||||
def add_counts(first: dict[int, int], second: dict[int, int]) -> dict[int, int]:
|
||||
combined = first.copy()
|
||||
for transfer_id, count in second.items():
|
||||
combined[transfer_id] = combined.get(transfer_id, 0) + count
|
||||
return combined
|
||||
|
||||
return HiSparseConnectorWorkerMetadata(
|
||||
enqueued_transfer_counts=add_counts(
|
||||
self.enqueued_transfer_counts, other.enqueued_transfer_counts
|
||||
),
|
||||
completed_transfer_counts=add_counts(
|
||||
self.completed_transfer_counts, other.completed_transfer_counts
|
||||
),
|
||||
# Every worker runs the same copies, so a union is the ack.
|
||||
completed_host_copy_dst_ids=tuple(
|
||||
{*self.completed_host_copy_dst_ids, *other.completed_host_copy_dst_ids}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HiSparseConnectorScheduler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
async_speculative: bool,
|
||||
draft_kv_lookahead: int = 0,
|
||||
) -> None:
|
||||
self.coordinator: HiSparseCoordinator | None = None
|
||||
self.async_speculative = async_speculative
|
||||
self.draft_kv_lookahead = draft_kv_lookahead
|
||||
self.requests: dict[str, Request] = {}
|
||||
|
||||
def bind_coordinator(self, coordinator: HiSparseCoordinator) -> None:
|
||||
assert self.coordinator is None
|
||||
self.coordinator = coordinator
|
||||
|
||||
def build_connector_meta(
|
||||
self, scheduler_output: SchedulerOutput
|
||||
) -> HiSparseConnectorMetadata:
|
||||
assert self.coordinator is not None
|
||||
# HiSparse rebinds worker state every step, so the load must start
|
||||
# before the forward rather than being deferred to post-forward.
|
||||
scheduler_output.has_sync_kv_loads = True
|
||||
scheduler_output.block_table_updates = (
|
||||
self.coordinator.take_block_table_updates() or None
|
||||
)
|
||||
command = self.coordinator.build_offload_command()
|
||||
host_block_copies = self.coordinator.take_host_block_copies()
|
||||
source_group_id = self.coordinator.host_group_id
|
||||
assert source_group_id is not None
|
||||
source_block_ids = [
|
||||
block_id
|
||||
for request in scheduler_output.scheduled_new_reqs
|
||||
for block_id in request.block_ids[source_group_id]
|
||||
]
|
||||
for new_block_ids in scheduler_output.scheduled_cached_reqs.new_block_ids:
|
||||
if new_block_ids is not None:
|
||||
source_block_ids.extend(new_block_ids[source_group_id])
|
||||
num_computed_tokens = {
|
||||
request.req_id: request.num_computed_tokens
|
||||
for request in scheduler_output.scheduled_new_reqs
|
||||
}
|
||||
num_computed_tokens.update(
|
||||
zip(
|
||||
scheduler_output.scheduled_cached_reqs.req_ids,
|
||||
scheduler_output.scheduled_cached_reqs.num_computed_tokens,
|
||||
)
|
||||
)
|
||||
scheduled_requests = tuple(
|
||||
(
|
||||
request_id,
|
||||
num_computed_tokens[request_id],
|
||||
scheduled_count,
|
||||
)
|
||||
for request_id, scheduled_count in (
|
||||
scheduler_output.num_scheduled_tokens.items()
|
||||
)
|
||||
)
|
||||
row_mirrors = {}
|
||||
for request_id, scheduled_start, scheduled_count in scheduled_requests:
|
||||
mirror_start = scheduled_start
|
||||
if self.async_speculative:
|
||||
mirror_start = max(
|
||||
0,
|
||||
scheduled_start - self.requests[request_id].num_output_placeholders,
|
||||
)
|
||||
row_mirrors[request_id] = self.coordinator.build_row_mirrors(
|
||||
(
|
||||
(
|
||||
request_id,
|
||||
mirror_start,
|
||||
scheduled_count
|
||||
+ scheduled_start
|
||||
- mirror_start
|
||||
+ self.draft_kv_lookahead,
|
||||
),
|
||||
),
|
||||
)
|
||||
return HiSparseConnectorMetadata(
|
||||
command,
|
||||
host_block_copies,
|
||||
tuple(source_block_ids),
|
||||
row_mirrors,
|
||||
self.coordinator.all_context_pages_resident(scheduled_requests),
|
||||
)
|
||||
|
||||
def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
|
||||
assert self.coordinator is not None
|
||||
for request_id in connector_output.finished_recving or ():
|
||||
self.coordinator.finish_host_import(
|
||||
request_id, failed=request_id in connector_output.failed_recving
|
||||
)
|
||||
metadata = connector_output.kv_connector_worker_meta
|
||||
if metadata is None:
|
||||
return
|
||||
assert isinstance(metadata, HiSparseConnectorWorkerMetadata)
|
||||
self.coordinator.release_completed_host_copies(
|
||||
metadata.completed_host_copy_dst_ids
|
||||
)
|
||||
self.coordinator.update_spills(
|
||||
metadata.enqueued_transfer_counts,
|
||||
metadata.completed_transfer_counts,
|
||||
)
|
||||
|
||||
|
||||
class HiSparseConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
"""Join the scheduler coordinator to the worker's transfer engine."""
|
||||
|
||||
@classmethod
|
||||
def get_required_kvcache_layout(cls, vllm_config: VllmConfig) -> str:
|
||||
return "BLHNC"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vllm_config: VllmConfig,
|
||||
role: KVConnectorRole,
|
||||
kv_cache_config: KVCacheConfig,
|
||||
) -> None:
|
||||
super().__init__(vllm_config, role, kv_cache_config)
|
||||
if kv_cache_config.hisparse_host_num_blocks is None:
|
||||
raise ValueError("HiSparseConnector requires a HiSparse host pool")
|
||||
self.connector_scheduler: HiSparseConnectorScheduler | None = None
|
||||
self.connector_worker: HiSparseConnectorWorker | None = None
|
||||
if role == KVConnectorRole.SCHEDULER:
|
||||
speculative_config = vllm_config.speculative_config
|
||||
self.connector_scheduler = HiSparseConnectorScheduler(
|
||||
async_speculative=bool(
|
||||
vllm_config.scheduler_config.async_scheduling
|
||||
and speculative_config is not None
|
||||
),
|
||||
draft_kv_lookahead=vllm_config.num_lookahead_tokens,
|
||||
)
|
||||
elif role == KVConnectorRole.WORKER:
|
||||
self.connector_worker = HiSparseConnectorWorker(
|
||||
vllm_config, kv_cache_config
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported KV connector role: {role}")
|
||||
|
||||
def bind_kv_cache_manager(self, kv_cache_manager: KVCacheManager) -> None:
|
||||
assert self.connector_scheduler is not None
|
||||
self.connector_scheduler.bind_coordinator(
|
||||
get_hisparse_coordinator(kv_cache_manager)
|
||||
)
|
||||
|
||||
@property
|
||||
def requires_kv_delivery(self) -> bool:
|
||||
return False
|
||||
|
||||
def has_pending_push_work(self) -> bool:
|
||||
assert self.connector_scheduler is not None
|
||||
assert self.connector_scheduler.coordinator is not None
|
||||
return self.connector_scheduler.coordinator.has_pending_work()
|
||||
|
||||
def has_pending_block_frees(self) -> bool:
|
||||
assert self.connector_scheduler is not None
|
||||
assert self.connector_scheduler.coordinator is not None
|
||||
return self.connector_scheduler.coordinator.has_pending_reclamation()
|
||||
|
||||
def finish_forward(self) -> None:
|
||||
assert self.connector_worker is not None
|
||||
self.connector_worker.finish_forward()
|
||||
|
||||
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]) -> None:
|
||||
assert self.connector_worker is not None
|
||||
self.connector_worker.register_kv_caches(kv_caches)
|
||||
|
||||
def reset_capture_state(self) -> None:
|
||||
assert self.connector_worker is not None
|
||||
self.connector_worker.reset_hot_state()
|
||||
|
||||
def start_load_kv(self, forward_context: ForwardContext, **kwargs: Any) -> None:
|
||||
assert self.connector_worker is not None
|
||||
metadata = self._get_connector_metadata()
|
||||
assert isinstance(metadata, HiSparseConnectorMetadata)
|
||||
request_state_indices = kwargs.get("request_state_indices")
|
||||
assert request_state_indices is None or isinstance(
|
||||
request_state_indices, torch.Tensor
|
||||
)
|
||||
request_ids = kwargs.get("request_ids")
|
||||
assert request_ids is None or isinstance(request_ids, list)
|
||||
attn_metadata = kwargs.get("attn_metadata")
|
||||
assert attn_metadata is None or isinstance(attn_metadata, dict)
|
||||
self.connector_worker.start_step(
|
||||
metadata,
|
||||
request_state_indices,
|
||||
request_ids,
|
||||
num_tokens=int(kwargs.get("num_tokens") or 0),
|
||||
)
|
||||
self.connector_worker.prepare_forward(attn_metadata)
|
||||
|
||||
def wait_for_layer_load(self, layer_name: str) -> None:
|
||||
return
|
||||
|
||||
def save_kv_layer(
|
||||
self,
|
||||
layer_name: str,
|
||||
kv_layer: torch.Tensor,
|
||||
attn_metadata: AttentionMetadata,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
return
|
||||
|
||||
def wait_for_save(self) -> None:
|
||||
return
|
||||
|
||||
def build_connector_worker_meta(self) -> KVConnectorWorkerMetadata | None:
|
||||
assert self.connector_worker is not None
|
||||
enqueued, completed = self.connector_worker.take_transfer_updates()
|
||||
host_copies = self.connector_worker.take_completed_host_copies()
|
||||
if not enqueued and not completed and not host_copies:
|
||||
return None
|
||||
return HiSparseConnectorWorkerMetadata(
|
||||
enqueued_transfer_counts={transfer_id: 1 for transfer_id in enqueued},
|
||||
completed_transfer_counts={transfer_id: 1 for transfer_id in completed},
|
||||
completed_host_copy_dst_ids=tuple(host_copies),
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self.connector_worker is not None:
|
||||
self.connector_worker.shutdown()
|
||||
|
||||
def get_num_new_matched_tokens(
|
||||
self, request: Request, num_computed_tokens: int
|
||||
) -> tuple[int | None, bool]:
|
||||
return 0, False
|
||||
|
||||
def update_state_after_alloc(
|
||||
self,
|
||||
request: Request,
|
||||
blocks: KVCacheBlocks,
|
||||
num_external_tokens: int,
|
||||
) -> None:
|
||||
assert self.connector_scheduler is not None
|
||||
self.connector_scheduler.requests[request.request_id] = request
|
||||
|
||||
def build_connector_meta(
|
||||
self, scheduler_output: SchedulerOutput
|
||||
) -> KVConnectorMetadata:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.build_connector_meta(scheduler_output)
|
||||
|
||||
def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
|
||||
assert self.connector_scheduler is not None
|
||||
self.connector_scheduler.update_connector_output(connector_output)
|
||||
|
||||
def request_finished_all_groups(
|
||||
self,
|
||||
request: Request,
|
||||
block_ids: tuple[list[int], ...],
|
||||
) -> tuple[bool, dict[str, Any] | None]:
|
||||
assert self.connector_scheduler is not None
|
||||
self.connector_scheduler.requests.pop(request.request_id, None)
|
||||
return False, None
|
||||
@@ -0,0 +1,832 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""HiSparse worker-side host/hot state and data movement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm import _custom_ops as ops
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.parallel_state import (
|
||||
get_tensor_model_parallel_rank,
|
||||
get_tp_group,
|
||||
)
|
||||
from vllm.utils.torch_utils import current_stream
|
||||
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
|
||||
from vllm.v1.hisparse.layout import HISPARSE_HOT_SUFFIX
|
||||
from vllm.v1.hisparse.runtime import HiSparseCacheHandle, release_pinned_state
|
||||
from vllm.v1.hisparse.types import SparseKVPageTransfer, SparseKVRowMirror
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
HiSparseHotSpec,
|
||||
KVCacheConfig,
|
||||
)
|
||||
from vllm.v1.worker.utils import copy_kv_cache_blocks_inplace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.hisparse.connector import (
|
||||
HiSparseConnectorMetadata,
|
||||
)
|
||||
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
|
||||
|
||||
|
||||
class _DMADescriptors(NamedTuple):
|
||||
src: torch.Tensor
|
||||
dst: torch.Tensor
|
||||
sizes: torch.Tensor
|
||||
src_np: np.ndarray
|
||||
dst_np: np.ndarray
|
||||
sizes_np: np.ndarray
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SlotMappingStaging:
|
||||
stream: torch.Stream
|
||||
event: torch.Event
|
||||
slots: torch.Tensor
|
||||
candidates: tuple[SparseKVRowMirror, ...] = ()
|
||||
num_tokens: int = 0
|
||||
source_index: int = 0
|
||||
|
||||
|
||||
def _allocate_dma_descriptors(size: int) -> _DMADescriptors:
|
||||
src, dst, sizes = (torch.empty(size, dtype=torch.int64) for _ in range(3))
|
||||
return _DMADescriptors(
|
||||
src,
|
||||
dst,
|
||||
sizes,
|
||||
src.numpy(),
|
||||
dst.numpy(),
|
||||
sizes.numpy(),
|
||||
)
|
||||
|
||||
|
||||
def _get_hisparse_cache(
|
||||
forward_context: dict[str, Any], layer_name: str
|
||||
) -> HiSparseCacheHandle:
|
||||
attention_layer = forward_context[layer_name]
|
||||
hisparse_cache = attention_layer.hisparse_cache
|
||||
assert hisparse_cache is not None
|
||||
return hisparse_cache
|
||||
|
||||
|
||||
def _flatten_row_mirrors(
|
||||
row_mirrors: Mapping[str, tuple[SparseKVRowMirror, ...]],
|
||||
request_ids: Sequence[str] | None,
|
||||
) -> tuple[SparseKVRowMirror, ...]:
|
||||
ordered_ids = row_mirrors if request_ids is None else request_ids
|
||||
return tuple(
|
||||
mirror
|
||||
for request_id in ordered_ids
|
||||
for mirror in row_mirrors.get(request_id, ())
|
||||
)
|
||||
|
||||
|
||||
def _select_written_row_mirrors(
|
||||
candidates: tuple[SparseKVRowMirror, ...],
|
||||
source_slots: np.ndarray,
|
||||
source_index: int,
|
||||
) -> tuple[SparseKVRowMirror, ...]:
|
||||
"""Select and coalesce GPU-written rows from a scheduler-owned envelope."""
|
||||
source_slots = source_slots[source_slots >= 0]
|
||||
if source_slots.size == 0 or not candidates:
|
||||
return ()
|
||||
starts = np.asarray([mirror.source_starts for mirror in candidates], dtype=np.int64)
|
||||
counts = np.fromiter((mirror.num_rows for mirror in candidates), dtype=np.int64)
|
||||
destinations = np.fromiter(
|
||||
[mirror.destination_start for mirror in candidates], dtype=np.int64
|
||||
)
|
||||
if source_index >= starts.shape[1]:
|
||||
raise RuntimeError("HiSparse row-mirror source index is out of range.")
|
||||
source_starts = starts[:, source_index]
|
||||
order = np.argsort(source_starts)
|
||||
matches = np.searchsorted(source_starts[order], source_slots, side="right") - 1
|
||||
candidate_ids = order[np.maximum(matches, 0)]
|
||||
offsets = source_slots - source_starts[candidate_ids]
|
||||
valid = (matches >= 0) & (offsets < counts[candidate_ids])
|
||||
if not np.all(valid):
|
||||
# A page the scheduler could not map has no host destination, so its
|
||||
# rows are simply not mirrorable this step.
|
||||
source_slots = source_slots[valid]
|
||||
candidate_ids = candidate_ids[valid]
|
||||
offsets = offsets[valid]
|
||||
if source_slots.size == 0:
|
||||
return ()
|
||||
sources = starts[candidate_ids] + offsets[:, None]
|
||||
destinations = destinations[candidate_ids] + offsets
|
||||
boundaries = (
|
||||
np.flatnonzero(
|
||||
(candidate_ids[1:] != candidate_ids[:-1])
|
||||
| (np.diff(destinations) != 1)
|
||||
| np.any(np.diff(sources, axis=0) != 1, axis=1)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
run_starts = np.concatenate(([0], boundaries))
|
||||
run_ends = np.concatenate((boundaries, [destinations.size]))
|
||||
return tuple(
|
||||
SparseKVRowMirror(
|
||||
source_starts=tuple(int(value) for value in sources[start]),
|
||||
destination_start=int(destinations[start]),
|
||||
num_rows=int(end - start),
|
||||
)
|
||||
for start, end in zip(run_starts, run_ends, strict=True)
|
||||
)
|
||||
|
||||
|
||||
def _create_hisparse_host_events(
|
||||
shared_host_region: SharedOffloadRegion | None,
|
||||
is_host_writer: bool,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Event, torch.Event]:
|
||||
if shared_host_region is None:
|
||||
return torch.Event(), torch.Event()
|
||||
|
||||
events: tuple[torch.Event, torch.Event] | None = None
|
||||
ipc_handles = None
|
||||
if is_host_writer:
|
||||
events = (
|
||||
torch.cuda.Event(interprocess=True),
|
||||
torch.cuda.Event(interprocess=True),
|
||||
)
|
||||
stream = current_stream()
|
||||
for event in events:
|
||||
event.record(stream)
|
||||
ipc_handles = tuple(event.ipc_handle() for event in events)
|
||||
ipc_handles = get_tp_group().broadcast_object(ipc_handles, src=0)
|
||||
if events is None:
|
||||
events = (
|
||||
torch.cuda.Event.from_ipc_handle(device, ipc_handles[0]),
|
||||
torch.cuda.Event.from_ipc_handle(device, ipc_handles[1]),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
class HiSparseConnectorWorker:
|
||||
"""Own HiSparse host/hot state and execute its worker-side transfers."""
|
||||
|
||||
def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig) -> None:
|
||||
self.vllm_config = vllm_config
|
||||
self.kv_cache_config = kv_cache_config
|
||||
self._initialized = False
|
||||
|
||||
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]) -> None:
|
||||
forward_context = self.vllm_config.compilation_config.static_forward_context
|
||||
cache_handles: list[HiSparseCacheHandle] = []
|
||||
cache_layer_names: list[str] = []
|
||||
for group in self.kv_cache_config.kv_cache_groups:
|
||||
if not isinstance(group.kv_cache_spec, HiSparseHotSpec):
|
||||
continue
|
||||
for cache_name in group.layer_names:
|
||||
assert cache_name.endswith(HISPARSE_HOT_SUFFIX)
|
||||
layer_name = cache_name[: -len(HISPARSE_HOT_SUFFIX)]
|
||||
cache_handles.append(_get_hisparse_cache(forward_context, layer_name))
|
||||
cache_layer_names.append(layer_name)
|
||||
|
||||
if not cache_handles:
|
||||
raise RuntimeError("HiSparse connector found no hot-cache handles.")
|
||||
hot_backings: dict[int, torch.Tensor] = {}
|
||||
registered_host_pools: dict[int, torch.Tensor] = {}
|
||||
for cache in cache_handles:
|
||||
hot_backing = cache.runtime.hot_backing
|
||||
hot_backings[hot_backing.untyped_storage().data_ptr()] = hot_backing
|
||||
registered_pool = cache.runtime.registered_host_pool
|
||||
registered_host_pools[registered_pool.data_ptr()] = registered_pool
|
||||
if len(hot_backings) != 1:
|
||||
raise RuntimeError("HiSparse hot tensors must share one GPU backing.")
|
||||
hot_backing = next(iter(hot_backings.values()))
|
||||
pinned_host_pools = list(registered_host_pools.values())
|
||||
|
||||
resident = cache_handles[0].view
|
||||
assert resident is not None
|
||||
host_num_blocks = self.kv_cache_config.hisparse_host_num_blocks
|
||||
assert host_num_blocks is not None
|
||||
try:
|
||||
self.initialize(
|
||||
cache_handles,
|
||||
cache_layer_names,
|
||||
hot_backing,
|
||||
self.vllm_config.scheduler_config.max_num_seqs,
|
||||
host_num_blocks,
|
||||
hot_backing.device,
|
||||
pinned_host_pools,
|
||||
)
|
||||
except Exception:
|
||||
release_pinned_state(
|
||||
[cache.runtime for cache in cache_handles],
|
||||
pinned_host_pools,
|
||||
)
|
||||
raise
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
cache_handles: list[HiSparseCacheHandle],
|
||||
cache_layer_names: list[str],
|
||||
hot_backing: torch.Tensor,
|
||||
max_num_reqs: int,
|
||||
host_num_blocks: int,
|
||||
device: torch.device,
|
||||
pinned_host_pools: list[torch.Tensor],
|
||||
*,
|
||||
shared_host_region: SharedOffloadRegion | None = None,
|
||||
is_host_writer: bool = True,
|
||||
) -> None:
|
||||
if self._initialized:
|
||||
raise RuntimeError("HiSparse connector worker is already initialized.")
|
||||
resident = cache_handles[0].view
|
||||
assert resident is not None
|
||||
self.kernel_block_size = resident.block_size
|
||||
self.host_num_blocks = host_num_blocks
|
||||
self.pinned_host_pools = pinned_host_pools
|
||||
self.shared_host_region = shared_host_region
|
||||
self.is_host_writer = is_host_writer
|
||||
self.dma_stream = (
|
||||
torch.cuda.Stream(device=device) if self.is_host_writer else None
|
||||
)
|
||||
self._slot_mapping_staging = None
|
||||
if self.is_host_writer:
|
||||
max_mirror_rows = (
|
||||
self.vllm_config.scheduler_config.max_num_batched_tokens
|
||||
+ max_num_reqs * (self.vllm_config.num_lookahead_tokens + 1)
|
||||
)
|
||||
self._slot_mapping_staging = _SlotMappingStaging(
|
||||
stream=torch.Stream(device=device),
|
||||
event=torch.Event(),
|
||||
slots=torch.empty(
|
||||
max_mirror_rows,
|
||||
dtype=torch.int64,
|
||||
pin_memory=True,
|
||||
),
|
||||
)
|
||||
self._dma_free_descriptors: list[_DMADescriptors] = []
|
||||
self._pending_dma_descriptors: deque[tuple[torch.Event, _DMADescriptors]] = (
|
||||
deque()
|
||||
)
|
||||
self._dma_submitted = False
|
||||
self._per_layer_mirrored: set[int] = set()
|
||||
self._submitted_mirror_layers: set[int] = set()
|
||||
self._layer_ready_events = tuple(torch.Event() for _ in cache_handles)
|
||||
self._forward_ready_event = torch.Event()
|
||||
self._set_row_mirrors(())
|
||||
self.cache_layer_names = cache_layer_names
|
||||
self._group_leaders = tuple(
|
||||
(layer_name, cache)
|
||||
for layer_name, cache in zip(cache_layer_names, cache_handles, strict=True)
|
||||
if cache.runtime.is_group_leader
|
||||
)
|
||||
self.host_write_events = _create_hisparse_host_events(
|
||||
shared_host_region, is_host_writer, device
|
||||
)
|
||||
self.host_write_event = self.host_write_events[1]
|
||||
self._next_host_write_event = 0
|
||||
self.cache_handles = cache_handles
|
||||
self.leader_runtimes = [cache.runtime for _, cache in self._group_leaders]
|
||||
request_state_indices = {
|
||||
indices.data_ptr(): indices
|
||||
for cache in cache_handles
|
||||
if (indices := cache.runtime.request_state_indices) is not None
|
||||
}
|
||||
if len(request_state_indices) != 1:
|
||||
raise RuntimeError(
|
||||
"HiSparse runtimes must share one request-state mapping."
|
||||
)
|
||||
self.request_state_indices = next(iter(request_state_indices.values()))
|
||||
if self.request_state_indices.numel() != max_num_reqs:
|
||||
raise RuntimeError(
|
||||
"HiSparse request-state mapping does not match max_num_seqs."
|
||||
)
|
||||
self.hot_backing = hot_backing
|
||||
self._pending_invalid_block_ids: list[int] = []
|
||||
# Destination block ids of host copies this worker has run.
|
||||
self._completed_host_copy_dst_ids: list[int] = []
|
||||
self._post_forward_transfers: list[SparseKVPageTransfer] = []
|
||||
self._enqueued_transfer_ids: list[int] = []
|
||||
self._pending_transfer_events: deque[tuple[torch.Event, tuple[int, ...]]] = (
|
||||
deque()
|
||||
)
|
||||
self._init_dma()
|
||||
if self.is_host_writer:
|
||||
for layer_index, handle in enumerate(cache_handles):
|
||||
handle.submit_layer_mirror = partial(
|
||||
self._enqueue_layer_mirror, layer_index
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def set_request_state_indices(self, indices: torch.Tensor) -> None:
|
||||
if indices.numel() > self.request_state_indices.numel():
|
||||
raise ValueError(
|
||||
"HiSparse request-state mapping exceeds max_num_seqs: "
|
||||
f"{indices.numel()} > {self.request_state_indices.numel()}."
|
||||
)
|
||||
if torch.cuda.is_current_stream_capturing():
|
||||
return
|
||||
# Attention indexes persistent request state by input-batch row. Refresh
|
||||
# that indirection after every batch compaction or reorder.
|
||||
self.request_state_indices.fill_(-1)
|
||||
self.request_state_indices[: indices.numel()].copy_(indices)
|
||||
if self._pending_invalid_block_ids:
|
||||
self.invalidate_blocks(self._pending_invalid_block_ids, indices)
|
||||
self._pending_invalid_block_ids.clear()
|
||||
|
||||
def _init_dma(self) -> None:
|
||||
host_caches = tuple(cache.runtime.host_cache for cache in self.cache_handles)
|
||||
resident_caches = []
|
||||
for cache in self.cache_handles:
|
||||
assert cache.view is not None and cache.slot_mapping is not None
|
||||
resident_caches.append(cache.view.cache)
|
||||
self.host_caches = host_caches
|
||||
self.resident_caches = tuple(resident_caches)
|
||||
|
||||
for resident_cache, host_cache in zip(resident_caches, host_caches):
|
||||
row_bytes = resident_cache.shape[-1] * resident_cache.element_size()
|
||||
if (
|
||||
resident_cache.ndim != 3
|
||||
or resident_cache.shape[1] != self.kernel_block_size
|
||||
or resident_cache.stride(1) * resident_cache.element_size() != row_bytes
|
||||
):
|
||||
raise RuntimeError("HiSparse DMA requires contiguous resident rows.")
|
||||
if (
|
||||
host_cache.ndim != 2
|
||||
or not host_cache.is_contiguous()
|
||||
or host_cache.shape[1] * host_cache.element_size() != row_bytes
|
||||
):
|
||||
raise RuntimeError("HiSparse DMA requires contiguous host rows.")
|
||||
|
||||
def start_step(
|
||||
self,
|
||||
metadata: HiSparseConnectorMetadata,
|
||||
request_state_indices: torch.Tensor | None,
|
||||
request_ids: list[str] | None = None,
|
||||
num_tokens: int = 0,
|
||||
) -> None:
|
||||
self._stage_row_mirror_mapping(num_tokens)
|
||||
previous_host_write_event = self.host_write_event
|
||||
self.host_write_event = self.host_write_events[self._next_host_write_event]
|
||||
self._next_host_write_event ^= 1
|
||||
current_stream().wait_event(previous_host_write_event)
|
||||
self._release_completed_dma_descriptors()
|
||||
mirrors = _flatten_row_mirrors(metadata.row_mirrors, request_ids)
|
||||
if self._slot_mapping_staging is not None:
|
||||
self._slot_mapping_staging.candidates = mirrors
|
||||
self._set_row_mirrors(mirrors)
|
||||
self._dma_submitted = False
|
||||
self._clear_forward_mirror_state()
|
||||
for handle in self.cache_handles:
|
||||
handle.all_context_pages_resident = metadata.all_context_pages_resident
|
||||
handle.mirror_from_resident = True
|
||||
self._copy_host_blocks(metadata.host_block_copies, previous_host_write_event)
|
||||
transfers = (
|
||||
metadata.command.page_transfers if metadata.command is not None else []
|
||||
)
|
||||
self._post_forward_transfers = [
|
||||
transfer for transfer in transfers if transfer.after_forward
|
||||
]
|
||||
self._submit_transfers(
|
||||
[transfer for transfer in transfers if not transfer.after_forward]
|
||||
)
|
||||
self._pending_invalid_block_ids.extend(metadata.source_block_ids)
|
||||
if request_state_indices is not None:
|
||||
self.set_request_state_indices(request_state_indices)
|
||||
|
||||
def _clear_forward_mirror_state(self) -> None:
|
||||
self._per_layer_mirrored.clear()
|
||||
self._submitted_mirror_layers.clear()
|
||||
for handle in self.cache_handles:
|
||||
handle.decode_batch = False
|
||||
handle.host_mirror_required = False
|
||||
handle.num_actual_tokens = 0
|
||||
handle.num_decode_tokens = 0
|
||||
handle.req_id_per_token = None
|
||||
|
||||
def prepare_forward(self, attn_metadata: Mapping[str, Any] | None) -> None:
|
||||
if attn_metadata is None:
|
||||
return
|
||||
for layer_name, handle in self._group_leaders:
|
||||
metadata = attn_metadata.get(layer_name)
|
||||
if metadata is not None:
|
||||
handle.prepare_group_for_batch(metadata)
|
||||
|
||||
def _stage_row_mirror_mapping(self, num_tokens: int) -> None:
|
||||
"""Snapshot the rows this forward will write, off the compute stream.
|
||||
|
||||
The resident slot mapping is a persistent view bound at registration
|
||||
time, so the rows are already staged by the time the forward launches.
|
||||
"""
|
||||
state = self._slot_mapping_staging
|
||||
if state is None or not num_tokens:
|
||||
return
|
||||
handle = self.cache_handles[0]
|
||||
slots = handle.slot_mapping
|
||||
assert slots is not None
|
||||
source_index = handle.runtime.resident_source_index
|
||||
start = state.num_tokens
|
||||
end = start + num_tokens
|
||||
if end > state.slots.shape[0]:
|
||||
raise ValueError(
|
||||
"HiSparse row mapping exceeds staging capacity: "
|
||||
f"{end} > {state.slots.shape[0]}."
|
||||
)
|
||||
main_stream = current_stream()
|
||||
state.stream.wait_stream(main_stream)
|
||||
with torch.cuda.stream(state.stream):
|
||||
state.slots[start:end].copy_(
|
||||
slots[:num_tokens],
|
||||
non_blocking=True,
|
||||
)
|
||||
state.event.record(state.stream)
|
||||
state.num_tokens = end
|
||||
state.source_index = source_index
|
||||
|
||||
def _resolve_row_mirrors(self, state: _SlotMappingStaging) -> None:
|
||||
"""Compute the final row mirrors from the staged GPU slot mapping."""
|
||||
self._set_row_mirrors(
|
||||
_select_written_row_mirrors(
|
||||
state.candidates,
|
||||
state.slots[: state.num_tokens].numpy(),
|
||||
state.source_index,
|
||||
)
|
||||
)
|
||||
state.num_tokens = 0
|
||||
|
||||
def _copy_host_blocks(
|
||||
self,
|
||||
host_block_copies: Sequence[KVCacheBlockCopy],
|
||||
previous_host_write_event: torch.Event,
|
||||
) -> None:
|
||||
if not host_block_copies:
|
||||
return
|
||||
self._completed_host_copy_dst_ids.extend(
|
||||
copy.dst_block_id for copy in host_block_copies
|
||||
)
|
||||
if self.shared_host_region is None or get_tensor_model_parallel_rank() == 0:
|
||||
if self.host_caches:
|
||||
previous_host_write_event.synchronize()
|
||||
copy_kv_cache_blocks_inplace(
|
||||
self.host_caches,
|
||||
self.host_num_blocks,
|
||||
host_block_copies,
|
||||
)
|
||||
if self.shared_host_region is not None:
|
||||
get_tp_group().barrier()
|
||||
|
||||
def invalidate_blocks(
|
||||
self, block_ids: list[int], request_state_indices: torch.Tensor
|
||||
) -> None:
|
||||
"""Invalidate recycled host slots in this worker's leader runtimes."""
|
||||
if not block_ids:
|
||||
return
|
||||
device = self.cache_handles[0].runtime.device
|
||||
staging = torch.tensor(block_ids, dtype=torch.int32, pin_memory=True)
|
||||
blocks = staging.to(device, dtype=torch.int32, non_blocking=True)
|
||||
offsets = torch.arange(self.kernel_block_size, dtype=torch.int32, device=device)
|
||||
slots = (blocks[:, None] * self.kernel_block_size + offsets[None, :]).flatten()
|
||||
sorted_slots = torch.sort(slots).values
|
||||
state_indices = request_state_indices.to(device=device, dtype=torch.long)
|
||||
for runtime in self.leader_runtimes:
|
||||
runtime.invalidate_sorted_slots(sorted_slots, state_indices)
|
||||
|
||||
def reset_hot_state(self) -> None:
|
||||
for runtime in self.leader_runtimes:
|
||||
runtime.reset_hot_state()
|
||||
|
||||
def _release_completed_dma_descriptors(self) -> None:
|
||||
pending = self._pending_dma_descriptors
|
||||
while pending and pending[0][0].query():
|
||||
_, descriptors = pending.popleft()
|
||||
self._dma_free_descriptors.append(descriptors)
|
||||
|
||||
def _acquire_dma_descriptors(self, size: int) -> _DMADescriptors:
|
||||
for index, descriptors in enumerate(self._dma_free_descriptors):
|
||||
if descriptors.src.numel() >= size:
|
||||
return self._dma_free_descriptors.pop(index)
|
||||
return _allocate_dma_descriptors(size)
|
||||
|
||||
def _submit_dma_descriptors(
|
||||
self,
|
||||
descriptors: _DMADescriptors,
|
||||
descriptor_count: int,
|
||||
transfer_ids: tuple[int, ...] = (),
|
||||
ready_event: torch.Event | None = None,
|
||||
) -> None:
|
||||
stream = self.dma_stream
|
||||
assert stream is not None
|
||||
if ready_event is None:
|
||||
stream.wait_stream(current_stream())
|
||||
else:
|
||||
stream.wait_event(ready_event)
|
||||
completion_event = torch.Event()
|
||||
with torch.cuda.stream(stream):
|
||||
ops.swap_blocks_batch(
|
||||
descriptors.src[:descriptor_count],
|
||||
descriptors.dst[:descriptor_count],
|
||||
descriptors.sizes[:descriptor_count],
|
||||
)
|
||||
self.host_write_event.record(stream)
|
||||
completion_event.record(stream)
|
||||
self._pending_dma_descriptors.append((completion_event, descriptors))
|
||||
self._dma_submitted = True
|
||||
if transfer_ids:
|
||||
self._pending_transfer_events.append((completion_event, transfer_ids))
|
||||
self._enqueued_transfer_ids.extend(transfer_ids)
|
||||
|
||||
def _set_row_mirrors(self, mirrors: tuple[SparseKVRowMirror, ...]) -> None:
|
||||
self._row_mirrors = mirrors
|
||||
self._row_mirror_destination_starts = np.fromiter(
|
||||
(mirror.destination_start for mirror in mirrors),
|
||||
dtype=np.int64,
|
||||
count=len(mirrors),
|
||||
)
|
||||
self._row_mirror_counts = np.fromiter(
|
||||
(mirror.num_rows for mirror in mirrors),
|
||||
dtype=np.int64,
|
||||
count=len(mirrors),
|
||||
)
|
||||
if mirrors:
|
||||
self._row_mirror_source_starts = np.asarray(
|
||||
[mirror.source_starts for mirror in mirrors], dtype=np.int64
|
||||
)
|
||||
if self._row_mirror_source_starts.ndim != 2:
|
||||
raise RuntimeError("HiSparse DMA source mappings must be rectangular.")
|
||||
else:
|
||||
self._row_mirror_source_starts = np.empty((0, 0), dtype=np.int64)
|
||||
self._row_mirror_num_rows = int(self._row_mirror_counts.sum())
|
||||
|
||||
def _enqueue_row_dma(
|
||||
self, layer_indices: Sequence[int], ready_event: torch.Event | None = None
|
||||
) -> None:
|
||||
if (
|
||||
not layer_indices
|
||||
or not self._row_mirror_num_rows
|
||||
or not self.is_host_writer
|
||||
):
|
||||
return
|
||||
mirrors = self._row_mirrors
|
||||
num_layers = len(layer_indices)
|
||||
descriptor_count = len(mirrors) * num_layers
|
||||
descriptors = self._acquire_dma_descriptors(descriptor_count)
|
||||
destination_starts = self._row_mirror_destination_starts
|
||||
row_counts = self._row_mirror_counts
|
||||
for descriptor_offset, layer_index in enumerate(layer_indices):
|
||||
cache = self.cache_handles[layer_index]
|
||||
source_index = cache.runtime.resident_source_index
|
||||
if source_index >= self._row_mirror_source_starts.shape[1]:
|
||||
raise RuntimeError("HiSparse row DMA source index is out of range.")
|
||||
source_rows = self._row_mirror_source_starts[:, source_index]
|
||||
source = self.resident_caches[layer_index]
|
||||
destination = self.host_caches[layer_index]
|
||||
row_bytes = source.shape[-1] * source.element_size()
|
||||
if (
|
||||
source.stride(1) * source.element_size() != row_bytes
|
||||
or destination.shape[1] * destination.element_size() != row_bytes
|
||||
):
|
||||
raise RuntimeError("HiSparse row DMA requires contiguous rows.")
|
||||
source_blocks, source_row_offsets = np.divmod(
|
||||
source_rows, self.kernel_block_size
|
||||
)
|
||||
source_out_of_range = (
|
||||
np.any(source_blocks < 0)
|
||||
or np.any(source_blocks >= source.shape[0])
|
||||
or np.any(source_row_offsets + row_counts > self.kernel_block_size)
|
||||
)
|
||||
if source_out_of_range:
|
||||
raise RuntimeError("HiSparse row DMA source is out of range.")
|
||||
if np.any(destination_starts < 0) or np.any(
|
||||
destination_starts + row_counts > destination.shape[0]
|
||||
):
|
||||
raise RuntimeError("HiSparse row DMA index is out of range.")
|
||||
descriptor_slice = slice(descriptor_offset, descriptor_count, num_layers)
|
||||
descriptors.src_np[descriptor_slice] = (
|
||||
source.data_ptr()
|
||||
+ source_blocks * source.stride(0) * source.element_size()
|
||||
+ source_row_offsets * row_bytes
|
||||
)
|
||||
descriptors.dst_np[descriptor_slice] = (
|
||||
destination.data_ptr() + destination_starts * row_bytes
|
||||
)
|
||||
descriptors.sizes_np[descriptor_slice] = row_counts * row_bytes
|
||||
|
||||
self._submit_dma_descriptors(
|
||||
descriptors, descriptor_count, ready_event=ready_event
|
||||
)
|
||||
|
||||
def _enqueue_layer_mirror(self, layer_index: int) -> None:
|
||||
handle = self.cache_handles[layer_index]
|
||||
if not handle.host_mirror_required:
|
||||
return
|
||||
if layer_index in self._per_layer_mirrored:
|
||||
raise RuntimeError(f"HiSparse layer {layer_index} mirrored twice.")
|
||||
state = self._slot_mapping_staging
|
||||
if state is not None and state.num_tokens:
|
||||
# The staging stream waited on the compute stream before the
|
||||
# forward launched, so this only drains pre-forward work that is
|
||||
# already queued ahead of the layer kernels: no GPU bubble.
|
||||
state.event.synchronize()
|
||||
self._resolve_row_mirrors(state)
|
||||
self._per_layer_mirrored.add(layer_index)
|
||||
next_layer = layer_index + 1
|
||||
if (
|
||||
next_layer < len(self.cache_handles)
|
||||
and self.cache_handles[next_layer].runtime.resident_source_index
|
||||
== handle.runtime.resident_source_index
|
||||
):
|
||||
return
|
||||
ready_event = self._layer_ready_events[layer_index]
|
||||
ready_event.record()
|
||||
pending_layers = tuple(
|
||||
sorted(self._per_layer_mirrored - self._submitted_mirror_layers)
|
||||
)
|
||||
self._enqueue_row_dma(
|
||||
pending_layers,
|
||||
ready_event=ready_event,
|
||||
)
|
||||
self._submitted_mirror_layers.update(pending_layers)
|
||||
|
||||
def _record_transfer_completion(
|
||||
self, transfers: list[SparseKVPageTransfer]
|
||||
) -> None:
|
||||
if not transfers or not self.is_host_writer:
|
||||
return
|
||||
stream = self.dma_stream
|
||||
assert stream is not None
|
||||
completion_event = torch.Event()
|
||||
completion_event.record(stream)
|
||||
transfer_ids = tuple(transfer.transfer_id for transfer in transfers)
|
||||
self._pending_transfer_events.append((completion_event, transfer_ids))
|
||||
self._enqueued_transfer_ids.extend(transfer_ids)
|
||||
|
||||
def _submit_transfers(self, transfers: list[SparseKVPageTransfer]) -> None:
|
||||
if self.cache_handles[0].runtime.eager_host_mirror:
|
||||
self._record_transfer_completion(transfers)
|
||||
else:
|
||||
self._enqueue_transfers(transfers)
|
||||
|
||||
def _enqueue_transfers(self, transfers: list[SparseKVPageTransfer]) -> None:
|
||||
if not transfers or not self.is_host_writer:
|
||||
return
|
||||
num_layers = len(self.cache_handles)
|
||||
descriptor_count = len(transfers) * num_layers
|
||||
descriptors = self._acquire_dma_descriptors(descriptor_count)
|
||||
destination_rows = np.fromiter(
|
||||
(
|
||||
transfer.destination_block_id * self.kernel_block_size
|
||||
for transfer in transfers
|
||||
),
|
||||
dtype=np.int64,
|
||||
count=len(transfers),
|
||||
)
|
||||
source_blocks_by_transfer = np.asarray(
|
||||
[transfer.source_block_ids for transfer in transfers], dtype=np.int64
|
||||
)
|
||||
if source_blocks_by_transfer.ndim != 2:
|
||||
raise RuntimeError(
|
||||
"HiSparse spill DMA source mappings must be rectangular."
|
||||
)
|
||||
for layer_index, cache in enumerate(self.cache_handles):
|
||||
source_index = cache.runtime.resident_source_index
|
||||
if source_index >= source_blocks_by_transfer.shape[1]:
|
||||
raise RuntimeError("HiSparse spill DMA source index is out of range.")
|
||||
source_blocks = source_blocks_by_transfer[:, source_index]
|
||||
source = self.resident_caches[layer_index]
|
||||
destination = self.host_caches[layer_index]
|
||||
if np.any(source_blocks < 0) or np.any(source_blocks >= source.shape[0]):
|
||||
raise RuntimeError("HiSparse spill DMA source is out of range.")
|
||||
if np.any(destination_rows < 0) or np.any(
|
||||
destination_rows + self.kernel_block_size > destination.shape[0]
|
||||
):
|
||||
raise RuntimeError("HiSparse spill DMA destination is out of range.")
|
||||
row_bytes = source.shape[-1] * source.element_size()
|
||||
descriptor_slice = slice(layer_index, descriptor_count, num_layers)
|
||||
descriptors.src_np[descriptor_slice] = (
|
||||
source.data_ptr()
|
||||
+ source_blocks * source.stride(0) * source.element_size()
|
||||
)
|
||||
descriptors.dst_np[descriptor_slice] = (
|
||||
destination.data_ptr() + destination_rows * row_bytes
|
||||
)
|
||||
descriptors.sizes_np[descriptor_slice] = self.kernel_block_size * row_bytes
|
||||
self._submit_dma_descriptors(
|
||||
descriptors,
|
||||
descriptor_count,
|
||||
transfer_ids=tuple(transfer.transfer_id for transfer in transfers),
|
||||
)
|
||||
|
||||
def _enqueue_host_mirror(
|
||||
self,
|
||||
ready_event: torch.Event | None = None,
|
||||
) -> None:
|
||||
active = [
|
||||
(index, handle)
|
||||
for index, handle in enumerate(self.cache_handles)
|
||||
if handle.num_actual_tokens != 0
|
||||
]
|
||||
if not active:
|
||||
return
|
||||
|
||||
def mirror_key(handle: HiSparseCacheHandle) -> tuple:
|
||||
slots = handle.mirror_slot_mapping
|
||||
return (
|
||||
handle.num_actual_tokens,
|
||||
handle.num_decode_tokens,
|
||||
handle.decode_batch,
|
||||
handle.host_mirror_required,
|
||||
handle.runtime.eager_host_mirror,
|
||||
None if slots is None else slots.data_ptr(),
|
||||
)
|
||||
|
||||
keys = {mirror_key(handle) for _, handle in active}
|
||||
if len(keys) > 1:
|
||||
raise RuntimeError(
|
||||
f"HiSparse cache layers disagree on mirror metadata: {list(keys)}."
|
||||
)
|
||||
cache = active[0][1]
|
||||
if not cache.host_mirror_required:
|
||||
return
|
||||
dst_slots = cache.mirror_slot_mapping
|
||||
if dst_slots is None:
|
||||
raise RuntimeError("HiSparse host mirror has no source slot mapping.")
|
||||
num_rows = min(cache.num_actual_tokens, dst_slots.numel())
|
||||
if num_rows == 0:
|
||||
return
|
||||
if self.is_host_writer:
|
||||
expected_layers = {index for index, _ in active}
|
||||
if self._per_layer_mirrored and self._per_layer_mirrored != expected_layers:
|
||||
raise RuntimeError(
|
||||
"HiSparse per-layer DMA did not mirror every active layer: "
|
||||
f"expected {sorted(expected_layers)}, got "
|
||||
f"{sorted(self._per_layer_mirrored)}."
|
||||
)
|
||||
pending_layers = tuple(
|
||||
sorted(expected_layers - self._submitted_mirror_layers)
|
||||
)
|
||||
if pending_layers:
|
||||
self._enqueue_row_dma(pending_layers, ready_event=ready_event)
|
||||
self._submitted_mirror_layers.update(pending_layers)
|
||||
assert cache.req_id_per_token is not None
|
||||
for _, handle in active:
|
||||
if handle.runtime.is_group_leader:
|
||||
handle.runtime.invalidate_written_slots(
|
||||
dst_slots[:num_rows],
|
||||
cache.req_id_per_token[:num_rows],
|
||||
)
|
||||
|
||||
def _finish_mirror_phase(self, ready_event: torch.Event | None = None) -> None:
|
||||
state = self._slot_mapping_staging
|
||||
if state is not None and state.num_tokens:
|
||||
# Measured free: blocking here costs no throughput (26.7 vs 27.6
|
||||
# gen tok/s) and lets every step mirror exactly the written rows.
|
||||
state.event.synchronize()
|
||||
self._resolve_row_mirrors(state)
|
||||
self._enqueue_host_mirror(ready_event)
|
||||
self._clear_forward_mirror_state()
|
||||
|
||||
def finish_forward(self) -> None:
|
||||
compute_stream = current_stream()
|
||||
self._forward_ready_event.record()
|
||||
self._finish_mirror_phase(self._forward_ready_event)
|
||||
transfers = self._post_forward_transfers
|
||||
self._post_forward_transfers = []
|
||||
self._submit_transfers(transfers)
|
||||
if self.is_host_writer:
|
||||
if self._dma_submitted:
|
||||
compute_stream.wait_event(self.host_write_event)
|
||||
self._dma_submitted = False
|
||||
else:
|
||||
self.host_write_event.record(compute_stream)
|
||||
self._release_completed_dma_descriptors()
|
||||
|
||||
def take_completed_host_copies(self) -> list[int]:
|
||||
"""Drain host copies this worker has enqueued for this step."""
|
||||
completed = self._completed_host_copy_dst_ids
|
||||
self._completed_host_copy_dst_ids = []
|
||||
return completed
|
||||
|
||||
def take_transfer_updates(self) -> tuple[list[int], list[int]]:
|
||||
enqueued = self._enqueued_transfer_ids
|
||||
self._enqueued_transfer_ids = []
|
||||
completed: list[int] = []
|
||||
while self._pending_transfer_events:
|
||||
event, transfer_ids = self._pending_transfer_events[0]
|
||||
if not event.query():
|
||||
break
|
||||
self._pending_transfer_events.popleft()
|
||||
completed.extend(transfer_ids)
|
||||
return enqueued, completed
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if not self._initialized:
|
||||
return
|
||||
if self._slot_mapping_staging is not None:
|
||||
self._slot_mapping_staging.stream.synchronize()
|
||||
if self.dma_stream is not None:
|
||||
self.dma_stream.synchronize()
|
||||
release_pinned_state(
|
||||
[cache.runtime for cache in self.cache_handles], self.pinned_host_pools
|
||||
)
|
||||
self._initialized = False
|
||||
@@ -702,9 +702,9 @@ class LMCacheMPConnectorUpstream(KVConnectorBase_V1):
|
||||
- Applies to both sync- and async-loading requests.
|
||||
- Async loading: failed blocks may be reported in any forward pass
|
||||
up to and including the pass where the request ID is returned by
|
||||
`get_finished()`. Even if failures occur, the request must still
|
||||
be reported via `get_finished()`, and the failed block IDs must
|
||||
appear here no later than that same pass.
|
||||
`get_transfer_results()`. Even if failures occur, the request
|
||||
must still be reported as finished receiving, and the failed
|
||||
block IDs must appear here no later than that same pass.
|
||||
- Sync loading: failed blocks should be reported in the forward
|
||||
pass in which they are detected.
|
||||
"""
|
||||
|
||||
@@ -635,6 +635,7 @@ class MooncakeConnectorScheduler:
|
||||
):
|
||||
self.vllm_config = vllm_config
|
||||
self.block_size = vllm_config.cache_config.block_size
|
||||
self.kv_cache_config = kv_cache_config
|
||||
|
||||
assert vllm_config.kv_transfer_config
|
||||
self.is_kv_producer: bool = (
|
||||
@@ -649,7 +650,7 @@ class MooncakeConnectorScheduler:
|
||||
not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
|
||||
and any(
|
||||
not isinstance(g.kv_cache_spec, FullAttentionSpec)
|
||||
for g in kv_cache_config.kv_cache_groups
|
||||
for g in kv_cache_config.transfer_groups
|
||||
)
|
||||
)
|
||||
# GDN is represented as a MambaSpec in vLLM. This Mooncake MambaSpec
|
||||
@@ -670,7 +671,7 @@ class MooncakeConnectorScheduler:
|
||||
(g.kv_cache_spec.sliding_window, g.kv_cache_spec.block_size)
|
||||
if isinstance(g.kv_cache_spec, SlidingWindowSpec)
|
||||
else (0, self.block_size)
|
||||
for g in kv_cache_config.kv_cache_groups
|
||||
for g in kv_cache_config.transfer_groups
|
||||
]
|
||||
# cdiv(n_tokens, block_size) gives blocks/window; add 1 to
|
||||
# conservatively account for boundary overlap.
|
||||
@@ -684,11 +685,17 @@ class MooncakeConnectorScheduler:
|
||||
block_ids: tuple[list[int], ...] | list[list[int]],
|
||||
) -> list[list[int]]:
|
||||
"""Clip per-group block IDs to sliding window size."""
|
||||
if len(block_ids) == 0 or not self._is_hma_required:
|
||||
return list(block_ids)
|
||||
if len(block_ids) == 0:
|
||||
return []
|
||||
if len(block_ids) == len(self.kv_cache_config.transfer_group_ids):
|
||||
selected = list(block_ids)
|
||||
else:
|
||||
selected = list(self.kv_cache_config.select_transfer_block_ids(block_ids))
|
||||
if len(selected) == 0 or not self._is_hma_required:
|
||||
return selected
|
||||
return [
|
||||
blocks[-self.blocks_per_sw[i] :] if self.blocks_per_sw[i] > 0 else blocks
|
||||
for i, blocks in enumerate(block_ids)
|
||||
for i, blocks in enumerate(selected)
|
||||
]
|
||||
|
||||
def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
|
||||
@@ -1046,18 +1053,13 @@ class MooncakeConnectorWorker:
|
||||
|
||||
self._tp_size: dict[EngineId, int] = {self.engine_id: self.tp_size}
|
||||
self._layer_specs: dict[str, KVCacheSpec] = {}
|
||||
for group in kv_cache_config.kv_cache_groups:
|
||||
for group in kv_cache_config.transfer_groups:
|
||||
group_spec = group.kv_cache_spec
|
||||
specs_by_layer = getattr(group_spec, "kv_cache_specs", {})
|
||||
for layer_name in group.layer_names:
|
||||
self._layer_specs[layer_name] = specs_by_layer.get(
|
||||
layer_name, group_spec
|
||||
)
|
||||
self._layer_group_indices: dict[str, int] = {
|
||||
layer: group_index
|
||||
for group_index, group in enumerate(kv_cache_config.kv_cache_groups)
|
||||
for layer in group.layer_names
|
||||
}
|
||||
self.transfer_topo = TransferTopology(
|
||||
tp_rank=self.tp_rank,
|
||||
tp_size=self.tp_size,
|
||||
@@ -1441,7 +1443,7 @@ class MooncakeConnectorWorker:
|
||||
block_arange = np.arange(self._physical_blocks_per_logical_kv_block).reshape(
|
||||
1, -1
|
||||
)
|
||||
group_specs = self.kv_cache_config.kv_cache_groups
|
||||
group_specs = self.kv_cache_config.transfer_groups
|
||||
return [
|
||||
BlockTable.map_to_kernel_blocks(
|
||||
np.array(group),
|
||||
@@ -1494,7 +1496,7 @@ class MooncakeConnectorWorker:
|
||||
local_block_ids_by_group: list[list[int]] = []
|
||||
remote_block_ids_by_group: list[list[int]] = []
|
||||
has_block_error = False
|
||||
group_specs = self.kv_cache_config.kv_cache_groups
|
||||
group_specs = self.kv_cache_config.transfer_groups
|
||||
for group_index, (local_group, remote_group) in enumerate(
|
||||
zip(send_meta.local_block_ids, remote_block_ids_per_group)
|
||||
):
|
||||
@@ -1750,7 +1752,7 @@ class MooncakeConnectorWorker:
|
||||
self.registered_layer_names.append(layer_name)
|
||||
self.registered_layer_indices.append(layer_index)
|
||||
self.registered_group_indices.append(
|
||||
self._layer_group_indices[layer_name]
|
||||
self.kv_cache_config.transfer_group_index_by_layer[layer_name]
|
||||
)
|
||||
storage = cache.untyped_storage()
|
||||
storage_addr = storage.data_ptr()
|
||||
@@ -2121,7 +2123,7 @@ class MooncakeConnectorWorker:
|
||||
) -> list[TransferRegion]:
|
||||
if not group_indices:
|
||||
group_indices = [
|
||||
self._layer_group_indices.get(layer_name, 0)
|
||||
self.kv_cache_config.transfer_group_index_by_layer.get(layer_name, 0)
|
||||
for layer_name in layer_names
|
||||
]
|
||||
return _expand_transfer_regions(
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
"""External-store cache-hit coordinator for MooncakeStoreConnector."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
from typing import NamedTuple, cast
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
|
||||
chunk_hashes_for_block_size,
|
||||
)
|
||||
from vllm.utils.math_utils import cdiv
|
||||
from vllm.v1.core.block_pool import BlockPool
|
||||
from vllm.v1.core.kv_cache_coordinator import SpecGroup
|
||||
from vllm.v1.core.kv_cache_utils import (
|
||||
BlockHash,
|
||||
KVCacheBlock,
|
||||
)
|
||||
from vllm.v1.core.single_type_kv_cache_manager import (
|
||||
SingleTypeKVCacheManager,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheGroupSpec,
|
||||
@@ -25,6 +27,13 @@ from vllm.v1.kv_cache_interface import (
|
||||
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
|
||||
|
||||
|
||||
class StoreSpecGroup(NamedTuple):
|
||||
spec: KVCacheSpec
|
||||
group_ids: list[int]
|
||||
manager_cls: type[SingleTypeKVCacheManager]
|
||||
use_eagle: bool
|
||||
|
||||
|
||||
class ExternalCachedBlockPool:
|
||||
"""Duck-typed BlockPool backed by a ``(group_id, hash)`` exists set."""
|
||||
|
||||
@@ -121,7 +130,7 @@ class MooncakeStoreCoordinator:
|
||||
"""Mirrors KVCacheCoordinator.verify_and_split_kv_cache_groups but
|
||||
dispatches via spec_manager_map (we don't allocate managers).
|
||||
"""
|
||||
attention_groups: list[SpecGroup] = []
|
||||
attention_groups: list[StoreSpecGroup] = []
|
||||
for i, g in enumerate(self.kv_cache_groups):
|
||||
# Skip groups that opt out of prefix caching (e.g. GLM-5.3-Flash
|
||||
# kpool tail): per-request scratch, never shareable, so they must
|
||||
@@ -143,7 +152,7 @@ class MooncakeStoreCoordinator:
|
||||
break
|
||||
else:
|
||||
attention_groups.append(
|
||||
SpecGroup(spec, [i], manager_cls, g.is_eagle_group)
|
||||
StoreSpecGroup(spec, [i], manager_cls, g.is_eagle_group)
|
||||
)
|
||||
# Full attention first (matches upstream convergence ordering).
|
||||
attention_groups.sort(key=lambda g: not isinstance(g.spec, FullAttentionSpec))
|
||||
|
||||
@@ -67,6 +67,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.math_utils import cdiv
|
||||
from vllm.utils.network_utils import get_ip, make_zmq_socket
|
||||
from vllm.utils.torch_utils import is_non_overlapping_and_dense
|
||||
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
|
||||
from vllm.v1.core.kv_cache_utils import (
|
||||
BlockHash,
|
||||
@@ -1909,21 +1910,31 @@ class MooncakeStoreWorker:
|
||||
logger.warning("No KV caches to offload.")
|
||||
return
|
||||
|
||||
# Resolve each entry to a representative tensor for storage
|
||||
# deduplication. For attention layers the value is already a tensor;
|
||||
# for Mamba layers it is a list of tensors that all share the same
|
||||
# underlying raw storage, so we take the first one.
|
||||
def _repr_tensor(v: torch.Tensor | list[torch.Tensor]) -> torch.Tensor:
|
||||
assert isinstance(v, torch.Tensor | list)
|
||||
return v if isinstance(v, torch.Tensor) else v[0]
|
||||
|
||||
assert self.cache_config.num_gpu_blocks is not None
|
||||
self.num_blocks = self.cache_config.num_gpu_blocks
|
||||
use_group_regions = self._kv_cache_config.hisparse_host_num_blocks is not None
|
||||
|
||||
seen_storage_ptrs: set[int] = set()
|
||||
cache_tensors: list[torch.Tensor] = []
|
||||
|
||||
for cache in kv_caches.values():
|
||||
cache = group_kernel_blocks(cache, self.num_blocks)
|
||||
cache_tensors.append(cache)
|
||||
cache_storage = cache.untyped_storage()
|
||||
base_addr = cache_storage.data_ptr()
|
||||
region_len = cache_storage.nbytes()
|
||||
|
||||
if base_addr not in seen_storage_ptrs:
|
||||
if not use_group_regions:
|
||||
seen_storage_ptrs: set[int] = set()
|
||||
cache_tensors = [
|
||||
group_kernel_blocks(_repr_tensor(cache), self.num_blocks)
|
||||
for cache in kv_caches.values()
|
||||
]
|
||||
for cache in cache_tensors:
|
||||
cache_storage = cache.untyped_storage()
|
||||
base_addr = cache_storage.data_ptr()
|
||||
if base_addr in seen_storage_ptrs:
|
||||
continue
|
||||
seen_storage_ptrs.add(base_addr)
|
||||
region_len = cache_storage.nbytes()
|
||||
ret = self.store.register_buffer(base_addr, region_len)
|
||||
if ret != 0:
|
||||
logger.error(
|
||||
@@ -1932,17 +1943,110 @@ class MooncakeStoreWorker:
|
||||
region_len,
|
||||
ret,
|
||||
)
|
||||
for db in self.token_dbs:
|
||||
db.store_layout.register_kv_caches(cache_tensors, self.num_blocks)
|
||||
|
||||
registered_buffers: dict[int, int] = {}
|
||||
num_host_segments = 0
|
||||
num_device_segments = 0
|
||||
|
||||
for group, db in zip(self._kv_cache_groups, self.token_dbs, strict=True):
|
||||
if not use_group_regions:
|
||||
continue
|
||||
missing_layers = [
|
||||
layer_name
|
||||
for layer_name in group.layer_names
|
||||
if layer_name not in kv_caches
|
||||
]
|
||||
if missing_layers:
|
||||
raise ValueError(
|
||||
f"Missing transferable KV caches for layers: {missing_layers}"
|
||||
)
|
||||
group_caches = [
|
||||
(layer_name, kv_caches[layer_name]) for layer_name in group.layer_names
|
||||
]
|
||||
|
||||
seen_group_ptrs: set[int] = set()
|
||||
addrs: list[int] = []
|
||||
block_lens: list[int] = []
|
||||
for layer_name, value in group_caches:
|
||||
cache = _repr_tensor(value)
|
||||
is_host_resident = group.host_resident
|
||||
if is_host_resident:
|
||||
num_blocks = self._kv_cache_config.hisparse_host_num_blocks
|
||||
assert num_blocks is not None
|
||||
else:
|
||||
num_blocks = self._kv_cache_config.num_blocks
|
||||
cache = group_kernel_blocks(cache, num_blocks)
|
||||
cache_storage = cache.untyped_storage()
|
||||
if is_host_resident:
|
||||
base_addr = cache.data_ptr()
|
||||
region_len = cache.nbytes
|
||||
else:
|
||||
base_addr = cache_storage.data_ptr()
|
||||
region_len = cache_storage.nbytes()
|
||||
if region_len % num_blocks != 0:
|
||||
raise ValueError(
|
||||
f"KV cache region for {layer_name} has size {region_len}, "
|
||||
f"which is not divisible by its {num_blocks} blocks."
|
||||
)
|
||||
|
||||
registered_len = registered_buffers.get(base_addr)
|
||||
if registered_len is None:
|
||||
ret = self.store.register_buffer(base_addr, region_len)
|
||||
if ret != 0:
|
||||
mem_kind = "host" if is_host_resident else "device"
|
||||
raise RuntimeError(
|
||||
"Mooncake register_buffer failed for addr "
|
||||
f"{base_addr:#x} len {region_len} ({mem_kind}): {ret}"
|
||||
)
|
||||
registered_buffers[base_addr] = region_len
|
||||
elif registered_len != region_len:
|
||||
raise ValueError(
|
||||
f"KV cache views at {base_addr:#x} expose inconsistent "
|
||||
f"region sizes: {registered_len} and {region_len}."
|
||||
)
|
||||
|
||||
regions: list[tuple[int, int]] = []
|
||||
if not is_non_overlapping_and_dense(cache[0]):
|
||||
for head_idx in range(cache.shape[1]):
|
||||
head_cache = cache[:, head_idx]
|
||||
assert is_non_overlapping_and_dense(head_cache[0])
|
||||
regions.append(
|
||||
(
|
||||
head_cache.data_ptr(),
|
||||
head_cache.stride(0) * head_cache.element_size(),
|
||||
)
|
||||
)
|
||||
elif cache.stride(0) * cache.element_size() * num_blocks == region_len:
|
||||
regions.append((base_addr, region_len // num_blocks))
|
||||
else:
|
||||
regions.append(
|
||||
(cache.data_ptr(), cache.stride(0) * cache.element_size())
|
||||
)
|
||||
|
||||
for region_addr, block_len in regions:
|
||||
if region_addr in seen_group_ptrs:
|
||||
continue
|
||||
seen_group_ptrs.add(region_addr)
|
||||
addrs.append(region_addr)
|
||||
block_lens.append(block_len)
|
||||
if is_host_resident:
|
||||
num_host_segments += 1
|
||||
else:
|
||||
num_device_segments += 1
|
||||
|
||||
db.set_kv_caches_base_addr(addrs)
|
||||
db.set_block_len(block_lens)
|
||||
|
||||
logger.info(
|
||||
"Registered KV caches: num_groups=%d, num_tensors=%d, num_blocks=%d",
|
||||
"Registered KV caches: num_groups=%d, num_segments=%d (host=%d, device=%d)",
|
||||
len(self.token_dbs),
|
||||
len(cache_tensors),
|
||||
self.num_blocks,
|
||||
num_host_segments + num_device_segments,
|
||||
num_host_segments,
|
||||
num_device_segments,
|
||||
)
|
||||
|
||||
for db in self.token_dbs:
|
||||
db.store_layout.register_kv_caches(cache_tensors, self.num_blocks)
|
||||
|
||||
# Start transfer threads
|
||||
if self.can_put:
|
||||
ready_event_sending = threading.Event()
|
||||
@@ -2316,6 +2420,19 @@ class MooncakeStoreWorker:
|
||||
if store is None:
|
||||
return
|
||||
self.store = None
|
||||
deadline = time.monotonic() + 5.0
|
||||
for thread in (self.kv_send_thread, *self.kv_recv_threads):
|
||||
if thread is None:
|
||||
continue
|
||||
while thread.request_queue.unfinished_tasks and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if thread.request_queue.unfinished_tasks:
|
||||
logger.warning(
|
||||
"Mooncake store %s still has %d in-flight requests at "
|
||||
"close; closing anyway.",
|
||||
thread.name,
|
||||
thread.request_queue.unfinished_tasks,
|
||||
)
|
||||
try:
|
||||
store.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,6 +17,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorHandshakeMetadata,
|
||||
KVConnectorMetadata,
|
||||
KVConnectorRole,
|
||||
KVConnectorTransferResults,
|
||||
KVConnectorWorkerMetadata,
|
||||
SupportsHMA,
|
||||
)
|
||||
@@ -35,7 +36,7 @@ if TYPE_CHECKING:
|
||||
from vllm.distributed.kv_events import KVCacheEvent
|
||||
from vllm.forward_context import ForwardContext
|
||||
from vllm.v1.core.block_pool import BlockPool
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks, KVCacheManager
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
from vllm.v1.request import Request
|
||||
|
||||
@@ -208,6 +209,10 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
# Propagated from scheduler to worker side via the connector metadata.
|
||||
self._extra_async_saves: dict[str, int] = {}
|
||||
|
||||
@property
|
||||
def sub_connectors(self) -> list[KVConnectorBase_V1]:
|
||||
return list(self._connectors)
|
||||
|
||||
@property
|
||||
def supports_divergent_local_hybrid_hits(self) -> bool:
|
||||
return bool(self._connectors) and all(
|
||||
@@ -248,6 +253,10 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
for c in self._connectors:
|
||||
c.register_kv_caches(kv_caches)
|
||||
|
||||
def bind_kv_cache_manager(self, kv_cache_manager: "KVCacheManager") -> None:
|
||||
for connector in self._connectors:
|
||||
connector.bind_kv_cache_manager(kv_cache_manager)
|
||||
|
||||
def bind_gpu_block_pool(self, gpu_block_pool: "BlockPool") -> None:
|
||||
for c in self._connectors:
|
||||
c.bind_gpu_block_pool(gpu_block_pool)
|
||||
@@ -292,6 +301,14 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
for c in self._connectors:
|
||||
c.start_load_kv(forward_context, **kwargs)
|
||||
|
||||
def finish_forward(self) -> None:
|
||||
for c in self._connectors:
|
||||
c.finish_forward()
|
||||
|
||||
def reset_capture_state(self) -> None:
|
||||
for c in self._connectors:
|
||||
c.reset_capture_state()
|
||||
|
||||
def wait_for_layer_load(self, layer_name: str) -> None:
|
||||
for c in self._connectors:
|
||||
c.wait_for_layer_load(layer_name)
|
||||
@@ -313,29 +330,31 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
def get_finished(
|
||||
self, finished_req_ids: set[str]
|
||||
) -> tuple[set[str] | None, set[str] | None]:
|
||||
finished_sending: set[str] = set()
|
||||
finished_recving: set[str] = set()
|
||||
for c in self._connectors:
|
||||
sending, recving = c.get_finished(finished_req_ids)
|
||||
if not recving and not sending:
|
||||
continue
|
||||
# Aggregate finished recving request ids.
|
||||
finished_recving.update(recving or ())
|
||||
# Aggregate finished sending request ids - only include
|
||||
# once we've drained the "extra" count (for cases where
|
||||
# more than one connector is async-saving the same request).
|
||||
for req_id in sending or ():
|
||||
results = self.get_transfer_results(finished_req_ids)
|
||||
return (
|
||||
results.finished_sending or None,
|
||||
results.finished_recving or None,
|
||||
)
|
||||
|
||||
def get_transfer_results(
|
||||
self, finished_req_ids: set[str]
|
||||
) -> KVConnectorTransferResults:
|
||||
results = KVConnectorTransferResults()
|
||||
for connector in self._connectors:
|
||||
child_results = connector.get_transfer_results(finished_req_ids)
|
||||
results.finished_recving.update(child_results.finished_recving)
|
||||
results.failed_recving.update(child_results.failed_recving)
|
||||
for req_id in child_results.finished_sending:
|
||||
extra_pending = self._extra_async_saves.get(req_id)
|
||||
if extra_pending is None:
|
||||
finished_sending.add(req_id)
|
||||
continue
|
||||
assert extra_pending > 0
|
||||
if extra_pending == 1:
|
||||
del self._extra_async_saves[req_id]
|
||||
results.finished_sending.add(req_id)
|
||||
else:
|
||||
self._extra_async_saves[req_id] = extra_pending - 1
|
||||
|
||||
return finished_sending or None, finished_recving or None
|
||||
assert extra_pending > 0
|
||||
if extra_pending == 1:
|
||||
del self._extra_async_saves[req_id]
|
||||
else:
|
||||
self._extra_async_saves[req_id] = extra_pending - 1
|
||||
return results
|
||||
|
||||
def get_block_ids_with_load_errors(self) -> set[int]:
|
||||
agg_block_ids: set[int] = set()
|
||||
@@ -563,6 +582,9 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
for c in self._connectors:
|
||||
yield from c.take_events()
|
||||
|
||||
def has_pending_block_frees(self) -> bool:
|
||||
return any(c.has_pending_block_frees() for c in self._connectors)
|
||||
|
||||
def has_pending_push_work(self) -> bool:
|
||||
return any(c.has_pending_push_work() for c in self._connectors)
|
||||
|
||||
@@ -630,7 +652,7 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
|
||||
return MultiKVConnectorStats(data=reconstructed_data)
|
||||
|
||||
def get_kv_connector_stats(self) -> MultiKVConnectorStats | None:
|
||||
def get_kv_connector_stats(self) -> KVConnectorStats | None:
|
||||
# Group connector stats by connector type.
|
||||
stats_by_connector: MultiKVConnectorStats | None = None
|
||||
for c in self._connectors:
|
||||
|
||||
@@ -93,10 +93,7 @@ class NixlBaseConnectorScheduler:
|
||||
for g in kv_cache_config.transfer_groups
|
||||
)
|
||||
)
|
||||
self._has_mamba = any(
|
||||
isinstance(g.kv_cache_spec, MambaSpec)
|
||||
for g in kv_cache_config.transfer_groups
|
||||
)
|
||||
self._has_mamba = kv_cache_config.has_mamba_layers
|
||||
|
||||
logger.info("Initializing NIXL Scheduler %s", engine_id)
|
||||
if vllm_config.scheduler_config.disable_hybrid_kv_cache_manager:
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Base worker-side logic for the NIXL connector."""
|
||||
|
||||
import contextlib
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
@@ -31,7 +33,10 @@ from vllm.distributed.kv_transfer.kv_connector.utils import (
|
||||
kv_postprocess_blksize_on_receive,
|
||||
kv_postprocess_layout_on_receive,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import CopyBlocksOp
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
CopyBlocksOp,
|
||||
KVConnectorTransferResults,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
GET_META_MSG,
|
||||
@@ -186,23 +191,28 @@ class NixlBaseConnectorWorker:
|
||||
num_blocks = dst_num_blocks
|
||||
if block_size_ratio is not None:
|
||||
num_blocks = int(num_blocks * block_size_ratio)
|
||||
num_regions = self.num_regions or len(self.block_len_per_layer)
|
||||
if region_num_blocks is None:
|
||||
region_num_blocks = [num_blocks] * self.num_regions
|
||||
region_num_blocks = [num_blocks] * num_regions
|
||||
elif block_size_ratio is not None:
|
||||
region_num_blocks = [
|
||||
int(count * block_size_ratio) for count in region_num_blocks
|
||||
]
|
||||
assert len(region_num_blocks) == self.num_regions
|
||||
if region_num_blocks:
|
||||
num_regions = len(region_num_blocks)
|
||||
assert len(region_num_blocks) == num_regions
|
||||
region_offsets = np.cumsum([0, *region_num_blocks[:-1]])
|
||||
num_fa_descs = sum(region_num_blocks)
|
||||
|
||||
# All-attention fast path: single vectorized broadcast.
|
||||
# All-attention fast path.
|
||||
if num_ssm_regions == 0:
|
||||
if region_group_ids is None:
|
||||
region_group_ids = self.region_group_ids
|
||||
if uses_region_group_mapping is None:
|
||||
uses_region_group_mapping = self._uses_region_group_mapping
|
||||
assert len(region_group_ids) == self.num_regions
|
||||
if not region_group_ids and num_regions == 1:
|
||||
region_group_ids = [0]
|
||||
assert len(region_group_ids) == num_regions
|
||||
if uses_region_group_mapping is None:
|
||||
uses_region_group_mapping = len(set(region_group_ids)) > 1
|
||||
region_group_ids_array = np.asarray(region_group_ids, dtype=np.int32)
|
||||
@@ -397,8 +407,6 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
# Config.
|
||||
self.vllm_config = vllm_config
|
||||
# mypy will complain on re-assignment otherwise.
|
||||
self.block_size: int = cast(int, vllm_config.cache_config.block_size)
|
||||
|
||||
if vllm_config.kv_transfer_config is None:
|
||||
raise ValueError("kv_transfer_config must be set for NixlConnector")
|
||||
@@ -420,6 +428,17 @@ class NixlBaseConnectorWorker:
|
||||
)
|
||||
|
||||
self.kv_cache_config = kv_cache_config
|
||||
transfer_block_sizes = [
|
||||
group.kv_cache_spec.block_size
|
||||
for group in kv_cache_config.transfer_groups
|
||||
if get_representative_spec_type(group.kv_cache_spec)
|
||||
not in (MambaSpec, CircularBufferSpec)
|
||||
]
|
||||
self.block_size = (
|
||||
math.lcm(*transfer_block_sizes)
|
||||
if transfer_block_sizes
|
||||
else cast(int, vllm_config.cache_config.block_size)
|
||||
)
|
||||
# Per-layer specs, unwrapping UniformTypeKVCacheSpecs group wrappers.
|
||||
self._layer_specs: dict[str, KVCacheSpec] = {}
|
||||
for group in kv_cache_config.transfer_groups:
|
||||
@@ -599,12 +618,15 @@ class NixlBaseConnectorWorker:
|
||||
# Number of NIXL regions. Currently one region per cache
|
||||
# (so 1 per layer for MLA, otherwise 2 per layer)
|
||||
self.num_regions = 0
|
||||
self.region_mem_types: list[str] = []
|
||||
self.region_group_ids: list[int] = []
|
||||
self._uses_region_group_mapping = False
|
||||
self.region_names: list[str] = []
|
||||
self.region_num_blocks: list[int] = []
|
||||
self.region_mem_types: list[str] = []
|
||||
self._mixed_mem_types = False
|
||||
self._desc_is_dram_by_block_size: dict[int, np.ndarray] = {}
|
||||
self._desc_pos_by_block_size: dict[int, np.ndarray] = {}
|
||||
self._dram_src_handles_by_block_size: dict[int, int] = {}
|
||||
|
||||
# PP>1 (push mode): this worker holds a contiguous layer slice and
|
||||
# transfers into the matching sub-range of a PP=1 remote's regions.
|
||||
@@ -644,9 +666,6 @@ class NixlBaseConnectorWorker:
|
||||
self.dst_region_group_ids: dict[EngineId, list[int]] = {}
|
||||
self.dst_uses_region_group_mapping: dict[EngineId, bool] = {}
|
||||
self.dst_region_mem_types: dict[EngineId, list[str]] = {}
|
||||
self._desc_is_dram_by_block_size: dict[int, np.ndarray] = {}
|
||||
self._desc_pos_by_block_size: dict[int, np.ndarray] = {}
|
||||
self._dram_src_handles_by_block_size: dict[int, int] = {}
|
||||
self._registered_descs: list[Any] = []
|
||||
|
||||
# In progress transfers.
|
||||
@@ -667,6 +686,7 @@ class NixlBaseConnectorWorker:
|
||||
# Uses Queue for thread-safe cross-thread coordination with the
|
||||
# background handshake thread, matching the _ready_requests pattern.
|
||||
self._failed_recv_reqs: queue.Queue[ReqId] = queue.Queue()
|
||||
self._failed_inflight_recvs: set[ReqId] = set()
|
||||
self._pending_recv_notifs: dict[ReqId, list[tuple[str, bytes]]] = {}
|
||||
|
||||
# Handshake metadata of this worker for NIXL transfers.
|
||||
@@ -690,7 +710,6 @@ class NixlBaseConnectorWorker:
|
||||
"engine_ttl", 3600.0
|
||||
)
|
||||
|
||||
self.block_size = vllm_config.cache_config.block_size
|
||||
self.model_config = vllm_config.model_config
|
||||
|
||||
self.use_mla = self.model_config.use_mla
|
||||
@@ -1240,7 +1259,7 @@ class NixlBaseConnectorWorker:
|
||||
self.use_host_buffer,
|
||||
)
|
||||
|
||||
registration_ranges: dict[tuple[int, str], tuple[int, int, int, str]] = {}
|
||||
registration_ranges: dict[tuple[int, str], tuple[int, int, int]] = {}
|
||||
region_mem_types: list[str] = []
|
||||
seen_base_addresses: list[int] = []
|
||||
self._ssm_region_indices = []
|
||||
@@ -1280,10 +1299,16 @@ class NixlBaseConnectorWorker:
|
||||
# K and V are packed into the content dim, so each attention layer is a
|
||||
# single NIXL region whose block transfers as one unit. Mamba layers instead
|
||||
# register separate conv/ssm sub-regions (see `_build_mamba_local`).
|
||||
# P and D may allocate equivalent transferable layers in different
|
||||
# cache-group orders. Keep their region lists aligned without putting
|
||||
# layers.10 before layers.2, which would break PP region slicing.
|
||||
layer_names = (
|
||||
xfer_buffers
|
||||
if self._is_csa_linear
|
||||
else sorted(xfer_buffers, key=_region_sort_key)
|
||||
else sorted(
|
||||
xfer_buffers,
|
||||
key=_region_sort_key,
|
||||
)
|
||||
)
|
||||
for layer_name in layer_names:
|
||||
cache = xfer_buffers[layer_name]
|
||||
@@ -1299,6 +1324,7 @@ class NixlBaseConnectorWorker:
|
||||
layer_name,
|
||||
)
|
||||
continue
|
||||
group_index = self.kv_cache_config.transfer_group_index_by_layer[layer_name]
|
||||
# `layer_spec.page_size_bytes` only accounts for logical page_size, that is
|
||||
# the page_size assuming constant `self._logical_num_blocks`.
|
||||
physical_page_size = (
|
||||
@@ -1307,17 +1333,48 @@ class NixlBaseConnectorWorker:
|
||||
else layer_spec.page_size_bytes
|
||||
// self._physical_blocks_per_logical_kv_block
|
||||
)
|
||||
group_id = self.kv_cache_config.transfer_group_index_by_layer[layer_name]
|
||||
group = self.kv_cache_config.transfer_groups[group_index]
|
||||
if group.host_resident:
|
||||
logical_num_blocks = self.kv_cache_config.hisparse_host_num_blocks
|
||||
assert logical_num_blocks is not None
|
||||
else:
|
||||
logical_num_blocks = self.kv_cache_config.num_blocks
|
||||
group_id = group_index
|
||||
num_blocks = (
|
||||
self._logical_num_blocks
|
||||
logical_num_blocks
|
||||
if isinstance(layer_spec, MambaSpec)
|
||||
else self.num_blocks
|
||||
else logical_num_blocks * self._physical_blocks_per_logical_kv_block
|
||||
)
|
||||
base_addr = cache.data_ptr()
|
||||
is_mla_region = isinstance(
|
||||
layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
|
||||
)
|
||||
logger.debug(
|
||||
"Registering layer %s with cache shape: %s", layer_name, cache.shape
|
||||
)
|
||||
storage = cache.untyped_storage()
|
||||
storage_addr = storage.data_ptr()
|
||||
is_host_resident = group.host_resident
|
||||
if cache.device.type == "cpu":
|
||||
mem_type = "DRAM"
|
||||
region_device_id = 0
|
||||
else:
|
||||
mem_type = self.nixl_memory_type
|
||||
region_device_id = max(cache.get_device(), 0)
|
||||
self.device_id = region_device_id
|
||||
if is_host_resident:
|
||||
registration_base = cache.data_ptr()
|
||||
registration_len = cache.nbytes
|
||||
else:
|
||||
registration_base = storage_addr
|
||||
registration_len = storage.nbytes()
|
||||
storage_key = (registration_base, mem_type)
|
||||
if storage_key not in registration_ranges:
|
||||
registration_ranges[storage_key] = (
|
||||
registration_base,
|
||||
registration_base + registration_len,
|
||||
region_device_id,
|
||||
)
|
||||
|
||||
if isinstance(layer_spec, KpoolTailSpec):
|
||||
compressed_owner = compressed_region_owners.get(cache.data_ptr())
|
||||
@@ -1345,22 +1402,6 @@ class NixlBaseConnectorWorker:
|
||||
)
|
||||
continue
|
||||
|
||||
if cache.device.type == "cpu":
|
||||
mem_type = "DRAM"
|
||||
region_device_id = 0
|
||||
else:
|
||||
mem_type = self.nixl_memory_type
|
||||
region_device_id = max(cache.get_device(), 0)
|
||||
self.device_id = region_device_id
|
||||
registration_ranges.setdefault(
|
||||
(storage_addr, mem_type),
|
||||
(storage_addr, storage.nbytes(), region_device_id, ""),
|
||||
)
|
||||
|
||||
is_mla_region = isinstance(
|
||||
layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
|
||||
)
|
||||
|
||||
if isinstance(layer_spec, MambaSpec):
|
||||
physical_ratio = self._physical_blocks_per_logical_kv_block
|
||||
block_len = physical_page_size // physical_ratio
|
||||
@@ -1381,14 +1422,13 @@ class NixlBaseConnectorWorker:
|
||||
block_stride = physical_page_size
|
||||
else:
|
||||
block_stride = cache.stride(0) * cache.element_size()
|
||||
storage_is_block_major = num_blocks * block_stride == storage.nbytes()
|
||||
# A layer whose [H, N, C] interior is dense addresses its own page
|
||||
# as one chunk. Otherwise the block's whole row is the only
|
||||
# contiguous transfer unit.
|
||||
hnc_contiguous = (
|
||||
cache.ndim == 4
|
||||
and cache.stride(2) == cache.shape[3]
|
||||
and cache.stride(1) == cache.shape[2] * cache.shape[3]
|
||||
storage_is_block_major = num_blocks * block_stride == registration_len
|
||||
# A view with a contiguous page can own a strided region even
|
||||
# when sibling layers share the same backing allocation.
|
||||
page_contiguous = (
|
||||
cache.ndim > 1
|
||||
and cache[0].is_contiguous()
|
||||
and cache[0].nbytes == physical_page_size
|
||||
)
|
||||
virtual_transfer_pages = _uses_dense_virtual_transfer_pages(
|
||||
layer_spec, cache, physical_page_size, num_blocks
|
||||
@@ -1399,13 +1439,14 @@ class NixlBaseConnectorWorker:
|
||||
(cache.data_ptr(), physical_page_size, physical_page_size)
|
||||
]
|
||||
elif storage_is_block_major and (
|
||||
(packed_storage and is_mla_region)
|
||||
or (not hnc_contiguous and not self._is_csa_linear)
|
||||
not page_contiguous
|
||||
and ((packed_storage and is_mla_region) or not self._is_csa_linear)
|
||||
):
|
||||
# Packed MLA layouts transfer the complete storage row.
|
||||
storage_block_len = storage.nbytes() // num_blocks
|
||||
# TODO(Lucas): handle TP slicing for packed_storage; for now
|
||||
# restrict to MLA (DSv4) where kv is replicated.
|
||||
storage_block_len = registration_len // num_blocks
|
||||
region_specs = [
|
||||
(storage_addr, storage_block_len, storage_block_len)
|
||||
(registration_base, storage_block_len, storage_block_len)
|
||||
]
|
||||
elif storage_is_block_major:
|
||||
region_specs = [
|
||||
@@ -1440,6 +1481,10 @@ class NixlBaseConnectorWorker:
|
||||
region_index = seen_base_addresses.index(base_addr)
|
||||
assert region_mem_types[region_index] == mem_type
|
||||
self._region_is_mla[region_index] |= is_mla_region
|
||||
if is_mla_region:
|
||||
self.block_len_per_layer[region_index] = block_len
|
||||
self.block_stride_per_layer[region_index] = block_stride
|
||||
self.region_num_blocks[region_index] = num_blocks
|
||||
if self.region_group_ids[region_index] != group_id:
|
||||
self.region_group_ids[region_index] = _SHARED_REGION_GROUP_ID
|
||||
else:
|
||||
@@ -1450,8 +1495,8 @@ class NixlBaseConnectorWorker:
|
||||
self.region_group_ids.append(group_id)
|
||||
self.region_names.append(layer_name)
|
||||
self.region_num_blocks.append(num_blocks)
|
||||
region_mem_types.append(mem_type)
|
||||
self._region_is_mla.append(is_mla_region)
|
||||
region_mem_types.append(mem_type)
|
||||
|
||||
if isinstance(layer_spec, MambaSpec):
|
||||
if layer_spec.tp_replicated:
|
||||
@@ -1496,7 +1541,6 @@ class NixlBaseConnectorWorker:
|
||||
== len(self.region_group_ids)
|
||||
== len(self.region_names)
|
||||
== len(self.region_num_blocks)
|
||||
== len(region_mem_types)
|
||||
)
|
||||
# Descriptor ids must be region-ordered, matching the remote side.
|
||||
self._scratch_region_indices.sort()
|
||||
@@ -1521,16 +1565,23 @@ class NixlBaseConnectorWorker:
|
||||
# Total local FA descriptors (boundary between FA and mamba descs).
|
||||
self.num_descs = sum(self.region_num_blocks)
|
||||
|
||||
self._mixed_mem_types = len(set(region_mem_types)) > 1
|
||||
if self._mixed_mem_types:
|
||||
assert self.use_mla and not self._has_mamba, (
|
||||
"Mixed-device KV registration is only supported for MLA "
|
||||
"models without Mamba layers."
|
||||
)
|
||||
for mem_type in sorted(set(region_mem_types)):
|
||||
ranges = [
|
||||
registration
|
||||
for (
|
||||
_,
|
||||
registration_mem_type,
|
||||
), registration in registration_ranges.items()
|
||||
if registration_mem_type == mem_type
|
||||
ranges_for_mem_type = [
|
||||
(start, end - start, device_id, "")
|
||||
for (_, cache_mem_type), (
|
||||
start,
|
||||
end,
|
||||
device_id,
|
||||
) in registration_ranges.items()
|
||||
if cache_mem_type == mem_type
|
||||
]
|
||||
descs = self.nixl_wrapper.get_reg_descs(ranges, mem_type)
|
||||
descs = self.nixl_wrapper.get_reg_descs(ranges_for_mem_type, mem_type)
|
||||
self.nixl_wrapper.register_memory(descs, backends=self.nixl_backends)
|
||||
self._registered_descs.append(descs)
|
||||
|
||||
@@ -1854,7 +1905,11 @@ class NixlBaseConnectorWorker:
|
||||
if self._mixed_mem_types:
|
||||
desc_is_dram = np.concatenate(
|
||||
[
|
||||
np.full(count * block_size_ratio, mem_type == "DRAM", dtype=bool)
|
||||
np.full(
|
||||
count * block_size_ratio,
|
||||
mem_type == "DRAM",
|
||||
dtype=bool,
|
||||
)
|
||||
for mem_type, count in zip(
|
||||
self.region_mem_types,
|
||||
self.region_num_blocks,
|
||||
@@ -1864,24 +1919,29 @@ class NixlBaseConnectorWorker:
|
||||
)
|
||||
assert len(desc_is_dram) == len(blocks_data)
|
||||
desc_pos = np.empty(len(desc_is_dram), dtype=np.int64)
|
||||
dram_indices = np.flatnonzero(desc_is_dram)
|
||||
device_indices = np.flatnonzero(~desc_is_dram)
|
||||
desc_pos[dram_indices] = np.arange(len(dram_indices), dtype=np.int64)
|
||||
desc_pos[device_indices] = np.arange(len(device_indices), dtype=np.int64)
|
||||
dram_idx = np.where(desc_is_dram)[0]
|
||||
vram_idx = np.where(~desc_is_dram)[0]
|
||||
desc_pos[dram_idx] = np.arange(len(dram_idx), dtype=np.int64)
|
||||
desc_pos[vram_idx] = np.arange(len(vram_idx), dtype=np.int64)
|
||||
self._desc_is_dram_by_block_size[block_size] = desc_is_dram
|
||||
self._desc_pos_by_block_size[block_size] = desc_pos
|
||||
blocks_data[desc_is_dram, 2] = 0
|
||||
dram_descs = self.nixl_wrapper.get_xfer_descs(
|
||||
blocks_data[desc_is_dram], "DRAM"
|
||||
)
|
||||
|
||||
# DRAM descriptors are registered under CPU device 0.
|
||||
blocks_data = [
|
||||
(addr, length, 0) if is_dram else (addr, length, dev)
|
||||
for (addr, length, dev), is_dram in zip(
|
||||
blocks_data, desc_is_dram, strict=True
|
||||
)
|
||||
]
|
||||
dram_blocks = [blocks_data[i] for i in dram_idx]
|
||||
vram_blocks = [blocks_data[i] for i in vram_idx]
|
||||
dram_descs = self.nixl_wrapper.get_xfer_descs(dram_blocks, "DRAM")
|
||||
self._dram_src_handles_by_block_size[block_size] = (
|
||||
self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", dram_descs)
|
||||
)
|
||||
device_descs = self.nixl_wrapper.get_xfer_descs(
|
||||
blocks_data[~desc_is_dram], self.nixl_memory_type
|
||||
)
|
||||
descs = self.nixl_wrapper.get_xfer_descs(vram_blocks, self.nixl_memory_type)
|
||||
return (
|
||||
self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", device_descs),
|
||||
self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs),
|
||||
blocks_data,
|
||||
)
|
||||
|
||||
@@ -1968,10 +2028,6 @@ class NixlBaseConnectorWorker:
|
||||
]
|
||||
nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
|
||||
nixl_agent_meta.block_strides = nixl_agent_meta.block_strides[start:end]
|
||||
if nixl_agent_meta.region_num_blocks is not None:
|
||||
nixl_agent_meta.region_num_blocks = nixl_agent_meta.region_num_blocks[
|
||||
start:end
|
||||
]
|
||||
if nixl_agent_meta.region_group_ids is not None:
|
||||
nixl_agent_meta.region_group_ids = nixl_agent_meta.region_group_ids[
|
||||
start:end
|
||||
@@ -1982,6 +2038,10 @@ class NixlBaseConnectorWorker:
|
||||
nixl_agent_meta.region_mem_types = nixl_agent_meta.region_mem_types[
|
||||
start:end
|
||||
]
|
||||
if nixl_agent_meta.region_num_blocks is not None:
|
||||
nixl_agent_meta.region_num_blocks = nixl_agent_meta.region_num_blocks[
|
||||
start:end
|
||||
]
|
||||
|
||||
### Register remote engine in TransferTopology (idempotent).
|
||||
assert self.transfer_topo is not None
|
||||
@@ -2026,11 +2086,12 @@ class NixlBaseConnectorWorker:
|
||||
nixl_agent_meta.region_num_blocks
|
||||
or [nixl_agent_meta.num_blocks] * num_remote_regions
|
||||
)
|
||||
self.dst_region_group_ids[engine_id] = nixl_agent_meta.region_group_ids or (
|
||||
remote_region_group_ids = nixl_agent_meta.region_group_ids or (
|
||||
self.region_group_ids
|
||||
if len(self.region_group_ids) == num_remote_regions
|
||||
else [0] * num_remote_regions
|
||||
)
|
||||
self.dst_region_group_ids[engine_id] = remote_region_group_ids
|
||||
self.dst_uses_region_group_mapping[engine_id] = (
|
||||
len(set(self.dst_region_group_ids[engine_id])) > 1
|
||||
)
|
||||
@@ -2138,8 +2199,6 @@ class NixlBaseConnectorWorker:
|
||||
mamba = self._build_mamba_remote(nixl_agent_meta, tp_ratio, transfer_info)
|
||||
blocks_data = np.concatenate([blocks_data, mamba])
|
||||
|
||||
# A mixed local destination is split into multiple READs below. The
|
||||
# producer remains a single descriptor list in this prerequisite.
|
||||
remote_mem_types = set(self.dst_region_mem_types[engine_id])
|
||||
if len(remote_mem_types) != 1:
|
||||
raise NotImplementedError(
|
||||
@@ -2540,9 +2599,10 @@ class NixlBaseConnectorWorker:
|
||||
indices=indices,
|
||||
)
|
||||
|
||||
def get_finished(self) -> tuple[set[str], set[str]]:
|
||||
def get_transfer_results(self) -> KVConnectorTransferResults:
|
||||
"""
|
||||
Get requests that are done sending or recving on this specific worker.
|
||||
Get transfers that completed on this specific worker.
|
||||
|
||||
The scheduler process (via the MultiprocExecutor) will use this output
|
||||
to track which workers are done.
|
||||
"""
|
||||
@@ -2564,7 +2624,6 @@ class NixlBaseConnectorWorker:
|
||||
# Add failed requests to done_recving for scheduler tracking
|
||||
# (blocks are already marked invalid, scheduler will handle recompute)
|
||||
done_recving.update(failed_recv_reqs)
|
||||
|
||||
if len(done_sending) > 0 or len(done_recving) > 0:
|
||||
logger.debug(
|
||||
"Rank %s, get_finished: %s requests done sending "
|
||||
@@ -2577,6 +2636,7 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
block_ids_for_blocksize_post_process = defaultdict(list)
|
||||
block_ids_for_heterogeneous_attn_post_process = list[list[int]]()
|
||||
direct_device_recving = set[str]()
|
||||
for req_id in done_recving:
|
||||
# clean up metadata for completed requests
|
||||
meta = self._recving_metadata.pop(req_id, None)
|
||||
@@ -2594,6 +2654,8 @@ class NixlBaseConnectorWorker:
|
||||
if self.use_host_buffer:
|
||||
self.sync_recved_kv_to_device(req_id, meta)
|
||||
|
||||
direct_device_recving.add(req_id)
|
||||
|
||||
# Post processing for heteroblocksize/layout, and for blocks the
|
||||
# transfer clipped. The latter happens either at remote-block
|
||||
# granularity (block_size_ratio > 1) or at kernel-block
|
||||
@@ -2642,7 +2704,7 @@ class NixlBaseConnectorWorker:
|
||||
for block_ids in block_ids_for_heterogeneous_attn_post_process:
|
||||
self.post_process_device_kv_on_receive_heterogeneous_attn(block_ids)
|
||||
|
||||
self._sync_device_after_mamba_recv(done_recving, failed_recv_reqs)
|
||||
self._sync_device_after_direct_recv(direct_device_recving)
|
||||
|
||||
# Handle timeout to avoid stranding blocks on remote.
|
||||
now = time.perf_counter()
|
||||
@@ -2664,20 +2726,21 @@ class NixlBaseConnectorWorker:
|
||||
del self._reqs_to_send[req_id]
|
||||
done_sending.add(req_id)
|
||||
|
||||
return done_sending, done_recving
|
||||
return KVConnectorTransferResults(
|
||||
finished_sending=done_sending,
|
||||
finished_recving=done_recving,
|
||||
failed_recving=failed_recv_reqs,
|
||||
)
|
||||
|
||||
def _sync_device_after_mamba_recv(
|
||||
self,
|
||||
done_recving: set[str],
|
||||
failed_recv_reqs: set[str],
|
||||
) -> None:
|
||||
"""Synchronize ROCm direct-GPU Mamba receives before model execution."""
|
||||
if (
|
||||
not current_platform.is_rocm()
|
||||
or not self._has_mamba
|
||||
or self.use_host_buffer
|
||||
or not (done_recving - failed_recv_reqs)
|
||||
):
|
||||
def get_finished(self) -> tuple[set[str], set[str]]:
|
||||
"""Compatibility wrapper for the legacy completion API."""
|
||||
results = self.get_transfer_results()
|
||||
return results.finished_sending, results.finished_recving
|
||||
|
||||
def _sync_device_after_direct_recv(self, done_recving: set[str]) -> None:
|
||||
"""Make direct NIXL writes visible before model execution."""
|
||||
requires_sync = current_platform.is_rocm() and self._has_mamba
|
||||
if self.use_host_buffer or not done_recving or not requires_sync:
|
||||
return
|
||||
|
||||
torch.accelerator.synchronize()
|
||||
@@ -2731,11 +2794,11 @@ class NixlBaseConnectorWorker:
|
||||
self.nixl_wrapper.release_xfer_handle(handle)
|
||||
elif xfer_state == "PROC":
|
||||
in_progress.append(handle)
|
||||
continue
|
||||
else:
|
||||
self._log_failure(
|
||||
failure_type="transfer_failed",
|
||||
msg="Marking blocks as invalid",
|
||||
msg="Deferring request completion until its last "
|
||||
"xfer is terminal",
|
||||
req_id=req_id,
|
||||
xfer_state=xfer_state,
|
||||
)
|
||||
@@ -2743,21 +2806,20 @@ class NixlBaseConnectorWorker:
|
||||
except Exception as e:
|
||||
self._log_failure(
|
||||
failure_type="transfer_exception",
|
||||
msg="Marking blocks as invalid",
|
||||
msg="Handle is unpollable; treating it as terminal",
|
||||
req_id=req_id,
|
||||
error=e,
|
||||
)
|
||||
self._handle_failed_transfer(req_id, handle)
|
||||
|
||||
if not in_progress:
|
||||
# Only report request as completed when all transfers are done.
|
||||
# A request failed in an earlier poll was already reported via
|
||||
# _failed_recv_reqs and its metadata popped by get_finished();
|
||||
# don't report it again, just drop the remaining handles.
|
||||
if req_id in self._recving_metadata:
|
||||
done_req_ids.add(req_id)
|
||||
self._send_pending_recv_notifs(req_id)
|
||||
del transfers[req_id]
|
||||
done_req_ids.add(req_id)
|
||||
if req_id in self._failed_inflight_recvs:
|
||||
self._failed_inflight_recvs.remove(req_id)
|
||||
self._report_failed_recv(req_id)
|
||||
else:
|
||||
self._send_pending_recv_notifs(req_id)
|
||||
else:
|
||||
transfers[req_id] = in_progress
|
||||
return done_req_ids
|
||||
@@ -2771,22 +2833,25 @@ class NixlBaseConnectorWorker:
|
||||
req_id: The request ID.
|
||||
handle: The transfer handle.
|
||||
"""
|
||||
# (multi-read) One handle is created per remote rank, and they do not
|
||||
# all fail in the same _pop_done_transfers poll. The request is
|
||||
# reported failed on the first one, which pops its metadata in
|
||||
# get_finished(); on the later failures only the handle cleanup is left.
|
||||
# TODO (NickLucche) handle failed transfer for HMA.
|
||||
# A split READ's notification is sent only after every handle succeeds.
|
||||
# A sibling READ may still be writing these blocks. Retain metadata
|
||||
# and defer invalidation until every handle is terminal.
|
||||
self._pending_recv_notifs.pop(req_id, None)
|
||||
if (meta := self._recving_metadata.get(req_id)) is not None:
|
||||
if not self._is_hma_required:
|
||||
self._invalid_block_ids.put(set(meta.local_block_ids[0]))
|
||||
self._failed_recv_reqs.put(req_id)
|
||||
if req_id in self._recving_transfers:
|
||||
self._failed_inflight_recvs.add(req_id)
|
||||
else:
|
||||
self._report_failed_recv(req_id)
|
||||
if handle is not None:
|
||||
self.nixl_wrapper.release_xfer_handle(handle)
|
||||
self.xfer_stats.record_failed_transfer()
|
||||
|
||||
def _report_failed_recv(self, req_id: str) -> None:
|
||||
if (meta := self._recving_metadata.get(req_id)) is not None:
|
||||
if not self._is_hma_required:
|
||||
self._invalid_block_ids.put(set(meta.local_block_ids[0]))
|
||||
self._failed_recv_reqs.put(req_id)
|
||||
|
||||
def _send_pending_recv_notifs(self, req_id: str) -> None:
|
||||
"""Send notifications deferred by split DRAM/VRAM reads."""
|
||||
for agent_name, notif_id in self._pending_recv_notifs.pop(req_id, []):
|
||||
try:
|
||||
self.nixl_wrapper.send_notif(agent_name, notif_msg=notif_id)
|
||||
@@ -2909,10 +2974,10 @@ class NixlBaseConnectorWorker:
|
||||
return block_ids
|
||||
block_arange = np.arange(0, ratio).reshape(1, -1)
|
||||
# Mamba blocks have no logical<>physical discrepancy (block-size=1)
|
||||
group_specs = self.kv_cache_config.transfer_groups
|
||||
physical_block_ids = []
|
||||
for i, group in enumerate(block_ids):
|
||||
if _is_ssm_spec(get_representative_spec_type(group_specs[i].kv_cache_spec)):
|
||||
spec = self.kv_cache_config.transfer_groups[i].kv_cache_spec
|
||||
if _is_ssm_spec(get_representative_spec_type(spec)):
|
||||
physical_block_ids.append(group)
|
||||
else:
|
||||
physical_block_ids.append(
|
||||
@@ -3215,9 +3280,44 @@ class NixlBaseConnectorWorker:
|
||||
)
|
||||
|
||||
def __del__(self):
|
||||
self.shutdown()
|
||||
with contextlib.suppress(Exception):
|
||||
self.shutdown()
|
||||
|
||||
def shutdown(self):
|
||||
def _finish_shutdown(self) -> None:
|
||||
self._recving_transfers.clear()
|
||||
try:
|
||||
for handle in self.src_xfer_handles_by_block_size.values():
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
for handles in self.src_xfer_handles_by_tp_ratio.values():
|
||||
for handle in handles:
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
for handles in self._dram_src_handles_by_tp_ratio.values():
|
||||
for handle in handles:
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
for handle in self._dram_src_handles_by_block_size.values():
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
except Exception:
|
||||
logger.exception("NIXL dlist-handle release failed at shutdown.")
|
||||
self.src_xfer_handles_by_block_size.clear()
|
||||
self.src_xfer_handles_by_tp_ratio.clear()
|
||||
self._dram_src_handles_by_tp_ratio.clear()
|
||||
self._dram_src_handles_by_block_size.clear()
|
||||
try:
|
||||
for engine_id in list(self._remote_agents):
|
||||
self._cleanup_remote_engine(engine_id, log_eviction=False)
|
||||
except Exception:
|
||||
logger.exception("NIXL remote-engine cleanup failed at shutdown.")
|
||||
try:
|
||||
for desc in self._registered_descs:
|
||||
self.nixl_wrapper.deregister_memory(desc)
|
||||
finally:
|
||||
self._registered_descs.clear()
|
||||
# Drop cache references before their owners release registered
|
||||
# host memory; handshake futures may outlive model-runner shutdown.
|
||||
self.device_kv_caches = {}
|
||||
self.host_xfer_buffers = {}
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Shutdown the connector worker."""
|
||||
if not hasattr(self, "_handshake_initiation_executor"):
|
||||
# error happens during init, no need to shutdown
|
||||
@@ -3227,22 +3327,4 @@ class NixlBaseConnectorWorker:
|
||||
for handle in handles:
|
||||
self.nixl_wrapper.release_xfer_handle(handle)
|
||||
self._recving_transfers.clear()
|
||||
for handle in self.src_xfer_handles_by_block_size.values():
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
self.src_xfer_handles_by_block_size.clear()
|
||||
for handles in self.src_xfer_handles_by_tp_ratio.values():
|
||||
for handle in handles:
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
self.src_xfer_handles_by_tp_ratio.clear()
|
||||
for handles in self._dram_src_handles_by_tp_ratio.values():
|
||||
for handle in handles:
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
self._dram_src_handles_by_tp_ratio.clear()
|
||||
for handle in self._dram_src_handles_by_block_size.values():
|
||||
self.nixl_wrapper.release_dlist_handle(handle)
|
||||
self._dram_src_handles_by_block_size.clear()
|
||||
for engine_id in list(self._remote_agents):
|
||||
self._cleanup_remote_engine(engine_id, log_eviction=False)
|
||||
for desc in self._registered_descs:
|
||||
self.nixl_wrapper.deregister_memory(desc)
|
||||
self._registered_descs.clear()
|
||||
self._finish_shutdown()
|
||||
|
||||
@@ -26,6 +26,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorHandshakeMetadata,
|
||||
KVConnectorMetadata,
|
||||
KVConnectorRole,
|
||||
KVConnectorTransferResults,
|
||||
SupportsHMA,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
|
||||
@@ -234,6 +235,18 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
assert self.connector_worker is not None
|
||||
return self.connector_worker.get_finished()
|
||||
|
||||
def get_transfer_results(
|
||||
self, finished_req_ids: set[str]
|
||||
) -> KVConnectorTransferResults:
|
||||
assert self.connector_worker is not None
|
||||
results = self.connector_worker.get_transfer_results()
|
||||
if (
|
||||
self.kv_transfer_config.kv_role == "kv_producer"
|
||||
and self.connector_worker.pcp_rank > 0
|
||||
):
|
||||
results.finished_sending.clear()
|
||||
return results
|
||||
|
||||
def get_block_ids_with_load_errors(self) -> set[int]:
|
||||
"""Get block IDs that failed to load via NIXL."""
|
||||
assert self.connector_worker is not None
|
||||
|
||||
@@ -305,7 +305,9 @@ class NixlConnectorMetadata(KVConnectorMetadata):
|
||||
local_num_computed_blocks: tuple[int, ...] = (),
|
||||
):
|
||||
req = self._add_new_req(
|
||||
local_block_ids, kv_transfer_params, local_num_computed_blocks
|
||||
local_block_ids,
|
||||
kv_transfer_params,
|
||||
local_num_computed_blocks,
|
||||
)
|
||||
req.remote = RemoteMeta(
|
||||
block_ids=kv_transfer_params["remote_block_ids"],
|
||||
|
||||
@@ -166,11 +166,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
|
||||
dcp_active = self.dcp_size > 1 or remote_info.remote_dcp_size > 1
|
||||
local_block_ids = meta.local_physical_block_ids
|
||||
local_region_groups = getattr(self, "region_group_ids", [])
|
||||
remote_region_groups = getattr(self, "dst_region_group_ids", {}).get(
|
||||
engine_id, local_region_groups
|
||||
)
|
||||
if local_region_groups != remote_region_groups:
|
||||
remote_region_groups = self.dst_region_group_ids[engine_id]
|
||||
local_region_groups = self.region_group_ids or remote_region_groups
|
||||
groups_differ = local_region_groups != remote_region_groups
|
||||
if groups_differ:
|
||||
if not self.use_mla or self._has_mamba:
|
||||
raise NotImplementedError(
|
||||
"Different NIXL cache-group layouts are only supported for "
|
||||
@@ -292,6 +291,9 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
spec.remote_rank
|
||||
]
|
||||
|
||||
# Once a read routes the request to failure reporting, the
|
||||
# scheduler may free and reuse its blocks, so no sibling READs
|
||||
# may be posted (and P must not be notified).
|
||||
if not self._read_blocks(
|
||||
read_spec=spec,
|
||||
request_id=req_id,
|
||||
@@ -329,6 +331,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
"""
|
||||
Post a READ point-to-point xfer request from a single local worker to
|
||||
a single remote worker.
|
||||
|
||||
Returns True when the read was posted (or was unnecessary), False
|
||||
when the request was routed to failure reporting — the caller must
|
||||
not post further transfers for it.
|
||||
"""
|
||||
assert self.transfer_topo is not None
|
||||
remote_rank = read_spec.remote_rank
|
||||
@@ -416,11 +422,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
dst_num_blocks=self.dst_num_blocks[dst_engine_id],
|
||||
block_size_ratio=None,
|
||||
physical_blocks_per_logical=remote_info.remote_physical_blocks_per_logical,
|
||||
region_num_blocks=self.dst_region_num_blocks[dst_engine_id],
|
||||
region_num_blocks=(self.dst_region_num_blocks.get(dst_engine_id) or None),
|
||||
region_group_ids=(
|
||||
list(range(self.num_regions))
|
||||
if read_spec.block_ids_by_region
|
||||
else self.dst_region_group_ids[dst_engine_id]
|
||||
else (self.dst_region_group_ids.get(dst_engine_id) or None)
|
||||
),
|
||||
uses_region_group_mapping=(
|
||||
self.num_regions > 1
|
||||
@@ -433,11 +439,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
dst_num_blocks=self.dst_num_blocks[self.engine_id],
|
||||
block_size_ratio=block_size_ratio,
|
||||
physical_blocks_per_logical=self._physical_blocks_per_logical_kv_block,
|
||||
region_num_blocks=self.dst_region_num_blocks[self.engine_id],
|
||||
region_num_blocks=(self.dst_region_num_blocks.get(self.engine_id) or None),
|
||||
region_group_ids=(
|
||||
list(range(self.num_regions))
|
||||
if read_spec.block_ids_by_region
|
||||
else self.region_group_ids
|
||||
else (self.region_group_ids or None)
|
||||
),
|
||||
uses_region_group_mapping=(
|
||||
self.num_regions > 1
|
||||
@@ -537,11 +543,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
self._pending_recv_notifs.setdefault(request_id, []).append(
|
||||
(notif_agent, notif_id)
|
||||
)
|
||||
for index, handle in enumerate(handles):
|
||||
for i, handle in enumerate(handles):
|
||||
try:
|
||||
self.nixl_wrapper.transfer(handle)
|
||||
except Exception:
|
||||
for unstarted in handles[index:]:
|
||||
for unstarted in handles[i:]:
|
||||
self.nixl_wrapper.release_xfer_handle(unstarted)
|
||||
raise
|
||||
self._recving_transfers[request_id].append(handle)
|
||||
|
||||
@@ -41,6 +41,9 @@ from typing import TYPE_CHECKING, Any
|
||||
import msgspec
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
|
||||
KVConnectorTransferResults,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
|
||||
NixlBaseConnectorWorker,
|
||||
)
|
||||
@@ -145,7 +148,7 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
self._push_writer_thread.start()
|
||||
logger.info("nixl-push-writer thread started (rank=%d)", self.tp_rank)
|
||||
|
||||
def shutdown(self):
|
||||
def shutdown(self) -> None:
|
||||
self._push_writer_stop.set()
|
||||
# Unblock the writer if it's waiting in the no-active-state branch.
|
||||
self._push_writer_wake.set()
|
||||
@@ -522,7 +525,14 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
)
|
||||
remote_block_ids = meta.remote.block_ids
|
||||
local_block_ids = meta.local_physical_block_ids
|
||||
num_groups = len(local_block_ids)
|
||||
local_region_groups = self.region_group_ids
|
||||
remote_region_groups = self.dst_region_group_ids[engine_id]
|
||||
groups_differ = local_region_groups != remote_region_groups
|
||||
if groups_differ:
|
||||
raise NotImplementedError(
|
||||
"NixlPushConnector does not support different producer and "
|
||||
"consumer cache-group layouts"
|
||||
)
|
||||
|
||||
# MLA latent is replicated across D's TP ranks: the tp-mapping
|
||||
# collapses it to one rank (fine for reads), but push must WRITE every
|
||||
@@ -537,6 +547,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
else:
|
||||
write_ranks = list(plan.all_source_ranks)
|
||||
|
||||
num_groups = len(local_block_ids)
|
||||
|
||||
def group_ids(block_ids: BlockIds, rank: int) -> BlockIds:
|
||||
return [
|
||||
list(block_ids[g])
|
||||
@@ -777,13 +789,14 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
self._reqs_to_send.pop(req_id, None)
|
||||
return notified_req_ids
|
||||
|
||||
def get_finished(self) -> tuple[set[str], set[str]]:
|
||||
def get_transfer_results(self) -> KVConnectorTransferResults:
|
||||
# Engine main thread asking for completions: also wake the writer
|
||||
# so it gets a chance to drain NIXL notifs (heartbeats, completion
|
||||
# notifs, late PUSH_REGs) even if it had been parked.
|
||||
self._push_writer_wake.set()
|
||||
|
||||
done_sending, done_recving = super().get_finished()
|
||||
results = super().get_transfer_results()
|
||||
done_sending = results.finished_sending
|
||||
|
||||
# ``_pop_done_transfers`` mutates ``_sending_transfers``; the
|
||||
# writer thread also appends to it, so guard the pop.
|
||||
@@ -803,4 +816,4 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
if done_sending:
|
||||
self._push_writer_wake.set()
|
||||
|
||||
return done_sending, done_recving
|
||||
return results
|
||||
|
||||
@@ -12,6 +12,7 @@ from vllm.v1.core.kv_cache_utils import (
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
AttentionSpec,
|
||||
FullAttentionSpec,
|
||||
KVCacheGroupRole,
|
||||
KVCacheSpec,
|
||||
MLAAttentionSpec,
|
||||
SlidingWindowMLASpec,
|
||||
@@ -31,6 +32,16 @@ if TYPE_CHECKING:
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
|
||||
|
||||
def get_offloading_group_ids(kv_cache_config: "KVCacheConfig") -> tuple[int, ...]:
|
||||
if kv_cache_config.hisparse_host_num_blocks is None:
|
||||
return tuple(range(len(kv_cache_config.kv_cache_groups)))
|
||||
return tuple(
|
||||
group_id
|
||||
for group_id, group in enumerate(kv_cache_config.kv_cache_groups)
|
||||
if group.role is KVCacheGroupRole.HISPARSE_INDEXER
|
||||
)
|
||||
|
||||
|
||||
def build_offloading_config(
|
||||
vllm_config: "VllmConfig",
|
||||
kv_cache_config: "KVCacheConfig",
|
||||
@@ -43,15 +54,22 @@ def build_offloading_config(
|
||||
engine_id = kv_transfer_config.engine_id
|
||||
|
||||
parallel_config = vllm_config.parallel_config
|
||||
selected_groups = tuple(
|
||||
(group_id, kv_cache_config.kv_cache_groups[group_id])
|
||||
for group_id in get_offloading_group_ids(kv_cache_config)
|
||||
)
|
||||
if not selected_groups:
|
||||
raise ValueError("KV offloading found no eligible cache groups.")
|
||||
groups = tuple(
|
||||
OffloadingGroupConfig(
|
||||
group_id=group_id,
|
||||
tokens_per_block=resolve_dcp_kv_block_size(
|
||||
group.kv_cache_spec,
|
||||
parallel_config.decode_context_parallel_size,
|
||||
),
|
||||
layer_names=tuple(group.layer_names),
|
||||
)
|
||||
for group in kv_cache_config.kv_cache_groups
|
||||
for group_id, group in selected_groups
|
||||
)
|
||||
|
||||
_, tokens_per_hash = resolve_kv_cache_block_sizes(kv_cache_config, vllm_config)
|
||||
@@ -103,11 +121,20 @@ def build_offloading_config(
|
||||
)
|
||||
|
||||
worker_kv_bytes_per_block = 0
|
||||
if kv_cache_config.num_blocks > 0 and kv_cache_config.kv_cache_tensors:
|
||||
all_groups_selected = len(selected_groups) == len(kv_cache_config.kv_cache_groups)
|
||||
if (
|
||||
all_groups_selected
|
||||
and kv_cache_config.num_blocks > 0
|
||||
and kv_cache_config.kv_cache_tensors
|
||||
):
|
||||
# Every KVCacheTensor describes placement within the same backing allocation,
|
||||
# so its size is the total, not a per-tensor share.
|
||||
total_gpu_kv_bytes = kv_cache_config.kv_cache_tensors[0].size
|
||||
worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
|
||||
elif kv_cache_config.num_blocks > 0:
|
||||
worker_kv_bytes_per_block = sum(
|
||||
group.kv_cache_spec.page_size_bytes for _, group in selected_groups
|
||||
)
|
||||
|
||||
single_group_spec = (
|
||||
kv_cache_config.kv_cache_groups[0].kv_cache_spec
|
||||
|
||||
@@ -152,8 +152,9 @@ def resolve_mamba_align_size(
|
||||
such groups agree on the same value.
|
||||
"""
|
||||
mamba_align_size: int | None = None
|
||||
for idx, tokens_per_block in enumerate(spec.tokens_per_block):
|
||||
kv_spec = kv_cache_config.kv_cache_groups[idx].kv_cache_spec
|
||||
for group in spec.config.groups:
|
||||
tokens_per_block = group.tokens_per_block
|
||||
kv_spec = kv_cache_config.kv_cache_groups[group.group_id].kv_cache_spec
|
||||
if isinstance(kv_spec, MambaSpec) and kv_spec.mamba_cache_mode in (
|
||||
"align",
|
||||
"all",
|
||||
@@ -188,8 +189,9 @@ class SchedulerOffloadConfig(NamedTuple):
|
||||
# each segment can never serve a load hit. Relevant for hybrid
|
||||
# architectures like DeepSeek V4 (MLA + SWA groups).
|
||||
full_attn_tokens_per_chunk: set[int] = set()
|
||||
for idx, tokens_per_block in enumerate(spec.tokens_per_block):
|
||||
kv_spec = kv_cache_config.kv_cache_groups[idx].kv_cache_spec
|
||||
for group in spec.config.groups:
|
||||
tokens_per_block = group.tokens_per_block
|
||||
kv_spec = kv_cache_config.kv_cache_groups[group.group_id].kv_cache_spec
|
||||
sw = get_sliding_window_size_in_chunks(
|
||||
kv_spec, tokens_per_block * spec.blocks_per_chunk
|
||||
)
|
||||
@@ -214,9 +216,9 @@ class SchedulerOffloadConfig(NamedTuple):
|
||||
retention_interval = vllm_config.cache_config.prefix_cache_retention_interval
|
||||
|
||||
eagle_groups = {
|
||||
idx
|
||||
for idx, g in enumerate(kv_cache_config.kv_cache_groups)
|
||||
if g.is_eagle_group
|
||||
group.group_id
|
||||
for group in spec.config.groups
|
||||
if kv_cache_config.kv_cache_groups[group.group_id].is_eagle_group
|
||||
}
|
||||
|
||||
use_eagle_block_drop = (
|
||||
@@ -249,8 +251,10 @@ class SchedulerOffloadConfig(NamedTuple):
|
||||
)
|
||||
|
||||
kv_group_configs_list: list[GroupOffloadConfig] = []
|
||||
for idx, tokens_per_block in enumerate(spec.tokens_per_block):
|
||||
kv_cache_group = kv_cache_config.kv_cache_groups[idx]
|
||||
for group in spec.config.groups:
|
||||
group_id = group.group_id
|
||||
tokens_per_block = group.tokens_per_block
|
||||
kv_cache_group = kv_cache_config.kv_cache_groups[group_id]
|
||||
kv_spec = kv_cache_group.kv_cache_spec
|
||||
sw = get_sliding_window_size_in_chunks(
|
||||
kv_spec, tokens_per_block * spec.blocks_per_chunk
|
||||
@@ -261,7 +265,7 @@ class SchedulerOffloadConfig(NamedTuple):
|
||||
)
|
||||
kv_group_configs_list.append(
|
||||
GroupOffloadConfig(
|
||||
group_idx=idx,
|
||||
group_idx=group_id,
|
||||
tokens_per_block=tokens_per_block,
|
||||
tokens_per_chunk=tokens_per_block * spec.blocks_per_chunk,
|
||||
hashes_per_chunk=(
|
||||
@@ -272,7 +276,7 @@ class SchedulerOffloadConfig(NamedTuple):
|
||||
kv_cache_spec=kv_spec,
|
||||
manager_cls=manager_cls,
|
||||
kv_event_group_spec=get_offloading_event_group_spec(kv_cache_group),
|
||||
is_eagle_group=idx in eagle_groups,
|
||||
is_eagle_group=group_id in eagle_groups,
|
||||
requires_cow_source=(
|
||||
isinstance(kv_spec, MambaSpec)
|
||||
and kv_spec.mamba_cache_mode == "align"
|
||||
@@ -408,9 +412,10 @@ class RequestOffloadState:
|
||||
if new_block_id_groups is None:
|
||||
return
|
||||
|
||||
assert len(new_block_id_groups) == len(self.group_states)
|
||||
for group_state, new_blocks in zip(self.group_states, new_block_id_groups):
|
||||
group_state.block_ids.extend(new_blocks)
|
||||
for group_config, group_state in zip(
|
||||
self.config.kv_group_configs, self.group_states
|
||||
):
|
||||
group_state.block_ids.extend(new_block_id_groups[group_config.group_idx])
|
||||
|
||||
def storable_chunks(
|
||||
self,
|
||||
@@ -541,11 +546,11 @@ class OffloadingConnectorScheduler:
|
||||
|
||||
full_attention_groups: list[int] = []
|
||||
sliding_window_groups: list[int] = []
|
||||
for group_config in self.config.kv_group_configs:
|
||||
for config_idx, group_config in enumerate(self.config.kv_group_configs):
|
||||
if group_config.sliding_window_size_in_chunks is None:
|
||||
full_attention_groups.append(group_config.group_idx)
|
||||
full_attention_groups.append(config_idx)
|
||||
else:
|
||||
sliding_window_groups.append(group_config.group_idx)
|
||||
sliding_window_groups.append(config_idx)
|
||||
|
||||
# sort sliding window groups by window size in decreasing order
|
||||
def _sliding_window_sort_key(i: int) -> int:
|
||||
@@ -737,7 +742,11 @@ class OffloadingConnectorScheduler:
|
||||
req_status.req_context,
|
||||
)
|
||||
|
||||
def _lookup_complete_chunks(self, req_status: RequestOffloadState) -> int | None:
|
||||
def _lookup_complete_chunks(
|
||||
self,
|
||||
req_status: RequestOffloadState,
|
||||
max_num_new_tokens: int | None = None,
|
||||
) -> int | None:
|
||||
"""
|
||||
Find how many tokens beyond num_locally_computed_tokens can be loaded.
|
||||
|
||||
@@ -748,6 +757,10 @@ class OffloadingConnectorScheduler:
|
||||
"""
|
||||
num_computed_tokens = req_status.num_locally_computed_tokens
|
||||
max_hit_size_tokens: int = req_status.req.num_tokens
|
||||
if max_num_new_tokens is not None:
|
||||
max_hit_size_tokens = min(
|
||||
max_hit_size_tokens, num_computed_tokens + max_num_new_tokens
|
||||
)
|
||||
if self._sliding_window_groups:
|
||||
# the last prompt token has to be recomputed to get the logprobs
|
||||
# for sliding window attention, we must reduce by 1 to make sure
|
||||
@@ -929,8 +942,12 @@ class OffloadingConnectorScheduler:
|
||||
hash_idx = boundary_tokens // self.config.tokens_per_hash - 1
|
||||
return make_offload_key(request.block_hashes[hash_idx], group_idx)
|
||||
|
||||
def _lookup(self, req_status: RequestOffloadState) -> int | None:
|
||||
complete_hit = self._lookup_complete_chunks(req_status)
|
||||
def _lookup(
|
||||
self,
|
||||
req_status: RequestOffloadState,
|
||||
max_num_new_tokens: int | None = None,
|
||||
) -> int | None:
|
||||
complete_hit = self._lookup_complete_chunks(req_status, max_num_new_tokens)
|
||||
req_status.partial_tail_boundary = None
|
||||
if complete_hit is None or not self.config.supports_partial_tail:
|
||||
return complete_hit
|
||||
@@ -939,9 +956,10 @@ class OffloadingConnectorScheduler:
|
||||
complete_boundary = local_tokens + complete_hit
|
||||
tokens_per_hash = self.config.tokens_per_hash
|
||||
block_end = complete_boundary + self._partial_tail_block_size
|
||||
max_boundary = round_down(
|
||||
min(req_status.req.num_prompt_tokens - 1, block_end - 1), tokens_per_hash
|
||||
)
|
||||
max_boundary = min(req_status.req.num_prompt_tokens - 1, block_end - 1)
|
||||
if max_num_new_tokens is not None:
|
||||
max_boundary = min(max_boundary, local_tokens + max_num_new_tokens)
|
||||
max_boundary = round_down(max_boundary, tokens_per_hash)
|
||||
if max_boundary <= complete_boundary:
|
||||
return complete_hit
|
||||
|
||||
@@ -990,7 +1008,10 @@ class OffloadingConnectorScheduler:
|
||||
self._req_status[request.request_id] = req_status
|
||||
|
||||
def get_num_new_matched_tokens(
|
||||
self, request: Request, num_computed_tokens: int
|
||||
self,
|
||||
request: Request,
|
||||
num_computed_tokens: int,
|
||||
max_num_new_tokens: int | None = None,
|
||||
) -> tuple[int | None, bool]:
|
||||
"""
|
||||
Get number of new tokens that can be loaded beyond the
|
||||
@@ -1030,7 +1051,7 @@ class OffloadingConnectorScheduler:
|
||||
num_hit_tokens = 0
|
||||
else:
|
||||
lookup_start = time.monotonic()
|
||||
num_hit_tokens = self._lookup(req_status)
|
||||
num_hit_tokens = self._lookup(req_status, max_num_new_tokens)
|
||||
self._connector_stats.observe_histogram(
|
||||
_ConnectorMetricName.LOOKUP_SYNC_DELAY,
|
||||
time.monotonic() - lookup_start,
|
||||
@@ -1065,11 +1086,11 @@ class OffloadingConnectorScheduler:
|
||||
# per group
|
||||
group_sizes: list[int] = []
|
||||
block_indices: list[int] = []
|
||||
for group_config, group_state, group_blocks in zip(
|
||||
for group_config, group_state in zip(
|
||||
self.config.kv_group_configs,
|
||||
req_status.group_states,
|
||||
blocks.blocks,
|
||||
):
|
||||
group_blocks = blocks.blocks[group_config.group_idx]
|
||||
self._current_batch_allocated_block_ids.update(
|
||||
block.block_id for block in group_blocks if block.block_id != 0
|
||||
)
|
||||
@@ -1184,7 +1205,8 @@ class OffloadingConnectorScheduler:
|
||||
for grp_idx in self._sliding_window_groups
|
||||
)
|
||||
req_status.update_block_id_groups(new_block_id_groups)
|
||||
for new_blocks in new_block_id_groups:
|
||||
for group_config in self.config.kv_group_configs:
|
||||
new_blocks = new_block_id_groups[group_config.group_idx]
|
||||
for bid in new_blocks:
|
||||
if bid != 0:
|
||||
self._current_batch_allocated_block_ids.add(bid)
|
||||
@@ -1218,6 +1240,10 @@ class OffloadingConnectorScheduler:
|
||||
) -> dict[int, TransferJob]:
|
||||
store_jobs: dict[int, TransferJob] = {}
|
||||
num_groups = len(self.config.kv_group_configs)
|
||||
config_idx_by_group = {
|
||||
config.group_idx: idx
|
||||
for idx, config in enumerate(self.config.kv_group_configs)
|
||||
}
|
||||
for req_id, entries in handoffs.items():
|
||||
req_status = self._req_status.get(req_id)
|
||||
if req_status is None:
|
||||
@@ -1225,7 +1251,10 @@ class OffloadingConnectorScheduler:
|
||||
req = req_status.req
|
||||
max_boundary = self._calc_num_offloadable_tokens(req_status, req.num_tokens)
|
||||
for group_idx, block_id, boundary in entries:
|
||||
group_config = self.config.kv_group_configs[group_idx]
|
||||
config_idx = config_idx_by_group.get(group_idx)
|
||||
if config_idx is None:
|
||||
continue
|
||||
group_config = self.config.kv_group_configs[config_idx]
|
||||
if (
|
||||
block_id == 0
|
||||
or boundary > max_boundary
|
||||
@@ -1254,9 +1283,11 @@ class OffloadingConnectorScheduler:
|
||||
fenced_block_ids=[block_id],
|
||||
)
|
||||
group_sizes = [0] * num_groups
|
||||
group_sizes[group_idx] = 1
|
||||
group_sizes[config_idx] = 1
|
||||
block_indices = [0] * num_groups
|
||||
block_indices[group_idx] = boundary // group_config.tokens_per_block - 1
|
||||
block_indices[config_idx] = (
|
||||
boundary // group_config.tokens_per_block - 1
|
||||
)
|
||||
store_jobs[job_id] = TransferJob(
|
||||
req_id=req_id,
|
||||
src_spec=GPULoadStoreSpec(
|
||||
@@ -1300,6 +1331,12 @@ class OffloadingConnectorScheduler:
|
||||
assert len(boundaries) == 1
|
||||
boundary = boundaries.pop()
|
||||
req = req_status.req
|
||||
group_states = {
|
||||
group.group_idx: state
|
||||
for group, state in zip(
|
||||
self.config.kv_group_configs, req_status.group_states
|
||||
)
|
||||
}
|
||||
max_boundary = min(
|
||||
req.num_prompt_tokens,
|
||||
req_status.max_offload_tokens or req.num_prompt_tokens,
|
||||
@@ -1315,7 +1352,7 @@ class OffloadingConnectorScheduler:
|
||||
block_idx = boundary // self._partial_tail_block_size
|
||||
if any(
|
||||
group.group_idx not in self._cow_source_groups
|
||||
and block_idx >= len(req_status.group_states[group.group_idx].block_ids)
|
||||
and block_idx >= len(group_states[group.group_idx].block_ids)
|
||||
for group in self.config.kv_group_configs
|
||||
):
|
||||
continue
|
||||
@@ -1326,7 +1363,7 @@ class OffloadingConnectorScheduler:
|
||||
block_ids = [
|
||||
cow_blocks[group.group_idx]
|
||||
if group.group_idx in self._cow_source_groups
|
||||
else req_status.group_states[group.group_idx].block_ids[block_idx]
|
||||
else group_states[group.group_idx].block_ids[block_idx]
|
||||
for group in self.config.kv_group_configs
|
||||
]
|
||||
assert all(block_id != 0 for block_id in block_ids)
|
||||
|
||||
@@ -14,6 +14,9 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
|
||||
OffloadingWorkerMetadata,
|
||||
ReqId,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
|
||||
get_offloading_group_ids,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
AttentionSpec,
|
||||
@@ -65,7 +68,10 @@ class OffloadingConnectorWorker:
|
||||
|
||||
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
|
||||
kv_cache_config = self.kv_cache_config
|
||||
num_blocks = kv_cache_config.num_blocks
|
||||
selected_group_ids = get_offloading_group_ids(kv_cache_config)
|
||||
selected_groups = tuple(
|
||||
kv_cache_config.kv_cache_groups[group_id] for group_id in selected_group_ids
|
||||
)
|
||||
mappings = derive_canonical_mappings(
|
||||
self.vllm_config, kv_cache_config, kv_caches
|
||||
)
|
||||
@@ -76,7 +82,9 @@ class OffloadingConnectorWorker:
|
||||
unpadded_page_size_bytes: dict[str, int] = {}
|
||||
# layer_name -> size of page in bytes
|
||||
page_size_bytes: dict[str, int] = {}
|
||||
for kv_cache_group in kv_cache_config.kv_cache_groups:
|
||||
for kv_cache_group in selected_groups:
|
||||
assert not kv_cache_group.host_resident
|
||||
num_blocks = kv_cache_config.num_blocks
|
||||
group_layer_names = kv_cache_group.layer_names
|
||||
group_kv_cache_spec = kv_cache_group.kv_cache_spec
|
||||
if isinstance(group_kv_cache_spec, UniformTypeKVCacheSpecs):
|
||||
@@ -131,8 +139,11 @@ class OffloadingConnectorWorker:
|
||||
),
|
||||
None,
|
||||
)
|
||||
if packed_layer_name is not None:
|
||||
if packed_layer_name is not None and len(selected_groups) == len(
|
||||
kv_cache_config.kv_cache_groups
|
||||
):
|
||||
(tensor,) = tensors_per_block[packed_layer_name]
|
||||
num_blocks = tensor.shape[0]
|
||||
block_stride = tensor.stride(0)
|
||||
packed_tensor = tensor.as_strided(
|
||||
(num_blocks, block_stride),
|
||||
@@ -142,10 +153,7 @@ class OffloadingConnectorWorker:
|
||||
self._init_worker(
|
||||
CanonicalKVCaches(
|
||||
[CanonicalKVCacheTensor(packed_tensor, block_stride)],
|
||||
[
|
||||
[CanonicalKVCacheRef(0, block_stride)]
|
||||
for _ in kv_cache_config.kv_cache_groups
|
||||
],
|
||||
[[CanonicalKVCacheRef(0, block_stride)] for _ in selected_groups],
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -192,7 +200,7 @@ class OffloadingConnectorWorker:
|
||||
)
|
||||
|
||||
group_data_refs: list[list[CanonicalKVCacheRef]] = []
|
||||
for kv_cache_group in kv_cache_config.kv_cache_groups:
|
||||
for kv_cache_group in selected_groups:
|
||||
group_refs: list[CanonicalKVCacheRef] = []
|
||||
for layer_name in kv_cache_group.layer_names:
|
||||
group_refs += block_data_refs[layer_name]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections.abc import Iterable
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
@@ -25,6 +26,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
|
||||
build_offloading_config,
|
||||
get_offloading_group_ids,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
|
||||
OffloadingConnectorStats,
|
||||
@@ -38,6 +40,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.worker import (
|
||||
)
|
||||
from vllm.forward_context import ForwardContext
|
||||
from vllm.v1.attention.backend import AttentionMetadata
|
||||
from vllm.v1.core.kv_cache_coordinator import HybridKVCacheCoordinator
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.kv_cache_interface import KVCacheConfig
|
||||
@@ -47,6 +50,25 @@ from vllm.v1.request import Request
|
||||
|
||||
|
||||
class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
@cached_property
|
||||
def _bounding_group_ids(self) -> tuple[int, ...]:
|
||||
"""Prefix-cacheable groups this connector does not offload.
|
||||
|
||||
Never offer tokens for a range some group cannot cover: past such a
|
||||
group's own cached prefix its KV would be left unwritten.
|
||||
"""
|
||||
offloaded = set(get_offloading_group_ids(self._kv_cache_config))
|
||||
return tuple(
|
||||
group_id
|
||||
for group_id, group in enumerate(self._kv_cache_config.kv_cache_groups)
|
||||
if group.kv_cache_spec.prefix_cacheable and group_id not in offloaded
|
||||
)
|
||||
|
||||
@property
|
||||
def scheduler(self) -> OffloadingConnectorScheduler:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler
|
||||
|
||||
@property
|
||||
def requires_kv_delivery(self) -> bool:
|
||||
# Runs as kv_both, but is a best-effort cache: a dropped save is just a
|
||||
@@ -138,9 +160,30 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
) -> tuple[int | None, bool]:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.get_num_new_matched_tokens(
|
||||
request, num_computed_tokens
|
||||
request,
|
||||
num_computed_tokens,
|
||||
max_num_new_tokens=self._max_loadable_tokens(request, num_computed_tokens),
|
||||
)
|
||||
|
||||
def _max_loadable_tokens(
|
||||
self, request: "Request", num_computed_tokens: int
|
||||
) -> int | None:
|
||||
"""How far past ``num_computed_tokens`` a load may reach, if bounded.
|
||||
|
||||
Bounded by the deepest prefix the groups this connector does not
|
||||
offload already hold, since nothing refills them beyond it.
|
||||
"""
|
||||
if not self._bounding_group_ids:
|
||||
return None
|
||||
assert self._kv_cache_manager is not None
|
||||
coordinator = self._kv_cache_manager.coordinator
|
||||
assert isinstance(coordinator, HybridKVCacheCoordinator)
|
||||
_, per_group_hits = coordinator.find_longest_cache_hit_per_group(
|
||||
request.block_hashes, request.num_tokens - 1
|
||||
)
|
||||
bound = min(per_group_hits[group_id] for group_id in self._bounding_group_ids)
|
||||
return max(0, bound - num_computed_tokens)
|
||||
|
||||
def update_state_after_alloc(
|
||||
self, request: "Request", blocks: "KVCacheBlocks", num_external_tokens: int
|
||||
):
|
||||
@@ -185,6 +228,8 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
|
||||
|
||||
@classmethod
|
||||
def get_required_kvcache_layout(cls, vllm_config: VllmConfig) -> str | None:
|
||||
if vllm_config.attention_config.hisparse_config is not None:
|
||||
return "BLHNC"
|
||||
return "LBHNC"
|
||||
|
||||
def reset_cache(self) -> bool | None:
|
||||
|
||||
@@ -78,13 +78,11 @@ def ensure_kv_transfer_initialized(
|
||||
|
||||
global _KV_CONNECTOR_AGENT
|
||||
|
||||
if vllm_config.kv_transfer_config is None:
|
||||
if _KV_CONNECTOR_AGENT is not None:
|
||||
return
|
||||
|
||||
if (
|
||||
vllm_config.kv_transfer_config.is_kv_transfer_instance
|
||||
and _KV_CONNECTOR_AGENT is None
|
||||
):
|
||||
kv_transfer_config = vllm_config.kv_transfer_config
|
||||
if kv_transfer_config is not None and kv_transfer_config.is_kv_transfer_instance:
|
||||
# NIXL P/D requires an interleave_size equal to block_size.
|
||||
vllm_config.adjust_dcp_kv_cache_interleave_size(kv_cache_config)
|
||||
_sync_engine_id_across_tp(vllm_config)
|
||||
|
||||
@@ -207,7 +207,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import Enum
|
||||
from math import lcm
|
||||
from typing import ClassVar, Generic, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -284,6 +284,7 @@ from vllm.v1.attention.backend import (
|
||||
CommonAttentionMetadata,
|
||||
MLAAttentionImpl,
|
||||
)
|
||||
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
|
||||
from vllm.v1.attention.backends.mla.prefill import (
|
||||
MLAPrefillBackend,
|
||||
get_mla_prefill_backend,
|
||||
@@ -309,6 +310,11 @@ from vllm.v1.kv_cache_interface import (
|
||||
get_kv_quant_mode,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_FP8_DTYPE = current_platform.fp8_dtype()
|
||||
@@ -433,6 +439,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
use_sparse: bool = False,
|
||||
indexer: object | None = None,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: "SparseMLAIndexGroupBuilder | None" = None,
|
||||
non_causal_multi_token_decode: bool = False,
|
||||
sliding_window: int | None = None,
|
||||
prefill_backend_cls: type[MLAPrefillBackend] | None = None,
|
||||
@@ -538,9 +545,11 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
# explicitly so backbone "skip" layers (indexer=None) still find it.
|
||||
if use_sparse:
|
||||
extra_impl_args["topk_indices_buffer"] = topk_indices_buffer
|
||||
if index_group_builder is not None:
|
||||
extra_impl_args["index_group_builder"] = index_group_builder
|
||||
|
||||
impl_cls = cast(type[MLAAttentionImpl], self.attn_backend.get_impl_cls())
|
||||
self.impl = impl_cls( # type: ignore[assignment] # impl_cls always returns an MLAAttentionImpl subclass
|
||||
impl = impl_cls(
|
||||
num_heads=self.num_heads,
|
||||
head_size=self.head_size,
|
||||
scale=self.scale,
|
||||
@@ -562,6 +571,13 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
indexer=indexer,
|
||||
**extra_impl_args,
|
||||
)
|
||||
self.impl = impl # type: ignore[assignment]
|
||||
index_group = getattr(impl, "index_group", None)
|
||||
self.hisparse_cache = (
|
||||
index_group.cache(cast(Any, impl).index_group_index)
|
||||
if isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
else None
|
||||
)
|
||||
self.q_pad_num_heads = getattr(self.impl, "q_pad_num_heads", None)
|
||||
self.is_amx_bmm_enabled = getattr(self.impl, "uses_amx_bmm", False)
|
||||
# MLA reads this weight directly to build W_UK/W_UV, so a backend must
|
||||
@@ -723,6 +739,61 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
)
|
||||
return self._chunked_prefill_workspace_size
|
||||
|
||||
def update_kv_cache(
|
||||
self,
|
||||
kv_c_normed: torch.Tensor,
|
||||
k_pe: torch.Tensor,
|
||||
kv_cache: torch.Tensor,
|
||||
slot_mapping: torch.Tensor | None,
|
||||
attn_metadata: "MLACommonMetadata | None",
|
||||
kv_cache_dtype: str,
|
||||
k_scale: torch.Tensor,
|
||||
) -> None:
|
||||
cache = self.hisparse_cache
|
||||
if slot_mapping is None or (cache is not None and cache.dummy_batch):
|
||||
return
|
||||
kv_c_normed, k_pe, slot_mapping = maybe_gather_mla_latent_cache_inputs(
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
slot_mapping,
|
||||
attn_metadata.num_decode_tokens if attn_metadata is not None else None,
|
||||
self.use_pcp,
|
||||
)
|
||||
assert slot_mapping is not None
|
||||
if cache is not None:
|
||||
kv_cache, slot_mapping, num_rows = cache.write_target(
|
||||
kv_c_normed.shape[0], slot_mapping.numel()
|
||||
)
|
||||
kv_c_normed = kv_c_normed[:num_rows]
|
||||
k_pe = k_pe[:num_rows]
|
||||
self.impl.do_kv_cache_update( # type: ignore[attr-defined]
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
kv_cache,
|
||||
slot_mapping,
|
||||
kv_cache_dtype,
|
||||
k_scale,
|
||||
)
|
||||
if cache is not None:
|
||||
mirror_target = cache.mirror_write_target(kv_c_normed.shape[0])
|
||||
if mirror_target is not None:
|
||||
mirror_cache, mirror_slots = mirror_target
|
||||
self.impl.do_kv_cache_update( # type: ignore[attr-defined]
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
mirror_cache,
|
||||
mirror_slots,
|
||||
kv_cache_dtype,
|
||||
k_scale,
|
||||
)
|
||||
|
||||
def prepare_kv_cache_update(
|
||||
self, attn_metadata: "MLACommonMetadata | None"
|
||||
) -> None:
|
||||
cache = self.hisparse_cache
|
||||
if cache is not None and cache.runtime.is_group_leader:
|
||||
cache.prepare_group_for_batch(attn_metadata)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
@@ -750,25 +821,18 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
f"Expected slot_mapping to be a dict, got {type(slot_mapping)}. "
|
||||
)
|
||||
layer_slot_mapping = slot_mapping.get(self.layer_name)
|
||||
kv_for_cache, kpe_for_cache, layer_slot_mapping = (
|
||||
maybe_gather_mla_latent_cache_inputs(
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
layer_slot_mapping,
|
||||
attn_metadata.num_decode_tokens
|
||||
if attn_metadata is not None
|
||||
else None,
|
||||
self.use_pcp,
|
||||
)
|
||||
)
|
||||
self.impl.do_kv_cache_update( # type: ignore[attr-defined]
|
||||
kv_for_cache,
|
||||
kpe_for_cache,
|
||||
self.prepare_kv_cache_update(attn_metadata)
|
||||
self.update_kv_cache(
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
self_kv_cache,
|
||||
layer_slot_mapping,
|
||||
attn_metadata,
|
||||
self.kv_cache_dtype,
|
||||
self._k_scale,
|
||||
)
|
||||
if self.hisparse_cache is not None:
|
||||
self.hisparse_cache.finish_kv_update()
|
||||
output = torch.empty(output_shape, dtype=q.dtype, device=q.device)
|
||||
self.forward_impl(
|
||||
q,
|
||||
@@ -1113,6 +1177,8 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
return output_padded
|
||||
|
||||
def _use_sparse_mha(self, attn_metadata: "MLACommonMetadata") -> bool:
|
||||
if self.hisparse_cache is not None:
|
||||
return False
|
||||
prefill = attn_metadata.prefill
|
||||
if prefill is None:
|
||||
return False
|
||||
@@ -1296,6 +1362,7 @@ class MLAAttention(nn.Module, AttentionLayerBase):
|
||||
)
|
||||
return MLAAttentionSpec(
|
||||
**common_kwargs,
|
||||
is_index_group_leader=self.indexer is not None,
|
||||
non_causal_multi_token_decode=self.non_causal_multi_token_decode,
|
||||
)
|
||||
|
||||
@@ -1349,19 +1416,14 @@ def unified_mla_kv_cache_update(
|
||||
attn_metadata, attn_layer, kv_cache, layer_slot_mapping = get_attention_context(
|
||||
layer_name
|
||||
)
|
||||
attn_layer.prepare_kv_cache_update(attn_metadata)
|
||||
if layer_slot_mapping is not None:
|
||||
kv_c_normed, k_pe, layer_slot_mapping = maybe_gather_mla_latent_cache_inputs(
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
layer_slot_mapping,
|
||||
attn_metadata.num_decode_tokens if attn_metadata is not None else None,
|
||||
attn_layer.use_pcp,
|
||||
)
|
||||
attn_layer.impl.do_kv_cache_update( # type: ignore[attr-defined]
|
||||
attn_layer.update_kv_cache(
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
kv_cache,
|
||||
layer_slot_mapping,
|
||||
attn_metadata,
|
||||
kv_cache_dtype,
|
||||
k_scale,
|
||||
)
|
||||
@@ -1409,6 +1471,8 @@ def unified_mla_attention_with_output(
|
||||
del kv_cache_dummy_dep
|
||||
layer_name = _resolve_layer_name(layer_name)
|
||||
attn_metadata, layer, kv_cache, _ = get_attention_context(layer_name)
|
||||
if layer.hisparse_cache is not None:
|
||||
layer.hisparse_cache.finish_kv_update()
|
||||
layer.forward_impl(
|
||||
q,
|
||||
kv_c_normed,
|
||||
@@ -1501,6 +1565,7 @@ class MLACommonBackend(AttentionBackend):
|
||||
def customize_spec(cls, spec: "AttentionSpec") -> "AttentionSpec":
|
||||
"""Per-token-head modes pack an inline fp32 scale pair after the
|
||||
latent data (single-sided: ``head_size_v == 0`` for MLA)."""
|
||||
spec = super().customize_spec(spec)
|
||||
mode = spec.kv_quant_mode
|
||||
if spec.state_content_bytes is not None or not mode.is_per_token_head:
|
||||
return spec
|
||||
@@ -2090,7 +2155,11 @@ def build_mla_chunked_context_metadata(
|
||||
local_token_offset += num_local_tokens
|
||||
|
||||
seq_lens_cpu = _flat_int32(seq_lens_flat)
|
||||
starts = _flat_int32(starts_flat).to(device, non_blocking=True)
|
||||
starts_and_context_lens = _flat_int32(starts_flat + context_lens).to(
|
||||
device, non_blocking=True
|
||||
)
|
||||
starts = starts_and_context_lens[: len(starts_flat)]
|
||||
context_lens_gpu = starts_and_context_lens[len(starts_flat) :]
|
||||
cu_seq_lens = _flat_int32(cu_seq_lens_flat).to(device, non_blocking=True)
|
||||
cu_seqlens_q = _flat_int32(cu_seqlens_q_flat).to(device, non_blocking=True)
|
||||
token_to_seq = _flat_int32(np.concatenate(token_to_seq_parts)).to(
|
||||
@@ -2145,7 +2214,7 @@ def build_mla_chunked_context_metadata(
|
||||
chunks.append(chunk)
|
||||
|
||||
return MLACommonPrefillMetadata.ChunkedContextMetadata(
|
||||
context_lens=context_lens_cpu.to(device, non_blocking=True),
|
||||
context_lens=context_lens_gpu,
|
||||
workspace=chunked_prefill_workspace,
|
||||
chunks=chunks,
|
||||
context_lens_list=context_lens,
|
||||
@@ -2835,12 +2904,12 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
|
||||
output_lse = None
|
||||
for chunk in chunked_context.chunks:
|
||||
toks = chunk.num_context_tokens
|
||||
block_table = prefill_metadata.block_table[chunk.request_slice]
|
||||
chunk_block_table = prefill_metadata.block_table[chunk.request_slice]
|
||||
if self.kv_cache_dtype == "fp8_ds_mla":
|
||||
ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
src_cache=kv_c_and_k_pe_cache,
|
||||
dst=workspace[:toks],
|
||||
block_table=block_table,
|
||||
block_table=chunk_block_table,
|
||||
workspace_starts=chunk.cu_seq_lens,
|
||||
batch_size=chunk.num_requests,
|
||||
seq_starts=chunk.starts,
|
||||
@@ -2853,7 +2922,7 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
|
||||
ops.gather_mla_context_cache_cpu(
|
||||
src_cache=kv_c_and_k_pe_cache,
|
||||
dst=workspace[:toks],
|
||||
block_table=block_table,
|
||||
block_table=chunk_block_table,
|
||||
starts=chunk.starts,
|
||||
cu_seq_lens=chunk.cu_seq_lens,
|
||||
)
|
||||
@@ -2861,7 +2930,7 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
|
||||
ops.gather_and_maybe_dequant_cache(
|
||||
src_cache=kv_c_and_k_pe_cache,
|
||||
dst=workspace,
|
||||
block_table=block_table,
|
||||
block_table=chunk_block_table,
|
||||
cu_seq_lens=chunk.cu_seq_lens,
|
||||
token_to_seq=chunk.token_to_seq,
|
||||
num_tokens=toks,
|
||||
@@ -2874,7 +2943,7 @@ class MLACommonBaseImpl(MLAAttentionImpl[A], Generic[A]):
|
||||
ops.cp_gather_cache(
|
||||
src_cache=kv_c_and_k_pe_cache,
|
||||
dst=workspace[:toks],
|
||||
block_table=block_table,
|
||||
block_table=chunk_block_table,
|
||||
cu_seq_lens=chunk.cu_seq_lens,
|
||||
batch_size=chunk.num_requests,
|
||||
seq_starts=chunk.starts,
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
"""Shared MHA implementation and metadata builder for sparse MLA backends."""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from shutil import which
|
||||
from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm import _custom_ops as ops
|
||||
from vllm.config import get_current_vllm_config
|
||||
from vllm.distributed import (
|
||||
get_dcp_group,
|
||||
get_tensor_model_parallel_world_size,
|
||||
@@ -17,6 +19,7 @@ from vllm.distributed import (
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.attention.mla_attention import (
|
||||
MLACommonBaseImpl,
|
||||
MLACommonDecodeMetadata,
|
||||
MLACommonMetadata,
|
||||
MLACommonPrefillMetadata,
|
||||
accumulate_mla_context_chunk,
|
||||
@@ -33,11 +36,19 @@ from vllm.utils.torch_utils import (
|
||||
is_quantized_kv_cache,
|
||||
np_to_pinned_tensor,
|
||||
)
|
||||
from vllm.v1.attention.backend import AttentionMetadata, AttentionMetadataBuilder
|
||||
from vllm.v1.attention.backend import AttentionMetadataBuilder
|
||||
from vllm.v1.attention.backends.fa_utils import get_flash_attn_version
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroup,
|
||||
SparseMLAIndexGroupBuilder,
|
||||
)
|
||||
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
|
||||
from vllm.v1.attention.ops.dcp import MLADCPManager
|
||||
from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
|
||||
from vllm.v1.hisparse.runtime import (
|
||||
HiSparsePrefillStagingPlan,
|
||||
build_hisparse_prefill_staging_plan,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import VllmConfig
|
||||
@@ -47,8 +58,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=AttentionMetadata)
|
||||
|
||||
GLOBAL_TOPK_MASK_MAX_BYTES = 128 * 1024 * 1024 # 128 MiB
|
||||
|
||||
|
||||
@@ -116,9 +125,45 @@ def _is_masked_mha_available(
|
||||
return fa_version == 4 and not is_quantized_kv_cache(kv_cache_dtype)
|
||||
|
||||
|
||||
def _use_dense_mha_prefill(
|
||||
vllm_config: "VllmConfig", prefill_max_seq_len: int, topk_tokens: int
|
||||
) -> bool:
|
||||
attention_config = vllm_config.attention_config
|
||||
return (
|
||||
attention_config.hisparse_config is None
|
||||
and not attention_config.sparse_mla_force_mqa
|
||||
and prefill_max_seq_len <= topk_tokens
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SparseMLAPrefillMetadata(MLACommonPrefillMetadata):
|
||||
host_staging_plan: HiSparsePrefillStagingPlan | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SparseMLACommonMetadata(MLACommonMetadata[MLACommonDecodeMetadata]):
|
||||
block_table: torch.Tensor
|
||||
req_id_per_token: torch.Tensor
|
||||
seq_lens: torch.Tensor | None = None
|
||||
block_size: int = 64
|
||||
topk_tokens: int = 2048
|
||||
num_decodes: int = 0
|
||||
num_prefills: int = 0
|
||||
num_decode_tokens: int = 0
|
||||
decode_max_query_len: int = 0
|
||||
prefill_max_seq_len: int = 0
|
||||
prefill: SparseMLAPrefillMetadata | None = None
|
||||
cp_kv_cache_interleave_size: int = 1
|
||||
|
||||
|
||||
T = TypeVar("T", bound=SparseMLACommonMetadata)
|
||||
|
||||
|
||||
class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
metadata_cls: type[T]
|
||||
require_uniform_decodes: ClassVar[bool] = False
|
||||
hisparse_supports_multi_token_decode: ClassVar[bool] = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -138,6 +183,12 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
dtype=torch.int32,
|
||||
device=device,
|
||||
)
|
||||
self._context_lens_cpu = torch.empty(
|
||||
vllm_config.scheduler_config.max_num_seqs,
|
||||
dtype=torch.int32,
|
||||
device="cpu",
|
||||
pin_memory=PIN_MEMORY,
|
||||
)
|
||||
parallel_config = vllm_config.parallel_config
|
||||
self.use_pcp = parallel_config.prefill_context_parallel_size > 1
|
||||
try:
|
||||
@@ -197,6 +248,22 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
layer_prefill_backend.clone() if layer_prefill_backend is not None else None
|
||||
)
|
||||
|
||||
def _init_reorder_batch_threshold(
|
||||
self,
|
||||
reorder_batch_threshold: int | None = 1,
|
||||
supports_spec_as_decode: bool = False,
|
||||
supports_dcp_with_varlen: bool = False,
|
||||
) -> None:
|
||||
if self.vllm_config.attention_config.hisparse_config is not None:
|
||||
reorder_batch_threshold = 1
|
||||
if not self.hisparse_supports_multi_token_decode:
|
||||
supports_spec_as_decode = False
|
||||
super()._init_reorder_batch_threshold(
|
||||
reorder_batch_threshold,
|
||||
supports_spec_as_decode,
|
||||
supports_dcp_with_varlen,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def determine_chunked_prefill_workspace_size(vllm_config: "VllmConfig") -> int:
|
||||
scheduler_config = vllm_config.scheduler_config
|
||||
@@ -245,9 +312,7 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
|
||||
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
|
||||
assert seq_lens_cpu is not None
|
||||
context_lens_cpu = torch.empty(
|
||||
num_prefills, dtype=seq_lens_cpu.dtype, pin_memory=PIN_MEMORY
|
||||
)
|
||||
context_lens_cpu = self._context_lens_cpu[:num_prefills]
|
||||
torch.subtract(
|
||||
seq_lens_cpu[num_decodes : num_decodes + num_prefills],
|
||||
prefill_query_lens_cpu,
|
||||
@@ -289,34 +354,66 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
prefill_max_query_len,
|
||||
prefill_query_lens_cpu,
|
||||
) = self._build_prefill_fields(common_attn_metadata, num_decodes, num_prefills)
|
||||
decode_max_query_len = 0
|
||||
if num_decodes:
|
||||
if common_attn_metadata.max_logits_per_req is not None:
|
||||
decode_max_query_len = common_attn_metadata.max_logits_per_req
|
||||
elif num_prefills == 0:
|
||||
decode_max_query_len = common_attn_metadata.max_query_len
|
||||
else:
|
||||
decode_query_lens_cpu = torch.diff(
|
||||
common_attn_metadata.query_start_loc_cpu[: num_decodes + 1]
|
||||
)
|
||||
decode_max_query_len = int(decode_query_lens_cpu.max().item())
|
||||
|
||||
prefill_max_seq_len = 0
|
||||
prefill: MLACommonPrefillMetadata | None = None
|
||||
prefill: SparseMLAPrefillMetadata | None = None
|
||||
if num_prefills > 0 and self._prefill_backend is not None:
|
||||
seq_lens_cpu = common_attn_metadata.seq_lens_cpu_upper_bound
|
||||
assert seq_lens_cpu is not None
|
||||
prefill_max_seq_len = int(
|
||||
seq_lens_cpu[num_decodes : num_decodes + num_prefills].max().item()
|
||||
)
|
||||
prefill = MLACommonPrefillMetadata(
|
||||
block_table=common_attn_metadata.block_table_tensor[num_decodes:, ...],
|
||||
block_table = common_attn_metadata.block_table_tensor[num_decodes:, ...]
|
||||
chunked_context = self._build_chunked_context_fields(
|
||||
common_attn_metadata,
|
||||
num_decodes,
|
||||
num_prefills,
|
||||
prefill_query_lens_cpu,
|
||||
)
|
||||
staging_plan = None
|
||||
if self.vllm_config.attention_config.hisparse_config is not None:
|
||||
prefill_seq_lens_cpu = seq_lens_cpu[
|
||||
num_decodes : num_decodes + num_prefills
|
||||
]
|
||||
staging_block_capacity = int(
|
||||
(
|
||||
(prefill_seq_lens_cpu + self.kv_cache_spec.block_size - 1)
|
||||
// self.kv_cache_spec.block_size
|
||||
).sum()
|
||||
)
|
||||
staging_plan = build_hisparse_prefill_staging_plan(
|
||||
block_table,
|
||||
common_attn_metadata.seq_lens[num_decodes:],
|
||||
self.kv_cache_spec.block_size,
|
||||
staging_block_capacity,
|
||||
)
|
||||
if chunked_context is not None:
|
||||
block_table = staging_plan.block_table
|
||||
prefill = SparseMLAPrefillMetadata(
|
||||
block_table=block_table,
|
||||
query_start_loc=prefill_query_start_loc,
|
||||
max_query_len=prefill_max_query_len,
|
||||
query_lens_cpu=prefill_query_lens_cpu,
|
||||
chunked_context=self._build_chunked_context_fields(
|
||||
common_attn_metadata,
|
||||
num_decodes,
|
||||
num_prefills,
|
||||
prefill_query_lens_cpu,
|
||||
),
|
||||
chunked_context=chunked_context,
|
||||
q_data_type=self.model_config.dtype,
|
||||
output_dtype=self.model_config.dtype,
|
||||
prefill_backend=self._prefill_backend,
|
||||
use_dense_mha=(
|
||||
prefill_max_seq_len <= self.topk_tokens
|
||||
and not self.vllm_config.attention_config.sparse_mla_force_mqa
|
||||
use_dense_mha=_use_dense_mha_prefill(
|
||||
self.vllm_config, prefill_max_seq_len, self.topk_tokens
|
||||
),
|
||||
topk_mask_workspace=self.topk_mask_workspace,
|
||||
host_staging_plan=staging_plan,
|
||||
)
|
||||
self._prefill_backend.prepare_metadata(prefill)
|
||||
|
||||
@@ -335,6 +432,7 @@ class SparseMLACommonMetadataBuilder(AttentionMetadataBuilder[T]):
|
||||
num_decodes=num_decodes,
|
||||
num_prefills=num_prefills,
|
||||
num_decode_tokens=num_decode_tokens,
|
||||
decode_max_query_len=decode_max_query_len,
|
||||
prefill_max_seq_len=prefill_max_seq_len,
|
||||
prefill=prefill,
|
||||
cp_kv_cache_interleave_size=self.cp_kv_cache_interleave_size,
|
||||
@@ -560,6 +658,7 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
kv_b_proj: "ColumnParallelLinear",
|
||||
indexer: object | None = None,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: SparseMLAIndexGroupBuilder | None = None,
|
||||
q_pad_num_heads: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -577,6 +676,21 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
)
|
||||
|
||||
self.init_topk_indices_buffer(indexer, topk_indices_buffer)
|
||||
self.index_group: SparseMLAIndexGroup | None = None
|
||||
self.index_group_index = 0
|
||||
if index_group_builder is None and self.topk_indices_buffer is not None:
|
||||
index_group_builder = SparseMLAIndexGroupBuilder(self.topk_indices_buffer)
|
||||
if index_group_builder is not None:
|
||||
vllm_config = get_current_vllm_config()
|
||||
self.index_group, self.index_group_index = (
|
||||
index_group_builder.register_layer(
|
||||
indexer is not None,
|
||||
vllm_config,
|
||||
head_size=head_size,
|
||||
kv_cache_dtype=kv_cache_dtype,
|
||||
)
|
||||
)
|
||||
|
||||
self._use_flashinfer_concat_mla_k = (
|
||||
has_flashinfer()
|
||||
and which("ninja") is not None
|
||||
@@ -593,6 +707,33 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
kv_cache_dtype=kv_cache_dtype,
|
||||
)
|
||||
|
||||
def record_logical_topk_ready(self) -> None:
|
||||
if self.index_group is not None:
|
||||
self.index_group.set_logical_topk_ready(self.index_group_index)
|
||||
|
||||
def prepare_for_batch(self, attn_metadata: T | None) -> None:
|
||||
if self.index_group is not None:
|
||||
self.index_group.prepare_for_batch(self.index_group_index, attn_metadata)
|
||||
|
||||
def _convert_logical_to_physical_topk(
|
||||
self,
|
||||
logical_topk_indices: torch.Tensor,
|
||||
attn_metadata: Any,
|
||||
*,
|
||||
block_stride_rows: int | None,
|
||||
return_valid_counts: bool,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
group = self.index_group
|
||||
assert group is not None
|
||||
assert self.dcp_world_size == 1
|
||||
return group.convert_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
logical_topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=block_stride_rows,
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def masked_mha_workspace_fits(prefill: MLACommonPrefillMetadata) -> bool:
|
||||
"""Whether this prefill batch's top-k masks fit the workspace."""
|
||||
@@ -853,8 +994,10 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
output: torch.Tensor,
|
||||
output_scale: torch.Tensor | None = None,
|
||||
) -> None:
|
||||
prefill_max_seq_len = attn_metadata.prefill_max_seq_len # type: ignore[attr-defined]
|
||||
topk_tokens = attn_metadata.topk_tokens # type: ignore[attr-defined]
|
||||
prefill_metadata = attn_metadata.prefill
|
||||
assert prefill_metadata is not None
|
||||
prefill_max_seq_len = attn_metadata.prefill_max_seq_len
|
||||
topk_tokens = attn_metadata.topk_tokens
|
||||
force_dense = getattr(self, "_sparse_mla_force_dense_mha", False)
|
||||
force_masked = getattr(self, "_sparse_mla_force_masked_mha", False)
|
||||
if force_dense or (prefill_max_seq_len <= topk_tokens and not force_masked):
|
||||
@@ -863,7 +1006,7 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
kv_c_normed,
|
||||
k_pe,
|
||||
kv_c_and_k_pe_cache,
|
||||
cast(MLACommonMetadata, attn_metadata),
|
||||
attn_metadata,
|
||||
k_scale,
|
||||
output,
|
||||
output_scale,
|
||||
@@ -871,13 +1014,11 @@ class SparseMLACommonImpl(MLACommonBaseImpl[T], SharedTopkIndicesBuffer, Generic
|
||||
|
||||
assert output_scale is None
|
||||
assert self.masked_mha_available
|
||||
prefill_metadata = attn_metadata.prefill # type: ignore[attr-defined]
|
||||
assert prefill_metadata is not None
|
||||
assert prefill_metadata.query_lens_cpu is not None
|
||||
assert self.topk_indices_buffer is not None
|
||||
|
||||
q_lens = prefill_metadata.query_lens_cpu.tolist()
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens # type: ignore[attr-defined]
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens
|
||||
topk_all = self.topk_indices_buffer[
|
||||
num_decode_tokens : num_decode_tokens + q.shape[0]
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
|
||||
@@ -11,6 +12,11 @@ from vllm.model_executor.layers.quantization import QuantizationConfig
|
||||
from vllm.models.common.ops import fused_q_kv_rmsnorm
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MLAModules:
|
||||
@@ -30,6 +36,7 @@ class MLAModules:
|
||||
topk_indices_buffer: torch.Tensor | None
|
||||
indexer_rotary_emb: torch.nn.Module | None = None
|
||||
g_proj: torch.nn.Module | None = None
|
||||
index_group_builder: "SparseMLAIndexGroupBuilder | None" = None
|
||||
|
||||
|
||||
# --8<-- [start:multi_head_latent_attention]
|
||||
@@ -128,6 +135,7 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
|
||||
use_sparse=self.is_sparse,
|
||||
indexer=self.indexer,
|
||||
topk_indices_buffer=mla_modules.topk_indices_buffer,
|
||||
index_group_builder=mla_modules.index_group_builder,
|
||||
non_causal_multi_token_decode=non_causal_multi_token_decode,
|
||||
)
|
||||
indexer_op = getattr(self.indexer, "indexer_op", None)
|
||||
@@ -223,6 +231,8 @@ class MultiHeadLatentAttentionWrapper(PluggableLayer):
|
||||
|
||||
if self.indexer and self.is_sparse and not self.skip_topk:
|
||||
self.indexer(hidden_states, q_c, positions, self.indexer_rope_emb)
|
||||
if self.is_sparse:
|
||||
self.mla_attn.impl.record_logical_topk_ready() # type: ignore[attr-defined]
|
||||
|
||||
if llama_4_scaling is not None:
|
||||
q *= llama_4_scaling
|
||||
|
||||
@@ -425,7 +425,6 @@ def sparse_attn_indexer(
|
||||
quant_block_size,
|
||||
scale_fmt,
|
||||
)
|
||||
|
||||
# The indexer and main MLA may classify the same short extend differently
|
||||
# because they use independent decode thresholds. Only the main MLA route
|
||||
# can determine whether the top-k indices will be consumed.
|
||||
@@ -955,6 +954,7 @@ class SparseAttnIndexer(CustomOp):
|
||||
self.dcp_rank,
|
||||
self.dcp_world_size,
|
||||
self.cp_kv_cache_interleave_size,
|
||||
False,
|
||||
candidate_blocks=self.candidate_blocks,
|
||||
candidate_block_size=self.candidate_block_size,
|
||||
candidate_write=self.candidate_write,
|
||||
|
||||
@@ -103,10 +103,14 @@ from vllm.platforms import current_platform
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.utils.torch_utils import direct_register_custom_op
|
||||
from vllm.v1.attention.backend import AttentionBackend, AttentionType
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
get_sparse_mla_index_group_max_rows,
|
||||
)
|
||||
from vllm.v1.attention.backends.mla.indexer import (
|
||||
DeepseekV32IndexerBackend,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpec, MLAAttentionSpec
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpec, MLAAttentionSpec, SparseCacheRole
|
||||
|
||||
from .interfaces import (
|
||||
MixtureOfExperts,
|
||||
@@ -656,6 +660,7 @@ class DeepseekV32IndexerCache(torch.nn.Module, AttentionLayerBase):
|
||||
num_kv_heads=1,
|
||||
head_size=self.head_dim,
|
||||
dtype=self.dtype,
|
||||
cache_role=SparseCacheRole.INDEXER,
|
||||
) # Only has one vector instead of K + V
|
||||
|
||||
def forward(self): ...
|
||||
@@ -1012,6 +1017,7 @@ class DeepseekV2MLAAttention(nn.Module):
|
||||
quant_config: QuantizationConfig | None = None,
|
||||
prefix: str = "",
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: SparseMLAIndexGroupBuilder | None = None,
|
||||
input_size: int | None = None,
|
||||
reduce_results: bool = True,
|
||||
non_causal_multi_token_decode: bool = False,
|
||||
@@ -1199,6 +1205,7 @@ class DeepseekV2MLAAttention(nn.Module):
|
||||
indexer_rotary_emb=self.indexer_rope_emb,
|
||||
is_sparse=self.is_v32,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
)
|
||||
|
||||
self.mla_attn = MultiHeadLatentAttentionWrapper(
|
||||
@@ -1242,6 +1249,7 @@ class DeepseekV2DecoderLayer(nn.Module):
|
||||
prefix: str,
|
||||
config: DeepseekV2Config | None = None,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: SparseMLAIndexGroupBuilder | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -1290,6 +1298,11 @@ class DeepseekV2DecoderLayer(nn.Module):
|
||||
and parallel_config.pipeline_parallel_size == 1
|
||||
and is_moe_layer
|
||||
)
|
||||
attn_kwargs = (
|
||||
{"index_group_builder": index_group_builder}
|
||||
if attn_cls is DeepseekV2MLAAttention
|
||||
else {}
|
||||
)
|
||||
self.self_attn = attn_cls(
|
||||
vllm_config=vllm_config,
|
||||
config=config,
|
||||
@@ -1306,6 +1319,7 @@ class DeepseekV2DecoderLayer(nn.Module):
|
||||
prefix=f"{prefix}.self_attn",
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
reduce_results=not self.use_sequence_parallel_moe,
|
||||
**attn_kwargs,
|
||||
)
|
||||
|
||||
if is_moe_layer:
|
||||
@@ -1429,6 +1443,14 @@ class DeepseekV2Model(nn.Module):
|
||||
)
|
||||
else:
|
||||
topk_indices_buffer = None
|
||||
index_group_builder = (
|
||||
SparseMLAIndexGroupBuilder(
|
||||
topk_indices_buffer,
|
||||
get_sparse_mla_index_group_max_rows(vllm_config),
|
||||
)
|
||||
if topk_indices_buffer is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if get_pp_group().is_first_rank:
|
||||
self.embed_tokens = VocabParallelEmbedding(
|
||||
@@ -1445,6 +1467,7 @@ class DeepseekV2Model(nn.Module):
|
||||
vllm_config=vllm_config,
|
||||
prefix=prefix,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
),
|
||||
prefix=f"{prefix}.layers",
|
||||
)
|
||||
|
||||
@@ -67,6 +67,10 @@ from vllm.model_executor.models.glm4_moe import (
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
get_sparse_mla_index_group_max_rows,
|
||||
)
|
||||
|
||||
from .interfaces import SupportsLoRA, SupportsPP
|
||||
from .utils import (
|
||||
@@ -108,6 +112,7 @@ class Glm4MoeLiteDecoderLayer(nn.Module):
|
||||
prefix: str,
|
||||
config: "Glm4MoeLiteConfig | None" = None,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: SparseMLAIndexGroupBuilder | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -136,6 +141,11 @@ class Glm4MoeLiteDecoderLayer(nn.Module):
|
||||
attn_cls = Glm4MoeLiteMLAAttention
|
||||
else:
|
||||
attn_cls = Glm4MoeLiteAttention
|
||||
attn_kwargs: dict[str, typing.Any] = (
|
||||
{"index_group_builder": index_group_builder}
|
||||
if attn_cls is Glm4MoeLiteMLAAttention
|
||||
else {}
|
||||
)
|
||||
|
||||
self.self_attn = attn_cls(
|
||||
vllm_config=vllm_config,
|
||||
@@ -152,6 +162,7 @@ class Glm4MoeLiteDecoderLayer(nn.Module):
|
||||
quant_config=quant_config,
|
||||
prefix=f"{prefix}.self_attn",
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
**attn_kwargs,
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -234,6 +245,14 @@ class Glm4MoeLiteModel(nn.Module):
|
||||
)
|
||||
else:
|
||||
topk_indices_buffer = None
|
||||
index_group_builder = (
|
||||
SparseMLAIndexGroupBuilder(
|
||||
topk_indices_buffer,
|
||||
get_sparse_mla_index_group_max_rows(vllm_config),
|
||||
)
|
||||
if topk_indices_buffer is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if get_pp_group().is_first_rank:
|
||||
self.embed_tokens = VocabParallelEmbedding(
|
||||
@@ -252,6 +271,7 @@ class Glm4MoeLiteModel(nn.Module):
|
||||
config=config,
|
||||
prefix=prefix,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
),
|
||||
prefix=f"{prefix}.layers",
|
||||
)
|
||||
|
||||
@@ -51,6 +51,10 @@ from vllm.model_executor.model_loader.weight_utils import (
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
get_sparse_mla_index_group_max_rows,
|
||||
)
|
||||
|
||||
from .glm4_moe_lite import (
|
||||
Glm4MixtureOfExperts,
|
||||
@@ -108,6 +112,14 @@ class Glm4MoeLiteMultiTokenPredictorLayer(nn.Module):
|
||||
)
|
||||
else:
|
||||
topk_indices_buffer = None
|
||||
index_group_builder = (
|
||||
SparseMLAIndexGroupBuilder(
|
||||
topk_indices_buffer,
|
||||
get_sparse_mla_index_group_max_rows(vllm_config),
|
||||
)
|
||||
if topk_indices_buffer is not None
|
||||
else None
|
||||
)
|
||||
|
||||
self.shared_head = SharedHead(
|
||||
config=config, prefix=prefix, quant_config=quant_config
|
||||
@@ -117,6 +129,7 @@ class Glm4MoeLiteMultiTokenPredictorLayer(nn.Module):
|
||||
prefix=prefix,
|
||||
config=self.config,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
)
|
||||
|
||||
def forward(
|
||||
|
||||
@@ -47,6 +47,35 @@ _FLASHINFER_SM120_SPARSE_MLA_DECODE_LABELS = {
|
||||
_SPARSE_MLA_MIXED_WARMUP_TOKENS = 16
|
||||
|
||||
|
||||
def autotune_hisparse_flashinfer_attention(runner: "GPUModelRunner") -> None:
|
||||
"""Autotune each HiSparse FlashInfer sparse-MLA configuration."""
|
||||
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
|
||||
FlashInferMLASparseImpl,
|
||||
)
|
||||
|
||||
tuned: set[tuple[object, ...]] = set()
|
||||
for layer in runner.vllm_config.compilation_config.static_forward_context.values():
|
||||
impl = getattr(layer, "impl", None)
|
||||
if not isinstance(impl, FlashInferMLASparseImpl):
|
||||
continue
|
||||
if getattr(layer, "hisparse_cache", None) is None:
|
||||
continue
|
||||
if impl.topk_indices_buffer is None:
|
||||
continue
|
||||
key = (
|
||||
impl.kv_cache_dtype,
|
||||
impl.num_heads,
|
||||
impl.qk_nope_head_dim,
|
||||
impl.qk_rope_head_dim,
|
||||
impl.kv_lora_rank,
|
||||
impl.topk_indices_buffer.shape[1],
|
||||
)
|
||||
if key in tuned:
|
||||
continue
|
||||
impl.autotune_hisparse_decode(layer)
|
||||
tuned.add(key)
|
||||
|
||||
|
||||
def _attention_backend_name(backend: object) -> str | None:
|
||||
get_name = getattr(backend, "get_name", None)
|
||||
if get_name is None:
|
||||
|
||||
@@ -25,6 +25,7 @@ from vllm.model_executor.warmup.flashinfer_autotune_cache import (
|
||||
write_flashinfer_autotune_cache,
|
||||
)
|
||||
from vllm.model_executor.warmup.flashinfer_sparse_mla_warmup import (
|
||||
autotune_hisparse_flashinfer_attention,
|
||||
deepseek_v4_sparse_mla_attention_warmup,
|
||||
flashinfer_sparse_mla_decode_autotune_warmup,
|
||||
)
|
||||
@@ -353,9 +354,12 @@ def _flashinfer_autotune_token_counts(runner: "GPUModelRunner") -> tuple[int, ..
|
||||
return tuple(dict.fromkeys(token_counts))
|
||||
|
||||
|
||||
def _run_flashinfer_autotune_dummy_runs(runner: "GPUModelRunner") -> None:
|
||||
def _run_flashinfer_autotune_dummy_runs(
|
||||
runner: "GPUModelRunner", *, skip_attn: bool = False
|
||||
) -> None:
|
||||
import vllm.utils.flashinfer as fi_utils
|
||||
|
||||
dummy_run_kwargs = {"skip_attn": True} if skip_attn else {}
|
||||
for num_tokens in _flashinfer_autotune_token_counts(runner):
|
||||
tuning_buckets = fi_utils.flashinfer_get_hybrid_num_tokens_buckets(num_tokens)
|
||||
logger.info(
|
||||
@@ -369,6 +373,7 @@ def _run_flashinfer_autotune_dummy_runs(runner: "GPUModelRunner") -> None:
|
||||
skip_eplb=True,
|
||||
is_profile=True,
|
||||
randomize_inputs=True,
|
||||
**dummy_run_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -431,7 +436,14 @@ def flashinfer_autotune(runner: "GPUModelRunner") -> None:
|
||||
torch.inference_mode(),
|
||||
fi_utils.autotune(tune_mode=True, **autotune_kwargs),
|
||||
):
|
||||
_run_flashinfer_autotune_dummy_runs(runner)
|
||||
hisparse_enabled = (
|
||||
runner.vllm_config.attention_config.hisparse_config is not None
|
||||
)
|
||||
if hisparse_enabled:
|
||||
# HiSparse hot-buffer attention is bounded by decode batch
|
||||
# size, not the prefill-sized batch used for the full model.
|
||||
autotune_hisparse_flashinfer_attention(runner)
|
||||
_run_flashinfer_autotune_dummy_runs(runner, skip_attn=hisparse_enabled)
|
||||
replayssm_autotune_warmup(runner)
|
||||
_autotune_kimi_k3_kda_qkvg(runner.get_model())
|
||||
finally:
|
||||
|
||||
@@ -42,6 +42,9 @@ from vllm.v1.attention.ops.pcp import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.model_executor.layers.attention.mla_attention import MLACommonMetadata
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
)
|
||||
|
||||
|
||||
class DeepseekV32Indexer(nn.Module):
|
||||
@@ -126,6 +129,7 @@ class DeepseekV32Attention(MLAAttention):
|
||||
prefix: str,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
attn_backend: "type | None" = None,
|
||||
index_group_builder: "SparseMLAIndexGroupBuilder | None" = None,
|
||||
) -> None:
|
||||
quant_config = vllm_config.quant_config
|
||||
cache_config = vllm_config.cache_config
|
||||
@@ -209,6 +213,7 @@ class DeepseekV32Attention(MLAAttention):
|
||||
use_sparse=True,
|
||||
indexer=indexer,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
attn_backend=attn_backend,
|
||||
)
|
||||
|
||||
@@ -304,6 +309,14 @@ class DeepseekV32Attention(MLAAttention):
|
||||
slot_mapping = forward_context.slot_mapping
|
||||
assert isinstance(slot_mapping, dict)
|
||||
mla_slot = slot_mapping.get(self.layer_name)
|
||||
indexer_slot = (
|
||||
slot_mapping.get(self.indexer.k_cache.prefix)
|
||||
if self.indexer is not None
|
||||
else None
|
||||
)
|
||||
hisparse_cache = self.hisparse_cache
|
||||
layer_attn_metadata, _, _, _ = get_attention_context(self.layer_name)
|
||||
self.impl.prepare_for_batch(layer_attn_metadata)
|
||||
|
||||
if self.indexer is not None and not self.skip_topk:
|
||||
has_indexer = True
|
||||
@@ -331,12 +344,17 @@ class DeepseekV32Attention(MLAAttention):
|
||||
mla_k_scale = None
|
||||
indexer_k_cache = None
|
||||
mla_slot = None
|
||||
indexer_slot = None
|
||||
else:
|
||||
mla_kv_cache = self.kv_cache
|
||||
mla_kv_cache = None if hisparse_cache is not None else self.kv_cache
|
||||
mla_k_scale = self._k_scale
|
||||
|
||||
kv_c_out = torch.empty_like(kv_c)
|
||||
k_pe_out = torch.empty_like(k_pe)
|
||||
if self.use_pcp or hisparse_cache is None:
|
||||
kv_c_out = torch.empty_like(kv_c)
|
||||
k_pe_out = torch.empty_like(k_pe)
|
||||
else:
|
||||
kv_c_out = torch.empty_like(kv_c)
|
||||
k_pe_out = torch.empty_like(k_pe)
|
||||
q_c = fused_norm_rope(
|
||||
positions,
|
||||
q_c,
|
||||
@@ -354,6 +372,7 @@ class DeepseekV32Attention(MLAAttention):
|
||||
indexer_k_rope_cos_sin_cache,
|
||||
self.topk_indices_buffer,
|
||||
slot_mapping=mla_slot,
|
||||
indexer_slot_mapping=indexer_slot,
|
||||
indexer_k_cache=indexer_k_cache,
|
||||
mla_kv_cache=mla_kv_cache,
|
||||
mla_kv_cache_dtype=self.kv_cache_dtype,
|
||||
@@ -365,6 +384,18 @@ class DeepseekV32Attention(MLAAttention):
|
||||
index_k_out=index_k_out,
|
||||
)
|
||||
|
||||
if hisparse_cache is not None and mla_slot is not None:
|
||||
assert kv_c_out is not None and k_pe_out is not None
|
||||
self.update_kv_cache(
|
||||
kv_c_out,
|
||||
k_pe_out,
|
||||
self.kv_cache,
|
||||
mla_slot,
|
||||
layer_attn_metadata,
|
||||
self.kv_cache_dtype,
|
||||
self._k_scale,
|
||||
)
|
||||
|
||||
q = self.q_b_proj(q_c)[0].view(-1, self.num_local_heads, self.qk_head_dim)
|
||||
q_nope, q_pe = q.split([self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
|
||||
ql_nope = torch.bmm(q_nope.transpose(0, 1), self.W_UK_T).transpose(0, 1)
|
||||
@@ -459,6 +490,7 @@ class DeepseekV32Attention(MLAAttention):
|
||||
),
|
||||
skip_topk_buffer_clear=True,
|
||||
)
|
||||
self.impl.record_logical_topk_ready() # type: ignore[attr-defined]
|
||||
|
||||
attn_metadata, _, kv_cache, layer_slot_mapping = get_attention_context(
|
||||
self.layer_name
|
||||
@@ -467,6 +499,7 @@ class DeepseekV32Attention(MLAAttention):
|
||||
output.zero_()
|
||||
return
|
||||
attn_metadata = cast("MLACommonMetadata", attn_metadata)
|
||||
self.impl.prepare_for_batch(attn_metadata)
|
||||
|
||||
if self.use_pcp:
|
||||
assert kv_c is not None and k_pe is not None
|
||||
|
||||
@@ -68,7 +68,7 @@ def _fp8_quant_and_cache_write(
|
||||
kv_cache_ptr,
|
||||
kv_cache_scale_ptr,
|
||||
cache_block_size,
|
||||
cache_stride,
|
||||
cache_block_stride,
|
||||
offsets,
|
||||
HEAD_DIM: tl.constexpr,
|
||||
):
|
||||
@@ -76,7 +76,7 @@ def _fp8_quant_and_cache_write(
|
||||
|
||||
block_idx = slot_idx // cache_block_size
|
||||
block_offset = slot_idx % cache_block_size
|
||||
block_start = block_idx * cache_block_size * cache_stride
|
||||
block_start = block_idx * cache_block_stride
|
||||
|
||||
tl.store(
|
||||
kv_cache_ptr + block_start + block_offset * HEAD_DIM + offsets,
|
||||
@@ -129,8 +129,9 @@ def _fused_norm_rope_kernel(
|
||||
index_k_out_ptr,
|
||||
index_k_out_stride,
|
||||
INDEX_K_HALF_ROT_DIM: tl.constexpr,
|
||||
# Cache params (shared by indexer K and MLA)
|
||||
# Cache params
|
||||
slot_mapping_ptr,
|
||||
indexer_slot_mapping_ptr,
|
||||
# Index K FP8 cache
|
||||
indexer_cache_ptr,
|
||||
indexer_cache_scale_ptr,
|
||||
@@ -400,8 +401,8 @@ def _fused_norm_rope_kernel(
|
||||
)
|
||||
|
||||
# PCP inserts index K after gathering; other paths write it directly.
|
||||
if indexer_cache_ptr is not None and slot_mapping_ptr is not None:
|
||||
slot_idx = tl.load(slot_mapping_ptr + tok_idx)
|
||||
if indexer_cache_ptr is not None and indexer_slot_mapping_ptr is not None:
|
||||
slot_idx = tl.load(indexer_slot_mapping_ptr + tok_idx)
|
||||
_fp8_quant_and_cache_write(
|
||||
result,
|
||||
index_k_mask,
|
||||
@@ -431,8 +432,9 @@ def fused_norm_rope(
|
||||
index_k_layer_norm_eps: float,
|
||||
index_k_rope_cos_sin_cache: torch.Tensor | None,
|
||||
topk_indices_buffer: torch.Tensor,
|
||||
# Cache params for fused writes (single slot_mapping for both caches)
|
||||
# Cache params for fused writes
|
||||
slot_mapping: torch.Tensor | None = None,
|
||||
indexer_slot_mapping: torch.Tensor | None = None,
|
||||
indexer_k_cache: torch.Tensor | None = None,
|
||||
mla_kv_cache: torch.Tensor | None = None,
|
||||
mla_kv_cache_dtype: str = "auto",
|
||||
@@ -476,9 +478,11 @@ def fused_norm_rope(
|
||||
# --- Indexer K cache setup ---
|
||||
if indexer_k_cache is not None:
|
||||
assert slot_mapping is not None
|
||||
if indexer_slot_mapping is None:
|
||||
indexer_slot_mapping = slot_mapping
|
||||
idx_cache_scale_view = indexer_k_cache.view(torch.uint8).view(torch.float32)
|
||||
idx_cache_block_size = indexer_k_cache.shape[1]
|
||||
idx_cache_stride = indexer_k_cache.shape[2]
|
||||
idx_cache_stride = indexer_k_cache.stride(0)
|
||||
if indexer_k_cache.dtype == torch.uint8:
|
||||
indexer_k_cache = indexer_k_cache.view(torch.float8_e4m3fn)
|
||||
else:
|
||||
@@ -578,6 +582,7 @@ def fused_norm_rope(
|
||||
index_k_rope_cos_sin_cache.shape[-1] // 2,
|
||||
# Cache params
|
||||
slot_mapping,
|
||||
indexer_slot_mapping,
|
||||
indexer_k_cache,
|
||||
idx_cache_scale_view,
|
||||
idx_cache_block_size,
|
||||
|
||||
@@ -46,6 +46,10 @@ from vllm.models.common.ops.sequence_parallel import (
|
||||
)
|
||||
from vllm.models.deepseek_v32.attention import DeepseekV32Attention
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
get_sparse_mla_index_group_max_rows,
|
||||
)
|
||||
|
||||
from .glm52_low_latency_gemm import enable_glm52_low_latency_gemm
|
||||
|
||||
@@ -57,6 +61,7 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
|
||||
prefix: str,
|
||||
config=None,
|
||||
topk_indices_buffer: torch.Tensor | None = None,
|
||||
index_group_builder: SparseMLAIndexGroupBuilder | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
@@ -80,6 +85,7 @@ class DeepseekV32DecoderLayer(torch.nn.Module):
|
||||
config=config,
|
||||
prefix=f"{prefix}.self_attn",
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -189,6 +195,10 @@ class DeepseekV32Model(torch.nn.Module):
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
index_group_builder = SparseMLAIndexGroupBuilder(
|
||||
self.topk_indices_buffer,
|
||||
get_sparse_mla_index_group_max_rows(vllm_config),
|
||||
)
|
||||
|
||||
if get_pp_group().is_first_rank:
|
||||
self.embed_tokens = make_input_embedding(
|
||||
@@ -209,6 +219,7 @@ class DeepseekV32Model(torch.nn.Module):
|
||||
vllm_config=vllm_config,
|
||||
prefix=prefix,
|
||||
topk_indices_buffer=self.topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
),
|
||||
prefix=f"{prefix}.layers",
|
||||
)
|
||||
|
||||
@@ -48,6 +48,10 @@ from vllm.models.common.ops.sequence_parallel import (
|
||||
from vllm.models.deepseek_v32.common.kernels import fused_eh_norm
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.attention.backends.mla.index_group import (
|
||||
SparseMLAIndexGroupBuilder,
|
||||
get_sparse_mla_index_group_max_rows,
|
||||
)
|
||||
|
||||
from .glm52_low_latency_gemm import (
|
||||
build_glm52_plan,
|
||||
@@ -80,6 +84,10 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
|
||||
dtype=torch.int32,
|
||||
device=current_platform.device_type,
|
||||
)
|
||||
index_group_builder = SparseMLAIndexGroupBuilder(
|
||||
topk_indices_buffer,
|
||||
get_sparse_mla_index_group_max_rows(vllm_config),
|
||||
)
|
||||
self.shared_head = SharedHead(
|
||||
config=config, prefix=prefix, quant_config=quant_config
|
||||
)
|
||||
@@ -88,6 +96,7 @@ class DeepseekV32MultiTokenPredictorLayer(nn.Module):
|
||||
prefix,
|
||||
config=config,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
index_group_builder=index_group_builder,
|
||||
)
|
||||
|
||||
def forward(
|
||||
|
||||
@@ -55,6 +55,7 @@ from vllm.v1.kv_cache_interface import (
|
||||
AttentionSpec,
|
||||
KVCacheSpec,
|
||||
MLAAttentionSpec,
|
||||
SparseCacheRole,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
@@ -148,6 +149,7 @@ class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
|
||||
num_kv_heads=1,
|
||||
head_size=self.head_dim,
|
||||
dtype=self.dtype,
|
||||
cache_role=SparseCacheRole.INDEXER,
|
||||
)
|
||||
|
||||
def forward(self) -> None: ...
|
||||
|
||||
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
|
||||
from vllm.model_executor.layers.linear import ColumnParallelLinear
|
||||
from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
from vllm.v1.hisparse.runtime import HiSparseCacheHandle
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
AttentionSpec,
|
||||
KVCacheLayout,
|
||||
@@ -838,6 +839,9 @@ class AttentionImplBase(ABC, Generic[T]):
|
||||
def process_weights_after_loading(self, act_dtype: torch.dtype):
|
||||
pass
|
||||
|
||||
def prepare_for_batch(self, attn_metadata: T | None) -> None:
|
||||
"""Prepare implementation-specific state for the current batch."""
|
||||
|
||||
|
||||
class AttentionImpl(AttentionImplBase[T], Generic[T]):
|
||||
"""Standard attention implementation with forward method."""
|
||||
@@ -956,6 +960,7 @@ class AttentionImpl(AttentionImplBase[T], Generic[T]):
|
||||
class MLAAttentionImpl(AttentionImplBase[T], Generic[T]):
|
||||
"""MLA attention implementation with forward_mqa and forward_mha methods."""
|
||||
|
||||
hisparse_cache: "HiSparseCacheHandle | None" = None
|
||||
supports_pcp: bool = True
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -8,9 +8,9 @@ import torch
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config.cache import CacheDType
|
||||
from vllm.model_executor.layers.attention.mla_attention import MLACommonPrefillMetadata
|
||||
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
||||
SparseMLACommonImpl,
|
||||
SparseMLACommonMetadata,
|
||||
SparseMLACommonMetadataBuilder,
|
||||
)
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
@@ -18,11 +18,11 @@ from vllm.v1.attention.backend import (
|
||||
AttentionBackend,
|
||||
AttentionCGSupport,
|
||||
AttentionLayer,
|
||||
AttentionMetadata,
|
||||
MLAAttentionImpl,
|
||||
MultipleOf,
|
||||
)
|
||||
from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla
|
||||
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
|
||||
from vllm.v1.attention.backends.mla.sparse_utils import (
|
||||
flat_kv_row_view,
|
||||
triton_convert_req_index_to_global_index,
|
||||
@@ -106,26 +106,8 @@ class FlashAttnMLASparseBackend(AttentionBackend):
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlashAttnMLASparseMetadata(AttentionMetadata):
|
||||
num_reqs: int
|
||||
max_query_len: int
|
||||
max_seq_len: int
|
||||
|
||||
num_actual_tokens: int
|
||||
query_start_loc: torch.Tensor
|
||||
slot_mapping: torch.Tensor
|
||||
|
||||
block_table: torch.Tensor
|
||||
req_id_per_token: torch.Tensor
|
||||
seq_lens: torch.Tensor
|
||||
block_size: int = 64
|
||||
topk_tokens: int = 2048
|
||||
num_decodes: int = 0
|
||||
num_prefills: int = 0
|
||||
num_decode_tokens: int = 0
|
||||
prefill_max_seq_len: int = 0
|
||||
prefill: MLACommonPrefillMetadata | None = None
|
||||
cp_kv_cache_interleave_size: int = 1
|
||||
class FlashAttnMLASparseMetadata(SparseMLACommonMetadata):
|
||||
pass
|
||||
|
||||
|
||||
class FlashAttnMLASparseMetadataBuilder(
|
||||
@@ -214,6 +196,76 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
|
||||
|
||||
assert self.topk_indices_buffer is not None
|
||||
topk_indices = self.topk_indices_buffer[:num_actual_toks]
|
||||
index_group = self.index_group
|
||||
if isinstance(index_group, HiSparseMLAIndexGroup):
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens
|
||||
outputs = []
|
||||
if num_decode_tokens:
|
||||
physical_topk, valid_counts = (
|
||||
index_group.convert_decode_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices[:num_decode_tokens],
|
||||
attn_metadata,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
outputs.append(
|
||||
self._run_mqa_kernel(
|
||||
q_nope[:num_decode_tokens],
|
||||
q_rope[:num_decode_tokens],
|
||||
index_group.physical_kv_cache(self.index_group_index).view(
|
||||
kv_c_and_k_pe_cache.dtype
|
||||
),
|
||||
physical_topk,
|
||||
valid_counts,
|
||||
attn_metadata.block_size,
|
||||
)
|
||||
)
|
||||
if num_decode_tokens < num_actual_toks:
|
||||
cache = index_group.cache(self.index_group_index)
|
||||
if num_decode_tokens == 0 and cache.all_context_pages_resident:
|
||||
physical_topk, valid_counts = (
|
||||
index_group.convert_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
prefill_cache = index_group.physical_kv_cache(
|
||||
self.index_group_index
|
||||
).view(kv_c_and_k_pe_cache.dtype)
|
||||
else:
|
||||
prefill_cache, block_table, req_ids = (
|
||||
index_group.stage_prefill_rows(
|
||||
self.index_group_index,
|
||||
kv_c_and_k_pe_cache,
|
||||
attn_metadata,
|
||||
)
|
||||
)
|
||||
physical_topk, valid_counts = (
|
||||
triton_convert_req_index_to_global_index(
|
||||
req_ids,
|
||||
block_table,
|
||||
topk_indices[num_decode_tokens:],
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
outputs.append(
|
||||
self._run_mqa_kernel(
|
||||
q_nope[num_decode_tokens:],
|
||||
q_rope[num_decode_tokens:],
|
||||
prefill_cache,
|
||||
physical_topk,
|
||||
valid_counts,
|
||||
attn_metadata.block_size,
|
||||
)
|
||||
)
|
||||
return torch.cat(outputs) if len(outputs) > 1 else outputs[0], None
|
||||
|
||||
kv_rows, block_stride_rows = flat_kv_row_view(
|
||||
kv_c_and_k_pe_cache, attn_metadata.block_size
|
||||
)
|
||||
@@ -226,9 +278,36 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
return_valid_counts=True,
|
||||
)
|
||||
return (
|
||||
self._run_mqa_kernel(
|
||||
q_nope,
|
||||
q_rope,
|
||||
kv_rows,
|
||||
topk_indices,
|
||||
valid_counts,
|
||||
attn_metadata.block_size,
|
||||
cache_is_flat=True,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _run_mqa_kernel(
|
||||
self,
|
||||
q_nope: torch.Tensor,
|
||||
q_rope: torch.Tensor,
|
||||
kv_cache: torch.Tensor,
|
||||
topk_indices: torch.Tensor,
|
||||
valid_counts: torch.Tensor,
|
||||
block_size: int,
|
||||
*,
|
||||
cache_is_flat: bool = False,
|
||||
) -> torch.Tensor:
|
||||
kv_rows = (
|
||||
kv_cache if cache_is_flat else flat_kv_row_view(kv_cache, block_size)[0]
|
||||
)
|
||||
|
||||
cu_seqlens_q = torch.arange(
|
||||
0, num_actual_toks + 1, dtype=torch.int32, device=q_rope.device
|
||||
0, q_rope.shape[0] + 1, dtype=torch.int32, device=q_rope.device
|
||||
)
|
||||
k_cache = kv_rows[:, self.kv_lora_rank :].unsqueeze(1).unsqueeze(1)
|
||||
v_cache = kv_rows[:, : self.kv_lora_rank].unsqueeze(1).unsqueeze(1)
|
||||
@@ -247,4 +326,4 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
|
||||
causal=True,
|
||||
fa_version=3,
|
||||
)
|
||||
return out, None
|
||||
return out
|
||||
|
||||
@@ -11,22 +11,23 @@ from vllm import envs
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config.cache import CacheDType
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.attention.mla_attention import MLACommonPrefillMetadata
|
||||
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
||||
SparseMLACommonImpl,
|
||||
SparseMLACommonMetadata,
|
||||
SparseMLACommonMetadataBuilder,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
from vllm.utils.torch_utils import is_quantized_kv_cache
|
||||
from vllm.v1.attention.backend import (
|
||||
AttentionBackend,
|
||||
AttentionCGSupport,
|
||||
AttentionLayer,
|
||||
AttentionMetadata,
|
||||
AttentionType,
|
||||
MLAAttentionImpl,
|
||||
MultipleOf,
|
||||
)
|
||||
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
|
||||
from vllm.v1.attention.backends.mla.sparse_utils import (
|
||||
flat_kv_row_view,
|
||||
triton_convert_req_index_to_global_index,
|
||||
@@ -224,33 +225,9 @@ class FlashInferMLASparseSM120Backend(_FlashInferMLASparseBackendBase):
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlashInferMLASparseMetadata(AttentionMetadata):
|
||||
class FlashInferMLASparseMetadata(SparseMLACommonMetadata):
|
||||
"""Attention metadata for FlashInfer MLA Sparse backend."""
|
||||
|
||||
num_reqs: int
|
||||
max_query_len: int
|
||||
max_seq_len: int
|
||||
num_actual_tokens: int
|
||||
|
||||
# Query start locations
|
||||
query_start_loc: torch.Tensor
|
||||
slot_mapping: torch.Tensor
|
||||
block_table: torch.Tensor
|
||||
req_id_per_token: torch.Tensor
|
||||
|
||||
# Sequence lengths for all requests (context + query)
|
||||
seq_lens: torch.Tensor
|
||||
num_decodes: int
|
||||
num_prefills: int
|
||||
num_decode_tokens: int
|
||||
prefill_max_seq_len: int = 0
|
||||
prefill: MLACommonPrefillMetadata | None = None
|
||||
|
||||
# Sparse-specific
|
||||
block_size: int = 64
|
||||
topk_tokens: int = 2048
|
||||
cp_kv_cache_interleave_size: int = 1
|
||||
|
||||
|
||||
class FlashInferMLASparseMetadataBuilder(
|
||||
SparseMLACommonMetadataBuilder[FlashInferMLASparseMetadata]
|
||||
@@ -286,6 +263,7 @@ class FlashInferMLASparseTRTLLMMetadataBuilder(FlashInferMLASparseMetadataBuilde
|
||||
"""Metadata builder for the SM100 TRT-LLM sparse MLA kernel."""
|
||||
|
||||
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
|
||||
hisparse_supports_multi_token_decode: ClassVar[bool] = True
|
||||
|
||||
def _build_req_id_per_token(
|
||||
self,
|
||||
@@ -402,6 +380,78 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
assert self.topk_indices_buffer is not None
|
||||
topk_indices = self.topk_indices_buffer[:num_actual_toks]
|
||||
|
||||
self._prepare_mqa_kernel(layer, q.device)
|
||||
|
||||
index_group = self.index_group
|
||||
if isinstance(index_group, HiSparseMLAIndexGroup):
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens
|
||||
decode_out: torch.Tensor | None = None
|
||||
decode_lse: torch.Tensor | None = None
|
||||
if num_decode_tokens > 0:
|
||||
physical_topk, valid_counts = (
|
||||
index_group.convert_decode_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices[:num_decode_tokens],
|
||||
attn_metadata,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
decode_out, decode_lse = self._run_mqa_kernel(
|
||||
q[:num_decode_tokens],
|
||||
index_group.physical_kv_cache(self.index_group_index).view(
|
||||
kv_c_and_k_pe_cache.dtype
|
||||
),
|
||||
physical_topk,
|
||||
valid_counts,
|
||||
)
|
||||
if num_decode_tokens == num_actual_toks:
|
||||
return decode_out, decode_lse
|
||||
|
||||
cache = index_group.cache(self.index_group_index)
|
||||
if num_decode_tokens == 0 and cache.all_context_pages_resident:
|
||||
physical_topk, valid_counts = (
|
||||
index_group.convert_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
return self._run_mqa_kernel(
|
||||
q,
|
||||
index_group.physical_kv_cache(self.index_group_index).view(
|
||||
kv_c_and_k_pe_cache.dtype
|
||||
),
|
||||
physical_topk,
|
||||
valid_counts,
|
||||
)
|
||||
|
||||
prefill_cache, block_table, req_ids = index_group.stage_prefill_rows(
|
||||
self.index_group_index, kv_c_and_k_pe_cache, attn_metadata
|
||||
)
|
||||
prefill_indices, prefill_lens = triton_convert_req_index_to_global_index(
|
||||
req_ids,
|
||||
block_table,
|
||||
topk_indices[num_decode_tokens:],
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
return_valid_counts=True,
|
||||
)
|
||||
prefill_out, prefill_lse = self._run_mqa_kernel(
|
||||
q[num_decode_tokens:],
|
||||
prefill_cache,
|
||||
prefill_indices,
|
||||
prefill_lens,
|
||||
)
|
||||
if decode_out is None:
|
||||
return prefill_out, prefill_lse
|
||||
output = torch.cat((decode_out, prefill_out))
|
||||
if decode_lse is None:
|
||||
return output, None
|
||||
assert prefill_lse is not None
|
||||
return output, torch.cat((decode_lse, prefill_lse))
|
||||
|
||||
_, block_stride_rows = flat_kv_row_view(
|
||||
kv_c_and_k_pe_cache, attn_metadata.block_size
|
||||
)
|
||||
@@ -420,18 +470,27 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
return_valid_counts=True,
|
||||
)
|
||||
else:
|
||||
topk_indices_physical, seq_lens = triton_convert_req_index_to_global_index(
|
||||
attn_metadata.req_id_per_token[:num_actual_toks],
|
||||
attn_metadata.block_table,
|
||||
topk_indices_physical, seq_lens = self._convert_logical_to_physical_topk(
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
BLOCK_STRIDE_ROWS=block_stride_rows,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
attn_metadata,
|
||||
block_stride_rows=block_stride_rows,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
|
||||
return self._run_mqa_kernel(
|
||||
q,
|
||||
kv_c_and_k_pe_cache,
|
||||
topk_indices_physical,
|
||||
seq_lens,
|
||||
)
|
||||
|
||||
def _prepare_mqa_kernel(
|
||||
self,
|
||||
layer: AttentionLayer,
|
||||
device: torch.device,
|
||||
) -> None:
|
||||
if self._workspace_buffer is None:
|
||||
self._workspace_buffer = _get_workspace_buffer(q.device)
|
||||
self._workspace_buffer = _get_workspace_buffer(device)
|
||||
|
||||
if self.bmm1_scale is None:
|
||||
self.bmm1_scale = self.scale
|
||||
@@ -442,15 +501,71 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
if is_quantized_kv_cache(self.kv_cache_dtype):
|
||||
self.bmm2_scale *= layer._k_scale_float
|
||||
|
||||
def autotune_hisparse_decode(self, layer: AttentionLayer) -> None:
|
||||
"""Autotune the largest legal HiSparse decode batch."""
|
||||
assert isinstance(self.index_group, HiSparseMLAIndexGroup)
|
||||
cache = self.index_group.cache(self.index_group_index)
|
||||
assert self.topk_indices_buffer is not None
|
||||
|
||||
runtime = cache.runtime
|
||||
kv_cache = runtime.hot.attention_cache
|
||||
num_tokens = runtime.max_num_reqs
|
||||
topk_tokens = self.topk_indices_buffer.shape[1]
|
||||
self._prepare_mqa_kernel(layer, kv_cache.device)
|
||||
|
||||
q_dtype = (
|
||||
current_platform.fp8_dtype()
|
||||
if is_quantized_kv_cache(self.kv_cache_dtype)
|
||||
else kv_cache.dtype
|
||||
)
|
||||
q = torch.zeros(
|
||||
(
|
||||
num_tokens,
|
||||
self.num_heads,
|
||||
self.kv_lora_rank + self.qk_rope_head_dim,
|
||||
),
|
||||
dtype=q_dtype,
|
||||
device=kv_cache.device,
|
||||
)
|
||||
topk_indices = (
|
||||
torch.arange(
|
||||
topk_tokens,
|
||||
dtype=torch.int32,
|
||||
device=kv_cache.device,
|
||||
)
|
||||
.expand(num_tokens, -1)
|
||||
.contiguous()
|
||||
)
|
||||
seq_lens = torch.full(
|
||||
(num_tokens,),
|
||||
topk_tokens,
|
||||
dtype=torch.int32,
|
||||
device=kv_cache.device,
|
||||
)
|
||||
self._run_mqa_kernel(q, kv_cache, topk_indices, seq_lens)
|
||||
|
||||
def _run_mqa_kernel(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
kv_cache: torch.Tensor,
|
||||
topk_indices: torch.Tensor,
|
||||
seq_lens: torch.Tensor,
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||||
assert self._workspace_buffer is not None
|
||||
assert self.bmm1_scale is not None
|
||||
assert self.bmm2_scale is not None
|
||||
|
||||
from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
|
||||
|
||||
kv_cache = kv_cache.view(q.dtype)
|
||||
|
||||
# Single-token sparse decode. trtllm-gen requires the q_len_per_request
|
||||
# dim, but the sparse attention mask is fully per-token (each query token
|
||||
# carries its own top-k index row), so unsqueeze is sufficient and
|
||||
# correct. The MTP/multi-token q_len grouping is a perf-only layout and is
|
||||
# deferred until MTP is validated end-to-end for this backend.
|
||||
query = q.unsqueeze(1)
|
||||
block_tables = topk_indices_physical.unsqueeze(1)
|
||||
block_tables = topk_indices.unsqueeze(1)
|
||||
seq_lens_arg = seq_lens
|
||||
|
||||
# page_table width = topk buffer width, which kpool widens past
|
||||
@@ -460,7 +575,7 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
# count), so the -1 padding slots past seq_lens are never attended to.
|
||||
# Use the actual buffer width instead of the fixed topk_tokens, which
|
||||
# mismatches the page_table when index_kpool > 1.
|
||||
sparse_topk_capacity = topk_indices_physical.shape[1]
|
||||
sparse_topk_capacity = topk_indices.shape[1]
|
||||
|
||||
extra_kwargs: dict[str, torch.Tensor] = {}
|
||||
empty_rows: torch.Tensor | None = None
|
||||
@@ -473,14 +588,12 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
# valid indices into a contiguous prefix, which is what the kernel
|
||||
# requires of the page table.
|
||||
empty_rows = seq_lens == 0
|
||||
topk_indices_physical[:, 0] = topk_indices_physical[:, 0].masked_fill(
|
||||
empty_rows, 0
|
||||
)
|
||||
topk_indices[:, 0] = topk_indices[:, 0].masked_fill(empty_rows, 0)
|
||||
extra_kwargs["sparse_mla_top_k_lens"] = seq_lens.clamp(min=1)
|
||||
|
||||
kernel_out = trtllm_batch_decode_with_kv_cache_mla(
|
||||
query=query,
|
||||
kv_cache=kv_c_and_k_pe_cache.unsqueeze(1),
|
||||
kv_cache=kv_cache.unsqueeze(1),
|
||||
workspace_buffer=self._workspace_buffer,
|
||||
qk_nope_head_dim=self.qk_nope_head_dim,
|
||||
kv_lora_rank=self.kv_lora_rank,
|
||||
@@ -506,7 +619,7 @@ class FlashInferMLASparseImpl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
if lse is not None:
|
||||
lse = self._normalize_lse(lse, out.shape[0], out.shape[1])
|
||||
if empty_rows is None and lse is not None:
|
||||
empty_rows = (topk_indices_physical == -1).all(dim=-1)
|
||||
empty_rows = (topk_indices == -1).all(dim=-1)
|
||||
if empty_rows is not None:
|
||||
out.masked_fill_(empty_rows.view(-1, 1, 1), 0.0)
|
||||
if lse is not None:
|
||||
|
||||
@@ -7,17 +7,14 @@ from typing import TYPE_CHECKING, cast
|
||||
import torch
|
||||
|
||||
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
||||
SharedTopkIndicesBuffer,
|
||||
)
|
||||
from vllm.v1.attention.backend import (
|
||||
AttentionLayer,
|
||||
AttentionType,
|
||||
MLAAttentionImpl,
|
||||
SparseMLACommonImpl,
|
||||
)
|
||||
from vllm.v1.attention.backend import AttentionLayer, AttentionType
|
||||
from vllm.v1.attention.backends.mla.flashinfer_mla_sparse import (
|
||||
FlashInferMLASparseMetadata,
|
||||
_get_workspace_buffer,
|
||||
)
|
||||
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
|
||||
from vllm.v1.attention.backends.mla.sparse_utils import (
|
||||
triton_convert_req_index_to_global_index,
|
||||
)
|
||||
@@ -32,9 +29,7 @@ def _kv_scale_format_for_model(model_type: str | None) -> str:
|
||||
return "pow2_fp32"
|
||||
|
||||
|
||||
class FlashInferMLASparseSM120Impl(
|
||||
MLAAttentionImpl[FlashInferMLASparseMetadata], SharedTopkIndicesBuffer
|
||||
):
|
||||
class FlashInferMLASparseSM120Impl(SparseMLACommonImpl[FlashInferMLASparseMetadata]):
|
||||
"""SM120 FlashInfer sparse-MLA implementation."""
|
||||
|
||||
is_sparse = True
|
||||
@@ -65,20 +60,28 @@ class FlashInferMLASparseSM120Impl(
|
||||
"FLASHINFER_MLA_SPARSE_SM120 only supports decoder self-attention"
|
||||
)
|
||||
|
||||
self.num_heads = num_heads
|
||||
self.head_size = head_size
|
||||
self.scale = float(scale)
|
||||
self.num_kv_heads = num_kv_heads
|
||||
self.kv_cache_dtype = kv_cache_dtype
|
||||
if self.kv_cache_dtype != "fp8_ds_mla":
|
||||
if kv_cache_dtype != "fp8_ds_mla":
|
||||
raise NotImplementedError(
|
||||
"FLASHINFER_MLA_SPARSE_SM120 requires the packed fp8_ds_mla "
|
||||
f"KV cache layout; got kv_cache_dtype={kv_cache_dtype!r}."
|
||||
)
|
||||
|
||||
self.kv_lora_rank: int = mla_args["kv_lora_rank"]
|
||||
self.qk_nope_head_dim: int = mla_args["qk_nope_head_dim"]
|
||||
self.qk_rope_head_dim: int = mla_args["qk_rope_head_dim"]
|
||||
topk_indices_buffer = mla_args.pop("topk_indices_buffer", None)
|
||||
super().__init__(
|
||||
num_heads,
|
||||
head_size,
|
||||
scale,
|
||||
num_kv_heads,
|
||||
alibi_slopes,
|
||||
sliding_window,
|
||||
kv_cache_dtype,
|
||||
logits_soft_cap,
|
||||
attn_type,
|
||||
kv_sharing_target_layer_name,
|
||||
indexer=indexer,
|
||||
topk_indices_buffer=topk_indices_buffer,
|
||||
**mla_args,
|
||||
)
|
||||
from vllm.config import get_current_vllm_config
|
||||
|
||||
vllm_config = get_current_vllm_config()
|
||||
@@ -89,9 +92,6 @@ class FlashInferMLASparseSM120Impl(
|
||||
)
|
||||
self.kv_scale_format = _kv_scale_format_for_model(model_type)
|
||||
|
||||
# Skip-topk layers are built with indexer=None and get the shared
|
||||
# buffer via mla_args instead (cf. FLASHMLA_SPARSE).
|
||||
self.init_topk_indices_buffer(indexer, mla_args.get("topk_indices_buffer"))
|
||||
from vllm.utils.flashinfer import has_flashinfer_sparse_mla_sm120
|
||||
|
||||
if not has_flashinfer_sparse_mla_sm120():
|
||||
@@ -118,6 +118,70 @@ class FlashInferMLASparseSM120Impl(
|
||||
|
||||
assert self.topk_indices_buffer is not None
|
||||
topk_indices = self.topk_indices_buffer[:num_actual_toks]
|
||||
index_group = self.index_group
|
||||
if isinstance(index_group, HiSparseMLAIndexGroup):
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens
|
||||
outputs = []
|
||||
if num_decode_tokens:
|
||||
topk_indices_physical = cast(
|
||||
torch.Tensor,
|
||||
index_group.convert_decode_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices[:num_decode_tokens],
|
||||
attn_metadata,
|
||||
return_valid_counts=False,
|
||||
),
|
||||
)
|
||||
outputs.append(
|
||||
self._run_mqa_kernel(
|
||||
q[:num_decode_tokens],
|
||||
index_group.physical_kv_cache(self.index_group_index),
|
||||
topk_indices_physical,
|
||||
)
|
||||
)
|
||||
if num_decode_tokens < num_actual_toks:
|
||||
cache = index_group.cache(self.index_group_index)
|
||||
if num_decode_tokens == 0 and cache.all_context_pages_resident:
|
||||
topk_indices_physical = cast(
|
||||
torch.Tensor,
|
||||
index_group.convert_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=False,
|
||||
),
|
||||
)
|
||||
prefill_cache = index_group.physical_kv_cache(
|
||||
self.index_group_index
|
||||
)
|
||||
else:
|
||||
prefill_cache, block_table, req_ids = (
|
||||
index_group.stage_prefill_rows(
|
||||
self.index_group_index,
|
||||
kv_c_and_k_pe_cache,
|
||||
attn_metadata,
|
||||
)
|
||||
)
|
||||
topk_indices_physical = cast(
|
||||
torch.Tensor,
|
||||
triton_convert_req_index_to_global_index(
|
||||
req_ids,
|
||||
block_table,
|
||||
topk_indices[num_decode_tokens:],
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
),
|
||||
)
|
||||
outputs.append(
|
||||
self._run_mqa_kernel(
|
||||
q[num_decode_tokens:],
|
||||
prefill_cache,
|
||||
topk_indices_physical,
|
||||
)
|
||||
)
|
||||
output = torch.cat(outputs) if len(outputs) > 1 else outputs[0]
|
||||
return output, None
|
||||
|
||||
topk_indices_physical = cast(
|
||||
torch.Tensor,
|
||||
@@ -129,6 +193,22 @@ class FlashInferMLASparseSM120Impl(
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
),
|
||||
)
|
||||
return (
|
||||
self._run_mqa_kernel(
|
||||
q,
|
||||
kv_c_and_k_pe_cache,
|
||||
topk_indices_physical,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _run_mqa_kernel(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
kv_cache: torch.Tensor,
|
||||
topk_indices_physical: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
num_actual_toks = q.shape[0]
|
||||
|
||||
output = q.new_empty(
|
||||
(num_actual_toks, self.num_heads, self.kv_lora_rank),
|
||||
@@ -142,20 +222,21 @@ class FlashInferMLASparseSM120Impl(
|
||||
flashinfer_trtllm_batch_decode_with_kv_cache_mla,
|
||||
)
|
||||
|
||||
sparse_capacity = topk_indices_physical.shape[1]
|
||||
out = flashinfer_trtllm_batch_decode_with_kv_cache_mla(
|
||||
query=q.unsqueeze(1),
|
||||
kv_cache=kv_c_and_k_pe_cache.view(torch.uint8).unsqueeze(1),
|
||||
kv_cache=kv_cache.view(torch.uint8).unsqueeze(1),
|
||||
workspace_buffer=self._workspace_buffer,
|
||||
qk_nope_head_dim=self.qk_nope_head_dim,
|
||||
kv_lora_rank=self.kv_lora_rank,
|
||||
qk_rope_head_dim=self.qk_rope_head_dim,
|
||||
block_tables=topk_indices_physical.unsqueeze(1),
|
||||
seq_lens=None,
|
||||
max_seq_len=attn_metadata.topk_tokens,
|
||||
max_seq_len=sparse_capacity,
|
||||
out=output.unsqueeze(1),
|
||||
bmm1_scale=self.scale,
|
||||
bmm2_scale=1.0,
|
||||
sparse_mla_top_k=attn_metadata.topk_tokens,
|
||||
sparse_mla_top_k=sparse_capacity,
|
||||
kv_scale_format=self.kv_scale_format,
|
||||
)
|
||||
return out.squeeze(1), None
|
||||
return out.squeeze(1)
|
||||
|
||||
@@ -9,23 +9,23 @@ from vllm import _custom_ops as ops
|
||||
from vllm.config import VllmConfig, get_current_vllm_config
|
||||
from vllm.config.cache import CacheDType
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.attention.mla_attention import MLACommonPrefillMetadata
|
||||
from vllm.model_executor.layers.attention.sparse_mla_attention import (
|
||||
SparseMLACommonImpl,
|
||||
SparseMLACommonMetadata,
|
||||
SparseMLACommonMetadataBuilder,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
from vllm.utils.platform_utils import num_compute_units
|
||||
from vllm.utils.torch_utils import is_quantized_kv_cache
|
||||
from vllm.utils.torch_utils import current_stream, is_quantized_kv_cache
|
||||
from vllm.v1.attention.backend import (
|
||||
AttentionBackend,
|
||||
AttentionCGSupport,
|
||||
AttentionLayer,
|
||||
AttentionMetadata,
|
||||
CommonAttentionMetadata,
|
||||
MultipleOf,
|
||||
)
|
||||
from vllm.v1.attention.backends.mla.index_group import HiSparseMLAIndexGroup
|
||||
from vllm.v1.attention.backends.mla.sparse_utils import (
|
||||
flat_kv_row_view,
|
||||
triton_convert_req_index_to_global_index,
|
||||
@@ -172,28 +172,7 @@ class FlashMLASparseBackend(AttentionBackend):
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlashMLASparseMetadata(AttentionMetadata):
|
||||
num_reqs: int
|
||||
max_query_len: int
|
||||
max_seq_len: int
|
||||
|
||||
num_actual_tokens: int # Number of tokens excluding padding.
|
||||
query_start_loc: torch.Tensor
|
||||
slot_mapping: torch.Tensor
|
||||
|
||||
block_table: torch.Tensor
|
||||
req_id_per_token: torch.Tensor
|
||||
block_size: int = 64
|
||||
topk_tokens: int = 2048
|
||||
|
||||
num_decodes: int = 0
|
||||
num_prefills: int = 0
|
||||
num_decode_tokens: int = 0
|
||||
seq_lens: torch.Tensor | None = None
|
||||
prefill_max_seq_len: int = 0
|
||||
prefill: MLACommonPrefillMetadata | None = None
|
||||
cp_kv_cache_interleave_size: int = 1
|
||||
|
||||
class FlashMLASparseMetadata(SparseMLACommonMetadata):
|
||||
@dataclass
|
||||
class FP8KernelMetadata:
|
||||
scheduler_metadata: FlashMLASchedMeta
|
||||
@@ -233,6 +212,7 @@ class FlashMLASparseMetadata(AttentionMetadata):
|
||||
req_start_idx: int
|
||||
workspace_starts: torch.Tensor
|
||||
chunk_tot_seqlen: int
|
||||
seq_lens: torch.Tensor | None = None
|
||||
|
||||
chunks: list[Chunk]
|
||||
|
||||
@@ -263,6 +243,7 @@ class FlashMLASparseMetadataBuilder(
|
||||
):
|
||||
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
|
||||
require_uniform_decodes: ClassVar[bool] = True
|
||||
hisparse_supports_multi_token_decode: ClassVar[bool] = True
|
||||
metadata_cls = FlashMLASparseMetadata
|
||||
|
||||
def __init__(
|
||||
@@ -283,6 +264,9 @@ class FlashMLASparseMetadataBuilder(
|
||||
)
|
||||
else:
|
||||
threshold = {16: 128, 32: 128, 64: 256, 128: 256}.get(num_q_heads, 256)
|
||||
self.use_hisparse = vllm_config.attention_config.hisparse_config is not None
|
||||
if self.use_hisparse:
|
||||
threshold = 1
|
||||
# Varlen decodes are safe under DCP: causality comes from the
|
||||
# indexer's top-k indices, not from the kernel metadata.
|
||||
self._init_reorder_batch_threshold(
|
||||
@@ -347,7 +331,9 @@ class FlashMLASparseMetadataBuilder(
|
||||
device=device,
|
||||
)
|
||||
|
||||
self.fp8_use_mixed_batch = self.num_heads < MIN_HEADS_FOR_BF16_PREFILL
|
||||
self.fp8_use_mixed_batch = (
|
||||
self.num_heads < MIN_HEADS_FOR_BF16_PREFILL and not self.use_hisparse
|
||||
)
|
||||
|
||||
if parallel_config.decode_context_parallel_size > 1:
|
||||
if parallel_config.dcp_comm_backend != "ag_rs":
|
||||
@@ -385,37 +371,21 @@ class FlashMLASparseMetadataBuilder(
|
||||
|
||||
def _build_fp8_mixed_decode_prefill(
|
||||
self,
|
||||
common_attn_metadata: CommonAttentionMetadata,
|
||||
) -> "FlashMLASparseMetadata.FP8KernelMetadata":
|
||||
) -> FlashMLASparseMetadata.FP8KernelMetadata:
|
||||
"""Build FP8 metadata treating MQA tokens as one batch.
|
||||
|
||||
The scheduler initializes lazily from the runtime query shape, which may
|
||||
be the full batch or only decodes when prefills use dense MHA. This avoids
|
||||
the BF16 prefill kernel's head-padding overhead at high TP.
|
||||
"""
|
||||
num_tokens = common_attn_metadata.num_actual_tokens
|
||||
|
||||
# Use padded head count since that's what the kernel will see
|
||||
padded_heads = self.fp8_decode_padded_heads
|
||||
|
||||
# Build metadata for all tokens as a single batch
|
||||
scheduler_metadata, _ = get_mla_metadata(
|
||||
cache_seqlens=self.topk_tokens_tensor[:1], # Single batch
|
||||
num_q_tokens_per_head_k=num_tokens * padded_heads,
|
||||
topk=self.topk_tokens,
|
||||
num_heads_q=padded_heads,
|
||||
num_heads_k=1,
|
||||
is_fp8_kvcache=True,
|
||||
)
|
||||
|
||||
fp8_metadata = FlashMLASparseMetadata.FP8KernelMetadata(
|
||||
scheduler_metadata, _ = get_mla_metadata()
|
||||
return FlashMLASparseMetadata.FP8KernelMetadata(
|
||||
scheduler_metadata=scheduler_metadata,
|
||||
cache_lens=self.max_model_len_tensor[:1],
|
||||
dummy_block_table=self.dummy_block_table[:1],
|
||||
)
|
||||
|
||||
return fp8_metadata
|
||||
|
||||
def _build_fp8_separate_prefill_decode(
|
||||
self,
|
||||
common_attn_metadata: CommonAttentionMetadata,
|
||||
@@ -520,6 +490,9 @@ class FlashMLASparseMetadataBuilder(
|
||||
chunk_block_table = common_attn_metadata.block_table_tensor[
|
||||
num_decodes + chunk_start : num_decodes + chunk_end
|
||||
]
|
||||
chunk_seq_lens = common_attn_metadata.seq_lens[
|
||||
num_decodes + chunk_start : num_decodes + chunk_end
|
||||
]
|
||||
|
||||
prefill_chunks.append(
|
||||
FP8Meta.Prefill.Chunk(
|
||||
@@ -528,6 +501,7 @@ class FlashMLASparseMetadataBuilder(
|
||||
req_start_idx=chunk_start,
|
||||
workspace_starts=chunk_workspace_starts,
|
||||
chunk_tot_seqlen=chunk_tot_seqlen,
|
||||
seq_lens=chunk_seq_lens,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -569,9 +543,7 @@ class FlashMLASparseMetadataBuilder(
|
||||
metadata.fp8_use_mixed_batch = self.fp8_use_mixed_batch
|
||||
if self.use_fp8_kv_cache:
|
||||
if self.fp8_use_mixed_batch:
|
||||
metadata.fp8_extra_metadata = self._build_fp8_mixed_decode_prefill(
|
||||
common_attn_metadata
|
||||
)
|
||||
metadata.fp8_extra_metadata = self._build_fp8_mixed_decode_prefill()
|
||||
else:
|
||||
metadata.fp8_extra_metadata = self._build_fp8_separate_prefill_decode(
|
||||
common_attn_metadata, metadata
|
||||
@@ -679,30 +651,83 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
topk_indices: torch.Tensor,
|
||||
attn_metadata: FlashMLASparseMetadata,
|
||||
actual_num_heads: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
# Convert per-request indices to global slots (decode) or workspace
|
||||
# offsets (prefill). req_id_per_token covers the whole batch; slice it
|
||||
# to the MQA tokens (q may exclude prefill tokens routed to dense MHA).
|
||||
) -> tuple[torch.Tensor, torch.Tensor | None]:
|
||||
index_group = self.index_group
|
||||
if isinstance(index_group, HiSparseMLAIndexGroup):
|
||||
cache = index_group.cache(self.index_group_index)
|
||||
else:
|
||||
cache = None
|
||||
block_table = attn_metadata.block_table
|
||||
# req_id_per_token covers the whole batch; slice it to the MQA tokens
|
||||
# (q may exclude prefill tokens routed to dense MHA).
|
||||
req_id_per_token = attn_metadata.req_id_per_token[: topk_indices.shape[0]]
|
||||
decode_out: torch.Tensor | None = None
|
||||
if cache is not None:
|
||||
assert isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
num_decode_tokens = attn_metadata.num_decode_tokens
|
||||
if num_decode_tokens > 0:
|
||||
decode_topk, decode_lengths = (
|
||||
index_group.convert_decode_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices[:num_decode_tokens],
|
||||
attn_metadata,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
)
|
||||
decode_out, _ = self._bf16_flash_mla_kernel(
|
||||
q[:num_decode_tokens],
|
||||
index_group.physical_kv_cache(self.index_group_index),
|
||||
decode_topk,
|
||||
decode_lengths,
|
||||
actual_num_heads,
|
||||
)
|
||||
if num_decode_tokens == q.shape[0]:
|
||||
return decode_out, None
|
||||
q = q[num_decode_tokens:]
|
||||
topk_indices = topk_indices[num_decode_tokens:]
|
||||
kv_c_and_k_pe_cache, block_table, req_id_per_token = (
|
||||
index_group.stage_prefill_rows(
|
||||
self.index_group_index, kv_c_and_k_pe_cache, attn_metadata
|
||||
)
|
||||
)
|
||||
# Convert per-request indices to global slots (decode) or workspace offsets.
|
||||
kv_rows, block_stride_rows = flat_kv_row_view(
|
||||
kv_c_and_k_pe_cache, attn_metadata.block_size
|
||||
)
|
||||
topk_indices, topk_length = triton_convert_req_index_to_global_index(
|
||||
attn_metadata.req_id_per_token[: topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
BLOCK_STRIDE_ROWS=block_stride_rows,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
return_valid_counts=True,
|
||||
decode_only = (
|
||||
attn_metadata.num_decode_tokens
|
||||
== attn_metadata.num_actual_tokens
|
||||
== topk_indices.shape[0]
|
||||
)
|
||||
uses_host_cache = isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
if not uses_host_cache and decode_only:
|
||||
topk_indices, topk_length = self._convert_logical_to_physical_topk(
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=block_stride_rows,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
else:
|
||||
topk_indices, topk_length = triton_convert_req_index_to_global_index(
|
||||
req_id_per_token,
|
||||
block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
BLOCK_STRIDE_ROWS=block_stride_rows,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
return_valid_counts=True,
|
||||
)
|
||||
|
||||
return self._bf16_flash_mla_kernel(
|
||||
attn_out, lse = self._bf16_flash_mla_kernel(
|
||||
q,
|
||||
kv_rows,
|
||||
topk_indices,
|
||||
topk_length,
|
||||
actual_num_heads,
|
||||
)
|
||||
if decode_out is None:
|
||||
return attn_out, lse
|
||||
return torch.cat([decode_out, attn_out], dim=0), None
|
||||
|
||||
def _forward_fp8_kv_separate_prefill_decode(
|
||||
self,
|
||||
@@ -721,6 +746,28 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
"FP8 sparse MLA expects either the decode subset or the full batch"
|
||||
)
|
||||
|
||||
decode_topk: torch.Tensor | None = None
|
||||
index_group = self.index_group
|
||||
uses_host_cache = isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
if uses_host_cache and num_decode_tokens > 0:
|
||||
decode_topk = topk_indices[:num_decode_tokens]
|
||||
|
||||
prefill_ready = None
|
||||
if num_prefill_tokens > 0 and uses_host_cache:
|
||||
assert fp8_metadata.prefill is not None
|
||||
first_chunk = fp8_metadata.prefill.chunks[0]
|
||||
assert isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
prefill_ready = index_group.gather_fp8_prefill(
|
||||
self.index_group_index,
|
||||
kv_c_and_k_pe_cache,
|
||||
self.prefill_bf16_workspace[: first_chunk.chunk_tot_seqlen],
|
||||
first_chunk.block_table,
|
||||
first_chunk.workspace_starts,
|
||||
len(first_chunk.block_table),
|
||||
attn_metadata,
|
||||
first_chunk.req_start_idx,
|
||||
)
|
||||
|
||||
prefill_request_ids = None
|
||||
prefill_workspace_starts = None
|
||||
has_prefill_workspace = False
|
||||
@@ -736,17 +783,26 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
# For BF16 cache: always use global cache slots (no workspace)
|
||||
# prefill_workspace_starts has been adjusted in-place per chunk so
|
||||
# prefill indices automatically come out chunk-local
|
||||
topk_indices, topk_length = triton_convert_req_index_to_global_index(
|
||||
attn_metadata.req_id_per_token[: topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
HAS_PREFILL_WORKSPACE=has_prefill_workspace,
|
||||
prefill_workspace_request_ids=prefill_request_ids,
|
||||
prefill_workspace_starts=prefill_workspace_starts,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
topk_length = None
|
||||
if num_prefill_tokens == 0 and not uses_host_cache:
|
||||
topk_indices, topk_length = self._convert_logical_to_physical_topk(
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
elif num_prefill_tokens > 0:
|
||||
topk_indices, topk_length = triton_convert_req_index_to_global_index(
|
||||
attn_metadata.req_id_per_token[: topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
HAS_PREFILL_WORKSPACE=has_prefill_workspace,
|
||||
prefill_workspace_request_ids=prefill_request_ids,
|
||||
prefill_workspace_starts=prefill_workspace_starts,
|
||||
return_valid_counts=True,
|
||||
)
|
||||
|
||||
fp8_metadata = attn_metadata.fp8_extra_metadata
|
||||
assert isinstance(fp8_metadata, FlashMLASparseMetadata.FP8SeparatePrefillDecode)
|
||||
@@ -755,6 +811,16 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
q: torch.Tensor,
|
||||
topk_indices: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
assert fp8_metadata.decode is not None
|
||||
if uses_host_cache:
|
||||
return self._host_backed_fp8_decode(
|
||||
q,
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
fp8_metadata.decode.kernel_metadata,
|
||||
num_decodes,
|
||||
fp8_metadata.decode.decode_query_len,
|
||||
)
|
||||
# Reshape q: (num_decode_tokens, num_heads, head_dim)
|
||||
# -> (num_decodes, seq_len, num_heads, head_dim)
|
||||
q = reshape_query_for_spec_decode(q, num_decodes)
|
||||
@@ -762,7 +828,6 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
# Reshape topk_indices: (num_decode_tokens, topk)
|
||||
# -> (num_decodes, seq_len, topk)
|
||||
topk_indices = topk_indices.view(num_decodes, seq_len, -1)
|
||||
assert fp8_metadata.decode is not None
|
||||
attn_out, _ = self._fp8_flash_mla_kernel(
|
||||
q=q,
|
||||
kv_c_and_k_pe_cache=kv_c_and_k_pe_cache,
|
||||
@@ -776,7 +841,9 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
# Pure decode: direct call without allocation
|
||||
if num_decode_tokens > 0 and num_prefill_tokens == 0:
|
||||
assert fp8_metadata.decode is not None
|
||||
attn_out = _fp8_decode(q, topk_indices)
|
||||
attn_out = _fp8_decode(
|
||||
q, decode_topk if decode_topk is not None else topk_indices
|
||||
)
|
||||
else:
|
||||
# Mixed or pure prefill: allocate output tensor
|
||||
attn_out = q.new_empty(
|
||||
@@ -788,13 +855,30 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
if num_decode_tokens > 0:
|
||||
attn_out[:num_decode_tokens] = _fp8_decode(
|
||||
q[:num_decode_tokens],
|
||||
topk_indices[:num_decode_tokens],
|
||||
decode_topk
|
||||
if decode_topk is not None
|
||||
else topk_indices[:num_decode_tokens],
|
||||
)
|
||||
|
||||
assert fp8_metadata.prefill is not None
|
||||
for chunk in fp8_metadata.prefill.chunks:
|
||||
for chunk_index, chunk in enumerate(fp8_metadata.prefill.chunks):
|
||||
chunk_workspace = self.prefill_bf16_workspace[: chunk.chunk_tot_seqlen]
|
||||
if self.kv_cache_dtype == "fp8_ds_mla":
|
||||
if uses_host_cache and chunk_index > 0:
|
||||
assert isinstance(index_group, HiSparseMLAIndexGroup)
|
||||
prefill_ready = index_group.gather_fp8_prefill(
|
||||
self.index_group_index,
|
||||
kv_c_and_k_pe_cache,
|
||||
chunk_workspace,
|
||||
chunk.block_table,
|
||||
chunk.workspace_starts,
|
||||
len(chunk.block_table),
|
||||
attn_metadata,
|
||||
chunk.req_start_idx,
|
||||
)
|
||||
if uses_host_cache:
|
||||
assert prefill_ready is not None
|
||||
current_stream().wait_event(prefill_ready)
|
||||
elif self.kv_cache_dtype == "fp8_ds_mla":
|
||||
ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
kv_c_and_k_pe_cache,
|
||||
chunk_workspace,
|
||||
@@ -813,6 +897,7 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
|
||||
chunk_q = q[chunk.tokens_slice]
|
||||
chunk_topk_indices_workspace = topk_indices[chunk.tokens_slice]
|
||||
assert topk_length is not None
|
||||
chunk_topk_length = topk_length[chunk.tokens_slice]
|
||||
|
||||
attn_out[chunk.tokens_slice], _ = self._bf16_flash_mla_kernel(
|
||||
@@ -839,6 +924,17 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
|
||||
The lse is only returned when DCP needs it, otherwise None.
|
||||
"""
|
||||
assert attn_metadata.fp8_extra_metadata is not None
|
||||
assert isinstance(
|
||||
attn_metadata.fp8_extra_metadata,
|
||||
FlashMLASparseMetadata.FP8KernelMetadata,
|
||||
)
|
||||
fp8_metadata = attn_metadata.fp8_extra_metadata
|
||||
|
||||
block_table = attn_metadata.block_table
|
||||
# req_id_per_token covers the whole batch; slice it to the MQA tokens
|
||||
# (q may exclude prefill tokens routed to dense MHA).
|
||||
req_id_per_token = attn_metadata.req_id_per_token[: topk_indices.shape[0]]
|
||||
if self.dcp_world_size > 1:
|
||||
# The indexer emits global token ids; keep this rank's shard and
|
||||
# convert to local slots. compact_valid_to_front=False keeps the
|
||||
@@ -846,8 +942,8 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
# empty-row neutralization below relies on. req_id is sliced to
|
||||
# topk_indices rows (the converter grids from req_id).
|
||||
topk_indices = triton_filter_and_convert_dcp_index(
|
||||
attn_metadata.req_id_per_token[: topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
req_id_per_token,
|
||||
block_table,
|
||||
topk_indices,
|
||||
dcp_size=self.dcp_world_size,
|
||||
dcp_rank=self.dcp_rank,
|
||||
@@ -859,19 +955,26 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
else:
|
||||
# Convert per-request indices to global slots (decode) or workspace
|
||||
# offsets (prefill).
|
||||
topk_indices = triton_convert_req_index_to_global_index(
|
||||
attn_metadata.req_id_per_token[: topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
decode_only = (
|
||||
attn_metadata.num_decode_tokens
|
||||
== attn_metadata.num_actual_tokens
|
||||
== topk_indices.shape[0]
|
||||
)
|
||||
|
||||
assert attn_metadata.fp8_extra_metadata is not None
|
||||
assert isinstance(
|
||||
attn_metadata.fp8_extra_metadata, FlashMLASparseMetadata.FP8KernelMetadata
|
||||
)
|
||||
fp8_metadata = attn_metadata.fp8_extra_metadata
|
||||
if decode_only:
|
||||
topk_indices = self._convert_logical_to_physical_topk(
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=False,
|
||||
)
|
||||
else:
|
||||
topk_indices = triton_convert_req_index_to_global_index(
|
||||
req_id_per_token,
|
||||
block_table,
|
||||
topk_indices,
|
||||
BLOCK_SIZE=attn_metadata.block_size,
|
||||
NUM_TOPK_TOKENS=topk_indices.shape[1],
|
||||
)
|
||||
|
||||
_attn_out, _lse = self._fp8_flash_mla_kernel(
|
||||
q=q.unsqueeze(0), # unsqueeze to add batch_dim: (T, H, D) -> (1, T, H, D)
|
||||
@@ -937,6 +1040,37 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
|
||||
|
||||
return out, lse
|
||||
|
||||
def _host_backed_fp8_decode(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
topk_indices: torch.Tensor,
|
||||
attn_metadata: FlashMLASparseMetadata,
|
||||
kernel_metadata: FlashMLASparseMetadata.FP8KernelMetadata,
|
||||
num_decodes: int,
|
||||
decode_query_len: int,
|
||||
) -> torch.Tensor:
|
||||
assert isinstance(self.index_group, HiSparseMLAIndexGroup)
|
||||
physical_topk = self.index_group.convert_decode_logical_to_physical_topk(
|
||||
self.index_group_index,
|
||||
topk_indices,
|
||||
attn_metadata,
|
||||
return_valid_counts=False,
|
||||
num_decodes=num_decodes,
|
||||
decode_query_len=decode_query_len,
|
||||
)
|
||||
assert isinstance(physical_topk, torch.Tensor)
|
||||
q = reshape_query_for_spec_decode(q, num_decodes)
|
||||
physical_topk = physical_topk.view(num_decodes, q.shape[1], -1)
|
||||
output, _ = self._fp8_flash_mla_kernel(
|
||||
q=q,
|
||||
kv_c_and_k_pe_cache=self.index_group.physical_kv_cache(
|
||||
self.index_group_index
|
||||
),
|
||||
topk_indices=physical_topk,
|
||||
kernel_metadata=kernel_metadata,
|
||||
)
|
||||
return reshape_attn_output_for_spec_decode(output)
|
||||
|
||||
def _bf16_flash_mla_kernel(
|
||||
self,
|
||||
q: torch.Tensor,
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from vllm import _custom_ops as ops
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.utils.torch_utils import current_stream
|
||||
from vllm.v1.attention.backends.mla.sparse_utils import (
|
||||
triton_convert_req_index_to_global_index,
|
||||
)
|
||||
from vllm.v1.hisparse.runtime import (
|
||||
FP8_DS_MLA_ROW_BYTES,
|
||||
HiSparseCacheHandle,
|
||||
HiSparsePrefillStagingPlan,
|
||||
create_hisparse_cache_handle,
|
||||
)
|
||||
|
||||
|
||||
def _create_side_stream(device: torch.device) -> torch.Stream:
|
||||
return torch.Stream(device=device)
|
||||
|
||||
|
||||
def _create_event() -> torch.Event:
|
||||
return torch.Event()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SparseMLAIndexGroup:
|
||||
"""Layers that consume one sparse-indexer result."""
|
||||
|
||||
logical_topk_indices: torch.Tensor
|
||||
physical_topk_indices: torch.Tensor
|
||||
valid_topk_counts: torch.Tensor
|
||||
request_ids: torch.Tensor
|
||||
side_stream: torch.Stream
|
||||
logical_topk_ready: torch.Event
|
||||
physical_topk_ready: torch.Event
|
||||
has_indexer: bool
|
||||
num_layers: int = 0
|
||||
|
||||
def register_layer(
|
||||
self,
|
||||
vllm_config: VllmConfig | None = None,
|
||||
*,
|
||||
head_size: int | None = None,
|
||||
kv_cache_dtype: str | None = None,
|
||||
) -> int:
|
||||
layer_index = self.num_layers
|
||||
self.num_layers += 1
|
||||
return layer_index
|
||||
|
||||
def set_logical_topk_ready(self, layer_index: int) -> None:
|
||||
if layer_index == 0 and self.has_indexer:
|
||||
self.logical_topk_ready.record(current_stream())
|
||||
|
||||
def prepare_for_batch(self, layer_index: int, attn_metadata: Any | None) -> None:
|
||||
pass
|
||||
|
||||
def gather_fp8_prefill(
|
||||
self,
|
||||
layer_index: int,
|
||||
source_cache: torch.Tensor,
|
||||
dst: torch.Tensor,
|
||||
block_table: torch.Tensor,
|
||||
workspace_starts: torch.Tensor,
|
||||
batch_size: int,
|
||||
attn_metadata: Any,
|
||||
request_start: int,
|
||||
) -> torch.Event | None:
|
||||
ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
source_cache,
|
||||
dst,
|
||||
block_table,
|
||||
workspace_starts,
|
||||
batch_size,
|
||||
)
|
||||
return None
|
||||
|
||||
def convert_logical_to_physical_topk(
|
||||
self,
|
||||
layer_index: int,
|
||||
logical_topk_indices: torch.Tensor,
|
||||
attn_metadata: Any,
|
||||
*,
|
||||
block_stride_rows: int | None,
|
||||
return_valid_counts: bool,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Convert one index result once for all layers in this group."""
|
||||
return self._convert_once(
|
||||
layer_index,
|
||||
logical_topk_indices,
|
||||
attn_metadata.req_id_per_token[: logical_topk_indices.shape[0]],
|
||||
attn_metadata.block_table,
|
||||
attn_metadata.block_size,
|
||||
block_stride_rows=block_stride_rows,
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
|
||||
def _convert_once(
|
||||
self,
|
||||
layer_index: int,
|
||||
logical_topk_indices: torch.Tensor,
|
||||
req_id_per_token: torch.Tensor,
|
||||
block_table: torch.Tensor,
|
||||
block_size: int,
|
||||
*,
|
||||
block_stride_rows: int | None,
|
||||
return_valid_counts: bool,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
num_tokens = logical_topk_indices.shape[0]
|
||||
if num_tokens > self.physical_topk_indices.shape[0]:
|
||||
return triton_convert_req_index_to_global_index(
|
||||
req_id_per_token,
|
||||
block_table,
|
||||
logical_topk_indices,
|
||||
BLOCK_SIZE=block_size,
|
||||
BLOCK_STRIDE_ROWS=block_stride_rows,
|
||||
NUM_TOPK_TOKENS=logical_topk_indices.shape[1],
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
|
||||
physical_topk_indices = self.physical_topk_indices[:num_tokens]
|
||||
valid_topk_counts = self.valid_topk_counts[:num_tokens]
|
||||
compute_stream = current_stream()
|
||||
if layer_index == 0:
|
||||
if self.has_indexer:
|
||||
self.side_stream.wait_event(self.logical_topk_ready)
|
||||
else:
|
||||
self.side_stream.wait_stream(compute_stream)
|
||||
with self.side_stream:
|
||||
triton_convert_req_index_to_global_index(
|
||||
req_id_per_token,
|
||||
block_table,
|
||||
logical_topk_indices,
|
||||
BLOCK_SIZE=block_size,
|
||||
BLOCK_STRIDE_ROWS=block_stride_rows,
|
||||
NUM_TOPK_TOKENS=logical_topk_indices.shape[1],
|
||||
return_valid_counts=return_valid_counts,
|
||||
out=physical_topk_indices,
|
||||
valid_counts_out=(
|
||||
valid_topk_counts if return_valid_counts else None
|
||||
),
|
||||
)
|
||||
self.physical_topk_ready.record(self.side_stream)
|
||||
compute_stream.wait_event(self.physical_topk_ready)
|
||||
|
||||
if return_valid_counts:
|
||||
return physical_topk_indices, valid_topk_counts
|
||||
return physical_topk_indices
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiSparseMLAIndexGroup(SparseMLAIndexGroup):
|
||||
"""Sparse index group with per-layer host-backed KV caches."""
|
||||
|
||||
caches: list[HiSparseCacheHandle] = field(default_factory=list)
|
||||
hisparse_group: Any | None = None
|
||||
prefill_stream: torch.Stream | None = None
|
||||
prefill_ready_events: list[torch.Event] = field(default_factory=list)
|
||||
|
||||
def register_layer(
|
||||
self,
|
||||
vllm_config: VllmConfig | None = None,
|
||||
*,
|
||||
head_size: int | None = None,
|
||||
kv_cache_dtype: str | None = None,
|
||||
) -> int:
|
||||
assert vllm_config is not None
|
||||
assert head_size is not None
|
||||
assert kv_cache_dtype is not None
|
||||
layer_index = super().register_layer()
|
||||
if self.prefill_stream is None:
|
||||
self.prefill_stream = _create_side_stream(self.logical_topk_indices.device)
|
||||
if kv_cache_dtype == "fp8_ds_mla":
|
||||
row_width = FP8_DS_MLA_ROW_BYTES
|
||||
kv_dtype = torch.uint8
|
||||
else:
|
||||
from vllm.utils.torch_utils import kv_cache_dtype_str_to_dtype
|
||||
|
||||
row_width = head_size
|
||||
kv_dtype = kv_cache_dtype_str_to_dtype(
|
||||
kv_cache_dtype, vllm_config.model_config
|
||||
)
|
||||
cache = create_hisparse_cache_handle(
|
||||
vllm_config,
|
||||
self.logical_topk_indices.shape[1],
|
||||
is_index_group_leader=layer_index == 0,
|
||||
row_width=row_width,
|
||||
kv_dtype=kv_dtype,
|
||||
index_group=self,
|
||||
)
|
||||
assert cache is not None
|
||||
self.caches.append(cache)
|
||||
cache.index_group_caches = self.caches
|
||||
self.prefill_ready_events.append(_create_event())
|
||||
return layer_index
|
||||
|
||||
def cache(self, layer_index: int) -> HiSparseCacheHandle:
|
||||
return self.caches[layer_index]
|
||||
|
||||
def physical_kv_cache(self, layer_index: int) -> torch.Tensor:
|
||||
cache = self.cache(layer_index)
|
||||
return cache.runtime.hot.attention_cache
|
||||
|
||||
def prepare_for_batch(self, layer_index: int, attn_metadata: Any | None) -> None:
|
||||
if layer_index == 0:
|
||||
self.cache(0).prepare_group_for_batch(attn_metadata)
|
||||
|
||||
def convert_logical_to_physical_topk(
|
||||
self,
|
||||
layer_index: int,
|
||||
logical_topk_indices: torch.Tensor,
|
||||
attn_metadata: Any,
|
||||
*,
|
||||
block_stride_rows: int | None,
|
||||
return_valid_counts: bool,
|
||||
req_id_per_token: torch.Tensor | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
cache = self.cache(layer_index)
|
||||
num_tokens = logical_topk_indices.shape[0]
|
||||
if req_id_per_token is None:
|
||||
req_id_per_token = attn_metadata.req_id_per_token[:num_tokens]
|
||||
if num_tokens > self.physical_topk_indices.shape[0]:
|
||||
# Prefill-sized batches do not fit the decode residency workspace.
|
||||
# Non-resident prefills are staged before reaching this path.
|
||||
assert cache.all_context_pages_resident
|
||||
leader = self.cache(0)
|
||||
assert leader.view is not None and leader.block_table is not None
|
||||
return self._convert_once(
|
||||
layer_index,
|
||||
logical_topk_indices,
|
||||
req_id_per_token,
|
||||
leader.block_table,
|
||||
leader.view.block_size,
|
||||
block_stride_rows=leader.view.attention_block_stride,
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
source_block_table = cache.source_block_table
|
||||
assert source_block_table is not None
|
||||
return cache.swap_in(
|
||||
req_id_per_token,
|
||||
block_table=source_block_table,
|
||||
logical_topk_indices=logical_topk_indices,
|
||||
block_size=attn_metadata.block_size,
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
|
||||
def convert_decode_logical_to_physical_topk(
|
||||
self,
|
||||
layer_index: int,
|
||||
logical_topk_indices: torch.Tensor,
|
||||
attn_metadata: Any,
|
||||
*,
|
||||
return_valid_counts: bool,
|
||||
num_decodes: int | None = None,
|
||||
decode_query_len: int | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
num_tokens = logical_topk_indices.shape[0]
|
||||
if num_decodes is None:
|
||||
num_decodes = attn_metadata.num_decodes
|
||||
if decode_query_len is None:
|
||||
decode_query_len = attn_metadata.decode_max_query_len
|
||||
if decode_query_len == 1:
|
||||
return self.convert_logical_to_physical_topk(
|
||||
layer_index,
|
||||
logical_topk_indices,
|
||||
attn_metadata,
|
||||
block_stride_rows=None,
|
||||
return_valid_counts=return_valid_counts,
|
||||
req_id_per_token=self.request_ids[:num_decodes],
|
||||
)
|
||||
|
||||
if num_tokens != num_decodes * decode_query_len:
|
||||
query_start_loc = attn_metadata.query_start_loc[: num_decodes + 1]
|
||||
physical_topk_indices = self.physical_topk_indices[: num_tokens + 1]
|
||||
valid_topk_counts = self.valid_topk_counts[: num_tokens + 1]
|
||||
request_ids = self.request_ids[:num_decodes]
|
||||
cache = self.cache(layer_index)
|
||||
source_block_table = cache.source_block_table
|
||||
assert source_block_table is not None
|
||||
for step in range(decode_query_len):
|
||||
token_indices = query_start_loc[:-1] + step
|
||||
active = (token_indices < query_start_loc[1:]) & (
|
||||
token_indices < num_tokens
|
||||
)
|
||||
output_indices = torch.where(
|
||||
active, token_indices, torch.full_like(token_indices, num_tokens)
|
||||
).long()
|
||||
token_indices = token_indices.clamp(0, num_tokens - 1).long()
|
||||
step_topk = logical_topk_indices.index_select(
|
||||
0, token_indices
|
||||
).masked_fill(~active.unsqueeze(1), -1)
|
||||
step_result = cache.swap_in(
|
||||
request_ids,
|
||||
block_table=source_block_table,
|
||||
logical_topk_indices=step_topk,
|
||||
block_size=attn_metadata.block_size,
|
||||
return_valid_counts=return_valid_counts,
|
||||
)
|
||||
if layer_index == 0:
|
||||
if return_valid_counts:
|
||||
step_indices, step_counts = step_result
|
||||
valid_topk_counts.index_copy_(0, output_indices, step_counts)
|
||||
else:
|
||||
step_indices = step_result
|
||||
physical_topk_indices.index_copy_(0, output_indices, step_indices)
|
||||
physical_topk_indices = physical_topk_indices[:num_tokens]
|
||||
if return_valid_counts:
|
||||
return physical_topk_indices, valid_topk_counts[:num_tokens]
|
||||
return physical_topk_indices
|
||||
|
||||
assert num_tokens == num_decodes * decode_query_len
|
||||
logical_topk_by_request = logical_topk_indices.view(
|
||||
num_decodes, decode_query_len, -1
|
||||
)
|
||||
physical_topk_by_request = self.physical_topk_indices[:num_tokens].view(
|
||||
num_decodes, decode_query_len, -1
|
||||
)
|
||||
valid_topk_by_request = self.valid_topk_counts[:num_tokens].view(
|
||||
num_decodes, decode_query_len
|
||||
)
|
||||
request_ids = self.request_ids[:num_decodes]
|
||||
for step in range(decode_query_len):
|
||||
cache = self.cache(layer_index)
|
||||
source_block_table = cache.source_block_table
|
||||
assert source_block_table is not None
|
||||
cache.swap_in(
|
||||
request_ids,
|
||||
block_table=source_block_table,
|
||||
logical_topk_indices=logical_topk_by_request[:, step],
|
||||
block_size=attn_metadata.block_size,
|
||||
return_valid_counts=return_valid_counts,
|
||||
attention_indices_out=(
|
||||
physical_topk_by_request[:, step] if layer_index == 0 else None
|
||||
),
|
||||
valid_counts_out=(
|
||||
valid_topk_by_request[:, step]
|
||||
if layer_index == 0 and return_valid_counts
|
||||
else None
|
||||
),
|
||||
)
|
||||
physical_topk_indices = physical_topk_by_request.view(num_tokens, -1)
|
||||
if return_valid_counts:
|
||||
return physical_topk_indices, valid_topk_by_request.view(num_tokens)
|
||||
return physical_topk_indices
|
||||
|
||||
def stage_prefill_rows(
|
||||
self, layer_index: int, kv_cache: torch.Tensor, attn_metadata: Any
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
cache = self.cache(layer_index)
|
||||
prefill = attn_metadata.prefill
|
||||
staging_plan = prefill.host_staging_plan if prefill is not None else None
|
||||
assert staging_plan is not None
|
||||
resident_cache = None
|
||||
if cache.view is not None and cache.block_table is not None:
|
||||
staging_plan.ensure_gpu_sources(
|
||||
cache.block_table[attn_metadata.num_decodes :],
|
||||
cache.view.block_size,
|
||||
)
|
||||
resident_cache = cache.view.cache
|
||||
staged_cache = cache.runtime.gather_prefill_cache(
|
||||
kv_cache,
|
||||
staging_plan,
|
||||
resident_cache=resident_cache,
|
||||
)
|
||||
req_ids = attn_metadata.req_id_per_token[attn_metadata.num_decode_tokens :]
|
||||
if attn_metadata.num_decodes > 0:
|
||||
req_ids = req_ids - attn_metadata.num_decodes
|
||||
return staged_cache, staging_plan.block_table, req_ids
|
||||
|
||||
def _prefill_gather_plan(
|
||||
self, layer_index: int, attn_metadata: Any
|
||||
) -> HiSparsePrefillStagingPlan:
|
||||
cache = self.cache(layer_index)
|
||||
prefill = attn_metadata.prefill
|
||||
plan = prefill.host_staging_plan if prefill is not None else None
|
||||
assert plan is not None
|
||||
assert cache.view is not None and cache.block_table is not None
|
||||
plan.ensure_gpu_sources(
|
||||
cache.block_table[attn_metadata.num_decodes :],
|
||||
cache.view.block_size,
|
||||
)
|
||||
assert plan.gpu_row_ids is not None
|
||||
return plan
|
||||
|
||||
def gather_fp8_prefill(
|
||||
self,
|
||||
layer_index: int,
|
||||
source_cache: torch.Tensor,
|
||||
dst: torch.Tensor,
|
||||
block_table: torch.Tensor,
|
||||
workspace_starts: torch.Tensor,
|
||||
batch_size: int,
|
||||
attn_metadata: Any,
|
||||
request_start: int,
|
||||
) -> torch.Event | None:
|
||||
cache = self.cache(layer_index)
|
||||
plan = self._prefill_gather_plan(layer_index, attn_metadata)
|
||||
assert cache.view is not None and plan.gpu_row_ids is not None
|
||||
row_width = source_cache.shape[-1]
|
||||
prefill_stream = self.prefill_stream
|
||||
assert prefill_stream is not None
|
||||
block_table = plan.block_table[request_start : request_start + batch_size]
|
||||
prefill_stream.wait_stream(current_stream())
|
||||
with prefill_stream:
|
||||
ops.cp_gather_and_upconvert_fp8_kv_cache(
|
||||
cache.view.cache,
|
||||
dst,
|
||||
block_table,
|
||||
workspace_starts,
|
||||
batch_size,
|
||||
host_cache=source_cache.view(-1, row_width),
|
||||
host_row_ids=plan.row_ids,
|
||||
device_row_ids=plan.gpu_row_ids,
|
||||
)
|
||||
ready = self.prefill_ready_events[layer_index]
|
||||
ready.record(prefill_stream)
|
||||
return ready
|
||||
|
||||
|
||||
class SparseMLAIndexGroupBuilder:
|
||||
"""Assign sparse MLA layers to their index-producing layer."""
|
||||
|
||||
def __init__(
|
||||
self, logical_topk_indices: torch.Tensor, max_decode_rows: int | None = None
|
||||
) -> None:
|
||||
self.logical_topk_indices = logical_topk_indices
|
||||
self.max_decode_rows = (
|
||||
logical_topk_indices.shape[0]
|
||||
if max_decode_rows is None
|
||||
else max_decode_rows
|
||||
)
|
||||
self.current_group: SparseMLAIndexGroup | None = None
|
||||
|
||||
def register_layer(
|
||||
self,
|
||||
is_index_producing_layer: bool,
|
||||
vllm_config: VllmConfig | None = None,
|
||||
*,
|
||||
head_size: int | None = None,
|
||||
kv_cache_dtype: str | None = None,
|
||||
) -> tuple[SparseMLAIndexGroup, int]:
|
||||
if is_index_producing_layer or self.current_group is None:
|
||||
group_cls = (
|
||||
HiSparseMLAIndexGroup
|
||||
if vllm_config is not None
|
||||
and vllm_config.attention_config.hisparse_config is not None
|
||||
else SparseMLAIndexGroup
|
||||
)
|
||||
workspace_rows = self.max_decode_rows
|
||||
physical_topk_indices = torch.empty(
|
||||
(workspace_rows + 1, self.logical_topk_indices.shape[1]),
|
||||
dtype=self.logical_topk_indices.dtype,
|
||||
device=self.logical_topk_indices.device,
|
||||
)
|
||||
self.current_group = group_cls(
|
||||
logical_topk_indices=self.logical_topk_indices,
|
||||
physical_topk_indices=physical_topk_indices,
|
||||
valid_topk_counts=torch.empty(
|
||||
workspace_rows + 1,
|
||||
dtype=torch.int32,
|
||||
device=self.logical_topk_indices.device,
|
||||
),
|
||||
request_ids=torch.arange(
|
||||
workspace_rows,
|
||||
dtype=torch.int32,
|
||||
device=self.logical_topk_indices.device,
|
||||
),
|
||||
side_stream=_create_side_stream(self.logical_topk_indices.device),
|
||||
logical_topk_ready=_create_event(),
|
||||
physical_topk_ready=_create_event(),
|
||||
has_indexer=is_index_producing_layer,
|
||||
)
|
||||
group = self.current_group
|
||||
group_index = group.register_layer(
|
||||
vllm_config,
|
||||
head_size=head_size,
|
||||
kv_cache_dtype=kv_cache_dtype,
|
||||
)
|
||||
return group, group_index
|
||||
|
||||
|
||||
def get_sparse_mla_index_group_max_rows(vllm_config: VllmConfig) -> int:
|
||||
max_query_len = 1
|
||||
speculative_config = vllm_config.speculative_config
|
||||
if (
|
||||
speculative_config is not None
|
||||
and speculative_config.num_speculative_tokens is not None
|
||||
):
|
||||
max_query_len += speculative_config.num_speculative_tokens * (
|
||||
2 if speculative_config.parallel_drafting else 1
|
||||
)
|
||||
scheduler_config = vllm_config.scheduler_config
|
||||
return min(
|
||||
scheduler_config.max_num_batched_tokens,
|
||||
scheduler_config.max_num_seqs * max_query_len,
|
||||
)
|
||||
@@ -407,6 +407,8 @@ def triton_convert_req_index_to_global_index(
|
||||
prefill_workspace_request_ids: torch.Tensor | None = None,
|
||||
prefill_workspace_starts: torch.Tensor | None = None,
|
||||
return_valid_counts: bool = False,
|
||||
out: torch.Tensor | None = None,
|
||||
valid_counts_out: torch.Tensor | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
"""
|
||||
out[token_id, indice_id] =
|
||||
@@ -462,16 +464,36 @@ def triton_convert_req_index_to_global_index(
|
||||
# contiguous prefix [0, valid_count) and leaves the tail unwritten, so
|
||||
# pre-fill -1 there. flash_mla_sparse_fwd then bounds attention to
|
||||
# [:topk_length] == exactly the valid set (no dropped tokens).
|
||||
if return_valid_counts:
|
||||
out = torch.full_like(token_indices_c, -1)
|
||||
if out is None:
|
||||
out = (
|
||||
torch.full_like(token_indices_c, -1)
|
||||
if return_valid_counts
|
||||
else torch.empty_like(token_indices_c)
|
||||
)
|
||||
else:
|
||||
out = torch.empty_like(token_indices_c)
|
||||
assert out.dtype == token_indices_c.dtype
|
||||
assert out.device == token_indices_c.device
|
||||
assert out.shape == token_indices_c.shape
|
||||
assert out.is_contiguous()
|
||||
if return_valid_counts:
|
||||
out.fill_(-1)
|
||||
|
||||
valid_counts: torch.Tensor | None = None
|
||||
if return_valid_counts:
|
||||
# Zero-init only matters for the atomic accumulation path.
|
||||
alloc = torch.empty if single_tile else torch.zeros
|
||||
valid_counts = alloc(num_tokens, dtype=torch.int32, device=token_indices.device)
|
||||
if valid_counts_out is None:
|
||||
# Zero-init only matters for the atomic accumulation path.
|
||||
alloc = torch.empty if single_tile else torch.zeros
|
||||
valid_counts = alloc(
|
||||
num_tokens, dtype=torch.int32, device=token_indices.device
|
||||
)
|
||||
else:
|
||||
assert valid_counts_out.dtype == torch.int32
|
||||
assert valid_counts_out.device == token_indices.device
|
||||
assert valid_counts_out.shape == (num_tokens,)
|
||||
assert valid_counts_out.is_contiguous()
|
||||
valid_counts = valid_counts_out
|
||||
if not single_tile:
|
||||
valid_counts.zero_()
|
||||
|
||||
# Prepare prefill pointers
|
||||
if HAS_PREFILL_WORKSPACE:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections.abc import Iterable, Sequence
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from vllm.distributed.kv_events import (
|
||||
@@ -157,6 +157,7 @@ class BlockPool:
|
||||
actual block size can be a multiple of hash_block_size.
|
||||
enable_kv_cache_events: Whether to enable kv cache events.
|
||||
metrics_collector: Optional metrics collector for tracking block residency.
|
||||
medium: Storage medium reported in KV cache events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -166,14 +167,16 @@ class BlockPool:
|
||||
hash_block_size: int,
|
||||
enable_kv_cache_events: bool = False,
|
||||
metrics_collector: KVCacheMetricsCollector | None = None,
|
||||
medium: str = MEDIUM_GPU,
|
||||
):
|
||||
assert isinstance(num_gpu_blocks, int) and num_gpu_blocks > 0
|
||||
self.num_gpu_blocks = num_gpu_blocks
|
||||
self.medium = medium
|
||||
self.enable_caching = enable_caching
|
||||
self.hash_block_size = hash_block_size
|
||||
# All kv-cache blocks.
|
||||
self.blocks: list[KVCacheBlock] = [
|
||||
KVCacheBlock(idx) for idx in range(num_gpu_blocks)
|
||||
KVCacheBlock(idx, pool=self) for idx in range(num_gpu_blocks)
|
||||
]
|
||||
# Free block queue that constructs and manipulates a doubly linked
|
||||
# list of free blocks (including eviction candidates when caching is
|
||||
@@ -194,6 +197,9 @@ class BlockPool:
|
||||
self.kv_event_queue: list[KVCacheEvent] = []
|
||||
|
||||
self.metrics_collector = metrics_collector
|
||||
# Callbacks for blocks released with ``unpin_blocks`` whose contents
|
||||
# are still being read until the pool reuses them.
|
||||
self._reuse_watchers: dict[int, Callable[[KVCacheBlock], None]] = {}
|
||||
|
||||
def get_cached_block(
|
||||
self, block_hash: BlockHash, kv_cache_group_ids: list[int]
|
||||
@@ -364,7 +370,7 @@ class BlockPool:
|
||||
token_ids=request.all_token_ids[start_token_idx:end_token_idx],
|
||||
block_size=block_size,
|
||||
lora_id=request.lora_request.adapter_id if request.lora_request else None,
|
||||
medium=MEDIUM_GPU,
|
||||
medium=self.medium,
|
||||
lora_name=request.lora_request.name if request.lora_request else None,
|
||||
extra_keys=extra_keys_list if extra_keys_list else None,
|
||||
group_idx=kv_cache_group_id,
|
||||
@@ -543,7 +549,7 @@ class BlockPool:
|
||||
lora_id=request.lora_request.adapter_id
|
||||
if request.lora_request
|
||||
else None,
|
||||
medium=MEDIUM_GPU,
|
||||
medium=self.medium,
|
||||
lora_name=request.lora_request.name
|
||||
if request.lora_request
|
||||
else None,
|
||||
@@ -610,7 +616,7 @@ class BlockPool:
|
||||
self.kv_event_queue.append(
|
||||
BlockRemoved(
|
||||
block_hashes=[maybe_convert_block_hash(get_block_hash(block_hash))],
|
||||
medium=MEDIUM_GPU,
|
||||
medium=self.medium,
|
||||
group_idx=get_group_id(block_hash),
|
||||
)
|
||||
)
|
||||
@@ -671,6 +677,9 @@ class BlockPool:
|
||||
|
||||
ret: list[KVCacheBlock] = self.free_block_queue.popleft_n(num_blocks)
|
||||
|
||||
if self._reuse_watchers:
|
||||
self._notify_reuse(ret)
|
||||
|
||||
# In order to only iterate the list once, we duplicated code a bit
|
||||
if self.enable_caching:
|
||||
for block in ret:
|
||||
@@ -687,6 +696,33 @@ class BlockPool:
|
||||
self.metrics_collector.on_block_allocated(block)
|
||||
return ret
|
||||
|
||||
def _notify_reuse(self, blocks: Iterable[KVCacheBlock]) -> None:
|
||||
for block in blocks:
|
||||
watcher = self._reuse_watchers.pop(block.block_id, None)
|
||||
if watcher is not None:
|
||||
watcher(block)
|
||||
|
||||
def unpin_blocks(
|
||||
self,
|
||||
blocks: Iterable[KVCacheBlock],
|
||||
on_reuse: Callable[[KVCacheBlock], None],
|
||||
) -> None:
|
||||
"""Release references to blocks that stay readable until reused.
|
||||
|
||||
The blocks become last-resort eviction candidates regardless of prefix
|
||||
caching: they join the tail of the free queue and count as free.
|
||||
``on_reuse`` fires when ``get_new_blocks`` hands a block out (or the
|
||||
cache is reset), so the caller can stop reading it.
|
||||
"""
|
||||
released: list[KVCacheBlock] = []
|
||||
for block in blocks:
|
||||
assert block.ref_cnt > 0 and not block.is_null
|
||||
block.ref_cnt -= 1
|
||||
self._reuse_watchers[block.block_id] = on_reuse
|
||||
if block.ref_cnt == 0:
|
||||
released.append(block)
|
||||
self.free_block_queue.append_n(released)
|
||||
|
||||
def _maybe_evict_cached_block(self, block: KVCacheBlock) -> bool:
|
||||
"""
|
||||
If a block is cached in `cached_block_hash_to_block`, we reset its hash
|
||||
@@ -742,7 +778,11 @@ class BlockPool:
|
||||
# Identify blocks with hash (LRU cache) and without it (never match APC)
|
||||
blocks_to_evict_last = []
|
||||
blocks_to_evict_first = []
|
||||
other_pools: dict[BlockPool, list[KVCacheBlock]] = {}
|
||||
for block in ordered_blocks:
|
||||
if block.pool is not None and block.pool is not self:
|
||||
other_pools.setdefault(block.pool, []).append(block)
|
||||
continue
|
||||
block.ref_cnt -= 1
|
||||
if block.ref_cnt == 0 and not block.is_null:
|
||||
if block.block_hash is None or not self.enable_caching:
|
||||
@@ -756,6 +796,8 @@ class BlockPool:
|
||||
self.free_block_queue.prepend_n(blocks_to_evict_first)
|
||||
# Blocks to reuse last are appended to the end of the free queue.
|
||||
self.free_block_queue.append_n(blocks_to_evict_last)
|
||||
for pool, blocks in other_pools.items():
|
||||
pool.free_blocks(blocks)
|
||||
|
||||
def evict_blocks(self, block_ids: set[int]) -> None:
|
||||
"""evict blocks from the prefix cache by their block IDs.
|
||||
@@ -797,6 +839,10 @@ class BlockPool:
|
||||
# Remove all hashes so that no new blocks will hit.
|
||||
self.cached_block_hash_to_block = BlockHashToBlockMap()
|
||||
self.cached_block_hashes_by_block.clear()
|
||||
if self._reuse_watchers:
|
||||
self._notify_reuse(
|
||||
[self.blocks[block_id] for block_id in list(self._reuse_watchers)]
|
||||
)
|
||||
|
||||
# Remove all hashes from all blocks.
|
||||
for block in self.blocks:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user