Merge remote-tracking branch 'origin/main' into sync/lesj/qwen4exp-bugfixes

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
This commit is contained in:
lesj0610
2026-09-20 22:55:38 +09:00
83 changed files with 4549 additions and 1058 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ steps:
- >-
bash .buildkite/scripts/hardware_ci/run-intel-test.sh
'cd tests && pip install dpctl &&
pytest -v -s v1/core --ignore=v1/core/test_reset_prefix_cache_e2e.py --ignore=v1/core/test_scheduler_e2e.py --ignore=v1/core/prefix_cache/test_hybrid_prefix_cache_hit_rate.py &&
pytest -v -s v1/core --ignore=v1/core/test_reset_prefix_cache_e2e.py --ignore=v1/core/test_scheduler_e2e.py &&
pytest -v -s v1/engine --ignore=v1/engine/test_output_processor.py &&
pytest -v -s v1/sample --ignore=v1/sample/test_logprobs.py --ignore=v1/sample/test_logprobs_e2e.py -k "not test_topk_only and not test_topp_only and not test_topk_and_topp" &&
pytest -v -s v1/worker --ignore=v1/worker/test_gpu_model_runner.py --ignore=v1/worker/test_worker_memory_snapshot.py &&
+36 -13
View File
@@ -956,6 +956,28 @@ void print_logits(const char* name, T* ptr, int32_t row, int32_t col,
std::printf("%s", ss.str().c_str());
}
// This allows us to store probabilities in a packed format
// for GEMM implementations that need packed A.
template <typename attention_impl_t, typename = void>
struct ProbabilityTokenStore {
using prob_buffer_t = typename attention_impl_t::prob_buffer_t;
static constexpr int32_t TokenStride = 1;
FORCE_INLINE static void store_probabilities(
prob_buffer_t* __restrict__ probability, const vec_op::FP32Vec16& values,
const int32_t, const int64_t) {
using prob_buffer_vec_t = typename VecTypeTrait<prob_buffer_t>::vec_t;
prob_buffer_vec_t output_vec(values);
output_vec.save(probability);
}
};
template <typename attention_impl_t>
struct ProbabilityTokenStore<
attention_impl_t,
std::void_t<typename attention_impl_t::ProbabilityTokenStore>>
: attention_impl_t::ProbabilityTokenStore {};
template <typename attention_impl_t>
class AttentionMainLoop {
public:
@@ -966,6 +988,7 @@ class AttentionMainLoop {
using partial_output_buffer_t =
typename attention_impl_t::partial_output_buffer_t;
using prob_buffer_t = typename attention_impl_t::prob_buffer_t;
using probability_token_store_t = ProbabilityTokenStore<attention_impl_t>;
static constexpr int64_t max_q_head_num_per_iter =
attention_impl_t::MaxQHeadNumPerIteration;
@@ -974,6 +997,8 @@ class AttentionMainLoop {
static constexpr int64_t headdim_alignment =
attention_impl_t::HeadDimAlignment;
static constexpr int64_t head_dim = attention_impl_t::HeadDim;
static constexpr int32_t probability_token_stride =
probability_token_store_t::TokenStride;
static constexpr ISA ISAType = attention_impl_t::ISAType;
static constexpr bool scale_on_logits =
attention_impl_t::scale_on_logits; // apply scale on logits, otherwise
@@ -1152,8 +1177,6 @@ class AttentionMainLoop {
? kv_tile_token_num
: kv_tile_token_num *
(sizeof(logits_buffer_t) / sizeof(prob_buffer_t));
constexpr int64_t prob_buffer_elem_size =
prequantize_probabilities ? sizeof(uint8_t) : sizeof(prob_buffer_t);
partial_output_buffer_t* curr_partial_q_buffer = partial_q_buffer;
bool accum_c = !is_first_iter;
for (int32_t block_idx = start_block_idx; block_idx < end_block_idx;
@@ -1187,9 +1210,7 @@ class AttentionMainLoop {
remaining_group_num -= curr_group_num_in_block;
curr_group_offset = 0;
curr_group_num_in_block = token_group_num_per_block;
curr_prob_buffer = reinterpret_cast<pv_prob_buffer_t*>(
reinterpret_cast<uint8_t*>(curr_prob_buffer) +
curr_token_num * prob_buffer_elem_size);
curr_prob_buffer += curr_token_num * probability_token_stride;
curr_partial_q_buffer = partial_q_buffer;
accum_c = true;
}
@@ -1262,7 +1283,6 @@ class AttentionMainLoop {
std::is_same_v<query_t, c10::Half>;
#endif
using prob_buffer_vec_t = typename VecTypeTrait<prob_buffer_t>::vec_t;
constexpr bool prequantize_probabilities = []() {
if constexpr (requires { tile_gemm_t::prequantize_probabilities; }) {
return tile_gemm_t::prequantize_probabilities;
@@ -1331,12 +1351,6 @@ class AttentionMainLoop {
vec = fast_exp(vec);
}
if constexpr (prequantize_probabilities) {
vec.save(curr_logits_buffer_iter);
} else {
prob_buffer_vec_t output_vec(vec);
output_vec.save(curr_prob_buffer_iter);
}
#else
vec.save(curr_logits_buffer_iter);
for (int32_t k = 0; k < 16; ++k) {
@@ -1345,10 +1359,19 @@ class AttentionMainLoop {
vec = vec_op::FP32Vec16(curr_logits_buffer_iter);
#endif
if constexpr (prequantize_probabilities) {
vec.save(curr_logits_buffer_iter);
} else {
probability_token_store_t::store_probabilities(
curr_prob_buffer_iter, vec, i,
logits_buffer_stride * sizeof(logits_buffer_t) /
sizeof(prob_buffer_t));
}
sum_vec = sum_vec + vec;
curr_logits_buffer_iter += 16;
curr_prob_buffer_iter += 16;
curr_prob_buffer_iter += 16 * probability_token_stride;
}
if constexpr (prequantize_probabilities) {
tile_gemm_t::quantize_probability_row(
+2 -2
View File
@@ -11,7 +11,7 @@ namespace cpu_attention {
namespace {
#define BLOCK_SIZE_ALIGNMENT 32
#define BLOCK_SIZE_ALIGNMENT 16
#define HEAD_SIZE_ALIGNMENT 32
#define MAX_Q_HEAD_NUM_PER_ITER 16
@@ -387,7 +387,7 @@ class AttentionImpl<ISA::NEON, scalar_t, head_dim, kv_cache_scalar_t> {
};
#ifdef ARM_BF16_SUPPORT
// For BF16 on Arm, reuse the BFMMLA kernels with 32-token alignment.
// For BF16 on Arm, reuse the BFMMLA kernels with the same token alignment.
template <int64_t head_dim>
class AttentionImpl<ISA::NEON, c10::BFloat16, head_dim, c10::BFloat16>
: public AttentionImplNEONBFMMLA<BLOCK_SIZE_ALIGNMENT, ISA::NEON,
+373 -500
View File
@@ -5,432 +5,275 @@
#include "cpu_attn_impl.hpp"
#include <arm_bf16.h>
#include <arm_neon.h>
#include <c10/util/BFloat16.h>
#include <algorithm>
#include <cstdint>
#include <vector>
namespace cpu_attention {
class BfmmlaGemm {
public:
static constexpr int32_t KTile = 4;
static constexpr int32_t NTile = 8;
static constexpr int32_t MaxRows = 8;
FORCE_INLINE static void gemm(const c10::BFloat16* __restrict__ a,
const c10::BFloat16* __restrict__ b,
float* __restrict__ c, const int32_t m,
const int32_t n, const int32_t k,
const int64_t a_pair_stride,
const int64_t b_n_group_stride,
const int64_t b_k_group_stride,
const int64_t ldc, const bool accumulate) {
const auto* a_ptr = reinterpret_cast<const bfloat16_t*>(a);
const auto* b_ptr = reinterpret_cast<const bfloat16_t*>(b);
for (int32_t n_idx = 0; n_idx < n; n_idx += 16) {
const auto* b_panel = b_ptr + (n_idx / NTile) * b_n_group_stride;
float* c_panel = c + n_idx;
// Preserve this range so the inactive row pair is optimized away.
if (m <= 2) {
gemm_4x16(a_ptr, b_panel, c_panel, m, k, a_pair_stride,
b_n_group_stride, b_k_group_stride, ldc, accumulate);
} else if (m <= 4) {
gemm_4x16(a_ptr, b_panel, c_panel, m, k, a_pair_stride,
b_n_group_stride, b_k_group_stride, ldc, accumulate);
} else {
gemm_8x8(a_ptr, b_panel, c_panel, m, k, a_pair_stride, b_k_group_stride,
ldc, accumulate);
gemm_8x8(a_ptr, b_panel + b_n_group_stride, c_panel + NTile, m, k,
a_pair_stride, b_k_group_stride, ldc, accumulate);
}
}
}
private:
FORCE_INLINE static float32x4_t zip_low_pairs(const float32x4_t a,
const float32x4_t b) {
return vreinterpretq_f32_f64(
vzip1q_f64(vreinterpretq_f64_f32(a), vreinterpretq_f64_f32(b)));
}
FORCE_INLINE static float32x4_t zip_high_pairs(const float32x4_t a,
const float32x4_t b) {
return vreinterpretq_f32_f64(
vzip2q_f64(vreinterpretq_f64_f32(a), vreinterpretq_f64_f32(b)));
}
FORCE_INLINE static void init_accumulators(
float32x4_t& acc01, float32x4_t& acc23, float32x4_t& acc45,
float32x4_t& acc67, const float* __restrict__ c, const int64_t ldc,
const int32_t rows, const bool accumulate) {
if (!accumulate || rows == 0) {
acc01 = vdupq_n_f32(0.0f);
acc23 = vdupq_n_f32(0.0f);
acc45 = vdupq_n_f32(0.0f);
acc67 = vdupq_n_f32(0.0f);
return;
}
const float32x4_t row0_0123 = vld1q_f32(c);
const float32x4_t row0_4567 = vld1q_f32(c + 4);
const float32x4_t row1_0123 =
(rows == 2) ? vld1q_f32(c + ldc) : vdupq_n_f32(0.0f);
const float32x4_t row1_4567 =
(rows == 2) ? vld1q_f32(c + ldc + 4) : vdupq_n_f32(0.0f);
acc01 = zip_low_pairs(row0_0123, row1_0123);
acc23 = zip_high_pairs(row0_0123, row1_0123);
acc45 = zip_low_pairs(row0_4567, row1_4567);
acc67 = zip_high_pairs(row0_4567, row1_4567);
}
FORCE_INLINE static void store_accumulators(
const float32x4_t acc01, const float32x4_t acc23, const float32x4_t acc45,
const float32x4_t acc67, float* __restrict__ c, const int64_t ldc,
const int32_t rows) {
if (rows == 0) {
return;
}
vst1q_f32(c, zip_low_pairs(acc01, acc23));
vst1q_f32(c + 4, zip_low_pairs(acc45, acc67));
if (rows == 2) {
vst1q_f32(c + ldc, zip_high_pairs(acc01, acc23));
vst1q_f32(c + ldc + 4, zip_high_pairs(acc45, acc67));
}
}
FORCE_INLINE static bfloat16x8_t load_a_pair(const bfloat16_t* __restrict__ a,
const int32_t rows) {
if (rows == 0) {
return vdupq_n_bf16(bfloat16_t{});
}
// Packed A reserves both rows for an M tail.
return vld1q_bf16(a);
}
FORCE_INLINE static void gemm_4x16(const bfloat16_t* __restrict__ a,
const bfloat16_t* __restrict__ b,
float* __restrict__ c, const int32_t m,
const int32_t k,
const int64_t a_pair_stride,
const int64_t b_n_group_stride,
const int64_t b_k_group_stride,
const int64_t ldc, const bool accumulate) {
const int32_t rows01 = std::min(2, std::max(0, m));
const int32_t rows23 = std::min(2, std::max(0, m - 2));
float32x4_t acc0101, acc0123, acc0145, acc0167;
float32x4_t acc2301, acc2323, acc2345, acc2367;
float32x4_t acc0189, acc011011, acc011213, acc011415;
float32x4_t acc2389, acc231011, acc231213, acc231415;
init_accumulators(acc0101, acc0123, acc0145, acc0167, c, ldc, rows01,
accumulate);
init_accumulators(acc2301, acc2323, acc2345, acc2367, c + 2 * ldc, ldc,
rows23, accumulate);
init_accumulators(acc0189, acc011011, acc011213, acc011415, c + 8, ldc,
rows01, accumulate);
init_accumulators(acc2389, acc231011, acc231213, acc231415, c + 2 * ldc + 8,
ldc, rows23, accumulate);
const bfloat16_t* a01 = a;
const bfloat16_t* a23 = a + a_pair_stride;
const bfloat16_t* b0 = b;
const bfloat16_t* b1 = b + b_n_group_stride;
#pragma GCC unroll 4
for (int32_t k_idx = 0; k_idx < k; k_idx += KTile) {
const bfloat16x8_t av01 = load_a_pair(a01, rows01);
const bfloat16x8_t av23 = load_a_pair(a23, rows23);
const bfloat16x8_t b01 = vld1q_bf16(b0);
const bfloat16x8_t b23 = vld1q_bf16(b0 + NTile);
const bfloat16x8_t b45 = vld1q_bf16(b0 + 2 * NTile);
const bfloat16x8_t b67 = vld1q_bf16(b0 + 3 * NTile);
const bfloat16x8_t b89 = vld1q_bf16(b1);
const bfloat16x8_t b1011 = vld1q_bf16(b1 + NTile);
const bfloat16x8_t b1213 = vld1q_bf16(b1 + 2 * NTile);
const bfloat16x8_t b1415 = vld1q_bf16(b1 + 3 * NTile);
acc0101 = vbfmmlaq_f32(acc0101, av01, b01);
acc2301 = vbfmmlaq_f32(acc2301, av23, b01);
acc0123 = vbfmmlaq_f32(acc0123, av01, b23);
acc2323 = vbfmmlaq_f32(acc2323, av23, b23);
acc0145 = vbfmmlaq_f32(acc0145, av01, b45);
acc2345 = vbfmmlaq_f32(acc2345, av23, b45);
acc0167 = vbfmmlaq_f32(acc0167, av01, b67);
acc2367 = vbfmmlaq_f32(acc2367, av23, b67);
acc0189 = vbfmmlaq_f32(acc0189, av01, b89);
acc2389 = vbfmmlaq_f32(acc2389, av23, b89);
acc011011 = vbfmmlaq_f32(acc011011, av01, b1011);
acc231011 = vbfmmlaq_f32(acc231011, av23, b1011);
acc011213 = vbfmmlaq_f32(acc011213, av01, b1213);
acc231213 = vbfmmlaq_f32(acc231213, av23, b1213);
acc011415 = vbfmmlaq_f32(acc011415, av01, b1415);
acc231415 = vbfmmlaq_f32(acc231415, av23, b1415);
a01 += 2 * KTile;
a23 += 2 * KTile;
b0 += b_k_group_stride;
b1 += b_k_group_stride;
}
store_accumulators(acc0101, acc0123, acc0145, acc0167, c, ldc, rows01);
store_accumulators(acc2301, acc2323, acc2345, acc2367, c + 2 * ldc, ldc,
rows23);
store_accumulators(acc0189, acc011011, acc011213, acc011415, c + 8, ldc,
rows01);
store_accumulators(acc2389, acc231011, acc231213, acc231415,
c + 2 * ldc + 8, ldc, rows23);
}
FORCE_INLINE static void gemm_8x8(const bfloat16_t* __restrict__ a,
const bfloat16_t* __restrict__ b,
float* __restrict__ c, const int32_t m,
const int32_t k,
const int64_t a_pair_stride,
const int64_t b_k_group_stride,
const int64_t ldc, const bool accumulate) {
const int32_t rows01 = std::min(2, std::max(0, m));
const int32_t rows23 = std::min(2, std::max(0, m - 2));
const int32_t rows45 = std::min(2, std::max(0, m - 4));
const int32_t rows67 = std::min(2, std::max(0, m - 6));
float32x4_t acc0101, acc0123, acc0145, acc0167;
float32x4_t acc2301, acc2323, acc2345, acc2367;
float32x4_t acc4501, acc4523, acc4545, acc4567;
float32x4_t acc6701, acc6723, acc6745, acc6767;
init_accumulators(acc0101, acc0123, acc0145, acc0167, c, ldc, rows01,
accumulate);
init_accumulators(acc2301, acc2323, acc2345, acc2367, c + 2 * ldc, ldc,
rows23, accumulate);
init_accumulators(acc4501, acc4523, acc4545, acc4567, c + 4 * ldc, ldc,
rows45, accumulate);
init_accumulators(acc6701, acc6723, acc6745, acc6767, c + 6 * ldc, ldc,
rows67, accumulate);
const bfloat16_t* a01 = a;
const bfloat16_t* a23 = a + a_pair_stride;
const bfloat16_t* a45 = a + 2 * a_pair_stride;
const bfloat16_t* a67 = a + 3 * a_pair_stride;
const bfloat16_t* b_ptr = b;
#pragma GCC unroll 4
for (int32_t k_idx = 0; k_idx < k; k_idx += KTile) {
const bfloat16x8_t av01 = load_a_pair(a01, rows01);
const bfloat16x8_t av23 = load_a_pair(a23, rows23);
const bfloat16x8_t av45 = load_a_pair(a45, rows45);
const bfloat16x8_t av67 = load_a_pair(a67, rows67);
const bfloat16x8_t b01 = vld1q_bf16(b_ptr);
const bfloat16x8_t b23 = vld1q_bf16(b_ptr + NTile);
const bfloat16x8_t b45 = vld1q_bf16(b_ptr + 2 * NTile);
const bfloat16x8_t b67 = vld1q_bf16(b_ptr + 3 * NTile);
acc0101 = vbfmmlaq_f32(acc0101, av01, b01);
acc2301 = vbfmmlaq_f32(acc2301, av23, b01);
acc4501 = vbfmmlaq_f32(acc4501, av45, b01);
acc6701 = vbfmmlaq_f32(acc6701, av67, b01);
acc0123 = vbfmmlaq_f32(acc0123, av01, b23);
acc2323 = vbfmmlaq_f32(acc2323, av23, b23);
acc4523 = vbfmmlaq_f32(acc4523, av45, b23);
acc6723 = vbfmmlaq_f32(acc6723, av67, b23);
acc0145 = vbfmmlaq_f32(acc0145, av01, b45);
acc2345 = vbfmmlaq_f32(acc2345, av23, b45);
acc4545 = vbfmmlaq_f32(acc4545, av45, b45);
acc6745 = vbfmmlaq_f32(acc6745, av67, b45);
acc0167 = vbfmmlaq_f32(acc0167, av01, b67);
acc2367 = vbfmmlaq_f32(acc2367, av23, b67);
acc4567 = vbfmmlaq_f32(acc4567, av45, b67);
acc6767 = vbfmmlaq_f32(acc6767, av67, b67);
a01 += 2 * KTile;
a23 += 2 * KTile;
a45 += 2 * KTile;
a67 += 2 * KTile;
b_ptr += b_k_group_stride;
}
store_accumulators(acc0101, acc0123, acc0145, acc0167, c, ldc, rows01);
store_accumulators(acc2301, acc2323, acc2345, acc2367, c + 2 * ldc, ldc,
rows23);
store_accumulators(acc4501, acc4523, acc4545, acc4567, c + 4 * ldc, ldc,
rows45);
store_accumulators(acc6701, acc6723, acc6745, acc6767, c + 6 * ldc, ldc,
rows67);
}
};
namespace {
// BFMMLA tile dimensions
constexpr int32_t TILE_ROWS = 2; // M dimension
constexpr int32_t TILE_K = 4; // K reduction
constexpr int32_t TILE_COLS = 2; // N dimension (column-pair)
// Derived constants
constexpr int32_t OUTPUT_COLS_PER_BLOCK = 8; // 4 column-pairs
constexpr int32_t K_TOKENS_PER_GROUP = 8; // Tokens grouped in K cache
constexpr int32_t V_TOKENS_PER_ROW_BLOCK = 4; // Tokens per V cache row block
constexpr int32_t K_INNER_STRIDE = K_TOKENS_PER_GROUP * TILE_K;
constexpr int32_t V_INNER_STRIDE = V_TOKENS_PER_ROW_BLOCK * TILE_COLS;
constexpr int32_t PACK_ELEMENTS_PER_K_CHUNK = TILE_ROWS * TILE_K; // A packing
// Matrix Packing and Accumulator
// Reshape two rows of Q into BFMMLA-friendly interleaved
// Input: row0 = [a0,a1,a2,a3], row1 = [b0,b1,b2,b3]
// Output: [a0,a1,a2,a3,b0,b1,b2,b3, a4,a5,a6,a7,b4,b5,b6,b7]
// For K tail (K % TILE_K != 0): pads with zeros to complete the final chunk
FORCE_INLINE void reshape_Q_2xK_for_bfmmla(const c10::BFloat16* __restrict r0,
const c10::BFloat16* __restrict r1,
c10::BFloat16* __restrict dst,
int32_t K) {
const uint16_t* s0 = reinterpret_cast<const uint16_t*>(r0);
const uint16_t* s1 = reinterpret_cast<const uint16_t*>(r1);
uint16_t* d = reinterpret_cast<uint16_t*>(dst);
// Process TILE_K elements at a time (PACK_ELEMENTS_PER_K_CHUNK output)
int32_t k = 0;
for (; k + TILE_K <= K; k += TILE_K, d += PACK_ELEMENTS_PER_K_CHUNK) {
vst1q_u16(d, vcombine_u16(vld1_u16(s0 + k), vld1_u16(s1 + k)));
}
// Handle K tail: pack remaining elements with zero-padding
const int32_t tail = K - k;
if (tail > 0) {
// Pack remaining tail elements: [r0[k..k+tail-1], pad, r1[k..k+tail-1],
// pad]
for (int32_t t = 0; t < tail; ++t) {
d[t] = s0[k + t];
d[t + TILE_K] = s1[k + t];
}
// Zero-pad the rest
for (int32_t t = tail; t < TILE_K; ++t) {
d[t] = 0;
d[t + TILE_K] = 0;
}
}
}
// 2x2 accumulator load/store with compile-time row count
template <int32_t m_rows>
FORCE_INLINE float32x4_t load_acc_2x2(float* base, int64_t ldc, int col_off) {
static_assert(m_rows == 1 || m_rows == 2);
float32x2_t row0 = vld1_f32(base + col_off);
float32x2_t row1 =
(m_rows == 2) ? vld1_f32(base + ldc + col_off) : vdup_n_f32(0.f);
return vcombine_f32(row0, row1);
}
template <int32_t m_rows>
FORCE_INLINE void store_acc_2x2(float32x4_t acc, float* base, int64_t ldc,
int col_off) {
static_assert(m_rows == 1 || m_rows == 2);
vst1_f32(base + col_off, vget_low_f32(acc));
if constexpr (m_rows == 2) {
vst1_f32(base + ldc + col_off, vget_high_f32(acc));
}
}
// Initialize 4 column-pair accumulators for 2 rows (8 columns total)
#define INIT_ACC_ROWPAIR_4(a0, a1, a2, a3, Crow, ldc, m_rows, accum) \
do { \
if (accum) { \
if (m_rows == 2) { \
a0 = load_acc_2x2<2>(Crow, ldc, 0); \
a1 = load_acc_2x2<2>(Crow, ldc, 2); \
a2 = load_acc_2x2<2>(Crow, ldc, 4); \
a3 = load_acc_2x2<2>(Crow, ldc, 6); \
} else { \
a0 = load_acc_2x2<1>(Crow, ldc, 0); \
a1 = load_acc_2x2<1>(Crow, ldc, 2); \
a2 = load_acc_2x2<1>(Crow, ldc, 4); \
a3 = load_acc_2x2<1>(Crow, ldc, 6); \
} \
} else { \
a0 = a1 = a2 = a3 = vdupq_n_f32(0.f); \
} \
} while (0)
// Store 4 column-pair accumulators back to C matrix
#define STORE_ACC_ROWPAIR_4(a0, a1, a2, a3, Crow, ldc, m_rows) \
do { \
if (m_rows == 2) { \
store_acc_2x2<2>(a0, Crow, ldc, 0); \
store_acc_2x2<2>(a1, Crow, ldc, 2); \
store_acc_2x2<2>(a2, Crow, ldc, 4); \
store_acc_2x2<2>(a3, Crow, ldc, 6); \
} else { \
store_acc_2x2<1>(a0, Crow, ldc, 0); \
store_acc_2x2<1>(a1, Crow, ldc, 2); \
store_acc_2x2<1>(a2, Crow, ldc, 4); \
store_acc_2x2<1>(a3, Crow, ldc, 6); \
} \
} while (0)
// Perform 4 BFMMLA operations: acc += A @ B for 4 column-pairs
#define BFMMLA_COMPUTE_4(r0, r1, r2, r3, a, b0, b1, b2, b3) \
do { \
r0 = vbfmmlaq_f32(r0, a, b0); \
r1 = vbfmmlaq_f32(r1, a, b1); \
r2 = vbfmmlaq_f32(r2, a, b2); \
r3 = vbfmmlaq_f32(r3, a, b3); \
} while (0)
// Micro-kernel: updates a small fixed tile using BFMMLA.
// RP = number of row-pairs (1,2,4)
// Computes C[TILE_ROWS*RP, OUTPUT_COLS_PER_BLOCK] += A_packed @ B.
// A_packed interleaves RP row-pairs; B layout is driven by the attention phase:
// - AttentionGemmPhase::QK -> token-column layout (Q @ K^T)
// - AttentionGemmPhase::PV -> token-row layout (P @ V)
// K_static < 0 enables runtime K (PV only)
template <int32_t RP, int32_t K_static, AttentionGemmPhase phase>
FORCE_INLINE void gemm_rowpairs_x8_bfmmla_neon(
const bfloat16_t* const* __restrict A_packed_rp,
const int32_t* __restrict m_rows_rp, const bfloat16_t* __restrict B_blk,
float* __restrict C, int64_t ldc, bool accumulate, int64_t b_stride,
int32_t K_runtime = 0) {
static_assert(RP == 1 || RP == 2 || RP == 4, "RP must be 1,2,4");
static_assert(K_static < 0 || K_static % TILE_K == 0,
"K must be divisible by TILE_K");
static_assert(K_static >= 0 || phase == AttentionGemmPhase::PV,
"Runtime K only supported for PV");
constexpr bool runtime_k = (K_static < 0);
const int32_t K_iters =
runtime_k ? (K_runtime / TILE_K) : (K_static / TILE_K);
const int32_t K_tail = runtime_k ? (K_runtime % TILE_K) : 0;
if (!runtime_k) {
// Help the compiler fold away unused K_runtime when K is compile-time
(void)K_runtime;
}
auto* C_al = C;
const auto* B_al = B_blk;
// Setup A pointers
const bfloat16_t* a_ptr[4] = {
A_packed_rp[0],
(RP >= 2) ? A_packed_rp[1] : nullptr,
(RP >= 4) ? A_packed_rp[2] : nullptr,
(RP >= 4) ? A_packed_rp[3] : nullptr,
};
// Setup B pointers based on layout
const bfloat16_t* b_ptr[4];
if constexpr (phase == AttentionGemmPhase::PV) {
b_ptr[0] = B_blk + 0 * b_stride;
b_ptr[1] = B_blk + 1 * b_stride;
b_ptr[2] = B_blk + 2 * b_stride;
b_ptr[3] = B_blk + 3 * b_stride;
}
float32x4_t acc[4][4];
// Initialize accumulators
#define INIT_RP(rp) \
if constexpr (RP > rp) { \
INIT_ACC_ROWPAIR_4(acc[rp][0], acc[rp][1], acc[rp][2], acc[rp][3], \
C_al + (rp * 2) * ldc, ldc, m_rows_rp[rp], accumulate); \
}
INIT_RP(0);
INIT_RP(1);
INIT_RP(2);
INIT_RP(3);
#undef INIT_RP
// Main compute loop
for (int32_t ki = 0; ki < K_iters; ++ki) {
bfloat16x8_t b0, b1, b2, b3;
if constexpr (phase == AttentionGemmPhase::PV) {
b0 = vld1q_bf16(b_ptr[0] + ki * V_INNER_STRIDE);
b1 = vld1q_bf16(b_ptr[1] + ki * V_INNER_STRIDE);
b2 = vld1q_bf16(b_ptr[2] + ki * V_INNER_STRIDE);
b3 = vld1q_bf16(b_ptr[3] + ki * V_INNER_STRIDE);
} else {
const bfloat16_t* b_base = B_al + ki * b_stride;
b0 = vld1q_bf16(b_base + 0 * V_INNER_STRIDE);
b1 = vld1q_bf16(b_base + 1 * V_INNER_STRIDE);
b2 = vld1q_bf16(b_base + 2 * V_INNER_STRIDE);
b3 = vld1q_bf16(b_base + 3 * V_INNER_STRIDE);
}
#define COMPUTE_RP(rp) \
if constexpr (RP > rp) { \
bfloat16x8_t a = vld1q_bf16(a_ptr[rp] + ki * PACK_ELEMENTS_PER_K_CHUNK); \
BFMMLA_COMPUTE_4(acc[rp][0], acc[rp][1], acc[rp][2], acc[rp][3], a, b0, \
b1, b2, b3); \
}
COMPUTE_RP(0);
COMPUTE_RP(1);
COMPUTE_RP(2);
COMPUTE_RP(3);
#undef COMPUTE_RP
}
// K tail for runtime PV: fallback path
if constexpr (runtime_k) {
if (K_tail > 0) {
const int32_t tail_offset = K_iters * V_INNER_STRIDE;
const int32_t a_tail_offset = K_iters * PACK_ELEMENTS_PER_K_CHUNK;
for (int32_t kt = 0; kt < K_tail; ++kt) {
float32x4_t b_vecs[4];
for (int32_t p = 0; p < 4; ++p) {
const bfloat16_t* bp = b_ptr[p] + tail_offset + kt * TILE_COLS;
const float b0 = vcvtah_f32_bf16(bp[0]);
const float b1 = vcvtah_f32_bf16(bp[1]);
const float32x2_t b_pair = vset_lane_f32(b1, vdup_n_f32(b0), 1);
b_vecs[p] = vcombine_f32(b_pair, b_pair);
}
#define TAIL_RP(rp) \
if constexpr (RP > rp) { \
const bfloat16_t* ap = A_packed_rp[rp] + a_tail_offset; \
float a_row0 = vcvtah_f32_bf16(ap[kt]); \
float a_row1 = \
(m_rows_rp[rp] == 2) ? vcvtah_f32_bf16(ap[kt + TILE_K]) : 0.0f; \
const float32x4_t a_vec = \
vcombine_f32(vdup_n_f32(a_row0), vdup_n_f32(a_row1)); \
for (int32_t p = 0; p < 4; ++p) { \
acc[rp][p] = vmlaq_f32(acc[rp][p], a_vec, b_vecs[p]); \
} \
}
TAIL_RP(0);
TAIL_RP(1);
TAIL_RP(2);
TAIL_RP(3);
#undef TAIL_RP
}
}
}
// Store results
#define STORE_RP(rp) \
if constexpr (RP > rp) { \
STORE_ACC_ROWPAIR_4(acc[rp][0], acc[rp][1], acc[rp][2], acc[rp][3], \
C_al + (rp * 2) * ldc, ldc, m_rows_rp[rp]); \
}
STORE_RP(0);
STORE_RP(1);
STORE_RP(2);
STORE_RP(3);
#undef STORE_RP
}
// Meso-kernel: packs a small MBxK slice of A, then tiles over N and calls the
// micro-kernel for each OUTPUT_COLS_PER_BLOCK chunk. K_static < 0 enables
// runtime K (PV only).
template <int32_t MB, int32_t N, int32_t K_static, AttentionGemmPhase phase>
FORCE_INLINE void gemm_packA_compute_MB_xN(
const c10::BFloat16* __restrict A, const c10::BFloat16* __restrict B,
float* __restrict C, int32_t K_runtime, int64_t lda, int64_t ldc,
int64_t b_layout_stride, int64_t b_reduction_stride, bool accumulate) {
static_assert(MB >= 1 && MB <= 8, "MB must be in [1,8]");
static_assert(N % OUTPUT_COLS_PER_BLOCK == 0,
"N must be a multiple of OUTPUT_COLS_PER_BLOCK");
static_assert(K_static < 0 || K_static % TILE_K == 0,
"K must be divisible by TILE_K");
static_assert(K_static >= 0 || phase == AttentionGemmPhase::PV,
"Runtime K only supported for PV");
constexpr bool runtime_k = (K_static < 0);
const int32_t K_val = runtime_k ? K_runtime : K_static;
// Keep small packs on-stack to avoid heap churn
constexpr int32_t STACK_PACK_STRIDE =
(1024 / TILE_K) * PACK_ELEMENTS_PER_K_CHUNK;
constexpr int32_t ROW_PAIRS = (MB + 1) / TILE_ROWS;
const int32_t pack_stride =
runtime_k ? ((K_val + TILE_K - 1) / TILE_K) * PACK_ELEMENTS_PER_K_CHUNK
: (K_static / TILE_K) * PACK_ELEMENTS_PER_K_CHUNK;
alignas(64) c10::BFloat16 A_packed_stack[ROW_PAIRS * STACK_PACK_STRIDE];
std::vector<c10::BFloat16> A_packed_heap;
c10::BFloat16* A_packed =
(pack_stride <= STACK_PACK_STRIDE)
? A_packed_stack
: (A_packed_heap.resize(ROW_PAIRS * pack_stride),
A_packed_heap.data());
for (int32_t rp = 0; rp < ROW_PAIRS; ++rp) {
const int32_t m = rp * TILE_ROWS;
const int32_t m_rows = (m + 1 < MB) ? TILE_ROWS : 1;
const c10::BFloat16* A0 = A + m * lda;
const c10::BFloat16* A1 = (m_rows == TILE_ROWS) ? (A + (m + 1) * lda) : A0;
reshape_Q_2xK_for_bfmmla(A0, A1, A_packed + rp * pack_stride, K_val);
}
for (int32_t n = 0; n < N; n += OUTPUT_COLS_PER_BLOCK) {
const c10::BFloat16* B_blk_c10 =
(phase == AttentionGemmPhase::PV)
? (B + (n / TILE_COLS) * b_layout_stride)
: (B + (n / OUTPUT_COLS_PER_BLOCK) * b_layout_stride);
const bfloat16_t* B_blk = reinterpret_cast<const bfloat16_t*>(B_blk_c10);
// Process row-pairs in groups of 4, 2, then 1
int32_t row_pair_idx = 0;
#define PROCESS_RP_GROUP(group_size) \
for (; row_pair_idx + (group_size - 1) < ROW_PAIRS; \
row_pair_idx += group_size) { \
const bfloat16_t* Ap[group_size]; \
int32_t mr[group_size]; \
for (int32_t i = 0; i < group_size; ++i) { \
Ap[i] = reinterpret_cast<const bfloat16_t*>( \
A_packed + (row_pair_idx + i) * pack_stride); \
mr[i] = (((row_pair_idx + i) * TILE_ROWS + 1) < MB) ? TILE_ROWS : 1; \
} \
float* C_blk = C + (row_pair_idx * TILE_ROWS) * ldc + n; \
if constexpr (runtime_k) { \
gemm_rowpairs_x8_bfmmla_neon<group_size, -1, phase>( \
Ap, mr, B_blk, C_blk, ldc, accumulate, b_layout_stride, K_val); \
} else { \
gemm_rowpairs_x8_bfmmla_neon<group_size, K_static, phase>( \
Ap, mr, B_blk, C_blk, ldc, accumulate, \
(phase == AttentionGemmPhase::PV) ? b_layout_stride \
: b_reduction_stride); \
} \
}
PROCESS_RP_GROUP(4);
PROCESS_RP_GROUP(2);
PROCESS_RP_GROUP(1);
#undef PROCESS_RP_GROUP
}
}
// Macro-kernel: iterates over M in MB={8,4,2,1} chunks.
// Supports compile-time K specialization when K >= 0; otherwise uses runtime K
// (runtime K path is only supported for PV).
template <AttentionGemmPhase phase, int32_t N, int32_t K = -1>
FORCE_INLINE void gemm_macro_neon_bfmmla(
const c10::BFloat16* __restrict A, const c10::BFloat16* __restrict B,
float* __restrict C, int32_t M, int32_t K_runtime, int64_t lda, int64_t ldc,
int64_t b_layout_stride, int64_t b_reduction_stride, bool accumulate) {
static_assert(N % OUTPUT_COLS_PER_BLOCK == 0,
"N must be a multiple of OUTPUT_COLS_PER_BLOCK");
if constexpr (K >= 0) {
static_assert(K % TILE_K == 0, "K must be divisible by TILE_K");
for (int32_t m = 0; m < M;) {
const int32_t rem = M - m;
const c10::BFloat16* A_blk = A + m * lda;
float* C_blk = C + m * ldc;
#define DISPATCH_MB(mb) \
gemm_packA_compute_MB_xN<mb, N, K, phase>(A_blk, B, C_blk, 0, lda, ldc, \
b_layout_stride, \
b_reduction_stride, accumulate)
if (rem >= 8) {
DISPATCH_MB(8);
m += 8;
} else if (rem >= 4) {
DISPATCH_MB(4);
m += 4;
} else if (rem >= 2) {
DISPATCH_MB(2);
m += 2;
} else {
DISPATCH_MB(1);
m += 1;
}
#undef DISPATCH_MB
}
} else {
static_assert(phase == AttentionGemmPhase::PV,
"Runtime K specialization only supported for PV.");
const int32_t K_val = K_runtime;
for (int32_t m = 0; m < M;) {
const int32_t rem = M - m;
const c10::BFloat16* A_blk = A + m * lda;
float* C_blk = C + m * ldc;
#define DISPATCH_MB_RUNTIME(mb) \
gemm_packA_compute_MB_xN<mb, N, -1, phase>(A_blk, B, C_blk, K_val, lda, ldc, \
b_layout_stride, \
b_reduction_stride, accumulate)
if (rem >= 8) {
DISPATCH_MB_RUNTIME(8);
m += 8;
} else if (rem >= 4) {
DISPATCH_MB_RUNTIME(4);
m += 4;
} else if (rem >= 2) {
DISPATCH_MB_RUNTIME(2);
m += 2;
} else {
DISPATCH_MB_RUNTIME(1);
m += 1;
}
#undef DISPATCH_MB_RUNTIME
}
}
}
#undef INIT_ACC_ROWPAIR_4
#undef STORE_ACC_ROWPAIR_4
#undef BFMMLA_COMPUTE_4
constexpr int32_t TILE_K = BfmmlaGemm::KTile;
constexpr int32_t TILE_COLS = 2;
constexpr int32_t OUTPUT_COLS_PER_BLOCK = BfmmlaGemm::NTile;
constexpr int32_t K_TOKENS_PER_GROUP = 8;
constexpr int32_t V_TOKENS_PER_ROW_BLOCK = 4;
constexpr int32_t K_CACHE_K_GROUP_STRIDE = K_TOKENS_PER_GROUP * TILE_K;
constexpr int32_t B_COL_PAIR_STRIDE = V_TOKENS_PER_ROW_BLOCK * TILE_COLS;
} // namespace
// TileGemm Adapter for Attention
template <typename kv_cache_t, int32_t BlockTokens, int32_t HeadDim>
class TileGemmNEONBFMMLA {
public:
@@ -443,49 +286,31 @@ class TileGemmNEONBFMMLA {
[[maybe_unused]] const int32_t block_size,
[[maybe_unused]] const int32_t dynamic_k_size,
const bool accum_c) {
static_assert(BlockTokens % OUTPUT_COLS_PER_BLOCK == 0);
// BFMMLA kernels require compile-time head_dim; keep head_dim_ct only for
// API parity with other tile_gemm implementations.
static_assert(BlockTokens % 16 == 0);
if constexpr (head_dim_ct >= 0) {
static_assert(head_dim_ct == HeadDim,
"BFMMLA expects head_dim_ct to match HeadDim; PV passes "
"-1 for API parity.");
static_assert(head_dim_ct == HeadDim);
}
const auto* a = reinterpret_cast<const c10::BFloat16*>(a_tile);
if constexpr (phase == AttentionGemmPhase::QK) {
const int64_t b_reduction_stride = K_INNER_STRIDE;
const int64_t b_token_block_stride = (HeadDim / TILE_K) * K_INNER_STRIDE;
constexpr int64_t b_n_group_stride =
(HeadDim / BfmmlaGemm::KTile) * K_CACHE_K_GROUP_STRIDE;
gemm_macro_neon_bfmmla<AttentionGemmPhase::QK, BlockTokens, HeadDim>(
reinterpret_cast<const c10::BFloat16*>(a_tile), b_tile, c_tile,
m_size, 0, lda, ldc, b_token_block_stride, b_reduction_stride,
accum_c);
for (int32_t row = 0; row < m_size; row += BfmmlaGemm::MaxRows) {
const int32_t panel_m = std::min(BfmmlaGemm::MaxRows, m_size - row);
BfmmlaGemm::gemm(a + row * HeadDim, b_tile, c_tile + row * ldc, panel_m,
BlockTokens, HeadDim, 2 * HeadDim, b_n_group_stride,
K_CACHE_K_GROUP_STRIDE, ldc, accum_c);
}
} else {
const int64_t b_pair_stride =
(block_size / V_TOKENS_PER_ROW_BLOCK) * V_INNER_STRIDE;
const int64_t b_n_group_stride =
(block_size / V_TOKENS_PER_ROW_BLOCK) * K_CACHE_K_GROUP_STRIDE;
// PV gemm with runtime K specialization
switch (dynamic_k_size) {
case 32:
gemm_macro_neon_bfmmla<AttentionGemmPhase::PV, HeadDim, 32>(
reinterpret_cast<const c10::BFloat16*>(a_tile), b_tile, c_tile,
m_size, 32, lda, ldc, b_pair_stride, 0, accum_c);
break;
case 128:
gemm_macro_neon_bfmmla<AttentionGemmPhase::PV, HeadDim, 128>(
reinterpret_cast<const c10::BFloat16*>(a_tile), b_tile, c_tile,
m_size, 128, lda, ldc, b_pair_stride, 0, accum_c);
break;
case 256:
gemm_macro_neon_bfmmla<AttentionGemmPhase::PV, HeadDim, 256>(
reinterpret_cast<const c10::BFloat16*>(a_tile), b_tile, c_tile,
m_size, 256, lda, ldc, b_pair_stride, 0, accum_c);
break;
default:
gemm_macro_neon_bfmmla<AttentionGemmPhase::PV, HeadDim>(
reinterpret_cast<const c10::BFloat16*>(a_tile), b_tile, c_tile,
m_size, dynamic_k_size, lda, ldc, b_pair_stride, 0, accum_c);
break;
for (int32_t row = 0; row < m_size; row += BfmmlaGemm::MaxRows) {
const int32_t panel_m = std::min(BfmmlaGemm::MaxRows, m_size - row);
BfmmlaGemm::gemm(a + row * lda, b_tile, c_tile + row * ldc, panel_m,
HeadDim, dynamic_k_size, 2 * lda, b_n_group_stride,
K_CACHE_K_GROUP_STRIDE, ldc, accum_c);
}
}
}
@@ -512,9 +337,11 @@ class AttentionImplNEONBFMMLA {
static constexpr int64_t HeadDim = head_dim;
static constexpr ISA ISAType = isa_type;
static constexpr bool scale_on_logits = false;
static constexpr int64_t VCacheNGroup = OUTPUT_COLS_PER_BLOCK;
static constexpr int64_t VCacheKGroupStride = VCacheNGroup * TILE_K;
static_assert(HeadDim % OUTPUT_COLS_PER_BLOCK == 0);
static_assert(BlockSizeAlignment % OUTPUT_COLS_PER_BLOCK == 0);
static_assert(HeadDim % (2 * OUTPUT_COLS_PER_BLOCK) == 0);
static_assert(BlockSizeAlignment % K_TOKENS_PER_GROUP == 0);
static_assert(HeadDim % TILE_K == 0, "HeadDim must be a multiple of TILE_K");
public:
@@ -527,19 +354,36 @@ class AttentionImplNEONBFMMLA {
attention_iteration(CPU_ATTENTION_PARAMS);
}
struct ProbabilityTokenStore {
static constexpr int32_t TokenStride = 2;
FORCE_INLINE static void store_probabilities(
c10::BFloat16* __restrict__ probability,
const vec_op::FP32Vec16& values, const int32_t row,
const int64_t row_stride) {
const int32_t row_in_pair = row & 1;
auto* dst = reinterpret_cast<bfloat16_t*>(
probability + row_in_pair * (BfmmlaGemm::KTile - row_stride));
vst1_bf16(dst, vcvt_bf16_f32(values.reg.val[0]));
vst1_bf16(dst + 2 * BfmmlaGemm::KTile, vcvt_bf16_f32(values.reg.val[1]));
vst1_bf16(dst + 4 * BfmmlaGemm::KTile, vcvt_bf16_f32(values.reg.val[2]));
vst1_bf16(dst + 6 * BfmmlaGemm::KTile, vcvt_bf16_f32(values.reg.val[3]));
}
};
// Key cache stride per token group (TokenColumn layout; QK)
static constexpr int64_t k_cache_token_group_stride(
[[maybe_unused]] const int32_t block_size) {
static_assert(BlockSizeAlignment % K_TOKENS_PER_GROUP == 0);
return (BlockSizeAlignment / K_TOKENS_PER_GROUP) *
((head_dim / TILE_K) * K_INNER_STRIDE);
((head_dim / TILE_K) * K_CACHE_K_GROUP_STRIDE);
}
// Value cache stride per token group (TokenRow layout; PV)
static constexpr int64_t v_cache_token_group_stride(
[[maybe_unused]] const int32_t block_size) {
static_assert(BlockSizeAlignment % V_TOKENS_PER_ROW_BLOCK == 0);
return (BlockSizeAlignment / V_TOKENS_PER_ROW_BLOCK) * V_INNER_STRIDE;
return (BlockSizeAlignment / V_TOKENS_PER_ROW_BLOCK) * VCacheKGroupStride;
}
// The stride to move to the "next" head_dim group
@@ -550,7 +394,7 @@ class AttentionImplNEONBFMMLA {
return head_dim * block_size;
}
// Convert Q heads to BF16 and apply scale factor using native BF16 intrinsics
// Scale Q and write row pairs in BFMMLA reduction order.
static void copy_q_heads_tile(c10::BFloat16* __restrict__ src,
c10::BFloat16* __restrict__ q_buffer,
const int32_t q_num,
@@ -559,25 +403,42 @@ class AttentionImplNEONBFMMLA {
const int64_t q_head_stride, float scale) {
constexpr int32_t dim = static_cast<int32_t>(head_dim);
const float32x4_t scale_vec = vdupq_n_f32(scale);
const bfloat16x4_t zero = vdup_n_bf16(bfloat16_t{});
const int32_t row_num = q_num * q_heads_per_kv;
for (int32_t qi = 0; qi < q_num; ++qi) {
for (int32_t hi = 0; hi < q_heads_per_kv; ++hi) {
c10::BFloat16* __restrict__ curr_q =
src + qi * q_num_stride + hi * q_head_stride;
c10::BFloat16* __restrict__ dst =
q_buffer + qi * q_heads_per_kv * head_dim + hi * head_dim;
for (int32_t row = 0; row < row_num; row += 2) {
const int32_t q0 = row / q_heads_per_kv;
const int32_t h0 = row % q_heads_per_kv;
const auto* row0 = reinterpret_cast<const bfloat16_t*>(
src + q0 * q_num_stride + h0 * q_head_stride);
const bool has_row1 = row + 1 < row_num;
const int32_t q1 = (row + 1) / q_heads_per_kv;
const int32_t h1 = (row + 1) % q_heads_per_kv;
const auto* row1 = has_row1
? reinterpret_cast<const bfloat16_t*>(
src + q1 * q_num_stride + h1 * q_head_stride)
: nullptr;
auto* dst = reinterpret_cast<bfloat16_t*>(q_buffer + row * head_dim);
for (int32_t i = 0; i < dim; i += OUTPUT_COLS_PER_BLOCK) {
bfloat16x8_t in8 =
vld1q_bf16(reinterpret_cast<const bfloat16_t*>(curr_q + i));
float32x4_t lo = vmulq_f32(vcvtq_low_f32_bf16(in8), scale_vec);
float32x4_t hi = vmulq_f32(vcvtq_high_f32_bf16(in8), scale_vec);
for (int32_t k = 0; k < dim; k += OUTPUT_COLS_PER_BLOCK) {
const bfloat16x8_t in0 = vld1q_bf16(row0 + k);
const bfloat16x4_t out0_lo =
vcvt_bf16_f32(vmulq_f32(vcvtq_low_f32_bf16(in0), scale_vec));
const bfloat16x4_t out0_hi =
vcvt_bf16_f32(vmulq_f32(vcvtq_high_f32_bf16(in0), scale_vec));
bfloat16x4_t lo_b = vcvt_bf16_f32(lo);
bfloat16x4_t hi_b = vcvt_bf16_f32(hi);
bfloat16x8_t out = vcombine_bf16(lo_b, hi_b);
vst1q_bf16(reinterpret_cast<bfloat16_t*>(dst + i), out);
bfloat16x4_t out1_lo = zero;
bfloat16x4_t out1_hi = zero;
if (has_row1) {
const bfloat16x8_t in1 = vld1q_bf16(row1 + k);
out1_lo =
vcvt_bf16_f32(vmulq_f32(vcvtq_low_f32_bf16(in1), scale_vec));
out1_hi =
vcvt_bf16_f32(vmulq_f32(vcvtq_high_f32_bf16(in1), scale_vec));
}
vst1q_bf16(dst + 2 * k, vcombine_bf16(out0_lo, out1_lo));
vst1q_bf16(dst + 2 * k + 8, vcombine_bf16(out0_hi, out1_hi));
}
}
}
@@ -585,11 +446,12 @@ class AttentionImplNEONBFMMLA {
public:
// Reshape and cache K/V into BFMMLA-optimized layouts
// K cache:
// [block_size/K_TOKENS_PER_GROUP][head_dim/TILE_K][K_INNER_STRIDE]
// [block_size/K_TOKENS_PER_GROUP][head_dim/TILE_K]
// [K_CACHE_K_GROUP_STRIDE]
// - TokenColumn
// V cache:
// [head_dim/TILE_COLS][block_size/V_TOKENS_PER_ROW_BLOCK][V_INNER_STRIDE]
// - TokenRows
// [head_dim/VCacheNGroup][block_size/V_TOKENS_PER_ROW_BLOCK]
// [VCacheKGroupStride]
static void reshape_and_cache(
const c10::BFloat16* __restrict__ key,
const c10::BFloat16* __restrict__ value,
@@ -604,13 +466,13 @@ class AttentionImplNEONBFMMLA {
const int64_t block_size,
[[maybe_unused]] const int64_t block_size_stride,
const float /*k_inv*/ = 0.0f, const float /*v_inv*/ = 0.0f) {
const int64_t k_block_stride = (head_dim / TILE_K) * K_INNER_STRIDE;
const int64_t v_pair_stride =
(block_size / V_TOKENS_PER_ROW_BLOCK) * V_INNER_STRIDE;
const int64_t k_block_stride = (head_dim / TILE_K) * K_CACHE_K_GROUP_STRIDE;
const int64_t v_n_group_stride =
(block_size / V_TOKENS_PER_ROW_BLOCK) * VCacheKGroupStride;
#pragma omp parallel for
for (int64_t head_idx = 0; head_idx < head_num; ++head_idx) {
for (int64_t token_idx = 0; token_idx < token_num; ++token_idx) {
#pragma omp parallel for collapse(2)
for (int64_t token_idx = 0; token_idx < token_num; ++token_idx) {
for (int64_t head_idx = 0; head_idx < head_num; ++head_idx) {
const int64_t pos = slot_mapping[token_idx];
if (pos < 0) continue;
@@ -636,12 +498,12 @@ class AttentionImplNEONBFMMLA {
key_base + block_in_block * k_block_stride;
for (int64_t hd4 = 0; hd4 < head_dim / TILE_K; ++hd4) {
uint16_t* dst_u16 = reinterpret_cast<uint16_t*>(
block_base + hd4 * K_INNER_STRIDE +
pair_in_block * V_INNER_STRIDE + lane_base);
const uint16_t* src_u16 =
reinterpret_cast<const uint16_t*>(key_src + hd4 * TILE_K);
vst1_u16(dst_u16, vld1_u16(src_u16));
bfloat16_t* dst = reinterpret_cast<bfloat16_t*>(
block_base + hd4 * K_CACHE_K_GROUP_STRIDE +
pair_in_block * B_COL_PAIR_STRIDE + lane_base);
const bfloat16_t* src =
reinterpret_cast<const bfloat16_t*>(key_src + hd4 * TILE_K);
vst1_bf16(dst, vld1_bf16(src));
}
}
@@ -655,22 +517,33 @@ class AttentionImplNEONBFMMLA {
value_cache + block_idx * num_blocks_stride +
head_idx * cache_head_num_stride;
const int64_t row_block = block_offset / V_TOKENS_PER_ROW_BLOCK;
const int64_t token_group = block_offset / V_TOKENS_PER_ROW_BLOCK;
const int64_t lane = block_offset & (V_TOKENS_PER_ROW_BLOCK - 1);
c10::BFloat16* __restrict row_block_base =
value_base + row_block * V_INNER_STRIDE;
const auto* src = reinterpret_cast<const bfloat16_t*>(value_src);
auto* dst = reinterpret_cast<bfloat16_t*>(value_base);
for (int64_t hd8 = 0; hd8 < head_dim / OUTPUT_COLS_PER_BLOCK; ++hd8) {
const bfloat16x8_t values =
vld1q_bf16(src + hd8 * OUTPUT_COLS_PER_BLOCK);
const bfloat16x4_t low = vget_low_bf16(values);
const bfloat16x4_t high = vget_high_bf16(values);
bfloat16_t* group =
dst + hd8 * v_n_group_stride + token_group * VCacheKGroupStride;
for (int64_t hd2 = 0; hd2 < head_dim / TILE_COLS; ++hd2) {
c10::BFloat16* __restrict dst_val =
row_block_base + hd2 * v_pair_stride;
const uint16_t* src_u16 =
reinterpret_cast<const uint16_t*>(value_src);
uint16_t* dst_u16 = reinterpret_cast<uint16_t*>(dst_val);
dst_u16[lane] = src_u16[hd2 * TILE_COLS + 0];
dst_u16[lane + V_TOKENS_PER_ROW_BLOCK] =
src_u16[hd2 * TILE_COLS + 1];
vst1_lane_bf16(group + lane, low, 0);
vst1_lane_bf16(group + V_TOKENS_PER_ROW_BLOCK + lane, low, 1);
vst1_lane_bf16(group + B_COL_PAIR_STRIDE + lane, low, 2);
vst1_lane_bf16(
group + B_COL_PAIR_STRIDE + V_TOKENS_PER_ROW_BLOCK + lane, low,
3);
vst1_lane_bf16(group + 2 * B_COL_PAIR_STRIDE + lane, high, 0);
vst1_lane_bf16(
group + 2 * B_COL_PAIR_STRIDE + V_TOKENS_PER_ROW_BLOCK + lane,
high, 1);
vst1_lane_bf16(group + 3 * B_COL_PAIR_STRIDE + lane, high, 2);
vst1_lane_bf16(
group + 3 * B_COL_PAIR_STRIDE + V_TOKENS_PER_ROW_BLOCK + lane,
high, 3);
}
}
}
@@ -680,4 +553,4 @@ class AttentionImplNEONBFMMLA {
} // namespace cpu_attention
#endif // CPU_ATTN_ASIMD_BFMMLA_HPP
#endif // CPU_ATTN_NEON_BFMMLA_HPP
@@ -106,6 +106,82 @@ If you enable prefill instance (`--prefill-servers-urls` not disabled), you will
| `--decode-servers-urls` | Comma-separated list of decode endpoints. Non-stream and stream paths both round-robin over this list. |
| `--host`, `--port` | Bind address for the proxy itself (defaults: `0.0.0.0:8000`). |
### Dynamic registration
Alternatively, let the external launcher register ready instances over HTTP.
No vLLM configuration changes or worker registration threads are required.
Set `ADMIN_API_KEY` on the proxy and supply it as `X-API-Key` for registration
and removal. These are trusted control-plane APIs; do not expose them publicly.
```bash
export ADMIN_API_KEY="your-admin-key"
python disagg_epd_proxy.py --port 8000 --dynamic-registration
```
Omit the static server URL flags and register every stage. The roles determine
the topology: `encode` + `prefill_decode` gives E+PD; `encode` + `prefill` +
`decode` gives E+P+D. Standalone `decode` always requires an available `prefill`,
even when D registers first or all P instances go offline; otherwise requests
return `503`. The proxy rejects mixing combined PD with standalone P/D,
including unhealthy instances still in the registry. Explicitly remove the
old topology's P/D or PD registrations before switching topologies.
After each instance is ready, the launcher registers its reachable HTTP URL:
```bash
curl --fail-with-body http://proxy-host:8000/instances \
-H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
-d '{"role":"encode","url":"http://e-host:8001"}'
curl --fail-with-body http://proxy-host:8000/instances \
-H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
-d '{"role":"prefill_decode","url":"http://pd-host:8002"}'
```
For E+P+D, register P with `role: "prefill"` and D with `role: "decode"`.
Example and NIXL EC connectors need no additional registration fields.
For Mooncake, register `ec_zmq_addrs` on the **EC consumer** (`prefill_decode`
or `prefill`), using its configured `ec_ip` and `ec_port`. Keep the fixed-port
layout: supply the TP-rank-0 address for each DP replica, in DP-rank order;
replica `r` uses `ec_port + r * tensor_parallel_size`. The connector discovers
the remaining TP ranks itself. For example, DP=2, TP=2, `ec_port=19019`:
```json
{
"role": "prefill_decode",
"url": "http://pd-host:8002",
"dp_size": 2,
"ec_zmq_addrs": ["tcp://pd-host:19019", "tcp://pd-host:19021"]
}
```
The proxy selects one consumer replica and uses it for both the encoder push
and the consumer HTTP request. Standalone D does not need EC control addresses.
Port allocation and avoiding collisions remain the launcher's responsibility.
Inspect or remove instances without restarting the proxy:
```bash
curl http://proxy-host:8000/instances
curl --fail-with-body -X DELETE \
'http://proxy-host:8000/instances?url=http://e-host:8001' \
-H "X-API-Key: $ADMIN_API_KEY"
```
Registration is idempotent. The proxy probes registered instances every
`--probe-interval` seconds (default 5), with `--probe-timeout` seconds per probe
(default 2). After `--fail-threshold` consecutive failures (default 3), it stops
sending new requests to that instance. Healthy instances rejoin automatically;
unreachable ones are forgotten after `--evicted-ttl` seconds (default 900;
`0` retains them indefinitely). Removal stops new routing; already routed
requests retain their selected endpoints, so drain requests before stopping
the instance. The launcher must re-register instances after a proxy restart.
The example launch scripts also support this flow: export `ADMIN_API_KEY` and
set `DYNAMIC_REGISTRATION=1` when running `disagg_1e1pd_example.sh` or
`disagg_1e1p1d_example.sh`. Their default remains static routing.
### Static configuration
The proxy batches images from the same user request assigned to the same encoder.
Set the proxy environment variable `ENCODER_MAX_BATCH_SIZE` to limit the number
of images per encoder subrequest. It defaults to `0` (unlimited); `1` sends each
@@ -137,6 +213,6 @@ For E + P + D setup:
```bash
$ python disagg_encoder_proxy.py \
--encode-servers-urls "http://e1:8001,http://e2:8001" \
--prefill-servers-urls "http://p1:8003,http://p2:8004" \
--prefill-servers-urls "http://p1:8003,http://p2:8004" \
--decode-servers-urls "http://d1:8005,http://d2:8006"
```
@@ -189,17 +189,34 @@ wait_for_server "$DECODE_PORT"
###############################################################################
# Proxy
###############################################################################
PROXY_ARGS=()
if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
: "${ADMIN_API_KEY:?Set ADMIN_API_KEY for dynamic registration}"
export ADMIN_API_KEY
PROXY_ARGS=(--dynamic-registration)
else
PROXY_ARGS=(
--encode-servers-urls "http://localhost:$ENCODE_PORT"
--prefill-servers-urls "http://localhost:$PREFILL_PORT"
--decode-servers-urls "http://localhost:$DECODE_PORT"
)
fi
python disagg_epd_proxy.py \
--host "0.0.0.0" \
--port "$PROXY_PORT" \
--encode-servers-urls "http://localhost:$ENCODE_PORT" \
--prefill-servers-urls "http://localhost:$PREFILL_PORT" \
--decode-servers-urls "http://localhost:$DECODE_PORT" \
"${PROXY_ARGS[@]}" \
>"${PROXY_LOG}" 2>&1 &
PIDS+=($!)
wait_for_server "$PROXY_PORT"
if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
for endpoint in "encode:$ENCODE_PORT" "prefill:$PREFILL_PORT" "decode:$DECODE_PORT"; do
curl --fail-with-body "http://127.0.0.1:$PROXY_PORT/instances" \
-H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
-d "{\"role\":\"${endpoint%%:*}\",\"url\":\"http://127.0.0.1:${endpoint#*:}\"}"
done
fi
echo "All services are up!"
###############################################################################
@@ -238,4 +255,4 @@ curl http://127.0.0.1:"${PROXY_PORT}"/v1/chat/completions \
# cleanup
echo "cleanup..."
cleanup
cleanup
@@ -152,17 +152,33 @@ wait_for_server "$PREFILL_DECODE_PORT"
###############################################################################
# Proxy
###############################################################################
PROXY_ARGS=()
if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
: "${ADMIN_API_KEY:?Set ADMIN_API_KEY for dynamic registration}"
export ADMIN_API_KEY
PROXY_ARGS=(--dynamic-registration)
else
PROXY_ARGS=(
--encode-servers-urls "http://localhost:$ENCODE_PORT"
--decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT"
)
fi
python disagg_epd_proxy.py \
--host "0.0.0.0" \
--port "$PROXY_PORT" \
--encode-servers-urls "http://localhost:$ENCODE_PORT" \
--prefill-servers-urls "disable" \
--decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT" \
"${PROXY_ARGS[@]}" \
>"${PROXY_LOG}" 2>&1 &
PIDS+=($!)
wait_for_server "$PROXY_PORT"
if [[ "${DYNAMIC_REGISTRATION:-0}" == "1" ]]; then
for endpoint in "encode:$ENCODE_PORT" "prefill_decode:$PREFILL_DECODE_PORT"; do
curl --fail-with-body "http://127.0.0.1:$PROXY_PORT/instances" \
-H "X-API-Key: $ADMIN_API_KEY" -H 'Content-Type: application/json' \
-d "{\"role\":\"${endpoint%%:*}\",\"url\":\"http://127.0.0.1:${endpoint#*:}\"}"
done
fi
echo "All services are up!"
###############################################################################
@@ -201,4 +217,4 @@ curl http://127.0.0.1:"${PROXY_PORT}"/v1/chat/completions \
# cleanup
echo "cleanup..."
cleanup
cleanup
@@ -1,12 +1,13 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""disagg_encoder_proxy.py
"""OpenAI-compatible proxy for E+PD and E+P+D disaggregation.
Proxy that routes OpenAI-compatible “/v1/chat/completions” requests to two
clusters:
• encode (multimodal feature extraction)
• decode (language-model inference)
Use static server URLs, or --dynamic-registration for launcher-managed HTTP
registration of E, P/PD and D instances. Both modes route OpenAI-compatible
``/v1/chat/completions`` requests through encoder and inference clusters and
share encoder fan-out, metadata-only rewriting, prefill, and retry/streaming
forwarding.
For MM input we:
1. Extract *every* image/audio/video item.
@@ -20,22 +21,27 @@ from __future__ import annotations
import argparse
import asyncio
import enum
import hashlib
import itertools
import json
import logging
import os
import random
import secrets
import time
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from typing import Any
import aiohttp
import msgspec
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
###############################################################################
# FastAPI app & global state
@@ -44,6 +50,340 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
logger = logging.getLogger("proxy")
DEFAULT_PROBE_INTERVAL = 5.0
DEFAULT_PROBE_TIMEOUT = 2.0
DEFAULT_FAIL_THRESHOLD = 3
# Stop probing an instance that has been down this long. 0 probes forever,
# which is what a cluster that restarts instances in place wants.
DEFAULT_EVICTED_TTL = 900.0
class InstanceRole(str, enum.Enum):
ENCODE = "encode"
PREFILL = "prefill"
DECODE = "decode"
PREFILL_DECODE = "prefill_decode"
@dataclass
class InstanceRecord:
"""One registered instance.
Attributes:
role: Which stage this instance serves.
url: Base OpenAI-compatible URL, e.g. ``http://host:8000``.
ec_zmq_addrs: Mooncake TP-rank-0 control addresses, one per DP replica,
supplied by the launcher from the consumer's fixed port config.
dp_size: Data-parallel replicas behind `url`, so the proxy can pick
a replica and name the same one to both halves of a request.
"""
role: InstanceRole
url: str
ec_zmq_addrs: list[str] = field(default_factory=list)
dp_size: int = 1
class InstanceRegistry:
def __init__(
self,
probe_interval: float = DEFAULT_PROBE_INTERVAL,
probe_timeout: float = DEFAULT_PROBE_TIMEOUT,
fail_threshold: int = DEFAULT_FAIL_THRESHOLD,
evicted_ttl: float = DEFAULT_EVICTED_TTL,
):
self._probe_interval = probe_interval
self._probe_timeout = probe_timeout
self._fail_threshold = fail_threshold
self._evicted_ttl = evicted_ttl
self._live: dict[str, InstanceRecord] = {}
self._evicted: dict[str, InstanceRecord] = {}
self._evicted_since: dict[str, float] = {}
self._fail_counts: dict[str, int] = {}
# One cursor per role, only ever incremented. Rebuilding it whenever
# the roster changes -- what an `itertools.cycle` over a mutable list
# forces -- restarts every fan-out at the first instance and hot-spots
# it after each registration.
self._cursors: dict[InstanceRole, int] = {role: 0 for role in InstanceRole}
self._replica_cursors: dict[str, int] = {}
self._probe_task: asyncio.Task | None = None
def register(self, record: InstanceRecord) -> bool:
"""Add or refresh an instance without overriding its health status."""
roles = {
other.role
for other in itertools.chain(self._live.values(), self._evicted.values())
}
split_roles = {InstanceRole.PREFILL, InstanceRole.DECODE}
if (record.role is InstanceRole.PREFILL_DECODE and roles & split_roles) or (
record.role in split_roles and InstanceRole.PREFILL_DECODE in roles
):
raise ValueError("Cannot mix prefill_decode with standalone prefill/decode")
key = record.url
previous = self._live.get(key) or self._evicted.get(key)
if previous is not None:
if previous == record:
return False
if previous.role is not record.role:
raise ValueError("Unregister the instance before changing its role")
target = self._live if key in self._live else self._evicted
target[key] = record
return False
self._live[key] = record
logger.info("Registered instance %s: %s", record.role.value, record.url)
return True
def unregister(self, url: str) -> bool:
"""Drop an instance for good, so a probe cannot bring it back."""
key = url
if key not in self._live and key not in self._evicted:
return False
self._live.pop(key, None)
self._evicted.pop(key, None)
self._evicted_since.pop(key, None)
self._fail_counts.pop(key, None)
self._replica_cursors.pop(key, None)
logger.info("Unregistered instance: %s", url)
return True
def instances(self, role: InstanceRole) -> list[InstanceRecord]:
return [record for record in self._live.values() if record.role is role]
def urls(self, role: InstanceRole) -> list[str]:
return [record.url for record in self.instances(role)]
def pick(self, role: InstanceRole) -> InstanceRecord | None:
"""Take the next instance of `role` in round-robin order."""
picked = self.pick_many(role, 1)
return picked[0] if picked else None
def pick_many(self, role: InstanceRole, count: int) -> list[InstanceRecord]:
"""Take `count` instances, continuing the rotation across calls.
A multimodal request fans out one encoder request per item, so the
assignment has to be contiguous with the previous request's rather
than restart at the first instance every time.
"""
alive = self.instances(role)
if not alive or count <= 0:
return []
start = self._cursors[role]
self._cursors[role] = start + count
return [alive[(start + offset) % len(alive)] for offset in range(count)]
def next_replica(self, record: InstanceRecord) -> int:
"""Take the next data-parallel replica of `record`, round-robin.
The encoder pushes to one replica's receive channel, so the request
has to run on that same replica; the caller names the rank to both
halves.
"""
if record.dp_size <= 1:
return 0
cursor = self._replica_cursors.get(record.url, 0)
self._replica_cursors[record.url] = cursor + 1
return cursor % record.dp_size
def status(self) -> dict[str, Any]:
return {
role.value: {
"live": [record.url for record in self.instances(role)],
"evicted": [
record.url
for record in self._evicted.values()
if record.role is role
],
}
for role in InstanceRole
}
def start_probing(self) -> None:
if self._probe_task is None and self._probe_interval > 0:
self._probe_task = asyncio.create_task(self._probe_loop())
async def stop_probing(self) -> None:
if self._probe_task is None:
return
self._probe_task.cancel()
with suppress(asyncio.CancelledError):
await self._probe_task
self._probe_task = None
async def _probe_loop(self) -> None:
timeout = aiohttp.ClientTimeout(total=self._probe_timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
while True:
await asyncio.sleep(self._probe_interval)
try:
await self._probe_once(session)
except Exception:
logger.exception("EPD registry probe round failed")
async def _probe_once(self, session: aiohttp.ClientSession) -> None:
targets = list(self._live.values()) + list(self._evicted.values())
if not targets:
return
results = await asyncio.gather(
*(self._probe(session, record.url) for record in targets),
return_exceptions=True,
)
now = time.monotonic()
for record, healthy in zip(targets, results):
current = self._live.get(record.url) or self._evicted.get(record.url)
if current is not record:
continue
if healthy is True:
self._on_probe_success(record)
else:
self._on_probe_failure(record, now)
self._drop_expired(now)
async def _probe(self, session: aiohttp.ClientSession, url: str) -> bool:
async with session.get(f"{url}/health") as resp:
return resp.status == 200
def _on_probe_success(self, record: InstanceRecord) -> None:
key = record.url
self._fail_counts.pop(key, None)
if key in self._evicted:
self._evicted.pop(key, None)
self._evicted_since.pop(key, None)
self._live[key] = record
logger.info(
"Instance %s (%s) is healthy again; routing resumed",
record.url,
record.role.value,
)
def _on_probe_failure(self, record: InstanceRecord, now: float) -> None:
key = record.url
if key not in self._live:
# Either already evicted, or unregistered while this probe was in
# flight. A round snapshots its targets and then awaits, so a
# removal inside that window would otherwise be undone by a result
# describing a registry that no longer exists.
return
failures = self._fail_counts.get(key, 0) + 1
self._fail_counts[key] = failures
if failures < self._fail_threshold:
return
self._live.pop(key, None)
self._evicted[key] = record
self._evicted_since[key] = now
logger.warning(
"Instance %s (%s) failed %d consecutive probes; stopped routing "
"to it. It rejoins on its own once it responds again.",
record.url,
record.role.value,
failures,
)
def _drop_expired(self, now: float) -> None:
if self._evicted_ttl <= 0:
return
for key, since in list(self._evicted_since.items()):
if now - since < self._evicted_ttl:
continue
record = self._evicted.pop(key, None)
self._evicted_since.pop(key, None)
self._fail_counts.pop(key, None)
self._replica_cursors.pop(key, None)
if record is not None:
logger.warning("Instance %s stayed down; forgetting it", record.url)
class InstanceRegistration(BaseModel):
role: InstanceRole
url: AnyHttpUrl
ec_zmq_addrs: list[str] = Field(default_factory=list)
dp_size: int = Field(default=1, ge=1)
@model_validator(mode="after")
def validate_consumer_addresses(self):
if self.ec_zmq_addrs and len(self.ec_zmq_addrs) != self.dp_size:
raise ValueError("Provide one Mooncake control address per DP replica")
return self
def require_admin_key(x_api_key: str = Header(default="")) -> None:
expected = os.getenv("ADMIN_API_KEY", "")
if not expected or not secrets.compare_digest(x_api_key, expected):
raise HTTPException(403, "Invalid admin API key")
@dataclass
class EPDProxyConfig:
probe_interval: float = 5.0
probe_timeout: float = 2.0
fail_threshold: int = 3
evicted_ttl: float = 900.0
@dataclass
class _Route:
"""The instances one request was assigned to.
Attributes:
consumer: The stage that receives the embedding, if any stage does.
consumer_zmq: The receive address named to the encoders.
dp_rank: Which replica of `consumer` was named, so the request can be
pinned to it.
"""
encoder_urls: list[str]
prefill: InstanceRecord | None
decode: InstanceRecord
consumer: InstanceRecord | None = None
consumer_zmq: str | None = None
dp_rank: int | None = None
class EPDProxy:
def __init__(self, registry: InstanceRegistry):
self.registry = registry
# ---------------------------------------------------------------- #
# Routing #
# ---------------------------------------------------------------- #
def route(self, num_items: int) -> _Route:
decode = self.registry.pick(InstanceRole.PREFILL_DECODE) or self.registry.pick(
InstanceRole.DECODE
)
if decode is None:
raise HTTPException(
status_code=503, detail="No decode instance is registered"
)
prefill = (
self.registry.pick(InstanceRole.PREFILL)
if decode.role is InstanceRole.DECODE
else None
)
if decode.role is InstanceRole.DECODE and prefill is None:
raise HTTPException(
status_code=503, detail="No prefill instance is registered"
)
encoders = self.registry.urls(InstanceRole.ENCODE)
if num_items and not encoders:
raise HTTPException(
status_code=503, detail="No encode instance is registered"
)
route = _Route(encoder_urls=encoders, prefill=prefill, decode=decode)
self._name_consumer(route)
return route
def _name_consumer(self, route: _Route) -> None:
"""Pin the EC consumer replica and, for push connectors, its endpoint."""
candidate = route.prefill or route.decode
route.consumer = candidate
rank = self.registry.next_replica(candidate)
route.dp_rank = rank if candidate.dp_size > 1 else None
if candidate.ec_zmq_addrs:
route.consumer_zmq = candidate.ec_zmq_addrs[rank]
app = FastAPI()
encode_session: aiohttp.ClientSession | None = None
prefill_session: aiohttp.ClientSession | None = None
@@ -448,6 +788,7 @@ async def maybe_prefill(
req_data: dict,
p_url: str,
req_id: str,
dp_rank: int | None = None,
) -> dict:
"""- Do prefill-only task if p_url exist;
- Return a new body carrying kv transfer params (for nixl connector)
@@ -459,7 +800,7 @@ async def maybe_prefill(
if p_url:
logger.info("[%s] Processing through prefill: %s", req_id, p_url)
prefill_response = await process_prefill_stage(req_data, p_url, req_id)
prefill_response = await process_prefill_stage(req_data, p_url, req_id, dp_rank)
# for nixl connector to facilitate kv transfer...
prefill_response_json = msgspec.json.decode(await prefill_response.read())
kv_transfer_params = prefill_response_json.get("kv_transfer_params", {})
@@ -473,6 +814,7 @@ async def process_prefill_stage(
req_data: dict,
p_url: str,
req_id: str,
dp_rank: int | None = None,
) -> dict:
"""Process request through Prefill stage and return kv_transfer_params"""
logger.info("[%s] Sending prefill request to: %s", req_id, p_url)
@@ -494,6 +836,8 @@ async def process_prefill_stage(
del prefill_request["stream_options"]
headers = {"x-request-id": req_id, "Content-Type": "application/json"}
if dp_rank is not None:
headers["X-data-parallel-rank"] = str(dp_rank)
try:
prefill_response = await prefill_session.post(
f"{p_url}/v1/chat/completions",
@@ -594,9 +938,7 @@ async def on_startup() -> None:
),
)
encode_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
if app.state.p_urls:
# only setup if prefill instance(s) exist
prefill_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
prefill_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
decode_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
@@ -622,6 +964,7 @@ async def prepare_for_decode(
e_urls: list[str],
p_url: str,
consumer_zmq: str | None,
prefill_dp_rank: int | None = None,
) -> tuple[dict, float, float]:
"""Encode, rewrite and prefill, returning the body to send to decode.
@@ -642,7 +985,7 @@ async def prepare_for_decode(
handles.update(ec_params)
prepared = {**prepared, "ec_transfer_params": handles}
_t2 = time.perf_counter()
prepared = await maybe_prefill(prepared, p_url, req_id)
prepared = await maybe_prefill(prepared, p_url, req_id, prefill_dp_rank)
return prepared, _t1 - _t0, _t2 - _t1
@@ -654,12 +997,13 @@ async def forward_non_stream(
d_url: str,
consumer_zmq: str | None,
dp_rank: int | None = None,
prefill_dp_rank: int | None = None,
) -> Response:
try:
for attempt in range(DECODE_RETRIES + 1):
_t0 = time.perf_counter()
prepared, encode_s, rewrite_s = await prepare_for_decode(
req_data, req_id, e_urls, p_url, consumer_zmq
req_data, req_id, e_urls, p_url, consumer_zmq, prefill_dp_rank
)
_t2 = time.perf_counter()
@@ -733,12 +1077,13 @@ async def forward_stream(
d_url: str,
consumer_zmq: str | None,
dp_rank: int | None = None,
prefill_dp_rank: int | None = None,
) -> AsyncIterator[bytes]:
try:
for attempt in range(DECODE_RETRIES + 1):
_t0 = time.perf_counter()
prepared, encode_s, rewrite_s = await prepare_for_decode(
req_data, req_id, e_urls, p_url, consumer_zmq
req_data, req_id, e_urls, p_url, consumer_zmq, prefill_dp_rank
)
_t2 = time.perf_counter()
@@ -976,10 +1321,147 @@ async def stop_profile(request: Request):
return await _profile_cmd("stop", body, e_url, p_url, d_url)
def build_app(config: EPDProxyConfig | None = None) -> FastAPI:
config = config or EPDProxyConfig()
registry = InstanceRegistry(
probe_interval=config.probe_interval,
probe_timeout=config.probe_timeout,
fail_threshold=config.fail_threshold,
evicted_ttl=config.evicted_ttl,
)
proxy = EPDProxy(registry)
@asynccontextmanager
async def lifespan(app: FastAPI):
await on_startup()
try:
registry.start_probing()
yield
finally:
await registry.stop_probing()
await on_shutdown()
app = FastAPI(lifespan=lifespan)
app.state.proxy = proxy
app.state.registry = registry
@app.get("/instances")
async def list_instances():
return registry.status()
@app.post("/instances", dependencies=[Depends(require_admin_key)])
async def register_instance(body: InstanceRegistration):
if body.ec_zmq_addrs and body.role not in (
InstanceRole.PREFILL,
InstanceRole.PREFILL_DECODE,
):
raise HTTPException(400, "Only EC consumers accept Mooncake addresses")
try:
created = registry.register(
InstanceRecord(
body.role,
str(body.url).rstrip("/"),
body.ec_zmq_addrs,
body.dp_size,
)
)
except ValueError as error:
raise HTTPException(409, str(error)) from error
return {"registered": created}
@app.delete("/instances", dependencies=[Depends(require_admin_key)])
async def unregister_instance(url: AnyHttpUrl):
return {"removed": registry.unregister(str(url).rstrip("/"))}
@app.post("/v1/chat/completions")
async def chat_completions(request: Request):
req_data = msgspec.json.decode(await request.body())
req_id = request.headers.get("x-request-id", str(uuid.uuid4()))
route = proxy.route(len(extract_mm_items(req_data)))
args = (
req_data,
req_id,
route.encoder_urls,
route.prefill.url if route.prefill else None,
route.decode.url,
route.consumer_zmq,
)
ranks = {
"dp_rank": route.dp_rank if route.prefill is None else None,
"prefill_dp_rank": route.dp_rank if route.prefill is not None else None,
}
if req_data.get("stream", False):
return StreamingResponse(
forward_stream(*args, **ranks), media_type="text/event-stream"
)
return await forward_non_stream(*args, **ranks)
@app.get("/v1/models")
async def list_models():
decode = registry.pick(InstanceRole.PREFILL_DECODE) or registry.pick(
InstanceRole.DECODE
)
if decode is None:
raise HTTPException(
status_code=503, detail="No decode instance is registered"
)
async with decode_session.get(f"{decode.url}/v1/models") as resp:
resp.raise_for_status()
return await resp.json()
@app.get("/health")
async def health():
status = registry.status()
# An empty roster is not unhealthy: the proxy is meant to come up
# before anything registers with it.
return JSONResponse({"proxy": "healthy", "instances": status})
@app.post("/start_profile")
async def start_profile(request: Request):
return await _profile(registry, "start", await request.json())
@app.post("/stop_profile")
async def stop_profile(request: Request):
return await _profile(registry, "stop", await request.json())
return app
async def _profile(registry: InstanceRegistry, cmd: str, payload: dict) -> dict:
headers = {"Authorization": f"Bearer {os.getenv('OPENAI_API_KEY', '')}"}
targets = {role.value: registry.pick(role) for role in InstanceRole}
results = await asyncio.gather(
*(
_post_if_available(
decode_session, f"{record.url}/{cmd}_profile", payload, headers
)
for record in targets.values()
if record is not None
)
)
reachable = [result for result in results if result is not None]
if not reachable:
raise HTTPException(
status_code=503,
detail="Profiling endpoints are disabled on every instance",
)
live = [name for name, record in targets.items() if record is not None]
return dict(zip(live, results))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument(
"--dynamic-registration",
action="store_true",
help="Enable launcher-managed HTTP registration for E+PD or E+P+D.",
)
parser.add_argument("--probe-interval", type=float, default=DEFAULT_PROBE_INTERVAL)
parser.add_argument("--probe-timeout", type=float, default=DEFAULT_PROBE_TIMEOUT)
parser.add_argument("--fail-threshold", type=int, default=DEFAULT_FAIL_THRESHOLD)
parser.add_argument("--evicted-ttl", type=float, default=DEFAULT_EVICTED_TTL)
parser.add_argument(
"--log-requests",
action="store_true",
@@ -995,12 +1477,12 @@ if __name__ == "__main__":
)
parser.add_argument(
"--encode-servers-urls",
required=True,
default="",
help='Comma-separated encode URLs ("http://e1:8001,http://e2:8001")',
)
parser.add_argument(
"--prefill-servers-urls",
required=True,
default="none",
help=(
'Comma-separated prefill URLs ("http://p1:8003,http://p2:8004") '
'to enable E->P->D, set "disable" or "none" to enable E->PD'
@@ -1008,7 +1490,7 @@ if __name__ == "__main__":
)
parser.add_argument(
"--decode-servers-urls",
required=True,
default="",
help='Comma-separated decode URLs ("http://d1:8005,http://d2:8006")',
)
parser.add_argument(
@@ -1043,58 +1525,87 @@ if __name__ == "__main__":
)
args = parser.parse_args()
if args.fail_threshold < 1:
parser.error("--fail-threshold must be at least 1")
if args.log_requests:
logging.getLogger().setLevel(logging.DEBUG)
app.middleware("http")(log_requests)
NO_REWRITE = args.no_rewrite
DECODE_RETRIES = max(0, args.decode_retries)
app.state.e_urls = [
u.strip() for u in args.encode_servers_urls.split(",") if u.strip()
]
app.state.d_urls = [
u.strip() for u in args.decode_servers_urls.split(",") if u.strip()
]
app.state.d_ec_urls = [
u.strip() for u in args.ec_consumer_zmq_addrs.split(",") if u.strip()
]
if args.ec_consumer_dp_size < 1:
parser.error("--ec-consumer-dp-size must be at least 1")
app.state.ec_consumer_dp_size = args.ec_consumer_dp_size
app.state.replica_counter = itertools.count()
expected = len(app.state.d_urls) * args.ec_consumer_dp_size
if app.state.d_ec_urls and len(app.state.d_ec_urls) != expected:
parser.error(
"--ec-consumer-zmq-addrs must contain one address per consumer "
f"replica: expected {expected} "
f"({len(app.state.d_urls)} servers x {args.ec_consumer_dp_size} replicas), "
f"got {len(app.state.d_ec_urls)}"
)
# handle prefill instances
if args.prefill_servers_urls.lower() in ("disable", "none", ""):
app.state.p_urls = []
logger.info(
"Disaggregated prefill phase explicitly disabled by user. Running E + PD..."
if args.dynamic_registration:
if not os.getenv("ADMIN_API_KEY"):
parser.error("--dynamic-registration requires ADMIN_API_KEY")
if (
args.encode_servers_urls
or args.prefill_servers_urls.lower() not in ("disable", "none", "")
or args.decode_servers_urls
or args.ec_consumer_zmq_addrs
):
parser.error(
"With --dynamic-registration, instances register through /instances"
)
app = build_app(
EPDProxyConfig(
probe_interval=args.probe_interval,
probe_timeout=args.probe_timeout,
fail_threshold=args.fail_threshold,
evicted_ttl=args.evicted_ttl,
)
)
if args.log_requests:
app.middleware("http")(log_requests)
else:
app.state.p_urls = [
u.strip() for u in args.prefill_servers_urls.split(",") if u.strip()
if not args.encode_servers_urls or not args.decode_servers_urls:
parser.error(
"Static routing requires --encode-servers-urls "
"and --decode-servers-urls"
)
app.state.e_urls = [
u.strip() for u in args.encode_servers_urls.split(",") if u.strip()
]
logger.info("Disaggregated prefill phase is enabled. Running E + P + D...")
try:
validate_ec_consumer_routing(app.state.p_urls, app.state.d_ec_urls)
except ValueError as exc:
parser.error(str(exc))
app.state.d_urls = [
u.strip() for u in args.decode_servers_urls.split(",") if u.strip()
]
app.state.d_ec_urls = [
u.strip() for u in args.ec_consumer_zmq_addrs.split(",") if u.strip()
]
if args.ec_consumer_dp_size < 1:
parser.error("--ec-consumer-dp-size must be at least 1")
app.state.ec_consumer_dp_size = args.ec_consumer_dp_size
app.state.replica_counter = itertools.count()
expected = len(app.state.d_urls) * args.ec_consumer_dp_size
if app.state.d_ec_urls and len(app.state.d_ec_urls) != expected:
parser.error(
"--ec-consumer-zmq-addrs must contain one address per consumer "
f"replica: expected {expected} "
f"({len(app.state.d_urls)} servers x "
f"{args.ec_consumer_dp_size} replicas), "
f"got {len(app.state.d_ec_urls)}"
)
# handle prefill instances
if args.prefill_servers_urls.lower() in ("disable", "none", ""):
app.state.p_urls = []
logger.info("Disaggregated prefill disabled. Running E + PD...")
else:
app.state.p_urls = [
u.strip() for u in args.prefill_servers_urls.split(",") if u.strip()
]
logger.info("Disaggregated prefill phase is enabled. Running E + P + D...")
try:
validate_ec_consumer_routing(app.state.p_urls, app.state.d_ec_urls)
except ValueError as exc:
parser.error(str(exc))
logger.info("Proxy listening on %s:%s", args.host, args.port)
logger.info("Encode servers: %s", app.state.e_urls)
logger.info("Prefill instances %s", app.state.p_urls)
logger.info("Decode servers: %s", app.state.d_urls)
if app.state.ec_consumer_dp_size > 1:
logger.info(
"EC consumer replicas per server: %d (control addresses: %s)",
app.state.ec_consumer_dp_size,
app.state.d_ec_urls,
)
logger.info("Proxy listening on %s:%s", args.host, args.port)
logger.info("Encode servers: %s", app.state.e_urls)
logger.info("Prefill instances %s", app.state.p_urls)
logger.info("Decode servers: %s", app.state.d_urls)
if app.state.ec_consumer_dp_size > 1:
logger.info(
"EC consumer replicas per server: %d (control addresses: %s)",
app.state.ec_consumer_dp_size,
app.state.d_ec_urls,
)
uvicorn.run(
app,
@@ -130,10 +130,36 @@ pub struct EngineCoreOutput {
#[serde(default)]
pub new_sampling_mask: Option<MaybeWireSamplingMask>,
/// Per-request speculative-decoding acceptance metrics, set on the final
/// output when `--per-request-spec-decode-metrics` is enabled. Opaque here;
/// the Rust frontend does not yet surface it in responses.
/// output when `--per-request-spec-decode-metrics` is enabled.
#[serde(default)]
pub spec_decode_metrics: Option<OpaqueValue>,
pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
}
/// Raw per-sequence speculative-decoding accumulator.
///
/// Python models this as a plain `@dataclass`, so it is serialized by msgspec
/// as a map (named fields). Derived values such as acceptance rates are
/// computed by the frontend, not carried on the wire.
///
/// Original Python definition: `RequestSpecDecodeMetrics` in
/// `vllm/v1/metrics/stats.py`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequestSpecDecodeMetrics {
/// Configured `num_speculative_tokens`.
#[serde(default)]
pub num_spec_tokens: u64,
/// Verify-step counts indexed by accepted draft-token count.
#[serde(default)]
pub histogram: Vec<u64>,
/// Total proposed draft tokens.
#[serde(default)]
pub num_draft_tokens: u64,
/// Accepted draft count per verify step; empty unless `detailed`.
#[serde(default)]
pub per_step_accepted: Vec<u64>,
/// Proposed draft count per verify step; empty unless `detailed`.
#[serde(default)]
pub per_step_drafted: Vec<u64>,
}
impl EngineCoreOutput {
+11 -1
View File
@@ -11,7 +11,9 @@ use futures::stream::FusedStream;
use futures::{Stream, StreamExt as _, pin_mut};
use serde::{Deserialize, Serialize};
use vllm_engine_core_client::protocol::logprobs::Logprobs;
use vllm_engine_core_client::protocol::output::{EngineCoreFinishReason, StopReason};
use vllm_engine_core_client::protocol::output::{
EngineCoreFinishReason, RequestSpecDecodeMetrics, StopReason,
};
use vllm_engine_core_client::protocol::sampling_mask::SamplingMask;
use vllm_engine_core_client::{AbortCause, EngineCoreOutputStream};
@@ -47,6 +49,8 @@ pub struct CollectedGenerateOutput {
pub ec_transfer_params: Option<serde_json::Value>,
/// Sampling support sets aligned one-to-one with generated token positions.
pub sampling_mask: Option<SamplingMask>,
/// Per-request speculative-decoding metrics from the terminal output.
pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
}
/// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for
@@ -160,6 +164,8 @@ pub struct GenerateOutput {
pub ec_transfer_params: Option<serde_json::Value>,
/// Sampling support sets aligned one-to-one with `token_ids`.
pub sampling_mask: Option<SamplingMask>,
/// Per-request speculative-decoding metrics, present on terminal outputs.
pub spec_decode_metrics: Option<RequestSpecDecodeMetrics>,
}
impl GenerateOutput {
@@ -208,6 +214,7 @@ impl GenerateOutput {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask: None,
spec_decode_metrics: None,
}
}
}
@@ -308,6 +315,7 @@ impl Stream for GenerateOutputStream {
kv_transfer_params: raw.kv_transfer_params,
ec_transfer_params: raw.ec_transfer_params,
sampling_mask,
spec_decode_metrics: raw.spec_decode_metrics,
};
Poll::Ready(Some(Ok(output)))
@@ -396,6 +404,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask,
spec_decode_metrics: None,
});
}
@@ -409,6 +418,7 @@ impl<T: Stream<Item = Result<GenerateOutput>> + Send> T {
};
collected.kv_transfer_params = output.kv_transfer_params;
collected.ec_transfer_params = output.ec_transfer_params;
collected.spec_decode_metrics = output.spec_decode_metrics;
if let Some(mask) = collected.sampling_mask.as_ref()
&& mask.rows.len() != collected.token_ids.len()
{
+1
View File
@@ -413,6 +413,7 @@ async fn collect_output_rejects_partial_sampling_mask() {
sampling_mask: Some(SamplingMask {
rows: vec![vec![1, 33, 99]],
}),
spec_decode_metrics: None,
};
let error = futures::stream::iter([Ok(output)]).collect_output().await.unwrap_err();
@@ -29,7 +29,8 @@ use vllm_llm::{
use self::convert::{ResponseOptions, prepare_generate_request};
use self::types::{
GenerateLogprob, GenerateResponse, GenerateResponseChoice, GenerateResponseStreamChoice,
GenerateStreamResponse, MultiModalPlaceholders, PlaceholderRangeInfo,
GenerateStreamResponse, MultiModalPlaceholders, PerRequestMetrics, PlaceholderRangeInfo,
SpeculativeDecodingMetrics, StreamingSpeculativeDecodingMetrics,
};
pub(crate) use self::types::{GenerateRequest, GenerateSamplingParams};
pub(crate) use self::validate::validate_request_compat;
@@ -160,10 +161,14 @@ async fn generate_chunk_stream(
let mut prompt_tokens = None;
let mut prompt_token_ids = None;
let mut usage = TokenUsage::default();
let mut spec_decode_metrics = None;
while let Some(next) = stream.next().await {
match next {
Ok(output) => {
if let Some(metrics) = output.spec_decode_metrics {
spec_decode_metrics = Some(SpeculativeDecodingMetrics::from(metrics));
}
if prompt_tokens.is_none()
&& let Some(info) = output.prompt_info.as_ref()
{
@@ -223,6 +228,7 @@ async fn generate_chunk_stream(
.then(|| Usage::from_token_usage(usage, enable_prompt_tokens_details)),
mm_placeholders: prompt_token_ids.as_ref().and_then(|_| mm_placeholders.take()),
prompt_token_ids,
metrics: None,
})
.await;
}
@@ -243,6 +249,11 @@ async fn generate_chunk_stream(
usage: Some(Usage::from_token_usage(usage, enable_prompt_tokens_details)),
prompt_token_ids: None,
mm_placeholders: None,
metrics: spec_decode_metrics.map(|speculative_decoding| PerRequestMetrics {
speculative_decoding: StreamingSpeculativeDecodingMetrics::from(
speculative_decoding,
),
}),
})
.await;
}
@@ -318,6 +329,9 @@ fn collect_generate(
mm_placeholders: return_token_ids.then_some(mm_placeholders).flatten(),
kv_transfer_params: collected.kv_transfer_params,
ec_transfer_params: collected.ec_transfer_params,
metrics: collected.spec_decode_metrics.map(|metrics| PerRequestMetrics {
speculative_decoding: SpeculativeDecodingMetrics::from(metrics),
}),
})
}
@@ -465,6 +479,7 @@ mod tests {
use futures::{TryStreamExt as _, stream};
use vllm_engine_core_client::protocol::multimodal::{MmModality, PlaceholderRange};
use vllm_engine_core_client::protocol::output::RequestSpecDecodeMetrics;
use vllm_llm::GeneratePromptInfo;
use super::*;
@@ -482,6 +497,7 @@ mod tests {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask: None,
spec_decode_metrics: None,
}),
Ok(GenerateOutput {
request_id: String::new(),
@@ -496,6 +512,7 @@ mod tests {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask: None,
spec_decode_metrics: None,
}),
]);
@@ -578,6 +595,7 @@ mod tests {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask: None,
spec_decode_metrics: None,
}
}
@@ -645,6 +663,80 @@ mod tests {
assert!(chunks[1].mm_placeholders.is_none());
}
#[tokio::test]
async fn generate_chunk_stream_returns_terminal_spec_decode_metrics() {
let metrics = RequestSpecDecodeMetrics {
num_spec_tokens: 3,
histogram: vec![0, 1, 1, 0],
num_draft_tokens: 6,
..Default::default()
};
let mut output = stream_output(None, Vec::new(), Some(FinishReason::stop_eos()));
output.spec_decode_metrics = Some(metrics.clone());
for include_usage in [false, true] {
let chunks: Vec<_> = generate_chunk_stream(
stream::iter(vec![Ok(output.clone())]),
"raw-stream".to_string(),
ApiServerOptions::default(),
ResponseOptions {
include_usage,
..Default::default()
},
None,
)
.try_collect()
.await
.expect("collect chunks");
assert_eq!(chunks.len(), 1 + usize::from(include_usage));
assert!(chunks[0].metrics.is_none());
if include_usage {
assert!(chunks[1].choices.is_empty());
assert_eq!(
chunks[1].metrics,
Some(PerRequestMetrics {
speculative_decoding: StreamingSpeculativeDecodingMetrics::from(
SpeculativeDecodingMetrics::from(metrics.clone())
)
})
);
let json = serde_json::to_value(&chunks[1]).expect("serialize chunk");
let stats = &json["metrics"]["speculative_decoding"];
assert_eq!(stats.as_object().expect("metrics object").len(), 7);
assert_eq!(stats["num_draft_tokens"], 6);
assert_eq!(stats["num_accepted_draft_tokens"], 3);
assert_eq!(stats["mean_acceptance_length"], 2.5);
assert_eq!(
stats["acceptance_histogram"],
serde_json::json!([0, 1, 1, 0])
);
assert!(stats.get("per_step_accepted").is_none());
}
}
}
#[test]
fn spec_decode_metrics_zero_steps_and_detailed() {
let empty = SpeculativeDecodingMetrics::from(RequestSpecDecodeMetrics {
num_spec_tokens: 2,
histogram: vec![0, 0, 0],
..Default::default()
});
assert_eq!(empty.mean_acceptance_length, 1.0);
assert_eq!(empty.draft_acceptance_rate, 0.0);
assert_eq!(empty.per_step_accepted, None);
let detailed = SpeculativeDecodingMetrics::from(RequestSpecDecodeMetrics {
num_spec_tokens: 2,
histogram: vec![0, 1, 1],
num_draft_tokens: 4,
per_step_accepted: vec![1, 2],
per_step_drafted: vec![2, 2],
});
assert_eq!(detailed.per_step_accepted, Some(vec![1, 2]));
assert_eq!(detailed.per_step_drafted, Some(vec![2, 2]));
}
#[test]
fn collect_generate_omits_prompt_metadata_by_default() {
let output = CollectedGenerateOutput {
@@ -662,6 +754,7 @@ mod tests {
ec_transfer_params: None,
prompt_token_ids: vec![10, 20],
sampling_mask: None,
spec_decode_metrics: None,
};
let response = collect_generate(
@@ -683,6 +776,50 @@ mod tests {
assert!(response.mm_placeholders.is_none());
}
#[test]
fn collect_generate_returns_spec_decode_metrics() {
let metrics = RequestSpecDecodeMetrics {
num_spec_tokens: 3,
histogram: vec![0, 1, 1, 0],
num_draft_tokens: 6,
..Default::default()
};
let output = CollectedGenerateOutput {
request_id: "raw-metrics".to_string(),
prompt_logprobs: None,
token_ids: vec![30],
logprobs: None,
finish_reason: FinishReason::stop_eos(),
usage: TokenUsage::default(),
kv_transfer_params: None,
ec_transfer_params: None,
prompt_token_ids: vec![10],
sampling_mask: None,
spec_decode_metrics: Some(metrics.clone()),
};
let response = collect_generate(
output,
"raw-metrics".to_string(),
ApiServerOptions::default(),
ResponseOptions::default(),
None,
)
.expect("response");
assert_eq!(
response.metrics,
Some(PerRequestMetrics {
speculative_decoding: SpeculativeDecodingMetrics::from(metrics)
})
);
let json = serde_json::to_value(&response).expect("serialize response");
let stats = &json["metrics"]["speculative_decoding"];
assert_eq!(stats.as_object().expect("metrics object").len(), 9);
assert!(stats["per_step_accepted"].is_null());
assert!(stats["per_step_drafted"].is_null());
}
#[test]
fn extract_mm_placeholders_returns_none_without_features() {
assert!(extract_mm_placeholders(None).is_none());
@@ -749,6 +886,7 @@ mod tests {
ec_transfer_params: None,
prompt_token_ids: vec![10, 20],
sampling_mask: None,
spec_decode_metrics: None,
};
let response = collect_generate(
@@ -782,6 +920,7 @@ mod tests {
kv_transfer_params: None,
ec_transfer_params: None,
sampling_mask: None,
spec_decode_metrics: None,
prompt_token_ids,
};
@@ -7,6 +7,7 @@ use llm_multimodal::MediaContentPart;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use validator::Validate;
use vllm_engine_core_client::protocol::output::RequestSpecDecodeMetrics;
use vllm_text::SamplingParams;
use crate::routes::openai::utils::types::{ChatLogProbs, Normalizable, StreamOptions, Usage};
@@ -84,6 +85,7 @@ pub(super) struct GenerateStreamResponse {
pub usage: Option<Usage>,
pub prompt_token_ids: Option<Vec<u32>>,
pub mm_placeholders: Option<MultiModalPlaceholders>,
pub metrics: Option<PerRequestMetrics<StreamingSpeculativeDecodingMetrics>>,
}
/// Mirrors the Python vLLM `GenerateResponse` class.
@@ -96,6 +98,91 @@ pub(super) struct GenerateResponse {
pub mm_placeholders: Option<MultiModalPlaceholders>,
pub kv_transfer_params: Option<Value>,
pub ec_transfer_params: Option<Value>,
pub metrics: Option<PerRequestMetrics<SpeculativeDecodingMetrics>>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(super) struct PerRequestMetrics<T> {
pub speculative_decoding: T,
}
/// Mirrors the Python vLLM `SpeculativeDecodingMetrics` class.
///
/// Derived from the raw engine accumulator the same way as Python
/// `RequestSpecDecodeMetrics.to_dict`.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(super) struct SpeculativeDecodingMetrics {
pub mean_acceptance_length: f64,
pub draft_acceptance_rate: f64,
pub acceptance_histogram: Vec<u64>,
pub num_spec_steps: u64,
pub num_accepted_draft_tokens: u64,
pub num_draft_tokens: u64,
pub num_spec_tokens: u64,
pub per_step_accepted: Option<Vec<u64>>,
pub per_step_drafted: Option<Vec<u64>>,
}
impl From<RequestSpecDecodeMetrics> for SpeculativeDecodingMetrics {
fn from(raw: RequestSpecDecodeMetrics) -> Self {
let num_spec_steps: u64 = raw.histogram.iter().sum();
let num_accepted_draft_tokens: u64 =
(0u64..).zip(&raw.histogram).map(|(accepted, count)| accepted * count).sum();
let ratio = |num: u64, den: u64| {
if den == 0 {
0.0
} else {
num as f64 / den as f64
}
};
let detailed = !raw.per_step_accepted.is_empty();
Self {
mean_acceptance_length: if num_spec_steps == 0 {
1.0
} else {
1.0 + ratio(num_accepted_draft_tokens, num_spec_steps)
},
draft_acceptance_rate: ratio(num_accepted_draft_tokens, raw.num_draft_tokens),
acceptance_histogram: raw.histogram,
num_spec_steps,
num_accepted_draft_tokens,
num_draft_tokens: raw.num_draft_tokens,
num_spec_tokens: raw.num_spec_tokens,
per_step_accepted: detailed.then_some(raw.per_step_accepted),
per_step_drafted: detailed.then_some(raw.per_step_drafted),
}
}
}
/// Streaming form omits detailed fields when summary metrics are requested.
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(super) struct StreamingSpeculativeDecodingMetrics {
pub mean_acceptance_length: f64,
pub draft_acceptance_rate: f64,
pub acceptance_histogram: Vec<u64>,
pub num_spec_steps: u64,
pub num_accepted_draft_tokens: u64,
pub num_draft_tokens: u64,
pub num_spec_tokens: u64,
pub per_step_accepted: Option<Vec<u64>>,
pub per_step_drafted: Option<Vec<u64>>,
}
impl From<SpeculativeDecodingMetrics> for StreamingSpeculativeDecodingMetrics {
fn from(metrics: SpeculativeDecodingMetrics) -> Self {
Self {
mean_acceptance_length: metrics.mean_acceptance_length,
draft_acceptance_rate: metrics.draft_acceptance_rate,
acceptance_histogram: metrics.acceptance_histogram,
num_spec_steps: metrics.num_spec_steps,
num_accepted_draft_tokens: metrics.num_accepted_draft_tokens,
num_draft_tokens: metrics.num_draft_tokens,
num_spec_tokens: metrics.num_spec_tokens,
per_step_accepted: metrics.per_step_accepted,
per_step_drafted: metrics.per_step_drafted,
}
}
}
pub(super) type MultiModalPlaceholders = HashMap<String, Vec<PlaceholderRangeInfo>>;
+6 -3
View File
@@ -14,6 +14,7 @@ import torch
import torch.multiprocessing as mp
from vllm.distributed import get_ep_group
from vllm.platforms import current_platform
from vllm.utils.flashinfer import (
has_flashinfer_nvlink_one_sided,
has_flashinfer_nvlink_two_sided,
@@ -23,6 +24,8 @@ from vllm.utils.network_utils import get_open_port
from ..utils import init_test_distributed_environment
DEVICE = current_platform.device_type
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -483,7 +486,7 @@ def _args_dispatch_combine_worker(rank, world_size):
from vllm.forward_context import get_forward_context
cpu_group = get_ep_group().cpu_group
device = torch.device(f"cuda:{rank}")
device = torch.device(f"{DEVICE}:{rank}")
hidden_size = 64
tokens_per_rank = 16
@@ -624,7 +627,7 @@ def _two_sided_data_worker(rank, world_size):
# Use DP group because MnnvlMoe workspace allocation calls get_dp_group()
# internally and requires dp_size == ep_size.
cpu_group = get_dp_group().cpu_group
device = torch.device(f"cuda:{rank}")
device = torch.device(f"{DEVICE}:{rank}")
num_gpus = torch.accelerator.device_count()
hidden_size = 128
@@ -769,7 +772,7 @@ def _one_sided_data_worker(rank, world_size):
from vllm.forward_context import get_forward_context
cpu_group = get_dp_group().cpu_group
device = torch.device(f"cuda:{rank}")
device = torch.device(f"{DEVICE}:{rank}")
hidden_size = 256
tokens_per_rank = 32
+11 -5
View File
@@ -15,6 +15,7 @@ import pytest
from tests.utils import multi_gpu_test
from vllm.config import VllmConfig
from vllm.engine.arg_utils import EngineArgs
from vllm.platforms import current_platform
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.executor import multiproc_executor
from vllm.v1.executor.multiproc_executor import MultiprocExecutor
@@ -378,8 +379,8 @@ def test_multiproc_executor_properties():
def test_multiproc_executor_multi_node():
"""Test MultiprocExecutor with multi-node configuration.
This simulates 2 nodes with TP=4:
- Node 0 (rank 0): Uses GPUs 0,1 (CUDA_VISIBLE_DEVICES=0,1) with TP=2
- Node 1 (rank 1): Uses GPUs 2,3 (CUDA_VISIBLE_DEVICES=2,3) with TP=2
- Node 0 (rank 0): Uses GPUs 0,1 (device_control_env_var=0,1) with TP=2
- Node 1 (rank 1): Uses GPUs 2,3 (device_control_env_var=2,3) with TP=2
Total world_size = 4, nnodes = 2
"""
# Python 3.14+ changed default multiprocessing start method to 'forkserver'
@@ -392,15 +393,20 @@ def test_multiproc_executor_multi_node():
# symm_mem does not work for simulating multi instance in single node
os.environ["VLLM_ALLREDUCE_USE_SYMM_MEM"] = "0"
# Use the platform-specific device control env var (e.g.
# CUDA_VISIBLE_DEVICES on CUDA, ZE_AFFINITY_MASK on XPU) so that each
# node only sees its own devices on different platform.
device_control_env_var = current_platform.device_control_env_var
def run_node(node_rank: int, result_queue: multiprocessing.Queue, port: int):
"""Run a single node's executor."""
executor = None
try:
# Set CUDA_VISIBLE_DEVICES for this node
# Restrict visible devices for this node
if node_rank == 0:
os.environ["CUDA_VISIBLE_DEVICES"] = "0,1"
os.environ[device_control_env_var] = "0,1"
else:
os.environ["CUDA_VISIBLE_DEVICES"] = "2,3"
os.environ[device_control_env_var] = "2,3"
# Create config for this node
vllm_config = create_vllm_config(
@@ -52,6 +52,7 @@ def _make_generate_response(
logprobs: dict | None = None,
prompt_logprobs: list | None = None,
kv_transfer_params: dict | None = None,
metrics: dict | None = None,
) -> dict:
choice: dict = {
"index": 0,
@@ -64,6 +65,7 @@ def _make_generate_response(
"choices": [choice],
"prompt_logprobs": prompt_logprobs,
"kv_transfer_params": kv_transfer_params,
"metrics": metrics,
}
@@ -266,6 +268,49 @@ async def test_derender_chat_kv_transfer_params_passthrough(client):
assert response.json()["kv_transfer_params"] == kv
@pytest.mark.asyncio
async def test_derender_chat_metrics_passthrough(client):
gen_req = await _render_chat(client)
metrics = {
"speculative_decoding": {
"mean_acceptance_length": 2.0,
"draft_acceptance_rate": 0.5,
"acceptance_histogram": [0, 1],
"num_spec_steps": 1,
"num_accepted_draft_tokens": 1,
"num_draft_tokens": 2,
"num_spec_tokens": 1,
}
}
response = await client.post(
"/v1/chat/completions/derender",
json={
"model": MODEL_NAME,
"generate_response": _make_generate_response(
gen_req["token_ids"][:3], metrics=metrics
),
},
)
assert response.status_code == 200
actual = response.json()["metrics"]
assert set(actual) == {
"time_to_first_token_ms",
"generation_time_ms",
"queue_time_ms",
"mean_itl_ms",
"tokens_per_second",
"speculative_decoding",
}
assert all(
actual[name] is None for name in actual if name != "speculative_decoding"
)
assert actual["speculative_decoding"] == {
**metrics["speculative_decoding"],
"per_step_accepted": None,
"per_step_drafted": None,
}
@pytest.mark.asyncio
async def test_derender_chat_empty_token_ids(client):
"""Empty token_ids list returns 400."""
@@ -27,6 +27,8 @@ from vllm.entrypoints.generate.base.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
PerRequestMetrics,
SpeculativeDecodingMetrics,
)
from vllm.entrypoints.scale_out.token_in_token_out.protocol import (
DerenderStreamState,
@@ -164,6 +166,7 @@ def _make_stream_chunk(
finish_reason: str | None = None,
request_id: str = "test-req",
usage: dict | None = None,
metrics: PerRequestMetrics | None = None,
) -> GenerateStreamResponse:
"""Build a GenerateStreamResponse SSE chunk."""
return GenerateStreamResponse(
@@ -176,6 +179,7 @@ def _make_stream_chunk(
)
],
usage=UsageInfo(**usage) if usage else None,
metrics=metrics,
)
@@ -512,6 +516,26 @@ class TestDerenderChatStream:
assert chunk1.choices[0].delta.role == "assistant"
assert chunk2.choices[0].delta.role is None
@pytest.mark.asyncio
async def test_metrics_passthrough(self, derenderer):
metrics = PerRequestMetrics(
speculative_decoding=SpeculativeDecodingMetrics(
mean_acceptance_length=2.0,
draft_acceptance_rate=0.5,
acceptance_histogram=[0, 1],
num_spec_steps=1,
num_accepted_draft_tokens=1,
num_draft_tokens=2,
num_spec_tokens=1,
)
)
chunk, _ = await derenderer.derender_chat_stream(
model=MODEL_NAME,
generate_chunk=_make_stream_chunk([], metrics=metrics),
state=DerenderStreamState(),
)
assert chunk.metrics == metrics
@pytest.mark.asyncio
async def test_chunked_equals_oneshot(self, derenderer, tokenizer):
"""Sum of streaming content deltas == one shot decode."""
@@ -28,6 +28,7 @@ from vllm.renderers import renderer_from_config
from vllm.renderers.online_renderer import OnlineRenderer
from vllm.sampling_params import SamplingParams
from vllm.v1.engine.async_llm import AsyncLLM
from vllm.v1.metrics.stats import RequestSpecDecodeMetrics
MODEL_NAME = "openai-community/gpt2"
BASE_MODEL_PATHS = [
@@ -130,6 +131,7 @@ def _make_request_output(
logprobs: list[dict[int, Any] | None] | None = None,
num_cached_tokens: int | None = None,
index: int = 0,
spec_decode_metrics: RequestSpecDecodeMetrics | None = None,
) -> RequestOutput:
return RequestOutput(
request_id=request_id,
@@ -144,6 +146,7 @@ def _make_request_output(
cumulative_logprob=None,
logprobs=logprobs,
finish_reason=finish_reason,
spec_decode_metrics=spec_decode_metrics,
)
],
finished=finished,
@@ -321,6 +324,145 @@ async def test_stream_basic():
assert data_chunks[2]["choices"][0]["finish_reason"] == "stop"
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_serve_tokens_returns_spec_decode_metrics(stream: bool):
engine = _mock_engine()
metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
metrics.observe(num_draft_tokens=3, num_accepted=2)
async def mock_generate(*args, **kwargs):
yield _make_request_output(
"req-1",
token_ids=[10],
finish_reason="stop",
finished=True,
spec_decode_metrics=metrics,
)
engine.generate = MagicMock(side_effect=mock_generate)
serving = _build_serving_tokens(engine)
request = GenerateRequest(
token_ids=[1, 2, 3],
sampling_params=SamplingParams(max_tokens=1),
model=MODEL_NAME,
stream=stream,
stream_options=StreamOptions(include_usage=True) if stream else None,
)
response = await serving.serve_tokens(request)
if stream:
chunks = [chunk async for chunk in response]
payload = next(
chunk
for chunk in _parse_sse_chunks(chunks)
if isinstance(chunk, dict) and chunk.get("metrics")
)["metrics"]["speculative_decoding"]
else:
assert isinstance(response, GenerateResponse)
assert response.metrics is not None
assert response.metrics.speculative_decoding is not None
payload = response.metrics.speculative_decoding.model_dump()
assert payload["num_draft_tokens"] == 3
assert payload["num_accepted_draft_tokens"] == 2
assert payload["num_spec_steps"] == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("include_usage", [False, True])
async def test_stream_returns_spec_decode_metrics_on_empty_terminal_output(
include_usage,
):
engine = _mock_engine()
metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
metrics.observe(num_draft_tokens=3, num_accepted=2)
async def mock_generate(*args, **kwargs):
yield _make_request_output("req-1", token_ids=[10])
yield _make_request_output(
"req-1",
token_ids=[],
finish_reason="stop",
finished=True,
spec_decode_metrics=metrics,
)
engine.generate = MagicMock(side_effect=mock_generate)
serving = _build_serving_tokens(engine)
request = GenerateRequest(
token_ids=[1, 2, 3],
sampling_params=SamplingParams(max_tokens=1),
model=MODEL_NAME,
stream=True,
stream_options=StreamOptions(include_usage=include_usage),
)
chunks = [chunk async for chunk in await serving.serve_tokens(request)]
data_chunks = [chunk for chunk in _parse_sse_chunks(chunks) if chunk != "[DONE]"]
assert "metrics" not in data_chunks[0]
assert len(data_chunks) == 1 + int(include_usage)
if include_usage:
assert data_chunks[1]["choices"] == []
assert (
data_chunks[1]["metrics"]["speculative_decoding"]["num_draft_tokens"] == 3
)
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_serve_tokens_omits_spec_decode_metrics_for_parallel_sampling(
stream: bool,
):
engine = _mock_engine()
metrics = RequestSpecDecodeMetrics.new(num_spec_tokens=3)
metrics.observe(num_draft_tokens=3, num_accepted=2)
result = _make_request_output(
"req-1",
token_ids=[10],
finish_reason="stop",
finished=True,
spec_decode_metrics=metrics,
)
result.outputs.append(
CompletionOutput(
index=1,
text="",
token_ids=[20],
cumulative_logprob=None,
logprobs=None,
finish_reason="stop",
spec_decode_metrics=metrics,
)
)
async def mock_generate(*args, **kwargs):
yield result
engine.generate = MagicMock(side_effect=mock_generate)
serving = _build_serving_tokens(engine)
request = GenerateRequest(
token_ids=[1, 2, 3],
sampling_params=SamplingParams(max_tokens=1, n=2),
model=MODEL_NAME,
stream=stream,
stream_options=StreamOptions(include_usage=True) if stream else None,
)
response = await serving.serve_tokens(request)
if stream:
chunks = [chunk async for chunk in response]
assert all(
"metrics" not in chunk
for chunk in _parse_sse_chunks(chunks)
if isinstance(chunk, dict)
)
else:
assert isinstance(response, GenerateResponse)
assert response.metrics is None
@pytest.mark.asyncio
async def test_non_stream_returns_final_prompt_token_ids_when_requested():
engine = _mock_engine()
+17 -3
View File
@@ -1384,24 +1384,33 @@ def test_humming_global_valid_shape_m(
assert result == expected
def test_humming_permute_scratch_is_keyed_by_runtime_topk(
def test_humming_permute_scratch_is_shared_by_config(
monkeypatch: pytest.MonkeyPatch,
):
from types import SimpleNamespace
from unittest.mock import Mock
import vllm.model_executor.layers.fused_moe.experts.fused_humming_moe as humming
import vllm.model_executor.layers.fused_moe.moe_permute_unpermute as permute
import vllm.v1.worker.workspace as workspace
manager = workspace.WorkspaceManager(torch.device("cpu"))
monkeypatch.setattr(workspace, "_manager", manager)
scratch_topk6 = Mock()
scratch_topk1 = Mock()
scratch_type = Mock(side_effect=[scratch_topk6, scratch_topk1])
monkeypatch.setattr(humming, "moe_permute_unpermute_supported", lambda: True)
monkeypatch.setattr(humming, "MoEPermuteScratch", scratch_type)
monkeypatch.setattr(permute, "MoEPermuteScratch", scratch_type)
moe_config = make_dummy_moe_config(max_num_tokens=512, experts_per_token=6)
moe_config.moe_parallel_config.dp_size = 2
experts = SimpleNamespace(_permute_scratch={}, moe_config=moe_config)
experts = SimpleNamespace(moe_config=moe_config)
other_layer = SimpleNamespace(moe_config=moe_config)
assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
assert (
humming.HummingExpertsBase._get_permute_scratch(other_layer, 6) is scratch_topk6
)
assert humming.HummingExpertsBase._get_permute_scratch(experts, 1) is scratch_topk1
assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
@@ -1412,6 +1421,11 @@ def test_humming_permute_scratch_is_keyed_by_runtime_topk(
assert second_call.kwargs["max_num_tokens"] == 6144
assert second_call.kwargs["topk"] == 1
manager.lock()
assert humming.HummingExpertsBase._get_permute_scratch(experts, 6) is scratch_topk6
with pytest.raises(AssertionError, match="was not allocated during warmup"):
humming.HummingExpertsBase._get_permute_scratch(experts, 2)
def test_humming_delegates_to_instance_activation():
from types import SimpleNamespace
+163 -12
View File
@@ -15,12 +15,14 @@ from vllm.model_executor.layers.fused_moe.expert_map_manager import (
)
from vllm.model_executor.layers.fused_moe.moe_permute_unpermute import (
MoEPermuteScratch,
get_moe_permute_scratch,
moe_permute,
moe_permute_unpermute_supported,
moe_unpermute,
)
from vllm.platforms import current_platform
from vllm.utils.torch_utils import set_random_seed
from vllm.v1.worker.workspace import current_workspace_manager
NUM_EXPERTS = [16, 64, 256]
TOP_KS = [2, 6, 8]
@@ -120,6 +122,7 @@ def torch_unpermute(
@pytest.mark.parametrize("topk", TOP_KS)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("ep_size", EP_SIZE)
@pytest.mark.parametrize("use_scratch", [False, True])
def test_moe_permute_unpermute(
n_token: int,
n_hidden: int,
@@ -127,6 +130,8 @@ def test_moe_permute_unpermute(
n_expert: int,
ep_size: int,
dtype: torch.dtype,
use_scratch: bool,
workspace_init,
):
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
@@ -160,6 +165,18 @@ def test_moe_permute_unpermute(
expert_map=expert_map,
)
scratch = None
if use_scratch:
scratch = get_moe_permute_scratch(
max_num_tokens=n_token,
topk=topk,
num_experts=n_expert,
num_local_experts=n_local_expert,
device=hidden_states.device,
hidden_size=n_hidden,
hidden_dtype=dtype,
)
(
permuted_hidden_states,
_,
@@ -173,6 +190,7 @@ def test_moe_permute_unpermute(
n_expert=n_expert,
n_local_expert=n_local_expert,
expert_map=expert_map,
scratch=scratch,
)
# check expert_first_token_offset
@@ -212,20 +230,22 @@ def test_moe_permute_unpermute(
torch.testing.assert_close(result4, gold4, atol=2e-2, rtol=0)
@pytest.mark.parametrize("dtype", [torch.bfloat16])
def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
@pytest.mark.parametrize("n_token", [1, 33, 128])
@pytest.mark.parametrize("topk", [1, 6, 8])
def test_moe_permute_reuses_scratch_buffers(
dtype: torch.dtype, n_token: int, topk: int, workspace_init
):
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
n_token = 64
n_hidden = 2048
n_expert = 16
topk = 4
hidden_states = torch.randn((n_token, n_hidden), device="cuda").to(dtype)
gating_output = torch.randn((n_token, n_expert), device="cuda").to(dtype)
_, topk_ids, _ = fused_topk(hidden_states, gating_output, topk, False)
scratch = MoEPermuteScratch(
scratch_config = dict(
max_num_tokens=n_token,
topk=topk,
num_experts=n_expert,
@@ -234,6 +254,7 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
hidden_size=n_hidden,
hidden_dtype=hidden_states.dtype,
)
scratch = get_moe_permute_scratch(**scratch_config)
first = moe_permute(
hidden_states=hidden_states,
@@ -242,13 +263,26 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
n_expert=n_expert,
scratch=scratch,
)
second = moe_permute(
hidden_states=hidden_states,
a1q_scale=None,
topk_ids=topk_ids,
n_expert=n_expert,
scratch=scratch,
)
current_workspace_manager().lock()
assert get_moe_permute_scratch(**scratch_config) is scratch
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
second = moe_permute(
hidden_states=hidden_states,
a1q_scale=None,
topk_ids=topk_ids,
n_expert=n_expert,
scratch=get_moe_permute_scratch(**scratch_config),
)
for _ in range(2):
hidden_states.add_(1)
topk_ids.copy_(topk_ids.roll(1, 0))
graph.replay()
expected = moe_permute(hidden_states, None, topk_ids, n_expert)
for actual, reference in zip(second, expected):
if actual is not None:
torch.testing.assert_close(actual, reference)
(
permuted_hidden_states_1,
@@ -288,6 +322,123 @@ def test_moe_permute_reuses_scratch_buffers(dtype: torch.dtype):
)
def test_moe_permute_scratch_reused_across_graph_sizes(workspace_init) -> None:
"""Switching graph sizes must not expose metadata left by a larger batch."""
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
device = torch.device("cuda:0")
scratch = get_moe_permute_scratch(
max_num_tokens=128,
topk=6,
num_experts=16,
num_local_experts=16,
device=device,
hidden_size=2048,
hidden_dtype=torch.bfloat16,
)
current_workspace_manager().lock()
runs = []
for n_token in (128, 1, 33):
hidden = torch.randn((n_token, 2048), dtype=torch.bfloat16, device=device)
topk_ids = torch.randint(16, (n_token, 6), device=device)
moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
output = moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
runs.append((graph, hidden, topk_ids, output))
for graph, hidden, topk_ids, output in runs + runs[::-1]:
hidden.add_(1)
topk_ids.copy_((topk_ids + 1) % 16)
graph.replay()
expected = moe_permute(hidden, None, topk_ids, 16)
for actual, reference in zip(output, expected):
if actual is not None:
torch.testing.assert_close(actual, reference)
def test_moe_permute_scratch_isolated_across_execution_slots(monkeypatch) -> None:
"""Concurrent graph replays in different ubatches/lanes cannot share scratch."""
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
from vllm.v1.worker import workspace
device = torch.device("cuda:0")
manager = workspace.WorkspaceManager(device, num_ubatches=2, num_lanes=2)
monkeypatch.setattr(workspace, "_manager", manager)
ubatch = 0
monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: ubatch)
scratch_config = dict(
max_num_tokens=64,
topk=6,
num_experts=16,
num_local_experts=16,
device=device,
hidden_size=2048,
hidden_dtype=torch.bfloat16,
)
runs = []
for i, n_token in enumerate((1, 7, 33, 64)):
ubatch = i // 2
stream = torch.cuda.Stream()
stream.wait_stream(torch.cuda.current_stream())
with workspace.use_workspace_lane(i % 2), torch.cuda.stream(stream):
hidden = torch.randn((n_token, 2048), dtype=torch.bfloat16, device=device)
topk_ids = torch.randint(16, (n_token, 6), device=device)
scratch = get_moe_permute_scratch(**scratch_config)
moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph, stream=stream):
output = moe_permute(hidden, None, topk_ids, 16, scratch=scratch)
runs.append((stream, graph, hidden, topk_ids, output, scratch))
torch.cuda.current_stream().wait_stream(stream)
assert len({run[-1].permuted_hidden_states.data_ptr() for run in runs}) == 4
manager.lock()
for _ in range(3):
for i, (stream, graph, hidden, topk_ids, _, scratch) in enumerate(runs):
ubatch = i // 2
with workspace.use_workspace_lane(i % 2), torch.cuda.stream(stream):
assert get_moe_permute_scratch(**scratch_config) is scratch
hidden.add_(1)
topk_ids.copy_((topk_ids + 1) % 16)
graph.replay()
for stream, _, hidden, topk_ids, output, _ in runs:
torch.cuda.current_stream().wait_stream(stream)
expected = moe_permute(hidden, None, topk_ids, 16)
for actual, reference in zip(output, expected):
if actual is not None:
torch.testing.assert_close(actual, reference)
def test_moe_permute_scratch_without_manager(monkeypatch) -> None:
"""Standalone calls get independent scratch with initialized row indices."""
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
from vllm.v1.worker import workspace
monkeypatch.setattr(workspace, "_manager", None)
config = dict(
max_num_tokens=4,
topk=2,
num_experts=4,
num_local_experts=4,
device=torch.device("cuda"),
)
first = get_moe_permute_scratch(**config)
second = get_moe_permute_scratch(**config)
assert (
first.token_expert_indices.data_ptr() != second.token_expert_indices.data_ptr()
)
torch.testing.assert_close(
first.token_expert_indices, torch.arange(8, dtype=torch.int32, device="cuda")
)
def test_moe_permute_ignores_invalid_expert_ids_with_scratch() -> None:
if not moe_permute_unpermute_supported():
pytest.skip("moe_permute_unpermute is not supported on this platform.")
@@ -17,6 +17,7 @@ from vllm.model_executor.layers.quantization.utils.int8_utils import (
per_token_quant_int8,
)
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
get_marlin_workspace,
marlin_make_empty,
marlin_make_workspace_new,
marlin_permute_bias,
@@ -43,6 +44,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
)
from vllm.platforms import current_platform
from vllm.scalar_type import scalar_types
from vllm.v1.worker.workspace import current_workspace_manager
if current_platform.is_rocm():
pytest.skip(
@@ -477,6 +479,62 @@ def test_marlin_gemm(
assert max_diff < 0.04
def test_marlin_persistent_locks_cuda_graph_streams(workspace_init):
"""Compiled GEMMs select stream-local locks at runtime and survive replay."""
size_m, size_k, size_n = 32, 1024, 2048
inputs = [rand_data((size_m, size_k)) for _ in range(2)]
weight = rand_data((size_k, size_n))
quant_type = scalar_types.uint4b8
w_ref, qweight, scales = marlin_quantize(weight, quant_type, 128)
zeros = marlin_make_empty(scales.device)
streams = [torch.cuda.Stream() for _ in inputs]
graphs = [torch.cuda.CUDAGraph() for _ in inputs]
locks = []
outputs = []
@torch.compile(backend="eager", fullgraph=True)
def run(x):
return torch.ops.vllm.marlin_gemm(
x,
qweight,
None,
scales,
None,
None,
zeros,
None,
quant_type.id,
size_m,
size_n,
size_k,
use_atomic_add=False,
use_fp32_reduce=True,
)
for stream, graph, x in zip(streams, graphs, inputs):
stream.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(stream):
locks.append(get_marlin_workspace(x.device))
run(x)
with torch.cuda.graph(graph, stream=stream):
outputs.append(run(x))
torch.cuda.current_stream().wait_stream(stream)
assert locks[0].data_ptr() != locks[1].data_ptr()
current_workspace_manager().lock()
for _ in range(3):
for stream, graph, x, lock in zip(streams, graphs, inputs, locks):
with torch.cuda.stream(stream):
assert get_marlin_workspace(x.device) is lock
x.add_(0.1)
graph.replay()
for stream in streams:
torch.cuda.current_stream().wait_stream(stream)
for x, output, lock in zip(inputs, outputs, locks):
assert compute_max_diff(output, x @ w_ref) < 0.04
assert torch.count_nonzero(lock) == 0
def test_marlin_gemm_subset_input():
quant_type = scalar_types.uint4b8
group_size = 128
@@ -14,6 +14,7 @@ from vllm import _custom_ops as ops
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
GPTQ_MARLIN_TILE,
apply_gptq_marlin_linear,
get_marlin_workspace,
marlin_make_empty,
marlin_make_workspace_new,
marlin_moe_padded_intermediate,
@@ -225,7 +226,7 @@ def test_fp8_marlin_padded_round_trip(shape, use_bias):
input=x,
weight=layer.weight,
weight_scale=layer.weight_scale,
workspace=layer.workspace,
workspace=get_marlin_workspace(layer.weight.device),
size_n=size_n,
size_k=size_k,
bias=layer.bias if use_bias else None,
@@ -288,7 +289,7 @@ def test_nvfp4_marlin_padded_round_trip(shape):
weight=layer.weight,
weight_scale=layer.weight_scale,
weight_global_scale=layer.weight_global_scale,
workspace=layer.workspace,
workspace=get_marlin_workspace(layer.weight.device),
size_n=size_n,
size_k=size_k,
)
@@ -392,7 +393,7 @@ def test_fp8_block_marlin_padded_round_trip(shape):
input=x,
weight=layer.weight,
weight_scale=layer.weight_scale_inv,
workspace=layer.workspace,
workspace=get_marlin_workspace(layer.weight.device),
size_n=size_n,
size_k=size_k,
bias=None,
@@ -440,7 +441,7 @@ def test_mxfp8_marlin_padded_round_trip(shape):
input=x,
weight=layer.weight,
weight_scale=layer.weight_scale,
workspace=layer.workspace,
workspace=get_marlin_workspace(layer.weight.device),
size_n=size_n,
size_k=size_k,
)
@@ -756,7 +757,7 @@ def test_fp8_marlin_moe_padded_round_trip(shape, quant):
topk_ids,
quant_type_id=scalar_types.float8_e4m3fn.id,
global_num_experts=e,
workspace=layer.workspace,
workspace=get_marlin_workspace(device),
)
with set_current_vllm_config(VllmConfig()):
ref = torch_experts(
@@ -833,7 +834,7 @@ def test_mxfp8_marlin_moe_padded_round_trip(shape):
topk_ids,
quant_type_id=scalar_types.float8_e4m3fn.id,
global_num_experts=e,
workspace=layer.workspace,
workspace=get_marlin_workspace(device),
)
with set_current_vllm_config(VllmConfig()):
ref = torch_experts(
@@ -384,9 +384,16 @@ def test_leading_invalid_tail_slot():
_assert_eq(r_ref, r_kern)
@pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm required")
def test_amd_prefill_seed_honors_padded_tail_block_stride():
"""The tail shares a padded indexer allocation in production."""
def test_prefill_seed_honors_padded_tail_block_stride():
"""The tail shares a padded indexer allocation in production.
``get_kv_cache_config_from_groups`` aliases each tail tensor onto its
indexer tensor with the indexer's block stride (38016 B for GLM-5.3-Flash
vs a dense 2048 B tail block), so a seed kernel that addresses blocks
densely writes into an unrelated indexer block and leaves the request's
tail block untouched. Runs on every platform; the NVIDIA kernel had this
bug while the AMD kernel did not.
"""
kpool = 4
num_blocks = 6
logical_block_elems = 2 * kpool * HEAD_DIM
+23 -8
View File
@@ -1871,17 +1871,32 @@ def test_sparse_indexer_topk_backend_resolution() -> None:
has_coop = _has_cooperative_topk()
has_fi = _has_flashinfer_topk()
# "auto" is exactly the pre-existing chain: cooperative -> persistent ->
# per_row. It must never select the opt-in backends by itself.
# "auto" is the chain cooperative -> persistent -> per_row, with the
# cooperative/persistent switch at cooperative_topk's cluster-wave row
# limit. It must never select the opt-in backends by itself.
if has_coop:
assert resolve("auto") == "cooperative"
from vllm.model_executor.layers.indexer_topk import (
AUTO_COOPERATIVE_MAX_ROWS,
)
# cooperative for every batch within its cluster-wave row limit...
assert (
resolve("auto", k=512, num_rows=AUTO_COOPERATIVE_MAX_ROWS) == "cooperative"
)
assert resolve("auto", k=512, num_rows=8) == "cooperative"
# Past cooperative's 64-row limit -> persistent, even where
# DeepSelect would be applicable.
# ...persistent past the row limit, even where DeepSelect would be
# applicable.
assert (
resolve("auto", k=512, num_rows=AUTO_COOPERATIVE_MAX_ROWS + 1)
== "persistent"
)
assert resolve("auto", k=512, num_rows=128) == "persistent"
# 16B-aligned (cooperative TMA ok) but not DeepSelect-aligned:
# still cooperative at <= 64 rows...
assert resolve("auto", unaligned_logits, num_rows=64) == "cooperative"
# 16B-aligned (cooperative TMA ok) but not DeepSelect-aligned: the
# same row-limit logic applies.
assert (
resolve("auto", unaligned_logits, num_rows=AUTO_COOPERATIVE_MAX_ROWS)
== "cooperative"
)
# ...and persistent past the row limit.
assert resolve("auto", unaligned_logits, num_rows=128) == "persistent"
# Unsupported topk (outside {512, 1024, 2048} for the workspace kernels)
@@ -330,8 +330,8 @@ def _load_marlin_checkpoint_format_weights(layer):
)
def test_marlin_post_load_preserves_runtime_tensor_addresses(monkeypatch, dist_init):
"""Marlin must reuse the workspace storage captured by CUDA graphs."""
def test_marlin_post_load_does_not_own_workspace(monkeypatch, dist_init):
"""Weight reload must not create layer-owned Marlin lock storage."""
_stub_marlin_ops(monkeypatch)
kernel = _make_marlin_kernel()
@@ -339,19 +339,17 @@ def test_marlin_post_load_preserves_runtime_tensor_addresses(monkeypatch, dist_i
_load_marlin_checkpoint_format_weights(layer)
kernel.process_weights_after_loading(layer)
workspace_ptr = kernel.workspace.data_ptr()
assert not hasattr(kernel, "workspace")
_load_marlin_checkpoint_format_weights(layer)
kernel.process_weights_after_loading(layer)
assert kernel.workspace.data_ptr() == workspace_ptr
assert torch.all(kernel.workspace == 0)
assert not hasattr(kernel, "workspace")
@pytest.mark.parametrize("variant", ["fp8", "mxfp8", "nvfp4"])
def test_marlin_prepare_layer_preserves_workspace_address(monkeypatch, variant):
"""The Marlin fallback prepare_* functions rerun on weight reload and must
reuse the workspace storage whose address captured CUDA graphs hold."""
def test_marlin_prepare_layer_does_not_own_workspace(monkeypatch, variant):
"""Weight preparation must not attach runtime workspace to model layers."""
from vllm import _custom_ops as ops
from vllm.model_executor.layers.quantization.utils import (
marlin_utils,
@@ -417,34 +415,57 @@ def test_marlin_prepare_layer_preserves_workspace_address(monkeypatch, variant):
load_checkpoint_format_weights()
prepare(layer)
workspace_ptr = layer.workspace.data_ptr()
assert not hasattr(layer, "workspace")
# Reload: fresh checkpoint-format tensors, prepare runs again
load_checkpoint_format_weights()
prepare(layer)
assert layer.workspace.data_ptr() == workspace_ptr
assert torch.all(layer.workspace == 0)
assert not hasattr(layer, "workspace")
def test_marlin_make_workspace_new_rejects_incompatible_existing(monkeypatch):
"""An incompatible existing workspace means the address captured by CUDA
graphs is already unusable; allocating a replacement would hide that."""
def test_marlin_workspace_uses_persistent_workspace_manager(monkeypatch):
"""Calls reuse initialized locks; independent streams get separate storage."""
from vllm.model_executor.layers.quantization.utils import marlin_utils
from vllm.utils import torch_utils
from vllm.v1.worker import workspace as workspace_module
monkeypatch.setattr(marlin_utils, "num_compute_units", lambda _: 4)
device = torch.device("cpu")
manager = workspace_module.WorkspaceManager(device)
monkeypatch.setattr(workspace_module, "_manager", manager)
stream = "main"
monkeypatch.setattr(torch_utils, "current_stream", lambda: stream)
workspace = marlin_utils.marlin_make_workspace_new(device)
reused = marlin_utils.marlin_make_workspace_new(device, existing=workspace)
assert reused is workspace
workspace = marlin_utils.get_marlin_workspace(device)
assert workspace.shape == (4 * marlin_utils.MARLIN_MAX_BLOCKS_PER_SM,)
assert workspace.dtype == torch.int32
assert torch.count_nonzero(workspace) == 0
with pytest.raises(ValueError, match="incompatible"):
marlin_utils.marlin_make_workspace_new(device, 4, existing=workspace)
with pytest.raises(ValueError, match="incompatible"):
marlin_utils.marlin_make_workspace_new(
device, existing=workspace.to(torch.int64)
)
workspace.fill_(1)
assert marlin_utils.get_marlin_workspace(device) is workspace
assert torch.all(workspace == 1)
stream = "aux"
aux_workspace = marlin_utils.get_marlin_workspace(device)
assert aux_workspace.data_ptr() != workspace.data_ptr()
assert torch.count_nonzero(aux_workspace) == 0
manager.lock()
assert marlin_utils.get_marlin_workspace(device) is aux_workspace
def test_marlin_workspace_without_manager(monkeypatch):
from vllm.model_executor.layers.quantization.utils import marlin_utils
from vllm.v1.worker import workspace as workspace_module
monkeypatch.setattr(workspace_module, "_manager", None)
monkeypatch.setattr(marlin_utils, "num_compute_units", lambda _: 4)
first = marlin_utils.get_marlin_workspace(torch.device("cpu"))
second = marlin_utils.get_marlin_workspace(torch.device("cpu"))
assert first.data_ptr() != second.data_ptr()
assert first.shape == (4 * marlin_utils.MARLIN_MAX_BLOCKS_PER_SM,)
assert torch.count_nonzero(first) == 0
def test_model_cleanup(dist_init, default_vllm_config):
@@ -1,11 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Focused unit tests for the GLM decode top-k dispatch."""
"""Tests for the GLM kpool indexer's top-k backend wiring."""
import pytest
import torch
from vllm.models.glm5next.nvidia.sparse_indexer import _use_cooperative_topk
from vllm.config import VllmConfig, set_current_vllm_config
from vllm.platforms import current_platform
pytestmark = pytest.mark.skipif(
@@ -13,29 +13,31 @@ pytestmark = pytest.mark.skipif(
)
@pytest.mark.parametrize("num_rows", [1, 64], ids=["1row", "64rows"])
@torch.inference_mode()
def test_cooperative_topk_is_selected_for_supported_batch(num_rows: int):
logits = torch.empty(num_rows, 8192, dtype=torch.float32, device="cuda")
assert _use_cooperative_topk(logits, 512, num_rows)
def _require_deep_gemm() -> None:
from vllm.utils.deep_gemm import has_deep_gemm
if not has_deep_gemm():
pytest.skip("kpool indexer requires DeepGEMM")
@pytest.mark.parametrize(
("num_rows", "select_k", "stride_padding"),
[
(65, 512, 0),
(1, 256, 0),
(1, 512, 1),
],
ids=["too_many_rows", "unsupported_k", "unaligned_stride"],
)
@torch.inference_mode()
def test_cooperative_topk_falls_back_on_unsupported_batch(
num_rows: int, select_k: int, stride_padding: int
):
logits = torch.empty(
num_rows, 8192 + stride_padding, dtype=torch.float32, device="cuda"
)
if stride_padding:
logits = logits[:, :8192]
assert not _use_cooperative_topk(logits, select_k, num_rows)
@pytest.mark.parametrize("backend", ["auto", "persistent", "cooperative", "torch"])
def test_kpool_indexer_dispatches_through_shared_topk_backend(backend: str) -> None:
"""The kpool indexer must read kernel_config.sparse_indexer_topk_backend
and hand it to the shared SparseIndexerTopk dispatcher, rather than
hard-coding its own cooperative/persistent/per_row choice."""
_require_deep_gemm()
from vllm.models.glm5next.nvidia.sparse_indexer import SparseAttnIndexerKpool
cfg = VllmConfig(kernel_config={"sparse_indexer_topk_backend": backend})
with set_current_vllm_config(cfg):
op = SparseAttnIndexerKpool(
k_cache=None,
quant_block_size=128,
scale_fmt="ue8m0",
topk_tokens=2048,
head_dim=128,
max_pool_len=4096,
max_total_seq_len=8192,
topk_indices_buffer=torch.empty(8, 2176, dtype=torch.int32, device="cuda"),
)
assert op.topk_backend == backend
@@ -55,6 +55,10 @@ def kimi_vl_chat_template(content: str) -> str:
)
def deepseek_v41_chat_template(content: str) -> str:
return f"<|begin▁of▁sentence|><|User|>{content}<|Assistant|></think>"
def step3_vl_chat_template(content: str) -> str:
return (
"<|begin▁of▁sentence|> You are a helpful assistant.<|BOT|>user\n "
@@ -221,6 +225,25 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
vllm_runner_kwargs={"gpu_memory_utilization": 0.80},
marks=[pytest.mark.core_model],
),
"deepseek_v41": VitCudagraphTestConfig(
model="deepseek-ai/DeepSeek-V4.1-Flash",
modalities=["image"],
image_prompt=deepseek_v41_chat_template(
"<|deepseek_image|>\n\nWhat is in this image?"
),
compilation_config_overrides={
"encoder_cudagraph_token_budgets": [1024],
},
vllm_runner_kwargs={
"load_format": "dummy",
"attention_backend": "FLASHMLA_SPARSE_DSV41",
"hf_overrides": partial(
dummy_hf_overrides,
model_arch="DeepseekV41ForCausalLM",
exist_overrides={"vision_n_layers": 1},
),
},
),
"step3_vl": VitCudagraphTestConfig(
model="stepfun-ai/Step3-VL-10B",
modalities=["image"],
@@ -218,8 +218,14 @@ def test_model_tensor_schema(model_id: str):
factories = model_cls._processor_factory
# Capture helpers return capture-buffer containers (e.g.
# EncoderCudaGraphCaptureInputs), not TensorSchema mm inputs.
capture_helpers = {"prepare_encoder_cudagraph_capture_inputs"}
inputs_parse_methods = []
for attr_name in dir(model_cls):
if attr_name in capture_helpers:
continue
attr = getattr(model_cls, attr_name)
if hasattr(attr, "__annotations__"):
return_type = attr.__annotations__.get("return", None)
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
import torch
from vllm.models.deepseek_v4.amd.rocm import _wo_a_block_scale_to_e8m0
def test_wo_a_block_scale_to_e8m0_from_float():
scale = torch.tensor([[0.5, 1.0, 2.0, 4.0]], dtype=torch.float32)
encoded = _wo_a_block_scale_to_e8m0(scale)
assert encoded is not None
torch.testing.assert_close(
encoded,
torch.tensor([[126, 127, 128, 129]], dtype=torch.uint8),
)
assert encoded.is_contiguous()
def test_wo_a_block_scale_to_e8m0_preserves_encoded_scales():
raw = torch.tensor([[125, 127, 131]], dtype=torch.uint8)
encoded = raw.view(torch.float8_e8m0fnu)
converted = _wo_a_block_scale_to_e8m0(encoded)
assert converted is not None
torch.testing.assert_close(converted, raw)
@pytest.mark.parametrize(
"scale",
[
torch.tensor([[0.0, 1.0]]),
torch.tensor([[-1.0, 1.0]]),
torch.tensor([[0.75, 1.0]]),
torch.tensor([[float("inf"), 1.0]]),
torch.ones(1, dtype=torch.int32),
],
)
def test_wo_a_block_scale_to_e8m0_rejects_invalid_scales(scale: torch.Tensor):
assert _wo_a_block_scale_to_e8m0(scale) is None
@@ -0,0 +1,166 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Truncation must bound the prompt *after* multimodal placeholders expand.
``TokenizeParams.apply_post_tokenization`` truncates the unexpanded prompt, so
one placeholder token can expand into many and push the result back over
``truncate_prompt_tokens`` (vllm-project/vllm#57038).
"""
import numpy as np
import pytest
import torch
from vllm.config import ModelConfig, VllmConfig
from vllm.exceptions import VLLMValidationError
from vllm.multimodal.inputs import PlaceholderRange
from vllm.renderers import renderer_from_config
from vllm.renderers.base import BaseRenderer
from vllm.renderers.hf import _ensure_prompt_embeds_placeholder_token
from vllm.renderers.params import TokenizeParams
def _renderer():
"""A BaseRenderer stub: only the tokenizer attribute is needed here."""
class _StubRenderer(BaseRenderer):
def __init__(self):
self.tokenizer = None
def render_messages(self, *args, **kwargs):
raise NotImplementedError
return _StubRenderer()
def _mm_input(num_tokens: int, offset: int, length: int, **extra):
return {
"type": "multimodal",
"prompt_token_ids": list(range(num_tokens)),
"mm_kwargs": {},
"mm_hashes": {},
"mm_placeholders": {"audio": [PlaceholderRange(offset=offset, length=length)]},
**extra,
}
def _params(limit: int | None, side: str = "right"):
return TokenizeParams(
max_total_tokens=4096,
truncate_prompt_tokens=limit,
truncation_side=side,
)
def test_expanded_prompt_is_bounded():
"""The reported case: audio fits, trailing text is cut to the limit."""
engine_input = _mm_input(num_tokens=147, offset=1, length=52)
_renderer()._truncate_expanded_prompt(engine_input, _params(96))
assert engine_input["prompt_token_ids"] == list(range(96))
assert engine_input["mm_placeholders"]["audio"][0] == PlaceholderRange(
offset=1, length=52
)
def test_prompt_within_limit_is_untouched():
engine_input = _mm_input(num_tokens=64, offset=1, length=52)
_renderer()._truncate_expanded_prompt(engine_input, _params(96))
assert engine_input["prompt_token_ids"] == list(range(64))
def test_split_multimodal_span_is_rejected():
"""Cutting mid-span would desync features from token ids, so reject."""
engine_input = _mm_input(num_tokens=147, offset=80, length=50)
with pytest.raises(VLLMValidationError, match="truncate_prompt_tokens"):
_renderer()._truncate_expanded_prompt(engine_input, _params(96))
def test_dropped_multimodal_span_is_rejected():
"""A span entirely past the cut would be discarded silently."""
engine_input = _mm_input(num_tokens=147, offset=100, length=20)
with pytest.raises(VLLMValidationError, match="truncate_prompt_tokens"):
_renderer()._truncate_expanded_prompt(engine_input, _params(96))
def test_left_truncation_shifts_placeholder_offsets():
engine_input = _mm_input(num_tokens=147, offset=100, length=20)
_renderer()._truncate_expanded_prompt(engine_input, _params(96, side="left"))
assert engine_input["prompt_token_ids"] == list(range(51, 147))
# The span started at 100 and 51 leading tokens were dropped.
assert engine_input["mm_placeholders"]["audio"][0] == PlaceholderRange(
offset=49, length=20
)
def test_text_only_input_is_untouched():
"""Only multimodal prompts grow after tokenization, so nothing else runs."""
engine_input = {
"type": "token",
"prompt_token_ids": list(range(147)),
"prompt_token_offsets": [(i, i + 1) for i in range(147)],
}
_renderer()._truncate_expanded_prompt(engine_input, _params(96))
assert engine_input["prompt_token_ids"] == list(range(147))
assert len(engine_input["prompt_token_offsets"]) == 147
def test_no_truncation_requested_is_noop():
engine_input = _mm_input(num_tokens=147, offset=1, length=52)
_renderer()._truncate_expanded_prompt(engine_input, _params(None))
assert engine_input["prompt_token_ids"] == list(range(147))
def test_mixed_prompt_embeds_truncation_covers_embeds_spans():
"""Bound in _process_singleton, where prompt_embeds spans are already attached."""
model_config = ModelConfig(
"Qwen/Qwen3-ASR-0.6B-hf",
revision="7f1569a48a89f3e3f4dc3a5c9d28bddd903bc76c",
dtype="float32",
max_model_len=4096,
mm_processor_cache_gb=0,
enable_prompt_embeds=True,
)
renderer = renderer_from_config(VllmConfig(model_config=model_config))
tok = renderer.tokenizer
pe_id = _ensure_prompt_embeds_placeholder_token(tok)
hidden = model_config.get_hidden_size()
audio = np.zeros(4 * 16000, dtype=np.float32)
pe_len = 20
tensors = [torch.zeros(pe_len, hidden, dtype=torch.float32)]
def _build_prompt():
text = "<|audio_start|><|audio_pad|><|audio_end|>"
ids = tok.encode(text) + [pe_id] + tok.encode(" tail" * 40)
return {
"prompt_token_ids": ids,
"multi_modal_data": {"audio": (audio, 16000)},
"_prompt_embeds": (tensors, pe_id),
}
# A limit past every span (audio ends at 52, prompt_embeds at 74):
# truncation should succeed and bound the prompt.
out = renderer._process_singleton(
_build_prompt(),
tok_params=_params(79),
)
assert len(out["prompt_token_ids"]) == 79
# A limit inside the prompt_embeds span (54..74): must be rejected.
with pytest.raises(VLLMValidationError, match="truncate_prompt_tokens"):
renderer._process_singleton(
_build_prompt(),
tok_params=_params(64),
)
+5
View File
@@ -1427,8 +1427,10 @@ def init_test_distributed_environment(
get_current_vllm_config_or_none,
set_current_vllm_config,
)
from vllm.platforms import current_platform
distributed_init_method = f"tcp://localhost:{distributed_init_port}"
backend = current_platform.dist_backend
if data_parallel_size > 1:
# For DP we need to set a common DP master port
@@ -1449,6 +1451,7 @@ def init_test_distributed_environment(
rank=rank % tp_pp_world,
distributed_init_method=distributed_init_method,
local_rank=local_rank if local_rank >= 0 else rank,
backend=backend,
)
ensure_model_parallel_initialized(tp_size, pp_size)
return
@@ -1460,6 +1463,7 @@ def init_test_distributed_environment(
rank=rank,
distributed_init_method=distributed_init_method,
local_rank=local_rank,
backend=backend,
)
ensure_model_parallel_initialized(tp_size, pp_size)
else:
@@ -1470,6 +1474,7 @@ def init_test_distributed_environment(
rank=rank,
distributed_init_method=distributed_init_method,
local_rank=local_rank,
backend=backend,
)
ensure_model_parallel_initialized(tp_size, pp_size)
@@ -391,3 +391,66 @@ def test_interleaved_decode_pollution_legacy_vs_circular():
# The circular mapping keeps the rings isolated under interleaving.
torch.testing.assert_close(circular, ground_truth)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA device")
@pytest.mark.parametrize(
"per_req,num_actual,padded_len",
[
([list(range(10)), list(range(12))], 22, 22),
([list(range(10)), list(range(12))], 22, 30),
([[3, 4], [0], [7, 8, 9]], 6, 8),
([[5]], 1, 1),
],
)
def test_triton_mapping_matches_cpu(per_req, num_actual, padded_len):
"""The CUDA (Triton) path must match the CPU torch reference, including
tokens between the last request boundary and num_actual_tokens (mapped to
the last request) and untouched padding beyond num_actual."""
positions, qsl, slot_mapping, _, num_reqs = make_batch(
per_req, padded_len=padded_len
)
# Replace the all--1 placeholder slots with sentinel values to check the
# padding range is copied through untouched.
slot_mapping = torch.arange(padded_len, dtype=torch.int64) + 1000
bt = make_tail_block_table(list(range(5, 5 + num_reqs)))
ref = circular_tail_slots(slot_mapping, bt, qsl, positions, num_actual, num_reqs)
got = circular_tail_slots(
slot_mapping.cuda(),
bt.cuda(),
qsl.cuda().to(torch.int32),
positions.cuda(),
num_actual,
num_reqs,
)
torch.testing.assert_close(got.cpu(), ref)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA device")
def test_triton_mapping_reads_strided_block_table():
"""The kernel must address the block table through its real stride(0),
not a dense assumption (the #57477 class of bug): a tail block table that
is a column view of a wider allocation must still read column 0."""
per_req = [list(range(10)), list(range(12))]
positions, qsl, slot_mapping, num_actual, num_reqs = make_batch(per_req)
own_blocks = [11, 22]
wide = torch.zeros(num_reqs, 8, dtype=torch.int32)
bt = wide[:, 1:4] # non-contiguous view, stride(0) == 8
bt[:, 0] = torch.tensor(own_blocks, dtype=torch.int32)
ref = circular_tail_slots(
slot_mapping, bt.contiguous(), qsl, positions, num_actual, num_reqs
)
got = circular_tail_slots(
slot_mapping.cuda(),
bt.cuda(),
qsl.cuda().to(torch.int32),
positions.cuda(),
num_actual,
num_reqs,
)
torch.testing.assert_close(got.cpu(), ref)
for req, blk in enumerate(own_blocks):
start, end = int(qsl[req]), int(qsl[req + 1])
assert (got[start:end] // KPOOL == blk).all()
@@ -4,6 +4,7 @@
import asyncio
import json
from math import lcm
from uuid import uuid4
import httpx
@@ -12,6 +13,7 @@ from openai.types.chat import ChatCompletionMessageParam
from prometheus_client.parser import text_string_to_metric_families
from tests.utils import RemoteOpenAIServer
from vllm.utils.math_utils import round_down
MODEL = "Qwen/Qwen3.5-0.8B"
NUM_CONVERSATIONS = 8
@@ -34,6 +36,29 @@ def _user_turns(conversation: int) -> list[str]:
]
async def _scheduler_block_size(client: httpx.AsyncClient) -> int:
"""Return the coarsest boundary an expected cache hit can land on.
Mirrors ``scheduler_block_size`` in ``resolve_kv_cache_block_sizes``, which
the engine keeps internal: the LCM of the resolved group block sizes. A
finer ``prefix_match_unit`` only adds hits, so this stays conservative.
"""
response = await client.get("/metrics")
response.raise_for_status()
for family in text_string_to_metric_families(response.text):
for sample in family.samples:
if sample.name != "vllm:cache_config_info":
continue
sizes = [
int(value)
for name in ("block_size", "mamba_block_size")
if (value := sample.labels.get(name, "None")) != "None"
]
assert sizes, f"no block size in {sample.labels}"
return lcm(*sizes)
raise AssertionError("missing vllm:cache_config_info metric")
async def _prefix_cache_counters(
client: httpx.AsyncClient,
) -> tuple[float, float]:
@@ -83,13 +108,13 @@ def server(request):
@pytest.mark.asyncio
@pytest.mark.parametrize(
"server, min_hit_rate",
# MTP re-prefills an extra block; both floors reject one more missed block.
[pytest.param(False, 0.90, id="base"), pytest.param(True, 0.75, id="mtp")],
"server, dropped_blocks",
# MTP's prefill lookahead pollutes the tail block, so EAGLE drops one.
[pytest.param(False, 0, id="base"), pytest.param(True, 1, id="mtp")],
indirect=["server"],
)
async def test_prefix_cache_hit_rate(
server: RemoteOpenAIServer, min_hit_rate: float
server: RemoteOpenAIServer, dropped_blocks: int
) -> None:
# Isolate sessions so another conversation cannot hide a cache miss.
salts = [uuid4().hex for _ in range(NUM_CONVERSATIONS)]
@@ -102,6 +127,8 @@ async def test_prefix_cache_hit_rate(
server.get_async_client() as client,
httpx.AsyncClient(base_url=server.url_root, timeout=METRICS_TIMEOUT) as metrics,
):
block_size = await _scheduler_block_size(metrics)
print(f"{block_size=}")
async def run_conversation(session: int, *, replay: bool) -> tuple[int, int]:
history = histories[session]
@@ -126,15 +153,30 @@ async def test_prefix_cache_hit_rate(
assert cached is not None, context
assert 0 <= cached < prompt, context
assert prompt >= MIN_PROMPT_TOKENS, context
if not replay and turn == 0:
assert cached == 0, context
else:
assert cached / prompt >= min_hit_rate, (
f"{context}, {min_hit_rate=}"
)
if replay:
assert prompt == prompt_lengths[session][turn], context
# The identical request computed this whole prompt, but at
# least one token is always recomputed.
computed = prompt - 1
elif turn:
computed = prompt_lengths[session][turn - 1]
else:
assert cached == 0, context
computed = 0
# Every token below the last reachable boundary must be reused.
floor = max(
0, round_down(computed, block_size) - dropped_blocks * block_size
)
print(
f"{session=} {turn=} {replay=} {prompt=} {cached=} {floor=} "
f"hit_rate={cached / prompt:.4f}"
)
assert cached >= floor, (
f"{context}, {computed=}, {block_size=}, "
f"hit_rate={cached / prompt:.4f}, "
f"expected_hit_rate={floor / prompt:.4f}"
)
if not replay:
prompt_lengths[session].append(prompt)
history.append(
{
@@ -0,0 +1,279 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Routing and registration behaviour of the EPD proxy."""
import asyncio
from typing import Any
from unittest.mock import AsyncMock, Mock
import msgspec
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from examples.disaggregated.disaggregated_encoder import (
disagg_epd_proxy as proxy_module,
)
from examples.disaggregated.disaggregated_encoder.disagg_epd_proxy import (
EPDProxy,
EPDProxyConfig,
InstanceRecord,
InstanceRegistry,
InstanceRole,
build_app,
extract_mm_items,
)
ENCODE = InstanceRole.ENCODE
PREFILL = InstanceRole.PREFILL
DECODE = InstanceRole.DECODE
PD = InstanceRole.PREFILL_DECODE
IMAGE_ITEM = {"type": "image_url", "image_url": {"url": "http://img/0.png"}}
@pytest.fixture
def proxy():
registry = InstanceRegistry(probe_interval=0)
return EPDProxy(registry)
class TestRouting:
def test_no_decode_instance_is_service_unavailable(self, proxy):
"""The proxy comes up before anything registers, so this is normal."""
with pytest.raises(HTTPException) as excinfo:
proxy.route(num_items=0)
assert excinfo.value.status_code == 503
def test_media_without_an_encoder_is_service_unavailable(self, proxy):
proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
assert proxy.route(num_items=0).decode.url == "http://d0:8000"
with pytest.raises(HTTPException) as excinfo:
proxy.route(num_items=1)
assert excinfo.value.status_code == 503
def test_prefill_is_optional(self, proxy):
"""An E+PD deployment registers no prefill instance at all."""
proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
assert proxy.route(num_items=0).prefill is None
def test_encoder_roster_tracks_new_instances(self, proxy):
proxy.registry.register(InstanceRecord(PD, "http://d0:8000"))
for index in range(2):
proxy.registry.register(InstanceRecord(ENCODE, f"http://e{index}:8000"))
assert proxy.route(num_items=3).encoder_urls == [
"http://e0:8000",
"http://e1:8000",
]
proxy.registry.unregister("http://e0:8000")
assert proxy.route(num_items=3).encoder_urls == ["http://e1:8000"]
@pytest.mark.parametrize("unregister", [False, True])
def test_standalone_decode_always_requires_healthy_prefill(self, unregister):
app = build_app()
proxy = app.state.proxy
proxy.registry.register(InstanceRecord(DECODE, "http://d0:8000"))
with pytest.raises(HTTPException) as excinfo:
proxy.route(num_items=0)
assert excinfo.value.status_code == 503
proxy.registry.register(InstanceRecord(PREFILL, "http://p0:8000"))
route = proxy.route(num_items=0)
assert route.decode.url == "http://d0:8000"
assert route.consumer.url == "http://p0:8000"
if unregister:
proxy.registry.unregister("http://p0:8000")
else:
for _ in range(proxy.registry._fail_threshold):
proxy.registry._on_probe_failure(route.prefill, 0)
with pytest.raises(HTTPException) as excinfo:
proxy.route(num_items=0)
assert excinfo.value.status_code == 503
class TestConsumerAddress:
"""Which stage receives the embedding depends on the topology."""
def test_shared_storage_connectors_name_no_target(self, proxy):
"""Rotate replicas without requiring a push endpoint."""
proxy.registry.register(InstanceRecord(PD, "http://d0:8000", dp_size=2))
route = proxy.route(num_items=0)
assert route.consumer_zmq is None
assert route.dp_rank == 0
assert proxy.route(num_items=0).dp_rank == 1
def test_decode_is_the_consumer_when_prefill_is_not_split_out(self, proxy):
proxy.registry.register(
InstanceRecord(PD, "http://d0:8000", ec_zmq_addrs=["tcp://d0:20001"])
)
assert proxy.route(num_items=0).consumer_zmq == "tcp://d0:20001"
def test_prefill_is_the_consumer_when_it_is_split_out(self, proxy):
"""In E+P+D the prefill instance consumes the embedding, not decode.
Reading the address out of whichever list it came from -- rather than
from the record that reported one -- sends the push to the wrong
instance in this topology.
"""
proxy.registry.register(
InstanceRecord(PREFILL, "http://p0:8000", ec_zmq_addrs=["tcp://p0:20001"])
)
proxy.registry.register(InstanceRecord(DECODE, "http://d0:8000"))
assert proxy.route(num_items=0).consumer_zmq == "tcp://p0:20001"
class TestProxyApi:
@pytest.fixture
def client(self, monkeypatch):
monkeypatch.setenv("ADMIN_API_KEY", "test-key")
config = EPDProxyConfig(probe_interval=0)
with TestClient(build_app(config)) as client:
client.headers["X-API-Key"] = "test-key"
yield client
def test_proxy_serves_before_anything_registers(self, client):
assert client.get("/health").status_code == 200
assert client.get("/instances").json()["encode"]["live"] == []
def test_requests_are_refused_until_a_decoder_registers(self, client):
response = client.post(
"/v1/chat/completions", json={"model": "m", "messages": []}
)
assert response.status_code == 503
assert client.get("/v1/models").status_code == 503
def test_http_registration_keeps_consumer_replica_and_address_together(
self, client
):
payload: dict[str, Any] = {
"role": "prefill_decode",
"url": "http://d0:8000/",
"dp_size": 2,
"ec_zmq_addrs": ["tcp://d0:20001", "tcp://d0:20003"],
}
assert client.post("/instances", json=payload).json() == {"registered": True}
assert client.post("/instances", json=payload).json() == {"registered": False}
proxy = client.app.state.proxy
for rank in [0, 1, 0]:
route = proxy.route(0)
assert route.dp_rank == rank
assert route.consumer_zmq == payload["ec_zmq_addrs"][rank]
assert client.delete("/instances", params={"url": payload["url"]}).json() == {
"removed": True
}
assert proxy.registry.pick(PD) is None
def test_registration_rejects_invalid_topology(self, client):
cases: list[tuple[dict[str, Any], int]] = [
({"dp_size": 0}, 422),
({"dp_size": 2, "ec_zmq_addrs": ["tcp://d0:20001"]}, 422),
({"role": "decode", "ec_zmq_addrs": ["tcp://d0:20001"]}, 400),
({"role": "encode", "ec_zmq_addrs": ["tcp://d0:20001"]}, 400),
]
for fields, status in cases:
response = client.post(
"/instances",
json={"url": "http://d0:8000", "role": "prefill_decode", **fields},
)
assert response.status_code == status
def test_registration_requires_admin_key(self, client):
client.headers.pop("X-API-Key")
assert (
client.post(
"/instances", json={"role": "encode", "url": "http://e0:8000"}
).status_code
== 403
)
assert (
client.delete("/instances", params={"url": "http://e0:8000"}).status_code
== 403
)
@pytest.mark.parametrize("split_role", ["prefill", "decode"])
@pytest.mark.parametrize("combined_first", [False, True])
def test_registration_rejects_mixed_topologies(
self, client, split_role, combined_first
):
roles = ["prefill_decode", split_role]
if not combined_first:
roles.reverse()
first = {"role": roles[0], "url": "http://first:8000"}
second = {"role": roles[1], "url": "http://second:8000"}
assert client.post("/instances", json=first).status_code == 200
assert client.post("/instances", json=second).status_code == 409
# Health eviction must not silently permit a topology switch.
registry = client.app.state.registry
record = registry._live[first["url"]]
for _ in range(registry._fail_threshold):
registry._on_probe_failure(record, 0)
assert client.post("/instances", json=second).status_code == 409
assert client.delete("/instances", params={"url": first["url"]}).json() == {
"removed": True
}
assert client.post("/instances", json=second).status_code == 200
def test_extract_mm_items_finds_media_across_messages():
req = {
"messages": [
{"role": "user", "content": "plain text"},
{"role": "user", "content": [IMAGE_ITEM, {"type": "text", "text": "hi"}]},
{"role": "user", "content": [IMAGE_ITEM]},
]
}
assert extract_mm_items(req) == [IMAGE_ITEM, IMAGE_ITEM]
@pytest.mark.parametrize("push", [False, True])
def test_encoder_handles_and_json_metadata_survive_rewrite(proxy, push, monkeypatch):
"""Keep main's hash-keyed handles and per-item push IDs after the proxy move."""
proxy.registry.register(InstanceRecord(ENCODE, "http://encoder"))
proxy.registry.register(
InstanceRecord(
PD,
"http://decode",
ec_zmq_addrs=["tcp://decode:14579"] if push else [],
)
)
handle = {
"metadata": {"image_grid_thw": [[1, 2, 3]]},
"item_indices": [0, 1],
"peer_port": 5601,
}
response = Mock(
status=200,
read=AsyncMock(
return_value=msgspec.json.encode(
{"ec_transfer_params": {"engine-hash": handle}}
)
),
)
session = Mock(post=AsyncMock(return_value=response))
monkeypatch.setattr(proxy_module, "encode_session", session)
original = {"messages": [{"role": "user", "content": [IMAGE_ITEM, IMAGE_ITEM]}]}
route = proxy.route(2)
prepared, _, _ = asyncio.run(
proxy_module.prepare_for_decode(
original, "request", route.encoder_urls, None, route.consumer_zmq
)
)
assert "ec_transfer_params" not in original
assert prepared["ec_transfer_params"]["engine-hash"] == handle
items = prepared["ec_transfer_params"]["ec_items"]
assert [item["mm_hash"] for item in items] == ["engine-hash", "engine-hash"]
assert items[0]["transfer_id"] != items[1]["transfer_id"]
assert session.post.call_count == 1
sent = msgspec.json.decode(session.post.call_args.kwargs["data"]).get(
"ec_transfer_params"
)
if push:
assert sent == {
"consumer_zmq": "tcp://decode:14579",
"ec_items": [{"transfer_id": item["transfer_id"]} for item in items],
}
else:
assert sent is None
for index in range(2):
assert prepared["messages"][0]["content"][index]["image_embeds"] == {
"image_grid_thw": [1, 2, 3]
}
@@ -8,6 +8,7 @@ change to them is what these tests catch.
import asyncio
import importlib.util
import sys
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@@ -27,6 +28,7 @@ def proxy():
spec = importlib.util.spec_from_file_location("disagg_epd_proxy_retry", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@@ -48,7 +50,7 @@ def test_maybe_prefill_leaves_the_caller_body_untouched(proxy, monkeypatch):
"""
served = [{"remote_block_ids": [1, 2]}, {}]
async def _stage(req_data, p_url, req_id):
async def _stage(req_data, p_url, req_id, dp_rank=None):
assert "kv_transfer_params" not in req_data
return _Response(served.pop(0))
@@ -222,7 +224,7 @@ def test_a_decode_retry_does_not_inherit_the_previous_handles(proxy, monkeypatch
_EncoderSession([{"encoder-side-hash": handle}, {}]),
)
async def _no_prefill(req_data, p_url, req_id):
async def _no_prefill(req_data, p_url, req_id, dp_rank=None):
return req_data
monkeypatch.setattr(proxy, "maybe_prefill", _no_prefill)
@@ -280,6 +282,7 @@ def test_raw_media_keeps_encoder_transfer_identity(proxy, monkeypatch):
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("prefill", [False, True])
@pytest.mark.parametrize("dynamic", [False, True])
@pytest.mark.parametrize(
"request_options",
[
@@ -293,7 +296,7 @@ def test_raw_media_keeps_encoder_transfer_identity(proxy, monkeypatch):
],
)
async def test_http_roundtrip_preserves_payload_and_response_bytes(
proxy, monkeypatch, stream, prefill, request_options
proxy, monkeypatch, stream, prefill, dynamic, request_options
):
"""Preserve media and scheduling context across HTTP hops and retries."""
seen: dict[str, list[dict]] = {"encode": [], "prefill": [], "decode": []}
@@ -307,6 +310,10 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
async def backend(request):
stage = request.match_info["stage"]
if dynamic:
consumer = "prefill" if prefill else "decode"
expected_rank = "0" if stage == consumer else None
assert request.headers.get("X-data-parallel-rank") == expected_rank
assert request.content_type == "application/json"
body = await request.json()
seen[stage].append(body)
@@ -354,6 +361,10 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
monkeypatch.setattr(proxy.app.state, "ec_consumer_dp_size", 1, raising=False)
monkeypatch.setattr(proxy, "DECODE_RETRIES", 1)
monkeypatch.setattr(proxy, "NO_REWRITE", False)
target_app = proxy.app
if dynamic:
monkeypatch.setenv("ADMIN_API_KEY", "test-key")
target_app = proxy.build_app(proxy.EPDProxyConfig(probe_interval=0))
item = {"type": "image_url", "image_url": {"url": "data:image/png;base64,YWJj"}}
body = {
"model": "test",
@@ -377,9 +388,37 @@ async def test_http_roundtrip_preserves_payload_and_response_bytes(
await proxy.on_startup()
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=proxy.app), base_url="http://proxy"
transport=httpx.ASGITransport(app=target_app), base_url="http://proxy"
) as client:
if dynamic:
registrations = [("encode", "encode")]
registrations += (
[("prefill", "prefill"), ("decode", "decode")]
if prefill
else [("prefill_decode", "decode")]
)
for role, stage in registrations:
consumer = "prefill" if prefill else "decode"
registered = await client.post(
"/instances",
headers={"X-API-Key": "test-key"},
json={
"role": role,
"url": str(server.make_url(f"/{stage}")),
"dp_size": 2 if stage == consumer else 1,
},
)
assert registered.status_code == 200
response = await client.post("/v1/chat/completions", json=body)
if dynamic:
removed = await client.delete(
"/instances",
headers={"X-API-Key": "test-key"},
params={"url": str(server.make_url("/decode"))},
)
assert removed.json() == {"removed": True}
unavailable = await client.post("/v1/chat/completions", json=body)
assert unavailable.status_code == 503
assert response.status_code == 200
assert response.content == expected
assert response.headers["content-type"].startswith(
@@ -14,6 +14,7 @@ across calls so consecutive requests keep advancing through every URL.
"""
import importlib.util
import sys
from collections import Counter
from pathlib import Path
@@ -27,6 +28,7 @@ def _load_proxy_module():
spec = importlib.util.spec_from_file_location("disagg_epd_proxy_under_test", path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@@ -0,0 +1,156 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Dynamic registration and liveness for the EPD proxy's instance registry."""
from unittest.mock import patch
import pytest
from examples.disaggregated.disaggregated_encoder.disagg_epd_proxy import (
InstanceRecord,
InstanceRegistry,
InstanceRole,
)
ENCODE = InstanceRole.ENCODE
DECODE = InstanceRole.DECODE
@pytest.fixture
def registry():
return InstanceRegistry(probe_interval=0, fail_threshold=3, evicted_ttl=60.0)
def _fill(registry, role, count, prefix="http://h"):
for index in range(count):
registry.register(InstanceRecord(role, f"{prefix}{index}:8000"))
async def _probe_round(registry, healthy: set[str], now: float = 0.0):
"""Run one probe pass where only `healthy` URLs answer."""
async def fake_probe(self, session, url):
return url in healthy
with (
patch.object(InstanceRegistry, "_probe", fake_probe),
patch(
"examples.disaggregated.disaggregated_encoder.disagg_epd_proxy.time.monotonic",
return_value=now,
),
):
await registry._probe_once(session=None)
class TestRegistration:
def test_registering_is_idempotent_and_reports_novelty(self, registry):
record = InstanceRecord(ENCODE, "http://e0:8000")
assert registry.register(record) is True
assert registry.register(record) is False
assert registry.urls(ENCODE) == ["http://e0:8000"]
def test_transfer_addresses_stay_with_the_registered_consumer(self, registry):
"""Roster updates must not shift a consumer's transfer addresses."""
registry.register(InstanceRecord(DECODE, "http://d0:8000"))
registry.register(
InstanceRecord(
DECODE,
"http://d0:8000",
ec_zmq_addrs=["tcp://d0:20001", "tcp://d0:20002"],
dp_size=2,
)
)
picked = registry.pick(DECODE)
assert picked.ec_zmq_addrs == ["tcp://d0:20001", "tcp://d0:20002"]
assert picked.dp_size == 2
def test_unregister_is_terminal(self, registry):
registry.register(InstanceRecord(ENCODE, "http://e0:8000"))
assert registry.unregister("http://e0:8000") is True
assert registry.unregister("http://e0:8000") is False
assert registry.pick(ENCODE) is None
def test_round_robin_survives_registration(self, registry):
_fill(registry, ENCODE, 2)
assert [record.url for record in registry.pick_many(ENCODE, 2)] == [
"http://h0:8000",
"http://h1:8000",
]
registry.register(InstanceRecord(ENCODE, "http://h2:8000"))
assert registry.pick(ENCODE).url == "http://h2:8000"
class TestLiveness:
@pytest.mark.asyncio
async def test_identical_reregistration_preserves_inflight_probe_results(
self, registry
):
"""Repeated registrations must not suppress eviction or recovery."""
url = "http://e0:8000"
registry.register(InstanceRecord(ENCODE, url))
healthy = False
async def probe(self, session, url):
registry.register(InstanceRecord(ENCODE, url))
return healthy
with patch.object(InstanceRegistry, "_probe", probe):
for _ in range(registry._fail_threshold):
await registry._probe_once(None)
assert registry.status()["encode"] == {"live": [], "evicted": [url]}
healthy = True
await registry._probe_once(None)
assert registry.status()["encode"] == {"live": [url], "evicted": []}
@pytest.mark.asyncio
async def test_consecutive_failures_stop_routing(self, registry):
registry.register(InstanceRecord(ENCODE, "http://e0:8000"))
registry.register(InstanceRecord(ENCODE, "http://e1:8000"))
for _ in range(registry._fail_threshold):
await _probe_round(registry, healthy={"http://e1:8000"})
assert registry.urls(ENCODE) == ["http://e1:8000"]
assert registry.status()["encode"]["evicted"] == ["http://e0:8000"]
@pytest.mark.asyncio
@pytest.mark.parametrize("role", [ENCODE, DECODE])
async def test_a_recovered_instance_rejoins_without_re_registering(
self, registry, role
):
"""Restarting every encoder to recover from a blip is not acceptable."""
registry.register(InstanceRecord(role, "http://e0:8000"))
for _ in range(registry._fail_threshold):
await _probe_round(registry, healthy=set())
assert registry.urls(role) == []
await _probe_round(registry, healthy={"http://e0:8000"}, now=1)
assert registry.urls(role) == ["http://e0:8000"]
@pytest.mark.asyncio
async def test_re_registration_does_not_override_failed_health_checks(
self, registry
):
record = InstanceRecord(ENCODE, "http://e0:8000")
registry.register(record)
for _ in range(registry._fail_threshold):
await _probe_round(registry, healthy=set())
registry.register(record)
assert registry.urls(ENCODE) == []
@pytest.mark.asyncio
@pytest.mark.parametrize("healthy", [False, True])
async def test_inflight_probe_cannot_restore_unregistered_instance(
self, registry, healthy
):
record = InstanceRecord(ENCODE, "http://e0:8000")
registry.register(record)
for _ in range(registry._fail_threshold):
await _probe_round(registry, healthy=set())
async def probe(self, session, url):
registry.unregister(url)
return healthy
with patch.object(InstanceRegistry, "_probe", probe):
await registry._probe_once(None)
assert registry.status()["encode"] == {"live": [], "evicted": []}
@@ -19,8 +19,10 @@ from tests.v1.simple_kv_offload.test_scheduler import (
SchedulerFixture,
_alloc_and_register,
_allocate_cp_gpu_blocks,
_allocate_gpu_blocks,
_make_cp_request,
_make_kv_cache_config,
_make_scratch_kv_cache_config,
_make_vllm_config,
make_request,
make_scheduler_output,
@@ -757,3 +759,37 @@ def test_mamba_align_skips_positional_event_metadata() -> None:
get_block_hash(make_block_hash_with_group_id(req.block_hashes[idx], 0))
)
assert ev.block_hashes == [expected_hash]
def test_scratch_group_store_emits_events_for_cacheable_groups_only() -> None:
"""Event metadata is resolved only for prefix-cacheable groups.
A scratch group (GLM-5.3-Flash kpool tail) has a block size that does not
divide the hash block size, so viewing the request hashes at its block
size is impossible; the store must skip it and still emit BlockStored
for the attention blocks.
"""
fix = make_events_scheduler(kv_cache_config=_make_scratch_kv_cache_config(16))
sched = fix.scheduler
gpu_pool = fix.gpu_block_pool
num_blocks = 2
req = make_request(num_blocks=num_blocks)
fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
scratch_block = gpu_pool.get_new_blocks(1)
kv_blocks = KVCacheBlocks(blocks=(fa_blocks, scratch_block))
req.num_computed_tokens = num_blocks * BLOCK_SIZE
sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
sched_out = make_scheduler_output(
{req.request_id: num_blocks * BLOCK_SIZE},
new_reqs={req.request_id: kv_blocks.get_block_ids()},
)
meta = sched.build_connector_meta(sched_out)
assert sorted(meta.store_gpu_blocks) == sorted(b.block_id for b in fa_blocks)
simulate_store_completion(sched, meta.store_event)
events = list(sched.take_events())
assert len(events) == num_blocks
assert all(isinstance(e, BlockStored) for e in events)
assert {e.group_idx for e in events} == {0}
assert all(len(e.token_ids) == BLOCK_SIZE for e in events)
+222 -1
View File
@@ -41,7 +41,9 @@ from vllm.v1.core.single_type_kv_cache_manager import (
register_all_kvcache_specs,
)
from vllm.v1.kv_cache_interface import (
CircularBufferSpec,
FullAttentionSpec,
KpoolTailSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheTensor,
@@ -125,6 +127,45 @@ def _make_kv_cache_config(
)
def _make_scratch_kv_cache_config(
num_blocks: int, scratch_block_size: int = 4
) -> KVCacheConfig:
"""FullAttention group plus a non-prefix-cacheable scratch group.
Mirrors GLM-5.3-Flash, whose kpool-tail group holds one per-request
scratch block of ``index_kpool`` tokens: it is excluded from prefix
caching, so its block size neither divides nor is divided by the
hash block size in general.
"""
register_all_kvcache_specs(vllm_config=None)
fa_config = _make_kv_cache_config(num_blocks, num_groups=1)
scratch_layers = ["layer_scratch"]
scratch_spec = KpoolTailSpec(
block_size=scratch_block_size,
num_kv_heads=2,
head_size=HEAD_SIZE,
head_size_v=0,
dtype=DTYPE,
sliding_window=scratch_block_size,
)
assert not scratch_spec.prefix_cacheable
scratch_bytes = scratch_spec.page_size_bytes * num_blocks
return KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=fa_config.kv_cache_tensors
+ [
KVCacheTensor(
size=scratch_bytes,
layers=scratch_layers,
layer_stride=scratch_bytes,
block_stride=scratch_spec.page_size_bytes,
)
],
kv_cache_groups=fa_config.kv_cache_groups
+ [KVCacheGroupSpec(scratch_layers, scratch_spec)],
)
def _make_vllm_config(block_size: int = BLOCK_SIZE) -> VllmConfig:
"""Minimal VllmConfig for scheduler tests (no GPU)."""
model_config = ModelConfig(
@@ -174,9 +215,13 @@ def make_scheduler(
num_gpu_blocks: int = 16,
num_groups: int = 1,
lazy: bool = False,
kv_cache_config: KVCacheConfig | None = None,
) -> SchedulerFixture:
"""Build a SimpleCPUOffloadScheduler with small block pools."""
kv_cache_config = _make_kv_cache_config(num_gpu_blocks, num_groups)
if kv_cache_config is None:
kv_cache_config = _make_kv_cache_config(num_gpu_blocks, num_groups)
else:
num_groups = len(kv_cache_config.kv_cache_groups)
vllm_config = _make_vllm_config()
cpu_capacity_bytes = _BYTES_PER_BLOCK * num_cpu_blocks * num_groups
@@ -1392,6 +1437,63 @@ def test_multi_group_null_blocks_skipped() -> None:
)
# ---------------------------------------------------------------------------
# Test 8b: Non-prefix-cacheable scratch groups take no part in store or load
# ---------------------------------------------------------------------------
def test_scratch_group_excluded_from_store_and_load() -> None:
"""A scratch group (GLM-5.3-Flash kpool tail) is never stored or loaded.
Its single per-request block carries no hash, so the store must offload
only the attention blocks and the load must pair only attention blocks.
"""
fix = make_scheduler(
num_cpu_blocks=8,
num_gpu_blocks=16,
kv_cache_config=_make_scratch_kv_cache_config(16),
)
sched = fix.scheduler
gpu_pool = fix.gpu_block_pool
num_blocks = 2
req = make_request(num_blocks=num_blocks)
fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
scratch_block = gpu_pool.get_new_blocks(1)
kv_blocks = KVCacheBlocks(blocks=(fa_blocks, scratch_block))
req.num_computed_tokens = num_blocks * BLOCK_SIZE
sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
sched_out = make_scheduler_output(
{req.request_id: num_blocks * BLOCK_SIZE},
new_reqs={req.request_id: kv_blocks.get_block_ids()},
)
meta = sched.build_connector_meta(sched_out)
assert sorted(meta.store_gpu_blocks) == sorted(b.block_id for b in fa_blocks)
simulate_store_completion(sched, meta.store_event)
req2 = Request(
request_id="req-scratch-load",
prompt_token_ids=req.prompt_token_ids,
sampling_params=req.sampling_params,
pooling_params=None,
mm_features=None,
block_hasher=req._block_hasher,
)
hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
assert hit_tokens == num_blocks * BLOCK_SIZE
assert is_async is True
fa_blocks2 = gpu_pool.get_new_blocks(num_blocks)
scratch_block2 = gpu_pool.get_new_blocks(1)
kv_blocks2 = KVCacheBlocks(blocks=(fa_blocks2, scratch_block2))
sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
sched_out2 = make_scheduler_output(
{req2.request_id: 1},
new_reqs={req2.request_id: kv_blocks2.get_block_ids()},
)
meta2 = sched.build_connector_meta(sched_out2)
assert sorted(meta2.load_gpu_blocks) == sorted(b.block_id for b in fa_blocks2)
assert len(meta2.load_cpu_blocks) == num_blocks
# ---------------------------------------------------------------------------
# Test 9: Chunked prefill accumulates block_ids across steps
# ---------------------------------------------------------------------------
@@ -2844,3 +2946,122 @@ def test_finished_eager_store_recovers_nulled_window_tail() -> None:
hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
assert hit_tokens == num_blocks * BLOCK_SIZE
assert is_async is True
QSA_RING_BLOCK_SIZE = 4
def _make_qsa_hybrid_kv_cache_config(num_blocks: int) -> KVCacheConfig:
register_all_kvcache_specs(vllm_config=None)
full_spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_SIZE,
dtype=DTYPE,
)
ring_spec = CircularBufferSpec(
block_size=QSA_RING_BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_SIZE,
head_size_v=0,
dtype=DTYPE,
)
groups = [
KVCacheGroupSpec(["layer_0"], full_spec),
KVCacheGroupSpec(["qsa_ring"], ring_spec),
]
tensors = [
KVCacheTensor(
size=group.kv_cache_spec.page_size_bytes * num_blocks,
layers=list(group.layer_names),
layer_stride=group.kv_cache_spec.page_size_bytes * num_blocks,
block_stride=group.kv_cache_spec.page_size_bytes,
)
for group in groups
]
return KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=tensors,
kv_cache_groups=groups,
)
def _make_qsa_scheduler(
lazy: bool = False,
) -> tuple[SimpleCPUOffloadScheduler, BlockPool]:
kv_cache_config = _make_qsa_hybrid_kv_cache_config(num_blocks=16)
sched = SimpleCPUOffloadScheduler(
vllm_config=_make_vllm_config(),
kv_cache_config=kv_cache_config,
cpu_capacity_bytes=_BYTES_PER_BLOCK * 8 * 2,
scheduler_block_size=BLOCK_SIZE,
hash_block_size=BLOCK_SIZE,
lazy_offload=lazy,
)
gpu_pool = BlockPool(
num_gpu_blocks=16, enable_caching=True, hash_block_size=BLOCK_SIZE
)
sched.bind_gpu_block_pool(gpu_pool)
return sched, gpu_pool
def test_qsa_ring_group_is_never_stored_or_loaded() -> None:
sched, gpu_pool = _make_qsa_scheduler()
assert sched.prefix_cacheable_group_ids == (0,)
num_blocks = 2
req = make_request(num_blocks=num_blocks)
fa_blocks = _allocate_gpu_blocks(gpu_pool, req, num_blocks, group_id=0)
ring_block = gpu_pool.get_new_blocks(1)[0]
kv_blocks = KVCacheBlocks(blocks=(fa_blocks, [ring_block]))
req.num_computed_tokens = num_blocks * BLOCK_SIZE
sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
sched_out = make_scheduler_output(
{req.request_id: num_blocks * BLOCK_SIZE},
new_reqs={req.request_id: kv_blocks.get_block_ids()},
)
meta = sched.build_connector_meta(sched_out)
assert set(meta.store_gpu_blocks) == {block.block_id for block in fa_blocks}
assert ring_block.block_id not in meta.store_gpu_blocks
assert meta.store_event >= 0
simulate_store_completion(sched, meta.store_event)
req2 = Request(
request_id="req-qsa-load",
prompt_token_ids=req.prompt_token_ids,
sampling_params=req.sampling_params,
pooling_params=None,
mm_features=None,
block_hasher=req._block_hasher,
)
hit_tokens, is_async = sched.get_num_new_matched_tokens(req2, num_computed_tokens=0)
assert hit_tokens == num_blocks * BLOCK_SIZE
assert is_async is True
fa_blocks2 = gpu_pool.get_new_blocks(num_blocks)
ring_block2 = gpu_pool.get_new_blocks(1)[0]
kv_blocks2 = KVCacheBlocks(blocks=(fa_blocks2, [ring_block2]))
sched.update_state_after_alloc(req2, kv_blocks2, num_external_tokens=hit_tokens)
meta2 = sched.build_connector_meta(make_scheduler_output({req2.request_id: 1}))
assert set(meta2.load_gpu_blocks) == {block.block_id for block in fa_blocks2}
assert ring_block2.block_id not in meta2.load_gpu_blocks
assert len(meta2.load_cpu_blocks) == num_blocks
def test_lazy_target_blocks_ignore_non_prefix_cacheable_groups() -> None:
with_ring = _make_qsa_hybrid_kv_cache_config(num_blocks=16)
without_ring = KVCacheConfig(
num_blocks=16,
kv_cache_tensors=with_ring.kv_cache_tensors[:1],
kv_cache_groups=with_ring.kv_cache_groups[:1],
)
max_batched = 64
target_with = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
with_ring, max_batched
)
target_without = SimpleCPUOffloadScheduler._estimate_lazy_target_blocks(
without_ring, max_batched
)
assert target_with == target_without
+63
View File
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from typing import cast
from unittest.mock import Mock
import pytest
import torch
@@ -124,3 +125,65 @@ def test_workspace_lane_validation(monkeypatch) -> None:
with pytest.raises(ValueError, match="at least one"):
workspace.WorkspaceManager(torch.device("cpu"), num_lanes=0)
def test_persistent_resources_are_lazy_and_isolated(monkeypatch) -> None:
"""Only the requesting slot allocates; each ubatch/lane owns its resource."""
active_ubatch = 0
monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: active_ubatch)
manager = workspace.WorkspaceManager(
torch.device("cpu"), num_ubatches=2, num_lanes=2
)
factory = Mock(side_effect=lambda: {"buffer": torch.zeros(8)})
resources = []
for ubatch in range(2):
active_ubatch = ubatch
for lane in range(2):
with workspace.use_workspace_lane(lane):
resource = manager.get_persistent_resource("scratch", factory)
resources.append(resource)
resource["buffer"].fill_(len(resources))
assert manager.get_persistent_resource("scratch", factory) is resource
assert factory.call_count == len(resources)
assert len({r["buffer"].data_ptr() for r in resources}) == 4
for i, resource in enumerate(resources, 1):
assert torch.all(resource["buffer"] == i)
def test_persistent_resource_lock_applies_to_each_slot(monkeypatch) -> None:
monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
manager = workspace.WorkspaceManager(torch.device("cpu"), num_lanes=2)
factory = Mock(side_effect=object)
first = manager.get_persistent_resource("scratch", factory)
manager.lock()
assert manager.get_persistent_resource("scratch", factory) is first
with pytest.raises(AssertionError, match="was not allocated during warmup"):
manager.get_persistent_resource("new", factory)
with workspace.use_workspace_lane(1):
with pytest.raises(AssertionError, match="was not allocated during warmup"):
manager.get_persistent_resource("scratch", factory)
assert factory.call_count == 1
manager.unlock()
assert manager.get_persistent_resource("scratch", factory) is not first
assert manager.get_persistent_resource("scratch", factory) is first
def test_persistent_tensor_preserves_contents_and_rejects_changes() -> None:
"""Initialization happens once and transient scratch cannot overwrite locks."""
manager = workspace.WorkspaceManager(torch.device("cpu"))
first = manager.get_persistent("locks", (8,), torch.int32, zero_init=True)
assert torch.count_nonzero(first) == 0
first.fill_(1)
(scratch,) = manager.get_simultaneous(((1024,), torch.uint8))
scratch.zero_()
assert manager.get_persistent("locks", (8,), torch.int32, zero_init=True) is first
assert torch.all(first == 1)
with pytest.raises(ValueError, match="requested shape"):
manager.get_persistent("locks", (16,), torch.int32)
with pytest.raises(ValueError, match="requested shape"):
manager.get_persistent("locks", (8,), torch.int64)
manager.lock()
assert manager.get_persistent("locks", (8,), torch.int32) is first
with pytest.raises(AssertionError, match="was not allocated during warmup"):
manager.get_persistent("new", (8,), torch.int32)
+15
View File
@@ -21,6 +21,21 @@ flowchart LR
The recipe is the baseline. Hardware and workload information can optionally
refine the initial configuration. Sweep tuning is an optional validation step.
## Getting Started
Use `serve_with_recipe.sh` to generate the recipe configuration and start vLLM
in one step:
```bash
tools/recipes/serve_with_recipe.sh \
--model meta-llama/Llama-3.1-8B-Instruct \
--hardware xeon6
```
For `xeon6`, the script enables hardware detection automatically. Generated
`config.yml` and `env.sh` files remain in the directory where the script was
invoked.
## 1. vLLM Recipes Only
Use the converter directly when the recipe already contains the deployment
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONVERTER="${SCRIPT_DIR}/recipe_json_to_vllm_config.py"
OUTPUT_DIR="$(pwd -P)"
CONFIG_PATH="${OUTPUT_DIR}/config.yml"
ENV_PATH="${OUTPUT_DIR}/env.sh"
args=("$@")
hardware=""
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[$i]}" in
--hardware)
if ((i + 1 < ${#args[@]})); then
hardware="${args[$((i + 1))]}"
fi
;;
--hardware=*)
hardware="${args[$i]#*=}"
;;
esac
done
if [[ "${hardware,,}" == "xeon6" ]]; then
args+=(--detect-hardware)
fi
echo "==> Generating vLLM configuration"
python3 "${CONVERTER}" "${args[@]}"
echo
echo "==> Loading recipe environment"
# shellcheck disable=SC1090
source "${ENV_PATH}"
# Released vLLM images keep recipe-relative assets such as examples/*.jinja
# under /vllm-workspace. Launch from there when available while keeping the
# generated config and environment files in the caller's output directory.
if [[ -d /vllm-workspace/examples ]]; then
cd /vllm-workspace
fi
echo
echo "==> Starting vLLM"
exec vllm serve --config "${CONFIG_PATH}"
@@ -179,6 +179,7 @@ class ServingDerender(BaseServing):
usage=usage,
prompt_logprobs=gen.prompt_logprobs,
kv_transfer_params=gen.kv_transfer_params,
metrics=gen.metrics,
)
async def derender_completion_response(
@@ -245,6 +246,9 @@ class ServingDerender(BaseServing):
choices=choices,
usage=usage,
kv_transfer_params=kv_params,
# Metrics describe one prompt. Parallel samples are already
# suppressed by /generate; multi-prompt responses cannot be merged.
metrics=first.metrics if len(request.generate_responses) == 1 else None,
)
async def derender_chat_stream_response(
@@ -11,7 +11,11 @@ from pydantic import (
)
from vllm.config import ModelConfig
from vllm.entrypoints.generate.base.protocol import StreamOptions, validate_cache_salt
from vllm.entrypoints.generate.base.protocol import (
PerRequestMetrics,
StreamOptions,
validate_cache_salt,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionLogProbs,
ChatCompletionRequest,
@@ -360,6 +364,7 @@ class GenerateStreamResponse(BaseModel):
usage: UsageInfo | None = Field(default=None)
prompt_token_ids: list[int] | None = None
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
metrics: PerRequestMetrics | None = None
class GenerateResponse(BaseModel):
@@ -378,6 +383,7 @@ class GenerateResponse(BaseModel):
prompt_logprobs: list[dict[int, Logprob] | None] | None = None
prompt_token_ids: list[int] | None = None
mm_placeholders: dict[str, list[PlaceholderRangeInfo]] | None = None
metrics: PerRequestMetrics | None = None
kv_transfer_params: dict[str, Any] | None = Field(
default=None,
@@ -12,9 +12,13 @@ from fastapi import Request
from vllm.engine.protocol import EngineClient
from vllm.entrypoints.chat_utils import AsyncMultiModalItemTracker
from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata
from vllm.entrypoints.generate.base.protocol import (
PerRequestMetrics,
RequestResponseMetadata,
)
from vllm.entrypoints.generate.base.serving import (
GenerateBaseServing,
build_spec_decoding_metrics,
clamp_prompt_logprobs,
)
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -373,6 +377,11 @@ class ServingTokens(GenerateBaseServing):
request_metadata.final_usage_info = usage
per_request_metrics = None
if request.sampling_params.n == 1:
spec_stats = build_spec_decoding_metrics(final_res)
if spec_stats is not None:
per_request_metrics = PerRequestMetrics(speculative_decoding=spec_stats)
response = GenerateResponse(
request_id=request_id,
created=created_time,
@@ -384,6 +393,7 @@ class ServingTokens(GenerateBaseServing):
final_res.prompt_token_ids if request.return_token_ids else None
),
mm_placeholders=request._response_mm_placeholders,
metrics=per_request_metrics,
kv_transfer_params=final_res.kv_transfer_params,
ec_transfer_params=final_res.ec_transfer_params,
)
@@ -423,6 +433,7 @@ class ServingTokens(GenerateBaseServing):
prompt_token_ids: list[int] | None = None
num_cached_tokens = None
sampling_params: SamplingParams = request.sampling_params
last_res: RequestOutput | None = None
include_usage, include_continuous_usage = should_include_usage(
request.stream_options, False
@@ -430,6 +441,7 @@ class ServingTokens(GenerateBaseServing):
try:
async for res in result_generator:
last_res = res
if first_iteration:
if res.prompt_token_ids is not None:
num_prompt_tokens = len(res.prompt_token_ids)
@@ -496,10 +508,10 @@ class ServingTokens(GenerateBaseServing):
total_tokens=(num_prompt_tokens + num_generated_tokens[i]),
)
# Omit absent prompt metadata (Rust skips None fields).
# Omit fields that are absent from token-bearing chunks.
exclude = {
name
for name in ("prompt_token_ids", "mm_placeholders")
for name in ("prompt_token_ids", "mm_placeholders", "metrics")
if getattr(chunk, name) is None
}
yield f"data: {chunk.model_dump_json(exclude=exclude)}\n\n"
@@ -517,10 +529,18 @@ class ServingTokens(GenerateBaseServing):
)
if include_usage:
per_request_metrics = None
if sampling_params.n == 1:
spec_stats = build_spec_decoding_metrics(last_res)
if spec_stats is not None:
per_request_metrics = PerRequestMetrics(
speculative_decoding=spec_stats
)
final_chunk = GenerateStreamResponse(
request_id=request_id,
choices=[],
usage=final_usage_info,
metrics=per_request_metrics,
)
yield f"data: {final_chunk.model_dump_json(exclude_none=True)}\n\n"
@@ -10,7 +10,6 @@ from vllm.model_executor.layers.quantization.utils.marlin_utils import (
apply_gptq_marlin_linear,
marlin_act_int8_process_scales,
marlin_make_empty,
marlin_make_workspace_new,
marlin_pad_dim,
marlin_pad_qweight,
marlin_pad_scales,
@@ -91,11 +90,6 @@ class MarlinLinearKernel(MPLinearKernel):
size_k, size_n = c.partition_weight_shape
padded_n, padded_k = marlin_padded_nk(size_n, size_k, c.group_size)
# Allocate marlin workspace, reusing existing storage on reload.
self.workspace = marlin_make_workspace_new(
device, existing=getattr(self, "workspace", None)
)
# Default name since marlin requires empty parameter for zp,
# TODO: remove this requirement from marlin (allow optional tensors)
if self.w_zp_name is None:
@@ -197,7 +191,7 @@ class MarlinLinearKernel(MPLinearKernel):
weight=w_q,
weight_scale=w_s,
weight_zp=w_zp, # type: ignore
workspace=self.workspace,
workspace=None,
wtype=c.weight_type,
input_size_per_partition=c.partition_weight_shape[0],
output_size_per_partition=c.partition_weight_shape[1],
@@ -58,7 +58,7 @@ class MarlinMxFp4LinearKernel(MxFp4LinearKernel):
weight=layer.weight,
weight_scale=layer.weight_scale,
weight_global_scale=None,
workspace=layer.workspace,
workspace=None,
size_n=layer.output_size_per_partition,
size_k=layer.input_size_per_partition,
bias=bias,
@@ -46,7 +46,7 @@ class MarlinMxfp8LinearKernel(Mxfp8LinearKernel):
input=x,
weight=layer.weight,
weight_scale=layer.weight_scale,
workspace=layer.workspace,
workspace=None,
size_n=layer.output_size_per_partition,
size_k=layer.input_size_per_partition,
bias=bias,
@@ -50,7 +50,7 @@ class MarlinNvFp4LinearKernel(NvFp4LinearKernel):
weight=layer.weight,
weight_scale=layer.weight_scale,
weight_global_scale=layer.weight_global_scale,
workspace=layer.workspace,
workspace=None,
size_n=layer.output_size_per_partition,
size_k=layer.input_size_per_partition,
bias=bias,
@@ -93,7 +93,7 @@ class MarlinFP8ScaledMMLinearKernel(FP8ScaledMMLinearKernel):
input=x,
weight=layer.weight,
weight_scale=weight_scale,
workspace=layer.workspace,
workspace=None,
size_n=layer.output_size_per_partition,
size_k=layer.input_size_per_partition,
input_dtype=self.marlin_input_dtype,
@@ -27,6 +27,7 @@ from vllm.model_executor.layers.fused_moe.moe_align_block_size import (
from vllm.model_executor.layers.fused_moe.moe_fused_mul_sum import moe_fused_mul_sum
from vllm.model_executor.layers.fused_moe.moe_permute_unpermute import (
MoEPermuteScratch,
get_moe_permute_scratch,
moe_permute,
moe_permute_unpermute_supported,
moe_unpermute,
@@ -176,7 +177,6 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
max_num_tokens=max_num_tokens,
num_dispatchers=num_dispatchers,
)
self._permute_scratch: dict[int, MoEPermuteScratch] = {}
def init_humming_moe(self):
from vllm.utils.humming import get_heuristics_config
@@ -259,24 +259,20 @@ class HummingExpertsBase(mk.FusedMoEExpertsModular):
if not moe_permute_unpermute_supported():
return None
scratch = self._permute_scratch.get(topk)
if scratch is None:
max_expanded_rows = (
self.moe_config.max_num_tokens
* self.moe_config.dp_size
* self.moe_config.experts_per_token
)
scratch = MoEPermuteScratch(
max_num_tokens=math.ceil(max_expanded_rows / topk),
topk=topk,
num_experts=self.moe_config.num_experts,
num_local_experts=self.moe_config.num_local_experts,
device=torch.device(self.moe_config.device),
hidden_size=self.moe_config.hidden_dim,
hidden_dtype=self.moe_config.in_dtype,
)
self._permute_scratch[topk] = scratch
return scratch
max_expanded_rows = (
self.moe_config.max_num_tokens
* self.moe_config.dp_size
* self.moe_config.experts_per_token
)
return get_moe_permute_scratch(
max_num_tokens=math.ceil(max_expanded_rows / topk),
topk=topk,
num_experts=self.moe_config.num_experts,
num_local_experts=self.moe_config.num_local_experts,
device=torch.device(self.moe_config.device),
hidden_size=self.moe_config.hidden_dim,
hidden_dtype=self.moe_config.in_dtype,
)
def get_global_valid_shape_m(self, topk_ids: torch.Tensor):
ctx = get_forward_context()
@@ -35,7 +35,7 @@ from vllm.model_executor.layers.fused_moe.topk_weight_and_reduce import (
from vllm.model_executor.layers.fused_moe.utils import _resize_cache
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
get_marlin_input_dtype,
marlin_make_workspace_new,
get_marlin_workspace,
marlin_moe_intermediate_size,
marlin_quant_input,
)
@@ -95,7 +95,7 @@ def _fused_marlin_moe(
N = marlin_moe_intermediate_size(w1, w2)
w13_num_shards = 2 if activation.is_gated else 1
if workspace is None:
workspace = marlin_make_workspace_new(hidden_states.device, 4)
workspace = get_marlin_workspace(hidden_states.device)
if intermediate_cache13 is None:
intermediate_cache13 = torch.empty(
@@ -5,6 +5,11 @@ from dataclasses import dataclass, field
import torch
from vllm.v1.worker.workspace import (
current_workspace_manager,
is_workspace_manager_initialized,
)
@dataclass
class MoEPermuteScratch:
@@ -102,6 +107,48 @@ class MoEPermuteScratch:
return topk_ids_int32
def get_moe_permute_scratch(
*,
max_num_tokens: int,
topk: int,
num_experts: int,
num_local_experts: int,
device: torch.device,
hidden_size: int | None = None,
hidden_dtype: torch.dtype | None = None,
) -> MoEPermuteScratch:
"""Share scratch across sequential layers in the current ubatch and lane.
Without a workspace manager, allocate new scratch for each call.
"""
def create_scratch() -> MoEPermuteScratch:
return MoEPermuteScratch(
max_num_tokens=max_num_tokens,
topk=topk,
num_experts=num_experts,
num_local_experts=num_local_experts,
device=device,
hidden_size=hidden_size,
hidden_dtype=hidden_dtype,
)
if not is_workspace_manager_initialized():
return create_scratch()
key = (
MoEPermuteScratch,
max_num_tokens,
topk,
num_experts,
num_local_experts,
device,
hidden_size,
hidden_dtype,
)
return current_workspace_manager().get_persistent_resource(key, create_scratch)
def moe_permute(
hidden_states: torch.Tensor,
a1q_scale: torch.Tensor | None,
+23 -5
View File
@@ -14,6 +14,12 @@ from vllm.v1.worker.workspace import current_workspace_manager
RADIX_TOPK_WORKSPACE_SIZE = 1024 * 1024
# cooperative_topk's hard row limit: one cluster wave covers at most this many
# rows. "auto" uses cooperative_topk for every batch within the limit and falls
# back to persistent_topk past it; explicit ``cooperative`` requests are
# validated against the same bound.
AUTO_COOPERATIVE_MAX_ROWS = 64
# ---------------------------------------------------------------------------
# DeepSelect (vllm._deepselect_C)
# ---------------------------------------------------------------------------
@@ -209,8 +215,13 @@ class SparseIndexerTopk(torch.nn.Module):
def _resolve_auto(
self, logits: torch.Tensor, topk_tokens: int, num_rows: int
) -> str:
"""The pre-existing priority chain: cooperative -> persistent ->
per_row. deep_select/flashinfer/torch are opt-in only."""
"""The priority chain: cooperative -> persistent -> per_row.
deep_select/flashinfer/torch are opt-in only.
cooperative_topk is preferred whenever it is applicable, i.e. within
its AUTO_COOPERATIVE_MAX_ROWS row limit; larger batches go to
persistent_topk.
"""
if not self._cooperative_constraints(logits, topk_tokens, num_rows):
return "cooperative"
if self._is_cuda and topk_tokens in (512, 1024, 2048):
@@ -228,8 +239,10 @@ class SparseIndexerTopk(torch.nn.Module):
failures.append(
f"topk_tokens must be in (512, 1024, 2048), got {topk_tokens}"
)
if num_rows > 64:
failures.append(f"num_rows must be <= 64, got {num_rows}")
if num_rows > AUTO_COOPERATIVE_MAX_ROWS:
failures.append(
f"num_rows must be <= {AUTO_COOPERATIVE_MAX_ROWS}, got {num_rows}"
)
if logits.stride(0) % 4 != 0:
failures.append(
f"logits.stride(0) must be divisible by 4, got {logits.stride(0)}"
@@ -293,13 +306,18 @@ class SparseIndexerTopk(torch.nn.Module):
(topk_workspace,) = current_workspace_manager().get_simultaneous(
((RADIX_TOPK_WORKSPACE_SIZE,), torch.uint8),
)
# persistent_topk's last argument is the host-side "max seq_len
# across rows" bound (it gates the sampled_topk path and clamps
# per-row lengths), not the padded row width: logits is sized to
# max_model_len, so passing its width would incorrectly enable the
# long-row kernels on short-context batches.
torch.ops._C.persistent_topk(
logits,
seq_lens,
topk_indices,
topk_workspace,
topk_tokens,
logits.shape[1],
max_seq_len,
)
elif backend == "flashinfer":
# Deferred: importing flashinfer initializes CUDA at import time.
@@ -167,6 +167,8 @@ def _log_gdn_backend_decision(
head_k_dim,
)
return
elif current_platform.is_xpu():
return
chosen = {
"flashinfer": "FlashInfer",
@@ -524,6 +526,8 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
self.gdn_decode_kernel = "triton"
elif current_platform.is_cpu():
self.gdn_decode_kernel = "CPU"
elif current_platform.is_xpu():
self.gdn_decode_kernel = "XPU"
self.enable_fused_gdn_decode = self.gdn_decode_kernel == "cuda"
logger.info_once("GDN decode kernel: %s", self.gdn_decode_kernel)
@@ -48,7 +48,6 @@ from vllm.model_executor.layers.quantization.utils.marlin_utils import (
check_marlin_supports_layer,
check_moe_marlin_supports_layer,
get_marlin_input_dtype,
marlin_make_workspace_new,
verify_marlin_supported,
)
from vllm.model_executor.layers.quantization.utils.quant_utils import (
@@ -642,9 +641,6 @@ class AutoAWQMoEMethod(FusedMoEMethodBase):
layer.register_parameter("w2_qzeros", w2_qzeros)
set_weight_attrs(w2_qzeros, extra_weight_attrs)
device = layer.w13_qweight.device
layer.workspace = marlin_make_workspace_new(device, 4)
def process_weights_after_loading(self, layer: RoutedExperts) -> None:
converted = convert_to_wna16_moe_kernel_format(
backend=self.wna16_moe_backend,
@@ -16,7 +16,6 @@ from vllm.model_executor.kernels.linear import (
)
from vllm.model_executor.layers.fused_moe import (
FusedMoEConfig,
FusedMoEExpertsModular,
FusedMoEMethodBase,
FusedMoEQuantConfig,
FusedMoeWeightScaleSupported,
@@ -46,7 +45,6 @@ from vllm.model_executor.layers.quantization.utils.gptq_utils import (
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
check_moe_marlin_supports_layer,
get_marlin_input_dtype,
marlin_make_workspace_new,
marlin_repeat_scales_on_all_ranks,
verify_marlin_supported,
)
@@ -601,12 +599,6 @@ class AutoGPTQMoEMethod(FusedMoEMethodBase):
layer.register_parameter("w2_bias", w2_bias)
set_weight_attrs(w2_bias, extra_weight_attrs)
if self.experts_cls is not None and issubclass(
self.experts_cls, FusedMoEExpertsModular
):
device = layer.w13_qweight.device
layer.workspace = marlin_make_workspace_new(device, 4)
def process_weights_after_loading(self, layer: RoutedExperts) -> None:
def replace_or_register(name: str, val: torch.Tensor | None):
if val is None:
@@ -11,7 +11,6 @@ from compressed_tensors.quantization import (
from vllm.logger import init_logger
from vllm.model_executor.layers.fused_moe import (
FusedMoEExpertsModular,
RoutedExperts,
SharedExperts,
)
@@ -36,7 +35,6 @@ from vllm.model_executor.layers.quantization.compressed_tensors.schemes.compress
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
check_moe_marlin_supports_config,
get_marlin_input_dtype,
marlin_make_workspace_new,
)
from vllm.model_executor.layers.quantization.utils.quant_utils import (
GroupShape,
@@ -466,18 +464,6 @@ class CompressedTensorsWNA16MoEMethod(CompressedTensorsMoEMethod):
torch.nn.Parameter(w2_input_global_scale, requires_grad=False),
)
# Marlin workspace — only needed for Marlin-family backends, not emulation.
if (
self.experts_cls is not None
and issubclass(self.experts_cls, FusedMoEExpertsModular)
and self.wna16_backend != WNA16MoEBackend.EMULATION
):
layer.workspace = marlin_make_workspace_new(
layer.w13_weight_packed.device,
4,
existing=getattr(layer, "workspace", None),
)
# Alias packed weights to w13_weight/w2_weight for the modular kernel interface
layer.w13_weight = layer.w13_weight_packed
layer.w2_weight = layer.w2_weight_packed
@@ -8,7 +8,6 @@ import numpy
import torch
import vllm.envs as envs
from vllm import _custom_ops as ops
from vllm.distributed.utils import verify_group_size_divides_partition
from vllm.logger import init_logger
from vllm.model_executor.layers.fused_moe import RoutedExperts
@@ -23,6 +22,7 @@ from vllm.platforms import current_platform
from vllm.scalar_type import ScalarType, scalar_types
from vllm.utils.math_utils import round_up
from vllm.utils.platform_utils import num_compute_units
from vllm.utils.torch_utils import direct_register_custom_op
from .quant_utils import pack_cols, unpack_cols
@@ -32,6 +32,7 @@ GPTQ_MARLIN_TILE = 16
GPTQ_MARLIN_MIN_THREAD_N = 64
GPTQ_MARLIN_MIN_THREAD_K = 128
GPTQ_MARLIN_MAX_PARALLEL = 16
MARLIN_MAX_BLOCKS_PER_SM = 4
MARLIN_SUPPORTED_GROUP_SIZES = [-1, 32, 64, 128]
@@ -407,29 +408,96 @@ def marlin_moe_intermediate_size(w1_packed: torch.Tensor, w2_packed: torch.Tenso
def marlin_make_workspace_new(
device: torch.device,
max_blocks_per_sm: int = 1,
existing: torch.Tensor | None = None,
) -> torch.Tensor:
# In the new marlin kernel, we use the num of threadblocks as workspace
# size. The num of threadblocks is sms_count * max_blocks_per_sm.
sms = num_compute_units(device.index)
size = sms * max_blocks_per_sm
# On weight reload, reuse the existing storage so the workspace address
# captured by CUDA graphs stays valid.
if existing is not None:
if (
existing.device != device
or existing.dtype != torch.int
or existing.numel() != size
):
raise ValueError(
f"Existing Marlin workspace is incompatible "
f"(device={existing.device}, dtype={existing.dtype}, "
f"numel={existing.numel()}; expected device={device}, "
f"dtype={torch.int}, numel={size}). Reload must reuse the "
f"workspace storage captured by CUDA graphs."
)
return existing.zero_()
return torch.zeros(size, dtype=torch.int, device=device, requires_grad=False)
return torch.zeros(sms * max_blocks_per_sm, dtype=torch.int, device=device)
def get_marlin_workspace(device: torch.device) -> torch.Tensor:
"""Get zero-initialized locks for the current stream, ubatch, and lane.
Marlin resets its locks after each call, so ordered calls can reuse them.
"""
from vllm.utils.torch_utils import current_stream
from vllm.v1.worker.workspace import (
current_workspace_manager,
is_workspace_manager_initialized,
)
if not is_workspace_manager_initialized():
return marlin_make_workspace_new(device, MARLIN_MAX_BLOCKS_PER_SM)
return current_workspace_manager().get_persistent(
("marlin_locks", current_stream()),
(num_compute_units(device.index) * MARLIN_MAX_BLOCKS_PER_SM,),
torch.int,
zero_init=True,
)
def _marlin_gemm(
a: torch.Tensor,
b_q_weight: torch.Tensor,
b_bias: torch.Tensor | None,
b_scales: torch.Tensor,
a_scales: torch.Tensor | None,
global_scale: torch.Tensor | None,
b_zeros: torch.Tensor | None,
workspace: torch.Tensor | None,
b_q_type_id: int,
size_m: int,
size_n: int,
size_k: int,
use_atomic_add: bool = False,
use_fp32_reduce: bool = False,
is_zp_float: bool = False,
) -> torch.Tensor:
if workspace is None:
workspace = get_marlin_workspace(a.device)
return torch.ops._C.marlin_gemm(
a,
None,
b_q_weight,
b_bias,
b_scales,
a_scales,
global_scale,
b_zeros,
workspace,
b_q_type_id,
size_m,
size_n,
size_k,
use_atomic_add,
use_fp32_reduce,
is_zp_float,
)
def _marlin_gemm_fake(
a: torch.Tensor,
b_q_weight: torch.Tensor,
b_bias: torch.Tensor | None,
b_scales: torch.Tensor,
a_scales: torch.Tensor | None,
global_scale: torch.Tensor | None,
b_zeros: torch.Tensor | None,
workspace: torch.Tensor | None,
b_q_type_id: int,
size_m: int,
size_n: int,
size_k: int,
use_atomic_add: bool = False,
use_fp32_reduce: bool = False,
is_zp_float: bool = False,
) -> torch.Tensor:
dtype = a.dtype if a.dtype in (torch.float16, torch.bfloat16) else b_scales.dtype
return torch.empty((size_m, size_n), device=a.device, dtype=dtype)
direct_register_custom_op("marlin_gemm", _marlin_gemm, fake_impl=_marlin_gemm_fake)
def marlin_repeat_scales_on_all_ranks(group_size: int, is_row_parallel: bool) -> bool:
@@ -686,7 +754,7 @@ def apply_gptq_marlin_linear(
weight: torch.Tensor,
weight_scale: torch.Tensor,
weight_zp: torch.Tensor,
workspace: torch.Tensor,
workspace: torch.Tensor | None,
wtype: ScalarType,
output_size_per_partition: int,
input_size_per_partition: int,
@@ -723,9 +791,8 @@ def apply_gptq_marlin_linear(
reshaped_x, a_scales = marlin_quant_input(reshaped_x, input_dtype)
output = ops.marlin_gemm(
output = torch.ops.vllm.marlin_gemm(
reshaped_x,
None,
weight,
bias,
weight_scale,
@@ -733,7 +800,7 @@ def apply_gptq_marlin_linear(
None,
weight_zp,
workspace,
wtype,
wtype.id,
size_m=reshaped_x.shape[0],
size_n=padded_n,
size_k=padded_k,
@@ -10,7 +10,6 @@ from vllm.model_executor.layers.fused_moe.routed_experts import RoutedExperts
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
USE_FP32_REDUCE_DEFAULT,
get_marlin_input_dtype,
marlin_make_workspace_new,
marlin_pad_dim,
marlin_pad_qweight,
marlin_pad_scales,
@@ -161,7 +160,7 @@ def apply_fp4_marlin_linear(
weight: torch.Tensor,
weight_scale: torch.Tensor,
weight_global_scale: torch.Tensor | None,
workspace: torch.Tensor,
workspace: torch.Tensor | None,
size_n: int,
size_k: int,
bias: torch.Tensor | None = None,
@@ -196,9 +195,8 @@ def apply_fp4_marlin_linear(
inputs, a_scales = marlin_quant_input(inputs, torch.float8_e4m3fn)
output = ops.marlin_gemm(
output = torch.ops.vllm.marlin_gemm(
a=inputs,
c=None,
b_q_weight=weight,
b_bias=bias,
b_scales=weight_scale,
@@ -206,7 +204,7 @@ def apply_fp4_marlin_linear(
global_scale=weight_global_scale,
b_zeros=None,
workspace=workspace,
b_q_type=scalar_types.float4_e2m1f,
b_q_type_id=scalar_types.float4_e2m1f.id,
size_m=reshaped_x.size(0),
size_n=padded_n,
size_k=padded_k,
@@ -237,13 +235,6 @@ def prepare_fp4_layer_for_marlin(
assert layer.weight.shape == (part_size_n, part_size_k // 2)
device = layer.weight.device
# WORKSPACE
layer.workspace = marlin_make_workspace_new(
device, existing=getattr(layer, "workspace", None)
)
# WEIGHT
# Repack weights to marlin format
qweight = layer.weight.view(torch.int32).T.contiguous()
@@ -389,15 +380,9 @@ def prepare_nvfp4_moe_layer_for_marlin(
return x
return torch.nn.functional.pad(x, (0, (padded_N - N) // packing))
device = w13.device
param_dtype = layer.params_dtype
is_a_8bit = input_dtype is not None and input_dtype.itemsize == 1
# WORKSPACE
layer.workspace = marlin_make_workspace_new(
device, 4, existing=getattr(layer, "workspace", None)
)
# WEIGHT
# Repack weights to marlin format
def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
@@ -481,12 +466,7 @@ def prepare_moe_fp4_layer_for_marlin(
k = layer.moe_config.hidden_dim
n = layer.moe_config.intermediate_size_per_partition
# WORKSPACE
device = layer.w13_weight.device
param_dtype = layer.params_dtype
layer.workspace = marlin_make_workspace_new(
device, 4, existing=getattr(layer, "workspace", None)
)
is_a_8bit = input_dtype is not None and input_dtype.itemsize == 1
# WEIGHT
@@ -9,7 +9,6 @@ from vllm.logger import init_logger
from vllm.model_executor.layers.quantization.utils.marlin_utils import (
USE_FP32_REDUCE_DEFAULT,
get_marlin_input_dtype,
marlin_make_workspace_new,
marlin_moe_padded_intermediate,
marlin_pad_dim,
marlin_pad_qweight,
@@ -50,7 +49,7 @@ def apply_fp8_marlin_linear(
input: torch.Tensor,
weight: torch.Tensor,
weight_scale: torch.Tensor,
workspace: torch.Tensor,
workspace: torch.Tensor | None,
size_n: int,
size_k: int,
bias: torch.Tensor | None,
@@ -80,9 +79,8 @@ def apply_fp8_marlin_linear(
# inputs, a_scales = marlin_quant_input(inputs, torch.float8_e4m3fn)
raise RuntimeError("Marlin W8A8 is not supported.")
output = ops.marlin_gemm(
output = torch.ops.vllm.marlin_gemm(
a=inputs,
c=None,
b_q_weight=weight,
b_bias=bias,
b_scales=weight_scale,
@@ -90,7 +88,7 @@ def apply_fp8_marlin_linear(
global_scale=None,
b_zeros=None,
workspace=workspace,
b_q_type=scalar_types.float8_e4m3fn,
b_q_type_id=scalar_types.float8_e4m3fn.id,
size_m=reshaped_x.size(0),
size_n=padded_n,
size_k=padded_k,
@@ -127,13 +125,6 @@ def prepare_fp8_layer_for_marlin(
else:
assert layer.weight.shape == (part_size_n, part_size_k)
device = layer.weight.device
# WORKSPACE
layer.workspace = marlin_make_workspace_new(
device, existing=getattr(layer, "workspace", None)
)
# WEIGHT
# Repack weights to marlin format
qweight = pack_fp8_to_int32(layer.weight, size_k_first)
@@ -241,12 +232,7 @@ def prepare_fp8_moe_layer_for_marlin(
w13_weight_scale: torch.Tensor,
w2_weight_scale: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
"""Shuffle weights and scales into marlin format.
Note that this function has the side effect of adding a `workspace`
attribute to the layer. This `workspace` does not need to be
registered as a Parameter as it is not used during weight reloading.
"""
"""Shuffle weights and scales into marlin format."""
logger.warning_once(
"Your GPU does not have native support for FP8 computation but "
"FP8 quantization is being used. Weight-only FP8 compression will "
@@ -272,12 +258,6 @@ def prepare_fp8_moe_layer_for_marlin(
w13_weight = _moe_pad_shard_rows(w13_weight, n, padded_n)
w2_weight = _moe_pad_last(w2_weight, n, padded_n)
# WORKSPACE
device = layer.w13_weight.device
layer.workspace = marlin_make_workspace_new(
device, 4, existing=getattr(layer, "workspace", None)
)
# WEIGHT
# Repack weights to marlin format
def repack_weight(name: str, weight: torch.Tensor) -> torch.Tensor:
@@ -396,7 +376,7 @@ def apply_mxfp8_marlin_linear(
input: torch.Tensor,
weight: torch.Tensor,
weight_scale: torch.Tensor,
workspace: torch.Tensor,
workspace: torch.Tensor | None,
size_n: int,
size_k: int,
bias: torch.Tensor | None = None,
@@ -416,9 +396,8 @@ def apply_mxfp8_marlin_linear(
dtype=input.dtype,
)
output = ops.marlin_gemm(
output = torch.ops.vllm.marlin_gemm(
a=reshaped_x,
c=None,
b_q_weight=weight,
b_bias=bias,
b_scales=weight_scale,
@@ -426,7 +405,7 @@ def apply_mxfp8_marlin_linear(
global_scale=None,
b_zeros=None,
workspace=workspace,
b_q_type=scalar_types.float8_e4m3fn,
b_q_type_id=scalar_types.float8_e4m3fn.id,
size_m=reshaped_x.size(0),
size_n=padded_n,
size_k=padded_k,
@@ -451,13 +430,6 @@ def prepare_mxfp8_layer_for_marlin(layer: torch.nn.Module) -> None:
group_size = 32 # MX standard block size
padded_n, padded_k = marlin_padded_nk(part_size_n, part_size_k, group_size)
device = layer.weight.device
# WORKSPACE
layer.workspace = marlin_make_workspace_new(
device, existing=getattr(layer, "workspace", None)
)
# WEIGHT - repack FP8 weights to Marlin format
qweight = pack_fp8_to_int32(layer.weight, size_k_first=False)
qweight = qweight.T.contiguous()
@@ -512,7 +484,7 @@ def prepare_mxfp8_moe_layer_for_marlin(
"""Repack MXFP8 MoE weights and scales into Marlin kernel format.
Args:
layer: MoE layer (used to read params_dtype and attach workspace).
layer: MoE layer.
w13: [E, 2*N, K] float8_e4m3fn weights.
w2: [E, K, N] float8_e4m3fn weights.
w13_scale: [E, 2*N, K//32] uint8 e8m0 scales.
@@ -537,13 +509,8 @@ def prepare_mxfp8_moe_layer_for_marlin(
n = padded_n
w13_n = w13.shape[1]
device = w13.device
param_dtype = torch.get_default_dtype()
layer.workspace = marlin_make_workspace_new(
device, 4, existing=getattr(layer, "workspace", None)
)
def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
if "w13" in name:
size_n, size_k = w13_n, k
@@ -543,7 +543,7 @@ def _compiled_sample_step(
if tp_size > 1:
soft_embeds = torch.ops.vllm.all_reduce(soft_embeds, group_name=tp_group_name)
soft_embeds = soft_embeds * normalizer
sc_embeds[decode_slots] = soft_embeds * sc_keep
sc_embeds[decode_slots] = (soft_embeds * sc_keep).to(sc_embeds.dtype)
# Overwrite canvas with argmax for newly converged denoise requests
newly_converged = (converged & is_denoise).unsqueeze(1)
+8 -5
View File
@@ -27,6 +27,7 @@ from vllm.model_executor.layers.fused_moe import (
FusedMoEFactory,
fused_moe_make_expert_params_mapping,
)
from vllm.model_executor.layers.fused_moe.router.gate_linear import GateLinear
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import (
MergedColumnParallelLinear,
@@ -156,14 +157,16 @@ class MiMoV2MoE(nn.Module):
dtype = getattr(config, "moe_router_dtype", "float32")
self.gate_dtype = str_dtype_to_torch_dtype(dtype)
self.gate = nn.Linear(
self.gate = GateLinear(
config.hidden_size,
config.n_routed_experts,
bias=False,
dtype=self.gate_dtype,
params_dtype=self.gate_dtype,
out_dtype=torch.float32,
prefix=f"{prefix}.gate",
)
self.gate.e_score_correction_bias = nn.Parameter(
torch.empty(config.n_routed_experts, dtype=self.gate_dtype)
torch.empty(config.n_routed_experts, dtype=self.gate.out_dtype)
)
self.experts = FusedMoEFactory(
@@ -182,7 +185,7 @@ class MiMoV2MoE(nn.Module):
num_expert_group=config.n_group,
topk_group=config.topk_group,
scoring_func="sigmoid",
router_logits_dtype=self.gate_dtype,
router_logits_dtype=self.gate.out_dtype,
)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
@@ -198,7 +201,7 @@ class MiMoV2MoE(nn.Module):
gate_input = hidden_states.to(self.gate_dtype)
else:
gate_input = hidden_states
router_logits = self.gate(gate_input)
router_logits, _ = self.gate(gate_input)
final_hidden_states = self.experts(
hidden_states=hidden_states, router_logits=router_logits
)
+4 -1
View File
@@ -52,6 +52,7 @@ from vllm.transformers_utils.processors.mimo_v2_omni import (
from .interfaces import (
MultiModalEmbeddings,
SupportsEagle3,
SupportsMultiModal,
SupportsPP,
SupportsQuant,
@@ -1209,7 +1210,9 @@ class MiMoV2OmniDummyInputsBuilder(BaseDummyInputsBuilder[MiMoV2OmniProcessingIn
info=MiMoV2OmniProcessingInfo,
dummy_inputs=MiMoV2OmniDummyInputsBuilder,
)
class MiMoV2OmniForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsQuant):
class MiMoV2OmniForCausalLM(
nn.Module, SupportsMultiModal, SupportsPP, SupportsQuant, SupportsEagle3
):
# To ensure correct weight loading and mapping.
hf_to_vllm_mapper = WeightsMapper(
orig_to_new_prefix={
@@ -168,6 +168,7 @@ class DFlashQwen3Attention(nn.Module):
rms_norm_eps: float = 1e-06,
attention_bias: bool = False,
add_swa_attention_sink_bias: bool = False,
v_scale: float | None = None,
sliding_window: int | None = None,
causal: bool = False,
is_neox_style: bool = True,
@@ -193,6 +194,7 @@ class DFlashQwen3Attention(nn.Module):
self.q_size = self.num_heads * self.head_dim
self.kv_size = self.num_kv_heads * self.head_dim
self.scaling = self.head_dim**-0.5
self.v_scale = v_scale
self.qkv_proj = QKVParallelLinear(
hidden_size,
@@ -264,6 +266,8 @@ class DFlashQwen3Attention(nn.Module):
q, k = self.rotary_emb(positions, q, k)
if self.v_scale is not None:
v = v * self.v_scale
attn_output = self.attn(q, k, v)
output, _ = self.o_proj(attn_output)
return output
@@ -312,6 +316,7 @@ class DFlashQwen3DecoderLayer(nn.Module):
rms_norm_eps=config.rms_norm_eps,
attention_bias=getattr(config, "attention_bias", False),
add_swa_attention_sink_bias=add_swa_attention_sink_bias,
v_scale=dflash_config.get("attention_value_scale"),
sliding_window=sliding_window,
causal=causal,
is_neox_style=is_neox_style,
@@ -633,6 +638,10 @@ class DFlashQwen3Model(nn.Module):
if context_slot_mapping is None:
return
v_scale = getattr(self.layers[0].self_attn, "v_scale", None)
if v_scale is not None:
all_v.mul_(v_scale)
# --- Per-layer cache insert ---
all_k_final = all_k_flat.view(L, num_ctx, nkv, hd)
per_layer = isinstance(context_slot_mapping, (list, tuple))
+7 -1
View File
@@ -8,6 +8,7 @@ import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.model_executor.models.interfaces import SupportsLoRA
from vllm.model_executor.models.qwen3 import Qwen3Model
from vllm.model_executor.models.utils import AutoWeightsLoader, maybe_prefix
from vllm.sequence import IntermediateTensors
@@ -15,7 +16,7 @@ from vllm.sequence import IntermediateTensors
WeightItem = tuple[str, torch.Tensor]
class VoyageQwen3BidirectionalEmbedModel(nn.Module):
class VoyageQwen3BidirectionalEmbedModel(nn.Module, SupportsLoRA):
"""Qwen3Model + Voyage embedding head + bidirectional attention.
Checkpoint conventions (HF):
@@ -30,6 +31,11 @@ class VoyageQwen3BidirectionalEmbedModel(nn.Module):
- No "model." prefix
"""
packed_modules_mapping = {
"qkv_proj": ["q_proj", "k_proj", "v_proj"],
"gate_up_proj": ["gate_proj", "up_proj"],
}
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
self.config = vllm_config.model_config.hf_config
+173 -11
View File
@@ -7,6 +7,7 @@ from typing import cast
import torch
from vllm import envs
from vllm.distributed import (
get_tensor_model_parallel_world_size,
tensor_model_parallel_all_reduce,
@@ -42,6 +43,49 @@ from vllm.v1.worker.workspace import current_workspace_manager
logger = init_logger(__name__)
def _wo_a_block_scale_to_e8m0(scale: torch.Tensor) -> torch.Tensor | None:
"""Normalize checkpoint WO_A scales to raw OCP MX E8M0 bytes.
E8M0 is an unsigned exponent-only scale format with bias 127. A finite
encoded byte ``b`` in ``[0, 254]`` represents ``2 ** (b - 127)``;
``0xFF`` is reserved for NaN. This is the vendor-neutral OCP encoding used
by AMD AITER/OPUS, not an NVIDIA-specific convention. Reference: OCP
Microscaling Formats (MX) Specification, section 5.4.1:
https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf
Loaders may preserve the encoded byte as ``float8_e8m0fnu``/``uint8``,
or decode it to a floating-point power of two. Floating-point inputs are
accepted only when the original E8M0 byte can be recovered losslessly.
This function does not quantize or round arbitrary scales.
"""
if scale.dtype == torch.float8_e8m0fnu:
# Reinterpret the native E8M0 storage. ``to(uint8)`` would perform a
# numeric conversion instead of preserving the encoded exponent byte.
return scale.view(torch.uint8).contiguous()
if scale.dtype == torch.uint8:
# The checkpoint loader already exposed the E8M0 wire representation.
return scale.contiguous()
if not scale.dtype.is_floating_point:
return None
scale_f32 = scale.detach().float()
if not bool(torch.isfinite(scale_f32).all()) or bool((scale_f32 <= 0).any()):
return None
# With no mantissa, E8M0 can represent only exact powers of two. Rebuild
# the value before encoding so this adapter never silently quantizes a
# general floating-point checkpoint scale.
exponent = torch.round(torch.log2(scale_f32))
if not torch.equal(torch.exp2(exponent), scale_f32):
return None
encoded = exponent.to(torch.int32) + 127
# 0xFF is NaN in OCP E8M0, not a finite exponent.
if int(encoded.min()) < 0 or int(encoded.max()) > 254:
return None
return encoded.to(torch.uint8).contiguous()
def _trust_dsv4_extra_cache_nan_free(
kv_cache_dtype: str,
has_kv_transfer: bool,
@@ -555,6 +599,10 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
# Block scale for the preshuffled weight; None = not preshuffled.
self._wqa_wkv_scale: torch.Tensor | None = None
self._wo_b_scale: torch.Tensor | None = None
self._wo_a_fp8_weight: torch.Tensor | None = None
self._wo_a_e8m0_scale: torch.Tensor | None = None
self._wo_a_cos_cache: torch.Tensor | None = None
self._wo_a_sin_cache: torch.Tensor | None = None
self._fused_compressor_weight: torch.Tensor | None
self.register_buffer("_fused_compressor_weight", None, persistent=False)
self._fused_compressor_split_sizes: tuple[int, int] | None = None
@@ -815,6 +863,93 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
self._wqa_wkv_scale = _prep(self.fused_wqa_wkv)
self._wo_b_scale = _prep(self.wo_b)
if _ON_GFX950 and envs.VLLM_ROCM_USE_AITER_FP8BMM:
self._prepare_fp8_wo_a()
def _prepare_fp8_wo_a(self) -> None:
try:
from aiter.ops.batched_gemm_op_a8w8 import (
batched_gemm_a8w8_mxscale as mxscale_op,
)
from aiter.ops.inverse_rope_group_quant import (
inverse_rope_group_quant as inverse_quant_op,
)
except ImportError:
logger.warning_once(
"The DeepSeek V4 FP8 WO_A path requires AITER >= 0.1.20; "
"falling back to BF16 WO_A."
)
return
del mxscale_op, inverse_quant_op
from vllm.model_executor.layers.quantization.utils.fp8_utils import (
get_fp8_block_weight_scale,
is_fp8,
)
weight = getattr(self.wo_a, "weight", None)
scale = get_fp8_block_weight_scale(self.wo_a)
if scale is None:
# ModelOpt MXFP8 stores the multiplicative E8M0 scale without the
# historical ``_inv`` suffix.
scale = getattr(self.wo_a, "weight_scale", None)
if weight is None or scale is None:
logger.warning_once(
"DeepSeek V4 FP8 WO_A needs a block-scaled FP8 wo_a weight; "
"the layer exposes no weight/weight scale. Falling back to "
"BF16 WO_A."
)
return
if weight.dim() != 2 or scale.dim() != 2 or not is_fp8(weight.dtype):
logger.warning_once(
"DeepSeek V4 FP8 WO_A needs a 2-D FP8 wo_a weight with a 2-D "
"block scale, got weight %s%s and scale %s. Falling back to "
"BF16 WO_A.",
weight.dtype,
tuple(weight.shape),
tuple(scale.shape),
)
return
groups = self.n_local_groups
out_per_group = self.o_lora_rank
out_features, in_features = weight.shape
if (
out_features != groups * out_per_group
or out_per_group % 128 != 0
or in_features % 128 != 0
or scale.shape != (out_features // 128, in_features // 128)
):
logger.warning_once(
"DeepSeek V4 FP8 WO_A needs group-128 blocks for %d groups of "
"%d outputs, got weight %s and scale %s. Falling back to BF16 "
"WO_A.",
groups,
out_per_group,
tuple(weight.shape),
tuple(scale.shape),
)
return
e8m0_scale = _wo_a_block_scale_to_e8m0(scale)
if e8m0_scale is None:
logger.warning_once(
"DeepSeek V4 FP8 WO_A could not losslessly encode the %s wo_a "
"block scale as OCP E8M0. Falling back to BF16 WO_A.",
scale.dtype,
)
return
self._wo_a_fp8_weight = weight.view(groups, out_per_group, in_features)
self._wo_a_e8m0_scale = e8m0_scale.view(
groups, out_per_group // 128, in_features // 128
)
cache = getattr(self.rotary_emb, "cos_sin_cache_bf16", None)
if cache is None:
cache = self.rotary_emb.cos_sin_cache.to(dtype=torch.bfloat16)
cos_cache, sin_cache = cache.chunk(2, dim=-1)
self._wo_a_cos_cache = cos_cache.contiguous()
self._wo_a_sin_cache = sin_cache.contiguous()
def prepare_compressor_gemm_fusion(self) -> bool:
if self._fused_compressor_weight is not None:
@@ -973,17 +1108,44 @@ class DeepseekV4ROCMAiterMLAAttention(DeepseekV4Attention):
)
def _o_proj(self, o: torch.Tensor, positions: torch.Tensor) -> torch.Tensor:
# ROCm BF16 reference wo_a path (inverse RoPE + einsum) + wo_b.
z = rocm_inv_rope_einsum(
self.rotary_emb,
o,
positions,
self.rope_head_dim,
self.n_local_groups,
self.o_lora_rank,
self.wo_a,
)
zf = z.flatten(1)
if self._wo_a_fp8_weight is not None:
from aiter.ops.batched_gemm_op_a8w8 import (
batched_gemm_a8w8_mxscale,
)
from aiter.ops.inverse_rope_group_quant import (
inverse_rope_group_quant,
)
assert self._wo_a_cos_cache is not None
assert self._wo_a_sin_cache is not None
o_fp8, o_scale = inverse_rope_group_quant(
o.view(o.shape[0], self.n_local_heads, self.head_dim),
positions.to(torch.int64),
self._wo_a_cos_cache,
self._wo_a_sin_cache,
num_groups=self.n_local_groups,
quant_group_size=128,
)
assert self._wo_a_e8m0_scale is not None
zf = batched_gemm_a8w8_mxscale(
o_fp8,
self._wo_a_fp8_weight,
o_scale,
self._wo_a_e8m0_scale,
dtype=o.dtype,
).flatten(1)
else:
# ROCm BF16 reference wo_a path (inverse RoPE + einsum) + wo_b.
z = rocm_inv_rope_einsum(
self.rotary_emb,
o,
positions,
self.rope_head_dim,
self.n_local_groups,
self.o_lora_rank,
self.wo_a,
)
zf = z.flatten(1)
if self._wo_b_scale is not None and zf.dim() == 2:
return self._bpre_attn_gemm(self.wo_b.weight, self._wo_b_scale, zf, True)
return self.wo_b(zf)
+156 -7
View File
@@ -40,8 +40,7 @@ from vllm.model_executor.models.vision import (
)
@lru_cache(8)
def get_vision_cos_sin(
def _compute_vision_cos_sin(
n_h: int, n_w: int, dim: int, theta: float
) -> tuple[torch.Tensor, torch.Tensor]:
inv_freq = 1.0 / (theta ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
@@ -52,6 +51,13 @@ def get_vision_cos_sin(
return freqs.cos().unsqueeze(1), freqs.sin().unsqueeze(1)
@lru_cache(8)
def get_vision_cos_sin(
n_h: int, n_w: int, dim: int, theta: float
) -> tuple[torch.Tensor, torch.Tensor]:
return _compute_vision_cos_sin(n_h, n_w, dim, theta)
def apply_rotary(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
dtype = x.dtype
x1, x2 = x.float().chunk(2, dim=-1)
@@ -123,15 +129,23 @@ class DeepseekV4VisionAttention(nn.Module):
)
def forward(
self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
self,
x: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
cu_seqlens: torch.Tensor | None = None,
max_seqlen: torch.Tensor | None = None,
) -> torch.Tensor:
n = x.size(0)
qkv, _ = self.wqkv(x)
q, k, v = (t.view(n, self.n_heads, self.head_dim) for t in qkv.chunk(3, -1))
q = apply_rotary(q, cos, sin).unsqueeze(0) # (b=1, n, h, d)
k = apply_rotary(k, cos, sin).unsqueeze(0)
# One image per call: a dense batch, no varlen packing metadata.
o = self.attn(q, k, v.unsqueeze(0))
# Dense (cu_seqlens=None): one image per call. Varlen (cu_seqlens
# set): packed multi-image batch for encoder CUDA graph replay.
o = self.attn(
q, k, v.unsqueeze(0), cu_seqlens=cu_seqlens, max_seqlen=max_seqlen
)
out, _ = self.wo(o.reshape(n, -1))
return out
@@ -173,9 +187,14 @@ class DeepseekV4VisionBlock(nn.Module):
self.mlp = DeepseekV4VisionMLP(config, prefix=f"{prefix}.mlp")
def forward(
self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
self,
x: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
cu_seqlens: torch.Tensor | None = None,
max_seqlen: torch.Tensor | None = None,
) -> torch.Tensor:
x = x + self.attn(self.norm1(x), cos, sin)
x = x + self.attn(self.norm1(x), cos, sin, cu_seqlens, max_seqlen)
return x + self.mlp(self.norm2(x))
@@ -206,6 +225,115 @@ class DeepseekV4ViT(nn.Module):
x = block(x, cos, sin)
return self.norm(x)
def forward_packed(
self,
patches: torch.Tensor,
cos: torch.Tensor,
sin: torch.Tensor,
cu_seqlens: torch.Tensor,
max_seqlen: torch.Tensor,
) -> torch.Tensor:
"""Varlen path for encoder CUDA graphs: multiple images packed along
rows, per-image attention via ``cu_seqlens``, RoPE tables precomputed
on device. Pure-tensor (no host reads, no H2D), safe to capture."""
x = self.patch_embed(patches)
for block in self.blocks:
x = block(x, cos, sin, cu_seqlens, max_seqlen)
return self.norm(x)
def build_packed_vit_metadata(
grids: list[list[int]] | list[tuple[int, int]],
*,
rope_dim: int,
rope_theta: float,
device: torch.device,
max_seqlen_override: int | None = None,
cached: bool = True,
) -> dict[str, torch.Tensor]:
"""Precompute packed-batch ViT metadata for ``DeepseekV4ViT.forward_packed``.
Args:
grids: ``[n_vit_h, n_vit_w]`` per image, in packing order.
rope_dim: RoPE embedding dimension of the ViT.
rope_theta: RoPE base frequency of the ViT.
device: Device for the returned tensors (except ``max_seqlen``,
which stays on the host).
max_seqlen_override: Worst-case value baked in at CUDA graph capture
(the attention wrapper reads ``max_seqlen`` on the host, so the
capture-time value becomes a graph constant).
cached: Use the shared ``lru_cache`` for RoPE tables. Capture-time
dummy grids pass ``False`` to avoid evicting real entries.
"""
cos_sin = get_vision_cos_sin if cached else _compute_vision_cos_sin
cos_list: list[torch.Tensor] = []
sin_list: list[torch.Tensor] = []
cu_seqlens = [0]
max_seqlen = 0
for n_h, n_w in grids:
cos, sin = cos_sin(n_h, n_w, rope_dim, rope_theta)
cos_list.append(cos)
sin_list.append(sin)
n = n_h * n_w
cu_seqlens.append(cu_seqlens[-1] + n)
max_seqlen = max(max_seqlen, n)
if cos_list:
cos = torch.cat(cos_list).to(device)
sin = torch.cat(sin_list).to(device)
else:
cos = torch.zeros((0, 1, rope_dim), dtype=torch.float32, device=device)
sin = torch.zeros((0, 1, rope_dim), dtype=torch.float32, device=device)
if max_seqlen_override is not None:
max_seqlen = max_seqlen_override
return {
"vit_cos": cos,
"vit_sin": sin,
"cu_seqlens": torch.tensor(cu_seqlens, dtype=torch.int32, device=device),
# Read on the host by the attention wrapper; keep on CPU.
"max_seqlen": torch.tensor(max_seqlen, dtype=torch.int32),
}
def build_packed_merge_metadata(
grids: list[list[int]] | list[tuple[int, int]],
downsample_ratio: int,
*,
device: torch.device,
dtype: torch.dtype,
) -> dict[str, torch.Tensor]:
"""Gather indices/mask for ``DeepseekV4Aligner.forward_packed``.
For each aligner output row, the ``r x r`` source patch rows in the
packed ViT output; positions past the image edge (the eager path pads
them with zeros) get mask 0 and a clamped in-range index.
"""
r = downsample_ratio
idx_list: list[torch.Tensor] = []
mask_list: list[torch.Tensor] = []
offset = 0
for n_h, n_w in grids:
rows = -(-n_h // r)
cols = -(-n_w // r)
bi = torch.arange(rows).repeat_interleave(cols)
bj = torch.arange(cols).repeat(rows)
di = torch.arange(r).repeat_interleave(r)
dj = torch.arange(r).repeat(r)
hi = bi[:, None] * r + di[None, :]
wj = bj[:, None] * r + dj[None, :]
valid = (hi < n_h) & (wj < n_w)
idx = offset + hi.clamp(max=n_h - 1) * n_w + wj.clamp(max=n_w - 1)
idx_list.append(idx)
mask_list.append(valid)
offset += n_h * n_w
if idx_list:
merge_idx = torch.cat(idx_list).to(device)
merge_mask = torch.cat(mask_list).unsqueeze(-1).to(device=device, dtype=dtype)
else:
merge_idx = torch.zeros((0, r * r), dtype=torch.int64, device=device)
merge_mask = torch.zeros((0, r * r, 1), dtype=dtype, device=device)
return {"merge_idx": merge_idx, "merge_mask": merge_mask}
class DeepseekV4Aligner(nn.Module):
"""Spatial merge (downsample_ratio x downsample_ratio) + MLP projector."""
@@ -240,6 +368,27 @@ class DeepseekV4Aligner(nn.Module):
out, _ = self.w2(F.gelu(hidden))
return out
def forward_packed(
self,
x: torch.Tensor,
merge_idx: torch.Tensor,
merge_mask: torch.Tensor,
) -> torch.Tensor:
"""Packed multi-image path for encoder CUDA graphs.
``merge_idx``/``merge_mask`` come from ``build_packed_merge_metadata``;
gather+mask reproduces the eager ``F.pad`` + ``F.unfold`` merge
exactly (unfold rows are channel-major, so the gathered patch rows
are transposed before flattening).
"""
r2 = self.downsample_ratio**2
m = merge_idx.shape[0]
gathered = (x[merge_idx] * merge_mask).view(m, r2, -1)
gathered = gathered.permute(0, 2, 1).reshape(m, -1)
hidden, _ = self.w1(gathered)
out, _ = self.w2(F.gelu(hidden))
return out
def run_dp_sharded_vision_tower(
vision_model: DeepseekV4ViT,
+66 -13
View File
@@ -13,7 +13,10 @@ from vllm.distributed import (
)
from vllm.forward_context import get_forward_context
from vllm.logger import init_logger
from vllm.models.deepseek_v41.attention import DeepseekV4Attention
from vllm.models.deepseek_v41.attention import (
DeepseekV4Attention,
_replace_layer_index,
)
from vllm.models.deepseek_v41.common.ops import dequantize_and_gather_k_cache
from vllm.models.deepseek_v41.sparse_mla import (
DeepseekV4FlashMLAMetadata,
@@ -391,6 +394,10 @@ def _copy_ragged_to_graph_buffers(
return ragged_out, indptr_out
# (ragged_indices, ragged_indptr, lens) as consumed by rocm_sparse_attn_decode.
_TopkRagged = tuple[torch.Tensor, torch.Tensor, torch.Tensor]
@dataclass
class DeepseekV4ROCMAiterSparseSWAMetadata(DeepseekSparseSWAMetadata):
decode_swa_ragged_indices: torch.Tensor | None = None
@@ -500,6 +507,23 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
self._fused_compressor_weight: torch.Tensor | None
self.register_buffer("_fused_compressor_weight", None, persistent=False)
self._fused_compressor_split_sizes: tuple[int, int] | None = None
# Decode ragged topk metadata is a pure function of the indices its
# index source published, so every consumer of a source rebuilds the
# same thing. The source owns one cache per compress ratio, refreshed
# when it runs; consumers below it read through.
self._topk_ragged_cache: dict[int, _TopkRagged] = {}
self._index_source_prefix: str | None = None
if self.compress_ratio > 0:
assert self.index_source_layer_id is not None
self._index_source_prefix = _replace_layer_index(
self.prefix, self.index_source_layer_id
)
if self._index_source_prefix not in self._static_forward_context:
raise NotImplementedError(
f"Index source {self._index_source_prefix} not found on "
"this rank; PP splits inside a v4.1 index-sharing group "
"are not supported."
)
@classmethod
def get_padded_num_q_heads(cls, num_heads: int) -> int:
@@ -733,6 +757,42 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
output=output[:num_decode_tokens],
)
def _decode_topk_ragged(
self,
swa_metadata: DeepseekV4ROCMAiterSparseSWAMetadata,
attn_metadata: DeepseekV4FlashMLAMetadata | None,
num_decodes: int,
num_decode_tokens: int,
) -> _TopkRagged:
"""Ragged form of the topk indices this layer's index source published.
The packing depends only on the shared ``topk_indices_buffer`` and
per-step metadata, apart from the layer's own compress ratio, so the
source memoizes one result per ratio for all the layers below it.
"""
assert attn_metadata is not None
assert swa_metadata.is_valid_token is not None
assert self.topk_indices_buffer is not None
assert self._index_source_prefix is not None
source = self._static_forward_context[self._index_source_prefix]
if source is self:
# Fresh indices as of this layer; drop what the last step cached.
source._topk_ragged_cache = {}
cached = source._topk_ragged_cache.get(self.compress_ratio)
if cached is not None:
return cached
built = compute_global_topk_ragged_indices_and_indptr(
self.topk_indices_buffer[:num_decode_tokens],
swa_metadata.token_to_req_indices,
attn_metadata.block_table[:num_decodes],
attn_metadata.block_size // self.compress_ratio,
swa_metadata.is_valid_token[:num_decode_tokens],
)
source._topk_ragged_cache[self.compress_ratio] = built
return built
def _forward_decode(
self,
q: torch.Tensor,
@@ -749,22 +809,15 @@ class DeepseekV41ROCMAiterMLAAttention(DeepseekV4Attention):
topk_ragged_indices = None
topk_ragged_indptr = None
if not swa_only:
# Local indices filled by the index-source layer's indexer.
assert attn_metadata is not None
assert swa_metadata.is_valid_token is not None
assert self.topk_indices_buffer is not None
block_size = attn_metadata.block_size // self.compress_ratio
is_valid = swa_metadata.is_valid_token[:num_decode_tokens]
(
topk_ragged_indices,
topk_ragged_indptr,
topk_lens,
) = compute_global_topk_ragged_indices_and_indptr(
self.topk_indices_buffer[:num_decode_tokens],
swa_metadata.token_to_req_indices,
attn_metadata.block_table[:num_decodes],
block_size,
is_valid,
) = self._decode_topk_ragged(
swa_metadata=swa_metadata,
attn_metadata=attn_metadata,
num_decodes=num_decodes,
num_decode_tokens=num_decode_tokens,
)
rocm_sparse_attn_decode(
+12 -28
View File
@@ -26,6 +26,7 @@ from vllm.distributed import get_tensor_model_parallel_world_size
from vllm.model_executor.models.interfaces import (
MultiModalEmbeddings,
SupportsEagle3,
SupportsEncoderCudaGraph,
SupportsMultiModal,
SupportsPP,
)
@@ -44,15 +45,12 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.utils.tensor_schema import TensorSchema, TensorShape
from ..common.mm_preprocess import (
IMAGE,
IMAGE_END,
IMAGE_NEW_LINE,
IMAGE_PLACEHOLDER,
IMAGE_START,
DeepseekV4VLDummyInputsBuilder,
DeepseekV4VLMultiModalProcessor,
DeepseekV4VLProcessingInfo,
)
from ..common.vl_cudagraph import DeepseekV4VLEncoderCudaGraphMixin
from .model import (
DeepseekV41LLMForCausalLM,
_linear_scale_param_name,
@@ -115,11 +113,20 @@ def _make_deepseek_v4_vl_weights_mapper(
info=DeepseekV4VLProcessingInfo,
dummy_inputs=DeepseekV4VLDummyInputsBuilder,
)
class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
class DeepseekV41ForCausalLM(
nn.Module,
DeepseekV4VLEncoderCudaGraphMixin,
SupportsMultiModal,
SupportsEncoderCudaGraph,
SupportsPP,
SupportsEagle3,
):
"""Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
delegates through ``language_model`` via the protocol defaults.
``SupportsEncoderCudaGraph`` (ViT CUDA graph capture/replay) is
implemented by ``DeepseekV4VLEncoderCudaGraphMixin``.
"""
supports_encoder_tp_data = True
@@ -196,29 +203,6 @@ class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, Supports
resolve_bindings={"p": self.config.vision_patch_size},
)
def _encode_image(
self,
patches: torch.Tensor,
n_vit_h: int,
n_vit_w: int,
) -> torch.Tensor:
# Aligner rows in reading order, one per IMAGE slot.
return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
def _build_image_span(
self, image_embeds: torch.Tensor, types: torch.Tensor
) -> torch.Tensor:
"""Full image span: aligner rows at IMAGE slots, the learned
delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
types = types.to(image_embeds.device)
span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
dtype = image_embeds.dtype
span[types == IMAGE_START] = self.image_start.to(dtype)
span[types == IMAGE_END] = self.image_end.to(dtype)
span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
span[types == IMAGE] = image_embeds
return span
def _process_image_input(
self,
image_input: DeepseekV4VLImagePixelInputs,
@@ -0,0 +1,390 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Encoder CUDA graph support for the DeepSeek-V4.1 vision tower.
Mixin implementing the ``SupportsEncoderCudaGraph`` protocol
(``vllm/model_executor/models/interfaces.py``) on top of the shared
``DeepseekV4ViT``/``DeepseekV4Aligner``. The captured graph packs a whole
batch of images into one varlen run: ViT blocks attend per image via
``cu_seqlens``, and the aligner's spatial merge becomes a gather+mask
against precomputed indices. Span assembly (IMAGE_START/IMAGE_NEW_LINE/
IMAGE_END delimiters) stays eager in ``postprocess_encoder_output`` but is
batched over all items of the packed batch.
Enabled via ``--compilation-config '{"cudagraph_mm_encoder": true}'``; the
manager (``vllm/v1/worker/encoder_cudagraph.py``) handles budget packing, DP
sharding and eager fallback.
"""
from collections.abc import Hashable
from typing import TYPE_CHECKING, Any
import torch
from vllm.models.deepseek_v4.common.vision import (
build_packed_merge_metadata,
build_packed_vit_metadata,
)
from vllm.v1.worker.encoder_cudagraph_defs import (
EncoderCudaGraphCaptureInputs,
EncoderCudaGraphConfig,
EncoderCudaGraphReplayBuffers,
EncoderItemSpec,
)
from .mm_preprocess import IMAGE, IMAGE_END, IMAGE_NEW_LINE, IMAGE_START
if TYPE_CHECKING:
from vllm.config import VllmConfig
from vllm.config.multimodal import MultiModalConfig
from vllm.models.deepseek_v4.common.vision import (
DeepseekV4Aligner,
DeepseekV4ViT,
)
from vllm.transformers_utils.configs.deepseek_v41 import DeepseekV41Config
class DeepseekV4VLEncoderCudaGraphMixin:
"""``SupportsEncoderCudaGraph`` for ``DeepseekV41ForCausalLM``.
Expects ``self.vision``/``self.aligner``/``self.config``/
``self.multimodal_config`` and the ``image_start``/``image_newline``/
``image_end`` parameters from the host model.
"""
# Provided by the host model; declared here so mixin methods type-check.
vision: "DeepseekV4ViT"
aligner: "DeepseekV4Aligner"
config: "DeepseekV41Config"
multimodal_config: "MultiModalConfig | None"
image_start: torch.nn.Parameter
image_end: torch.nn.Parameter
image_newline: torch.nn.Parameter
# -- helpers shared with the eager multimodal path --
def _encode_image(
self,
patches: torch.Tensor,
n_vit_h: int,
n_vit_w: int,
) -> torch.Tensor:
# Aligner rows in reading order, one per IMAGE slot.
return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
def _build_image_span(
self, image_embeds: torch.Tensor, types: torch.Tensor
) -> torch.Tensor:
"""Full image span: aligner rows at IMAGE slots, the learned
delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
types = types.to(image_embeds.device)
span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
dtype = image_embeds.dtype
span[types == IMAGE_START] = self.image_start.to(dtype)
span[types == IMAGE_END] = self.image_end.to(dtype)
span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
span[types == IMAGE] = image_embeds
return span
@staticmethod
def _get_grid_list(mm_kwargs: dict[str, Any], key: str) -> list[list[int]]:
grid = mm_kwargs[key]
if isinstance(grid, torch.Tensor):
grid = grid.tolist()
return [[int(v) for v in row] for row in grid]
_encoder_cg_pad_totals: dict[int, int] = {}
# -- SupportsEncoderCudaGraph protocol --
def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
mm_cfg = self.multimodal_config
if (
mm_cfg is not None
and mm_cfg.mm_encoder_attn_dtype == "fp8"
and mm_cfg.mm_encoder_fp8_scale_path is None
):
raise ValueError(
"cudagraph_mm_encoder is incompatible with dynamic FP8 "
"scaling for the vision encoder (the amax history is "
"host-side state that cannot be captured). Provide a static "
"scale file via --mm-encoder-fp8-scale-path or disable "
"cudagraph_mm_encoder."
)
from vllm.v1.attention.backends.registry import AttentionBackendEnum
backend = self.vision.blocks[0].attn.attn.attn_backend
# FlashInfer reads max_seqlen on the host and TORCH_SDPA calls
# .tolist() on CUDA cu_seqlens; neither can be captured.
if backend in (
AttentionBackendEnum.FLASHINFER,
AttentionBackendEnum.TORCH_SDPA,
):
raise ValueError(
f"cudagraph_mm_encoder is not supported with the {backend.name} "
"ViT attention backend for DeepSeek-V4.1. Set "
"--mm-encoder-attn-backend FLASH_ATTN or disable "
"cudagraph_mm_encoder."
)
pad_totals = self._encoder_cg_pad_totals
def pad_cu_seqlens(dst: torch.Tensor, src: torch.Tensor) -> None:
# Varlen attention requires cu_seqlens[-1] to equal the number of
# rows actually passed in. The captured buffers are sized for the
# full token budget, so a smaller real batch is completed with one
# trailing padding sequence; declaring fewer rows than the buffer
# holds is undefined behaviour and returns NaN on FlashAttn.
total = pad_totals.get(dst.data_ptr())
if total is None:
raise RuntimeError(
"cu_seqlens replay buffer was not registered at capture "
"time; the manager must replay into the capture-time "
"buffers."
)
n = min(src.shape[0], dst.shape[0])
dst[:n].copy_(src[:n])
dst[n:] = total
return EncoderCudaGraphConfig(
modalities=["image"],
buffer_keys=[
"patches",
"vit_cos",
"vit_sin",
"cu_seqlens",
"max_seqlen",
"merge_idx",
"merge_mask",
],
out_hidden_size=self.config.hidden_size,
padding_logics={"cu_seqlens": pad_cu_seqlens},
)
def get_encoder_cudagraph_budget_range(
self,
vllm_config: "VllmConfig",
) -> tuple[int, int]:
# Min: comfortably above the smallest possible image span (4 tokens
# for a 1x1 grid); also sets max_batch_size = 64 via the manager's
# auto-inference (max_batch_size <= min_budget invariant).
min_budget = 64
max_budget = min(
vllm_config.scheduler_config.max_num_batched_tokens,
vllm_config.model_config.max_model_len,
)
return (min_budget, max_budget)
def get_encoder_cudagraph_item_specs(
self,
mm_kwargs: dict[str, Any],
) -> list[EncoderItemSpec]:
vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
llm_grid = self._get_grid_list(mm_kwargs, "llm_grid")
return [
EncoderItemSpec(
input_size=h * w,
output_tokens=lh * (lw + 1) + 2,
)
for (h, w), (lh, lw) in zip(vit_grid, llm_grid, strict=True)
]
def select_encoder_cudagraph_items(
self,
mm_kwargs: dict[str, Any],
indices: list[int],
) -> dict[str, Any]:
vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
llm_grid = self._get_grid_list(mm_kwargs, "llm_grid")
patches = mm_kwargs["patches"]
types = mm_kwargs["types"]
if len(indices) == 0:
return {
"patches": patches[:0],
"vit_grid": torch.zeros((0, 2), dtype=torch.int64),
"llm_grid": torch.zeros((0, 2), dtype=torch.int64),
"types": types[:0],
}
cum_patches = [0]
for h, w in vit_grid:
cum_patches.append(cum_patches[-1] + h * w)
cum_spans = [0]
for lh, lw in llm_grid:
cum_spans.append(cum_spans[-1] + lh * (lw + 1) + 2)
return {
"patches": torch.cat(
[patches[cum_patches[i] : cum_patches[i + 1]] for i in indices]
),
"vit_grid": torch.tensor([vit_grid[i] for i in indices], dtype=torch.int64),
"llm_grid": torch.tensor([llm_grid[i] for i in indices], dtype=torch.int64),
"types": torch.cat(
[types[cum_spans[i] : cum_spans[i + 1]] for i in indices]
),
}
def prepare_encoder_cudagraph_capture_inputs(
self,
token_budget: int,
max_batch_size: int,
max_frames_per_batch: int,
device: torch.device,
dtype: torch.dtype,
path: str = "default",
axis_keys: tuple[Hashable, ...] | None = None,
) -> EncoderCudaGraphCaptureInputs:
if (
self.multimodal_config is not None
and self.multimodal_config.get_limit_per_prompt("image") == 0
):
raise RuntimeError(
"cudagraph_mm_encoder requires the vision tower, but it was "
"stubbed out because the image limit is 0. Disable "
"cudagraph_mm_encoder."
)
config = self.config
r = config.vision_downsample_ratio
# Span rows per dummy item (ceiling so the buffers tile the budget).
# Each dummy image is a single aligner row of per_item_out blocks:
# r x (r * per_item_out) patches. One real image using the whole
# budget needs at most r^2 * token_budget patches, which this fits.
per_item_out = -(-token_budget // max_batch_size)
grids = [[r, r * per_item_out]] * max_batch_size
total_patches = r * r * per_item_out * max_batch_size
p = config.vision_patch_size
patches = torch.zeros(total_patches, 3, p, p, device=device, dtype=dtype)
metadata = build_packed_vit_metadata(
grids,
rope_dim=self.vision.rope_dim,
rope_theta=self.vision.rope_theta,
device=device,
max_seqlen_override=total_patches,
cached=False,
)
# Spare cu_seqlens slots let replay append a padding sequence that
# covers the rows a smaller real batch does not fill.
real_cu = metadata.pop("cu_seqlens")
cu_seqlens = torch.full(
(max_batch_size + 2,), total_patches, dtype=torch.int32, device=device
)
cu_seqlens[: real_cu.numel()] = real_cu
merge = build_packed_merge_metadata(grids, r, device=device, dtype=dtype)
self._encoder_cg_pad_totals[cu_seqlens.data_ptr()] = total_patches
return EncoderCudaGraphCaptureInputs(
values={
"patches": patches,
**metadata,
"cu_seqlens": cu_seqlens,
**merge,
}
)
def prepare_encoder_cudagraph_replay_buffers(
self,
mm_kwargs: dict[str, Any],
max_batch_size: int,
max_frames_per_batch: int,
path: str = "default",
) -> EncoderCudaGraphReplayBuffers:
vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
patches = mm_kwargs["patches"]
dtype = self.aligner.w1.weight.dtype
if patches.dtype != dtype:
patches = patches.to(dtype)
# Unpadded: the manager zero-pads patches/cos/sin/merge buffers, and
# pad_cu_seqlens appends the padding sequence covering the tail rows.
metadata = build_packed_vit_metadata(
vit_grid,
rope_dim=self.vision.rope_dim,
rope_theta=self.vision.rope_theta,
device=patches.device,
)
merge = build_packed_merge_metadata(
vit_grid,
self.config.vision_downsample_ratio,
device=patches.device,
dtype=dtype,
)
return EncoderCudaGraphReplayBuffers(
values={"patches": patches, **metadata, **merge}
)
def encoder_cudagraph_forward(
self,
inputs: dict[str, torch.Tensor],
path: str = "default",
) -> torch.Tensor:
vit_out = self.vision.forward_packed(
inputs["patches"],
inputs["vit_cos"],
inputs["vit_sin"],
inputs["cu_seqlens"],
inputs["max_seqlen"],
)
return self.aligner.forward_packed(
vit_out, inputs["merge_idx"], inputs["merge_mask"]
)
def encoder_eager_forward(
self,
mm_kwargs: dict[str, Any],
path: str = "default",
) -> torch.Tensor:
patches = mm_kwargs["patches"].to(self.aligner.w1.weight.dtype)
vit_grid = self._get_grid_list(mm_kwargs, "vit_grid")
outs: list[torch.Tensor] = []
offset = 0
for n_vit_h, n_vit_w in vit_grid:
n_vit = n_vit_h * n_vit_w
outs.append(
self._encode_image(patches[offset : offset + n_vit], n_vit_h, n_vit_w)
)
offset += n_vit
if not outs:
return patches.new_zeros((0, self.config.hidden_size))
return torch.cat(outs, dim=0)
def postprocess_encoder_output(
self,
outputs: dict[str, torch.Tensor],
indices: list[int],
per_item_out_tokens: list[int],
dest: dict[int, torch.Tensor] | list[torch.Tensor | None],
clone: bool = False,
batch_mm_kwargs: dict[str, Any] | None = None,
) -> None:
if not indices:
return
assert batch_mm_kwargs is not None
aligner_out = outputs["default"]
r = self.config.vision_downsample_ratio
vit_grid = self._get_grid_list(batch_mm_kwargs, "vit_grid")
llm_grid = self._get_grid_list(batch_mm_kwargs, "llm_grid")
types = batch_mm_kwargs["types"].to(aligner_out.device)
n_rows = sum(-(-h // r) * (-(-w // r)) for h, w in vit_grid)
span_lens = [lh * (lw + 1) + 2 for lh, lw in llm_grid]
# Batched span assembly: one masked fill per role across all items.
dtype = aligner_out.dtype
span = aligner_out.new_empty(sum(span_lens), self.config.hidden_size)
span[types == IMAGE] = aligner_out[:n_rows]
span[types == IMAGE_START] = self.image_start.to(dtype)
span[types == IMAGE_END] = self.image_end.to(dtype)
span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
# Freshly allocated, so later replays cannot clobber the results.
offset = 0
for idx, span_len in zip(indices, span_lens):
dest[idx] = span[offset : offset + span_len]
offset += span_len
+12 -28
View File
@@ -26,6 +26,7 @@ from vllm.distributed import get_tensor_model_parallel_world_size
from vllm.model_executor.models.interfaces import (
MultiModalEmbeddings,
SupportsEagle3,
SupportsEncoderCudaGraph,
SupportsMultiModal,
SupportsPP,
)
@@ -44,15 +45,12 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
from vllm.utils.tensor_schema import TensorSchema, TensorShape
from ..common.mm_preprocess import (
IMAGE,
IMAGE_END,
IMAGE_NEW_LINE,
IMAGE_PLACEHOLDER,
IMAGE_START,
DeepseekV4VLDummyInputsBuilder,
DeepseekV4VLMultiModalProcessor,
DeepseekV4VLProcessingInfo,
)
from ..common.vl_cudagraph import DeepseekV4VLEncoderCudaGraphMixin
from .model import (
DeepseekV41LLMForCausalLM,
_linear_scale_param_name,
@@ -115,11 +113,20 @@ def _make_deepseek_v4_vl_weights_mapper(
info=DeepseekV4VLProcessingInfo,
dummy_inputs=DeepseekV4VLDummyInputsBuilder,
)
class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3):
class DeepseekV41ForCausalLM(
nn.Module,
DeepseekV4VLEncoderCudaGraphMixin,
SupportsMultiModal,
SupportsEncoderCudaGraph,
SupportsPP,
SupportsEagle3,
):
"""Multimodal entry point for DeepSeek-V4.1 checkpoints with a vision tower.
``SupportsEagle3`` (aux hidden-state plumbing for MTP/DSpark drafters)
delegates through ``language_model`` via the protocol defaults.
``SupportsEncoderCudaGraph`` (ViT CUDA graph capture/replay) is
implemented by ``DeepseekV4VLEncoderCudaGraphMixin``.
"""
supports_encoder_tp_data = True
@@ -196,29 +203,6 @@ class DeepseekV41ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP, Supports
resolve_bindings={"p": self.config.vision_patch_size},
)
def _encode_image(
self,
patches: torch.Tensor,
n_vit_h: int,
n_vit_w: int,
) -> torch.Tensor:
# Aligner rows in reading order, one per IMAGE slot.
return self.aligner(self.vision(patches, n_vit_h, n_vit_w), n_vit_h, n_vit_w)
def _build_image_span(
self, image_embeds: torch.Tensor, types: torch.Tensor
) -> torch.Tensor:
"""Full image span: aligner rows at IMAGE slots, the learned
delimiter vectors at IMAGE_START/IMAGE_NEW_LINE/IMAGE_END."""
types = types.to(image_embeds.device)
span = image_embeds.new_empty(types.numel(), image_embeds.shape[-1])
dtype = image_embeds.dtype
span[types == IMAGE_START] = self.image_start.to(dtype)
span[types == IMAGE_END] = self.image_end.to(dtype)
span[types == IMAGE_NEW_LINE] = self.image_newline.to(dtype)
span[types == IMAGE] = image_embeds
return span
def _process_image_input(
self,
image_input: DeepseekV4VLImagePixelInputs,
@@ -383,6 +383,8 @@ def _kpool_tail_seed_kernel(
tslot_ptr,
tail_ptr,
n_tokens,
TAIL_BLOCK_ELEMS: tl.constexpr,
KPOOL_HEAD: tl.constexpr,
HEAD_DIM: tl.constexpr,
KPOOL: tl.constexpr,
BLOCK_D: tl.constexpr,
@@ -393,6 +395,11 @@ def _kpool_tail_seed_kernel(
ahead belongs to a different tail block (or is past the batch / padding,
slot < 0). ``tslot = block * KPOOL + pos % KPOOL``; the destination is
``tail[block, {0:K, 1:score}, pos % KPOOL, :]``.
The tail cache aliases the indexer cache with the indexer's (padded) block
stride, so blocks are addressed through ``TAIL_BLOCK_ELEMS`` /
``KPOOL_HEAD`` (``tail.stride(0)`` / ``tail.stride(1)``), never as a dense
``[num_blocks, 2, KPOOL, HEAD_DIM]`` array.
"""
i = tl.program_id(0)
t = tl.load(tslot_ptr + i).to(tl.int64)
@@ -409,11 +416,11 @@ def _kpool_tail_seed_kernel(
return
offs = tl.arange(0, BLOCK_D)
m = offs < HEAD_DIM
base = (blk * 2 * KPOOL + t % KPOOL) * HEAD_DIM
base = blk * TAIL_BLOCK_ELEMS + (t % KPOOL) * HEAD_DIM
k = tl.load(key_ptr + i * HEAD_DIM + offs, mask=m)
s = tl.load(score_ptr + i * HEAD_DIM + offs, mask=m)
tl.store(tail_ptr + base + offs, k, mask=m)
tl.store(tail_ptr + base + KPOOL * HEAD_DIM + offs, s, mask=m)
tl.store(tail_ptr + base + KPOOL_HEAD + offs, s, mask=m)
def kpool_seed_tail_cache(
@@ -426,6 +433,8 @@ def kpool_seed_tail_cache(
) -> None:
"""Seed the paged tail cache from a prefill batch (see the kernel)."""
assert tail_kv_cache.dtype == torch.bfloat16
assert tail_kv_cache.ndim == 4 and tail_kv_cache.shape[1] == 2
assert tail_kv_cache.stride(3) == 1 and tail_kv_cache.stride(2) == head_dim
assert key.dtype == torch.bfloat16
n = tslot.shape[0]
if n == 0:
@@ -436,6 +445,8 @@ def kpool_seed_tail_cache(
tslot,
tail_kv_cache,
n,
TAIL_BLOCK_ELEMS=tail_kv_cache.stride(0),
KPOOL_HEAD=tail_kv_cache.stride(1),
HEAD_DIM=head_dim,
KPOOL=kpool,
BLOCK_D=triton.next_power_of_2(head_dim),
+20 -44
View File
@@ -10,6 +10,7 @@ from vllm.config import get_current_vllm_config_or_none
from vllm.forward_context import get_forward_context
from vllm.logger import init_logger
from vllm.model_executor.custom_op import CustomOp
from vllm.model_executor.layers.indexer_topk import get_indexer_topk
from vllm.models.glm5next.common.sparse_indexer import (
RADIX_TOPK_WORKSPACE_SIZE,
_build_decode_scatter_indices,
@@ -39,21 +40,6 @@ if current_platform.is_cuda_alike():
logger = init_logger(__name__)
def _use_cooperative_topk(
logits: torch.Tensor,
select_k: int,
num_rows: int,
) -> bool:
return (
current_platform.is_cuda()
and select_k in (512, 1024, 2048)
and num_rows <= 64
and logits.stride(0) % 4 == 0
and current_platform.has_device_capability(90)
and not current_platform.is_device_capability_family(120)
)
# kpool write helper: form pools from the current token batch and compress them
# into the index K cache via the fused Triton kernel.
@@ -134,6 +120,7 @@ def sparse_attn_indexer_kpool(
# path and when the tail cache is disabled.
tail_kv_cache: torch.Tensor | None = None,
tail_prefix: str | None = None,
topk_backend: str = "auto",
) -> torch.Tensor:
# careful! this will be None in dummy run
attn_metadata = get_forward_context().attn_metadata
@@ -584,35 +571,18 @@ def sparse_attn_indexer_kpool(
else:
topk_dst = topk_indices_buffer[:num_padded_tokens, :topk_tokens]
if current_platform.is_cuda() and select_k in (512, 1024, 2048):
workspace_manager = current_workspace_manager()
(topk_workspace,) = workspace_manager.get_simultaneous(
((RADIX_TOPK_WORKSPACE_SIZE,), torch.uint8),
)
topk_op = (
torch.ops._C.cooperative_topk
if _use_cooperative_topk(logits, select_k, num_rows)
else torch.ops._C.persistent_topk
)
topk_op(
logits,
seq_lens,
topk_dst,
topk_workspace,
select_k,
attn_metadata_narrowed.max_seq_len,
)
else:
torch.ops._C.top_k_per_row_decode(
logits,
next_n,
seq_lens,
topk_dst,
num_rows,
logits.stride(0),
logits.stride(1),
select_k,
)
# Shared dispatcher with the DSA sparse indexer, so the kpool select
# honors kernel_config.sparse_indexer_topk_backend and picks between
# cooperative/persistent by batch size instead of always taking the
# same kernel.
get_indexer_topk(topk_backend)(
logits,
seq_lens,
next_n,
topk_dst,
select_k,
attn_metadata_narrowed.max_seq_len,
)
# Resolve to token-level indices in the output buffer.
if index_kpool > 1:
@@ -692,6 +662,11 @@ class SparseAttnIndexerKpool(CustomOp):
"Sparse Attention Indexer CUDA op requires DeepGEMM to be installed."
)
_cfg = get_current_vllm_config_or_none()
self.topk_backend = (
_cfg.kernel_config.sparse_indexer_topk_backend
if _cfg is not None
else "auto"
)
_parallel = _cfg.parallel_config if _cfg is not None else None
if (
_parallel is not None
@@ -766,4 +741,5 @@ class SparseAttnIndexerKpool(CustomOp):
positions,
self.tail_cache.kv_cache if self.tail_cache is not None else None,
self.tail_cache.prefix if self.tail_cache is not None else None,
self.topk_backend,
)
+94 -8
View File
@@ -7,11 +7,13 @@ from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import ExitStack
from dataclasses import replace
from functools import cached_property
from typing import TYPE_CHECKING, Any, Generic, overload
from typing_extensions import TypeVar
from vllm.exceptions import VLLMValidationError
from vllm.inputs import (
EmbedsInput,
EmbedsPrompt,
@@ -962,27 +964,103 @@ class BaseRenderer(ABC, Generic[_T]):
return engine_input
def _truncate_expanded_prompt(
self,
engine_input: TokensInput | MultiModalInput,
params: TokenizeParams | None,
) -> None:
"""Re-apply `truncate_prompt_tokens` once placeholders are expanded.
`TokenizeParams.apply_post_tokenization` truncates the prompt before
multimodal expansion, so a single placeholder token can expand into
many and push the result back over the requested bound.
Multimodal spans are never cut: a request whose spans would not all
survive intact is rejected, matching the post-expansion rejection in
`vllm.v1.engine.input_processor._validate_model_input`.
Only multimodal inputs can grow after `apply_post_tokenization`, so
everything else returns immediately.
"""
if engine_input["type"] != "multimodal":
return
if params is None or params.truncate_prompt_tokens is None:
return
prompt_token_ids = engine_input.get("prompt_token_ids")
if prompt_token_ids is None:
return
num_tokens = len(prompt_token_ids)
truncation = params._truncation_slice(self.tokenizer, num_tokens)
if truncation is None:
return
start, stop, _ = truncation.indices(num_tokens)
mm_placeholders = engine_input["mm_placeholders"]
for modality, positions in mm_placeholders.items():
for position in positions:
end = position.offset + position.length
if position.offset >= start and end <= stop:
continue
raise VLLMValidationError(
f"truncate_prompt_tokens={params.truncate_prompt_tokens} "
f"would split or drop a(n) {modality} item, which spans "
f"tokens {position.offset} to {end} of the "
f"{num_tokens}-token prompt. Multimodal placeholders "
f"expand after truncation is applied, so the limit must "
f"leave every multimodal item intact. Please raise "
f"truncate_prompt_tokens or shorten the multimodal input.",
parameter="truncate_prompt_tokens",
value=params.truncate_prompt_tokens,
)
if start:
engine_input["mm_placeholders"] = {
modality: [
replace(position, offset=position.offset - start)
for position in positions
]
for modality, positions in mm_placeholders.items()
}
engine_input["prompt_token_ids"] = prompt_token_ids[truncation]
def _process_singleton(
self,
prompt: SingletonTokPrompt,
*,
skip_mm_cache: bool = False,
tok_params: TokenizeParams | None = None,
) -> SingletonInput:
if "prompt_embeds" in prompt:
return self._process_embeds(prompt) # type: ignore[arg-type]
return self._process_tokens(prompt, skip_mm_cache=skip_mm_cache) # type: ignore[arg-type]
engine_input = self._process_tokens( # type: ignore[arg-type]
prompt, # type: ignore[arg-type]
skip_mm_cache=skip_mm_cache,
)
self._truncate_expanded_prompt(engine_input, tok_params)
return engine_input
async def _process_singleton_async(
self,
prompt: SingletonTokPrompt,
*,
skip_mm_cache: bool = False,
tok_params: TokenizeParams | None = None,
) -> SingletonInput:
if "prompt_embeds" in prompt:
return self._process_embeds(prompt) # type: ignore[arg-type]
return await self._process_tokens_async(prompt, skip_mm_cache=skip_mm_cache) # type: ignore[arg-type]
engine_input = await self._process_tokens_async( # type: ignore[arg-type]
prompt, # type: ignore[arg-type]
skip_mm_cache=skip_mm_cache,
)
self._truncate_expanded_prompt(engine_input, tok_params)
return engine_input
def _get_skip_decoder_start_token(self) -> bool:
"""Whether the multimodal processor supplies a complete decoder prefix."""
@@ -1048,12 +1126,15 @@ class BaseRenderer(ABC, Generic[_T]):
arrival_time: float,
*,
skip_mm_cache: bool = False,
tok_params: TokenizeParams | None = None,
) -> EngineInput:
engine_input: EngineInput
if "encoder_prompt" in prompt:
engine_input = self._process_enc_dec(prompt, skip_mm_cache=skip_mm_cache) # type: ignore[arg-type]
else:
engine_input = self._process_singleton(prompt, skip_mm_cache=skip_mm_cache)
engine_input = self._process_singleton(
prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
engine_input["arrival_time"] = arrival_time
@@ -1065,6 +1146,7 @@ class BaseRenderer(ABC, Generic[_T]):
arrival_time: float,
*,
skip_mm_cache: bool = False,
tok_params: TokenizeParams | None = None,
) -> EngineInput:
engine_input: EngineInput
if "encoder_prompt" in prompt:
@@ -1074,7 +1156,7 @@ class BaseRenderer(ABC, Generic[_T]):
)
else:
engine_input = await self._process_singleton_async(
prompt, skip_mm_cache=skip_mm_cache
prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
engine_input["arrival_time"] = arrival_time
@@ -1101,7 +1183,9 @@ class BaseRenderer(ABC, Generic[_T]):
self._apply_prompt_extras(tok_prompts, prompt_extras)
return [
self.process_for_engine(prompt, arrival_time, skip_mm_cache=skip_mm_cache)
self.process_for_engine(
prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
for prompt in tok_prompts
]
@@ -1126,7 +1210,7 @@ class BaseRenderer(ABC, Generic[_T]):
return await asyncio.gather(
*(
self.process_for_engine_async(
p, arrival_time, skip_mm_cache=skip_mm_cache
p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
for p in tok_prompts
)
@@ -1164,7 +1248,9 @@ class BaseRenderer(ABC, Generic[_T]):
self._apply_prompt_extras(tok_prompts, prompt_extras)
eng_prompts = [
self.process_for_engine(prompt, arrival_time, skip_mm_cache=skip_mm_cache)
self.process_for_engine(
prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
for prompt in tok_prompts
]
@@ -1204,7 +1290,7 @@ class BaseRenderer(ABC, Generic[_T]):
eng_prompts = await asyncio.gather(
*(
self.process_for_engine_async(
p, arrival_time, skip_mm_cache=skip_mm_cache
p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
for p in tok_prompts
)
+25 -27
View File
@@ -1194,22 +1194,20 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
coordinate space, no offset shifting needed afterwards.
"""
prompt_embeds_info = cast(dict, prompt).pop("_prompt_embeds", None)
if prompt_embeds_info is not None:
tensors, placeholder_token_id = prompt_embeds_info
mm_updates = _build_prompt_embeds_updates(tensors, placeholder_token_id)
cast(dict, prompt)["prompt_token_ids"] = _expand_prompt_embeds_placeholders(
list(prompt["prompt_token_ids"]), mm_updates
)
if prompt_embeds_info is None:
return super()._process_tokens(prompt, skip_mm_cache=skip_mm_cache)
tensors, placeholder_token_id = prompt_embeds_info
mm_updates = _build_prompt_embeds_updates(tensors, placeholder_token_id)
cast(dict, prompt)["prompt_token_ids"] = _expand_prompt_embeds_placeholders(
list(prompt["prompt_token_ids"]), mm_updates
)
engine_input = super()._process_tokens(prompt, skip_mm_cache=skip_mm_cache)
if prompt_embeds_info is not None:
tensors, _ = prompt_embeds_info
self._apply_prompt_embeds_to_engine_input(
cast(MultiModalInput, engine_input),
tensors,
mm_updates,
)
self._apply_prompt_embeds_to_engine_input(
cast(MultiModalInput, engine_input),
tensors,
mm_updates,
)
return engine_input
@@ -1222,24 +1220,24 @@ class HfRenderer(BaseRenderer[HfTokenizer]):
) -> TokensInput | MultiModalInput:
"""Async equivalent of `_process_tokens`."""
prompt_embeds_info = cast(dict, prompt).pop("_prompt_embeds", None)
if prompt_embeds_info is not None:
tensors, placeholder_token_id = prompt_embeds_info
mm_updates = _build_prompt_embeds_updates(tensors, placeholder_token_id)
cast(dict, prompt)["prompt_token_ids"] = _expand_prompt_embeds_placeholders(
list(prompt["prompt_token_ids"]), mm_updates
if prompt_embeds_info is None:
return await super()._process_tokens_async(
prompt, skip_mm_cache=skip_mm_cache
)
tensors, placeholder_token_id = prompt_embeds_info
mm_updates = _build_prompt_embeds_updates(tensors, placeholder_token_id)
cast(dict, prompt)["prompt_token_ids"] = _expand_prompt_embeds_placeholders(
list(prompt["prompt_token_ids"]), mm_updates
)
engine_input = await super()._process_tokens_async(
prompt, skip_mm_cache=skip_mm_cache
)
if prompt_embeds_info is not None:
tensors, _ = prompt_embeds_info
self._apply_prompt_embeds_to_engine_input(
cast(MultiModalInput, engine_input),
tensors,
mm_updates,
)
self._apply_prompt_embeds_to_engine_input(
cast(MultiModalInput, engine_input),
tensors,
mm_updates,
)
return engine_input
+3
View File
@@ -398,6 +398,7 @@ class OnlineDerenderer:
model=model,
choices=stream_choices,
usage=usage,
metrics=generate_chunk.metrics,
)
return chunk, updated_state
@@ -595,6 +596,7 @@ class OnlineDerenderer:
model=model,
choices=stream_choices,
usage=usage,
metrics=generate_chunk.metrics,
)
return chunk, updated_state
@@ -741,6 +743,7 @@ class OnlineDerenderer:
model=model,
choices=stream_choices,
usage=usage,
metrics=generate_chunk.metrics,
)
return chunk, updated_state
+3 -4
View File
@@ -512,11 +512,10 @@ def _get_attn_isa(
)
if supports_amx and dtype in (torch.bfloat16,) and block_size % 32 == 0:
return "amx"
elif supports_arm:
return "neon"
elif block_size % 32 == 0:
if supports_arm:
# support ARM NEON FMLA and BFMMLA (bf16) for block size 32
return "neon"
elif supports_riscv and _riscv_supports_rvv():
if supports_riscv and _riscv_supports_rvv():
return "rvv"
elif supports_vxe:
return "vxe"
+57 -2
View File
@@ -634,6 +634,38 @@ class DeepseekV32IndexerMetadata:
prefill: DeepseekV32IndexerPrefillMetadata | None = None
@triton.jit(do_not_specialize=["num_reqs", "num_actual_tokens", "num_tokens"])
def _kpool_tail_slot_mapping_kernel(
slot_mapping_ptr,
block_table_ptr,
block_table_stride,
query_start_loc_ptr,
positions_ptr,
out_ptr,
num_reqs,
num_actual_tokens,
num_tokens,
kpool,
BLOCK: tl.constexpr,
):
pid = tl.program_id(0)
if pid < num_reqs:
start = tl.load(query_start_loc_ptr + pid)
# Tokens past the last request's boundary (if any) also map to it.
end = tl.load(query_start_loc_ptr + pid + 1)
end = tl.where(pid == num_reqs - 1, num_actual_tokens, end)
own_block = tl.load(block_table_ptr + pid * block_table_stride).to(tl.int64)
for i in range(start, end, BLOCK):
offs = i + tl.arange(0, BLOCK)
mask = offs < end
pos = tl.load(positions_ptr + offs, mask=mask, other=0).to(tl.int64)
tl.store(out_ptr + offs, own_block * kpool + pos % kpool, mask=mask)
else:
offs = num_actual_tokens + (pid - num_reqs) * BLOCK + tl.arange(0, BLOCK)
mask = offs < num_tokens
tl.store(out_ptr + offs, tl.load(slot_mapping_ptr + offs, mask=mask), mask=mask)
def compute_kpool_tail_slot_mapping(
slot_mapping: torch.Tensor,
block_table: torch.Tensor,
@@ -646,10 +678,33 @@ def compute_kpool_tail_slot_mapping(
) -> torch.Tensor:
"""Map every token to its request's one circular tail block."""
if out is None:
out = slot_mapping.clone()
out = torch.empty_like(slot_mapping)
else:
assert out.shape == slot_mapping.shape
out.copy_(slot_mapping)
if slot_mapping.is_cuda and slot_mapping.dim() == 1 and num_reqs > 0:
block = 256
num_tokens = slot_mapping.shape[0]
num_actual_tokens = min(num_actual_tokens, num_tokens)
grid = (num_reqs + triton.cdiv(num_tokens - num_actual_tokens, block),)
_kpool_tail_slot_mapping_kernel[grid](
slot_mapping,
block_table,
block_table.stride(0),
query_start_loc,
positions,
out,
num_reqs,
num_actual_tokens,
num_tokens,
kpool,
BLOCK=block,
num_warps=4,
)
return out
# Torch fallback: CPU tensors (the CPU unit tests), non-1D or empty inputs.
# Production always takes the Triton path above — spec-decode tokens arrive
# flattened token-major, so slot_mapping is 1D there too.
out.copy_(slot_mapping)
if num_actual_tokens == 0:
return out
tokens = torch.arange(num_actual_tokens, device=slot_mapping.device)
+9 -1
View File
@@ -165,6 +165,9 @@ class SimpleCPUOffloadScheduler:
kv_cache_config, offload_capacity
)
self.num_cpu_blocks = self.cpu_kv_cache_config.num_blocks
self.prefix_cacheable_group_ids = (
self.cpu_kv_cache_config.prefix_cacheable_group_ids
)
self.kv_event_medium = MEDIUM_STORAGE if disk_capacity_bytes > 0 else MEDIUM_CPU
# Find the full attention kv group for prefix cache matching.
self.fa_gidx = -1
@@ -297,7 +300,7 @@ class SimpleCPUOffloadScheduler:
"""GPU blocks to keep available (free/offloaded) per step in lazy mode."""
WATERMARK_RATIO = 1.0 # Reserve larger space to avoid running out of GPU blocks
target = 0
for g in kv_cache_config.kv_cache_groups:
for g in kv_cache_config.prefix_cacheable_groups:
spec = g.kv_cache_spec
# Only full attention is sharded across DCP ranks; replicated specs
# (mamba, sliding window, chunked-local) keep their own block size.
@@ -442,6 +445,9 @@ class SimpleCPUOffloadScheduler:
# the rest will be released along with the temp pin below.
cpu_hit_blocks: list[list[KVCacheBlock]] = []
for g in range(num_groups):
if g not in self.prefix_cacheable_group_ids:
cpu_hit_blocks.append([])
continue
g_block_size = self.group_block_sizes[g]
n_take_g = cdiv(num_external_tokens, g_block_size)
cpu_hit_blocks.append(cpu_hit_blocks_full[g][:n_take_g])
@@ -828,6 +834,8 @@ class SimpleCPUOffloadScheduler:
num_free = self.cpu_block_pool.get_num_free_blocks()
for g, group_gpu_ids in enumerate(block_ids_by_group):
if g not in self.prefix_cacheable_group_ids:
continue
if len(gpu_block_ids) >= num_free:
break
group_manager = self.cpu_coordinator.single_type_managers[g]
+1
View File
@@ -662,6 +662,7 @@ class ModelCudaGraphManager(CudaGraphManager):
model_inputs = {
"input_ids": input_buffers.input_ids[:num_tokens],
"positions": input_buffers.positions[:num_tokens],
"intermediate_tensors": None,
**model_state.prepare_dummy_inputs(num_reqs, num_tokens),
}
if not self.is_first_pp_rank:
+8 -6
View File
@@ -1291,7 +1291,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
# Get the number of draft tokens for each request.
draft_tokens = scheduler_output.scheduled_spec_decode_tokens
num_draft_tokens_per_req = None
if not draft_tokens:
if not draft_tokens and self.model_state.num_new_sampled_tokens_per_step == 1:
# No draft token scheduled (common case).
total_num_draft_tokens = 0
total_num_logits = num_reqs
@@ -1322,9 +1322,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
np.cumsum(num_logits, out=cu_num_logits_np[1:])
cu_num_logits = async_tensor_h2d(cu_num_logits_np, device=self.device)
adaptive_verification = (
self.adaptive_verification if num_draft_tokens_per_req is not None else None
)
# The general branch also serves a no-draft batch with k != 1, and
# compact_batch's budget is only primed when drafts are scheduled.
adaptive_verification = self.adaptive_verification if draft_tokens else None
num_scheduled_tokens_upper_bound = num_scheduled_tokens_np
if adaptive_verification is not None:
# num_scheduled_tokens represents the draft budget evenly distributed across
@@ -1354,7 +1354,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
adaptive_verification.reallocate_drafts(req_ids, idx_mapping)
)
total_num_logits = num_reqs * num_bonus_tokens + total_num_draft_tokens
if draft_tokens:
if num_draft_tokens_per_req is not None:
expanded_idx_mapping, expanded_local_pos = expand_idx_mapping(
idx_mapping, total_num_logits, cu_num_logits, self.decode_query_len
)
@@ -1534,7 +1534,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
sample_hidden_states = hidden_states[input_batch.logits_indices]
logits = self.model.compute_logits(sample_hidden_states)
if grammar_output is not None:
# A diffusion prefill has no logit rows even when a bitmask row
# arrived for it.
if grammar_output is not None and logits.shape[0] > 0:
# Apply grammar bitmask to the logits in-place.
assert self.structured_outputs_worker is not None
self.structured_outputs_worker.apply_grammar_bitmask(
+75 -21
View File
@@ -3,11 +3,12 @@
import inspect
import os
from collections.abc import Iterator
from collections.abc import Callable, Hashable, Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from itertools import accumulate
from math import prod
from typing import TypeVar, cast
import torch
@@ -17,6 +18,7 @@ from vllm.utils.math_utils import round_up
from vllm.v1.worker.ubatching import dbo_current_ubatch_id
logger = init_logger(__name__)
T = TypeVar("T")
def _compute_bytes(shape: tuple[int, ...], dtype: torch.dtype) -> int:
@@ -47,8 +49,8 @@ def use_workspace_lane(lane: int) -> Iterator[None]:
class WorkspaceManager:
"""Manager for workspace allocation.
Manages one workspace buffer per active ``(ubatch, lane)`` slot.
Can be locked to prevent further growth during execution.
Manages shared scratch and keyed persistent resources per ``(ubatch, lane)``.
Can be locked to prevent growth or new resources during execution.
"""
def __init__(
@@ -66,6 +68,9 @@ class WorkspaceManager:
self._current_workspaces: list[torch.Tensor | None] = [None] * (
self._num_ubatches * self._num_lanes
)
self._persistent_resources: list[dict[Hashable, object]] = [
{} for _ in self._current_workspaces
]
self._locked: bool = False
@staticmethod
@@ -76,10 +81,10 @@ class WorkspaceManager:
return workspace.numel() * workspace.element_size()
def lock(self) -> None:
"""Lock the workspace to prevent further growth.
"""Lock the workspace to prevent growth or new persistent resources.
After locking, any attempt to allocate a larger workspace will raise
an assertion error. This ensures workspace size is fixed during execution.
Larger scratch requests and new persistent resources raise an assertion
error. Existing resources retain their storage throughout execution.
"""
self._locked = True
if envs.VLLM_DEBUG_WORKSPACE:
@@ -113,6 +118,63 @@ class WorkspaceManager:
"""Check if workspace is locked."""
return self._locked
def _get_workspace_id(self) -> int:
lane = _workspace_lane.get()
if lane >= self._num_lanes:
raise RuntimeError(
f"Workspace lane {lane} is not configured; manager has "
f"{self._num_lanes} lane(s)."
)
return dbo_current_ubatch_id() * self._num_lanes + lane
def get_persistent_resource(self, key: Hashable, factory: Callable[[], T]) -> T:
"""Return the resource cached for ``key`` in the current ubatch and lane.
The first request calls ``factory`` and stores its result for the lifetime
of this manager. Later requests return the same object without calling
the factory again. Each ubatch/lane pair has a separate cache. Once the
manager is locked, requesting a key missing from that cache raises an
AssertionError.
Keys must identify the resource type and configuration. Callers must not
resize or replace tensor storage held by a cached resource. Uses of the
same resource must not overlap; include the CUDA stream in the key when
streams can execute concurrently.
With torch.compile, call this inside a runtime custom op so resource
selection happens during execution.
"""
resources = self._persistent_resources[self._get_workspace_id()]
if key not in resources:
if self._locked:
raise AssertionError(
f"Workspace is locked but {key!r} was not allocated during "
"warmup for the current ubatch and lane."
)
resources[key] = factory()
return cast(T, resources[key])
def get_persistent(
self,
key: Hashable,
shape: tuple[int, ...],
dtype: torch.dtype,
*,
zero_init: bool = False,
) -> torch.Tensor:
"""Get a fixed-shape tensor, optionally zeroed on first allocation only."""
factory = torch.zeros if zero_init else torch.empty
workspace = self.get_persistent_resource(
(torch.Tensor, key),
lambda: factory(shape, dtype=dtype, device=self._device),
)
if workspace.shape != shape or workspace.dtype != dtype:
raise ValueError(
f"Persistent workspace {key!r} has shape={workspace.shape}, "
f"dtype={workspace.dtype}; requested shape={shape}, dtype={dtype}."
)
return workspace
def get_simultaneous(
self, *shapes_and_dtypes: tuple[tuple[int, ...], torch.dtype]
) -> list[torch.Tensor]:
@@ -151,14 +213,7 @@ class WorkspaceManager:
The current workspace tensor.
"""
ubatch_id = dbo_current_ubatch_id()
lane = _workspace_lane.get()
if lane >= self._num_lanes:
raise RuntimeError(
f"Workspace lane {lane} is not configured; manager has "
f"{self._num_lanes} lane(s)."
)
workspace_id = ubatch_id * self._num_lanes + lane
workspace_id = self._get_workspace_id()
current_workspace = self._current_workspaces[workspace_id]
current_size = self._workspace_size_bytes(current_workspace)
@@ -218,8 +273,8 @@ class WorkspaceManager:
get_caller_info(),
current_size / _MB,
required_bytes / _MB,
ubatch_id,
lane,
workspace_id // self._num_lanes,
workspace_id % self._num_lanes,
)
return current_workspace
@@ -277,12 +332,11 @@ def init_workspace_manager(
def lock_workspace() -> None:
"""Lock the workspace to prevent further growth.
"""Lock the workspace to prevent growth or new persistent resources.
After calling this function, any attempt to allocate a workspace larger
than the current size will raise an AssertionError. This ensures that
workspace size is fixed during execution and prevents unexpected memory
allocations in the hot path.
Larger scratch requests and new persistent resources raise an AssertionError.
All required resources must be initialized in each execution slot during
warmup, so their storage remains fixed during execution.
Example:
# During initialization