Skip to content

Commit d4c5517

Browse files
author
fengyan.119
committed
feat: add NPU DFlash block-diffusion speculative decoding.
Introduce DFlash (block-diffusion) speculative decoding on the NPU/ATB backend. The draft proposes a full block of candidate tokens in a single non-causal forward from the target's captured intermediate-layer hidden states, and the target verifies the block via the shared rejection sampler. Supported targets: - Qwen3 dense (e.g. Qwen3-8B) and Qwen3-MoE (e.g. Qwen3-Coder-30B-A3B) as the verify target. Both are standard-attention, head_dim=128, 4D dense KV cache; aux-hidden capture is enabled on qwen3.h and qwen3_moe.h. - The draft is always a small dense Qwen3 (model_type=DFlashDraftModel), regardless of whether the target is dense or MoE; it never runs experts. - Hybrid / linear-attention targets (e.g. Qwen3.5/Next) are NOT supported in this PR (deferred: they need a TORCH-backend draft and target-side capture). Core pieces: - DFlashWorkerImpl (runtime): prepare draft query / run draft / validate / materialize context K/V / write accepted context, decoupled from MTP. - DFlashContextKV helper: backend-independent fc -> RMSNorm -> fused per-layer K/V linear -> per-layer k_norm + RoPE -> scatter, parameterized by DFlashContextKVConfig (head_dim, rotary_dim from partial_rotary_factor, rope_theta, k_norm) so it can be reused across draft bodies. - DFlashQwen3 draft model + registration. - DFlash always runs eager (both target validate and draft query). ACL graph was measured net-negative for DFlash (validate flattens to DECODE, so its captured-shape count grows with concurrency: -2% at C=1, -38% at C=16), so the target and draft engines force enable_graph off; the shared graph engine and the MTP/Eagle3 spec-verify graph path are untouched. - Guard: DFlash and EAGLE-3 reject context parallelism (cp_size > 1), which would feed the draft the wrong (lm_head-gathered) hidden states. Correctness fixes folded in: - chunked-prefill graph mask reset stale tail columns [kv_len, max_seq_len) across replays; a heterogeneous-kv_len batch otherwise let a shorter sequence attend to another sequence's KV (graph accept rate 1.11% -> 34.10%, matching eager). - qwen3-moe forward asserts capture_idx == layers_to_capture_set_.size() before returning aux hidden, mirroring qwen3.h, so a misconfigured target_layer_id fails fast instead of feeding uninitialized memory. - dflash validate reconstructs dense draft probs by default (honoring enable_opt_validate_probs like MTP) so the shared rejection sampler and its fused kernel always receive dim==3 probs; output-distribution-equivalent to selected-only. scatter_selected_to_dense() is shared with build_validate_tensors. Validated (gsm8k, greedy, num_spec=15, eager): - Qwen3-8B: accept rate 34.67% / accept length 6.20, matching the transformers reference (34.87% / 6.23). - Qwen3-Coder-30B-A3B (MoE, pure TP=2): accept rate 25.0% / 4.75 end-to-end.
1 parent 044aede commit d4c5517

24 files changed

Lines changed: 2206 additions & 97 deletions

tests/core/runtime/spec_input_builder_test.cpp

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,54 @@ TEST(DraftProbsBuilderTest, BuildValidateTensorsDenseInputFallback) {
529529
draft_probs, torch::tensor({{0.7f}, {0.6f}}, torch::kFloat32)));
530530
}
531531

532+
TEST(DraftProbsBuilderTest, BuildValidateTensorsFromBlockDenseDefault) {
533+
// DFlash default path: selected-only cached probs are scattered back into a
534+
// dense [batch, n_spec, vocab] tensor so the shared rejection sampler (and
535+
// its fused kernel) always receives dim==3 draft probs, matching MTP.
536+
auto token_ids_block = torch::tensor({{1, 2}, {0, 3}}, torch::kInt64);
537+
auto probs_block =
538+
torch::tensor({{0.2f, 0.7f}, {0.9f, 0.1f}}, torch::kFloat32);
539+
540+
auto [draft_token_ids, draft_probs] =
541+
draftProbs::build_validate_tensors_from_block(
542+
token_ids_block,
543+
probs_block,
544+
/*vocab_size=*/5,
545+
/*enable_opt_validate_probs=*/false);
546+
547+
EXPECT_EQ(draft_token_ids.dim(), 2);
548+
EXPECT_EQ(draft_probs.dim(), 3);
549+
EXPECT_EQ(draft_probs.size(0), 2);
550+
EXPECT_EQ(draft_probs.size(1), 2);
551+
EXPECT_EQ(draft_probs.size(2), 5);
552+
553+
// The scattered dense holds the selected prob at the drafted token and zero
554+
// elsewhere; gather recovers the selected values and each row sums to them.
555+
auto selected =
556+
draft_probs.gather(/*dim=*/-1, draft_token_ids.unsqueeze(-1)).squeeze(-1);
557+
auto expected = torch::tensor({{0.2f, 0.7f}, {0.9f, 0.1f}}, torch::kFloat32);
558+
EXPECT_TRUE(torch::allclose(selected, expected));
559+
EXPECT_TRUE(torch::allclose(draft_probs.sum(/*dim=*/-1), expected));
560+
}
561+
562+
TEST(DraftProbsBuilderTest, BuildValidateTensorsFromBlockSelectedOnlyOpt) {
563+
// Opt-in selected-only path returns the [batch, n_spec] block as-is.
564+
auto token_ids_block = torch::tensor({{1, 2}, {0, 3}}, torch::kInt64);
565+
auto probs_block =
566+
torch::tensor({{0.2f, 0.7f}, {0.9f, 0.1f}}, torch::kFloat32);
567+
568+
auto [draft_token_ids, draft_probs] =
569+
draftProbs::build_validate_tensors_from_block(
570+
token_ids_block,
571+
probs_block,
572+
/*vocab_size=*/5,
573+
/*enable_opt_validate_probs=*/true);
574+
575+
EXPECT_EQ(draft_token_ids.dim(), 2);
576+
EXPECT_EQ(draft_probs.dim(), 2);
577+
EXPECT_TRUE(torch::allclose(draft_probs, probs_block));
578+
}
579+
532580
TEST(SpecDecodeInputBuilderTest, MultiBlockDraftSingleRowPerSeq) {
533581
std::vector<int32_t> kv_seq_lens = to_layout_seq_lens({5, 9});
534582
torch::Tensor positions = torch::tensor({4, 8}, torch::kInt);

xllm/core/distributed_runtime/llm_engine.cpp

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,17 @@ bool LLMEngine::init_model(MasterStatus master_status) {
182182
auto model_loader = ModelLoader::create(model_path);
183183
LOG(INFO) << "Initializing model from: " << model_path;
184184

185-
tokenizer_ = model_loader->tokenizer();
186-
CHECK(tokenizer_ != nullptr);
187-
188185
args_ = model_loader->model_args();
189186
quant_args_ = model_loader->quant_args();
190-
tokenizer_args_ = model_loader->tokenizer_args();
187+
188+
// Every speculative algorithm drives its draft from the target's hidden
189+
// states and shares the target vocabulary, so a draft engine never needs its
190+
// own tokenizer.
191+
if (!options_.is_draft_engine()) {
192+
tokenizer_ = model_loader->tokenizer();
193+
CHECK(tokenizer_ != nullptr);
194+
tokenizer_args_ = model_loader->tokenizer_args();
195+
}
191196

192197
// compute the number of local kv heads and head dim
193198
const uint32_t world_size = dp_local_tp_size_;
@@ -213,21 +218,23 @@ bool LLMEngine::init_model(MasterStatus master_status) {
213218
<< ", dtype: " << dtype_
214219
<< ", kv_cache_dtype: " << options_.kv_cache_dtype();
215220

216-
const int64_t tokenizer_vocab_size = tokenizer_->vocab_size();
217-
int64_t model_vocab_size = args_.vocab_size();
218-
if (tokenizer_vocab_size != model_vocab_size) {
219-
// use tokenizer vocab size if model vocab size is not set
220-
if (model_vocab_size <= 0) {
221-
LOG(WARNING) << "Model vocab size is not set, using tokenizer vocab "
222-
"size: "
223-
<< tokenizer_vocab_size;
224-
args_.vocab_size(tokenizer_vocab_size);
225-
} else if (tokenizer_vocab_size > model_vocab_size) {
226-
LOG(WARNING) << "Unsafe vocab mismatch: tokenizer: "
227-
<< tokenizer_vocab_size << ", model: " << model_vocab_size;
228-
} else {
229-
LOG(INFO) << "Tokenizer/model vocab differ: tokenizer="
230-
<< tokenizer_vocab_size << ", model=" << model_vocab_size;
221+
if (tokenizer_ != nullptr) {
222+
const int64_t tokenizer_vocab_size = tokenizer_->vocab_size();
223+
int64_t model_vocab_size = args_.vocab_size();
224+
if (tokenizer_vocab_size != model_vocab_size) {
225+
// use tokenizer vocab size if model vocab size is not set
226+
if (model_vocab_size <= 0) {
227+
LOG(WARNING) << "Model vocab size is not set, using tokenizer vocab "
228+
"size: "
229+
<< tokenizer_vocab_size;
230+
args_.vocab_size(tokenizer_vocab_size);
231+
} else if (tokenizer_vocab_size > model_vocab_size) {
232+
LOG(WARNING) << "Unsafe vocab mismatch: tokenizer: "
233+
<< tokenizer_vocab_size << ", model: " << model_vocab_size;
234+
} else {
235+
LOG(INFO) << "Tokenizer/model vocab differ: tokenizer="
236+
<< tokenizer_vocab_size << ", model=" << model_vocab_size;
237+
}
231238
}
232239
}
233240

xllm/core/distributed_runtime/speculative_engine.cpp

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ SpeculativeEngine::SpeculativeEngine(const runtime::Options& options,
5555
.devices(options.draft_devices())
5656
.num_decoding_tokens(1)
5757
.enable_speculative_decode(/*enable_speculative_decode=*/false)
58-
.enable_graph(/*enable_graph=*/false)
58+
.enable_graph(false)
5959
.is_draft_engine(true);
6060
draft_engine_ =
6161
std::make_unique<LLMEngine>(draft_engine_options, dist_manager_);
@@ -97,27 +97,9 @@ bool SpeculativeEngine::init_model() {
9797
return false;
9898
}
9999

100-
// check if the tokenizers are compatible
101-
const auto* draft_tokenizer = draft_engine_->tokenizer();
102-
const auto* target_tokenizer = engine_->tokenizer();
103-
if (draft_tokenizer->vocab_size() != target_tokenizer->vocab_size()) {
104-
LOG(ERROR) << "draft and target tokenizers have different vocab sizes, "
105-
"draft vocab_size: "
106-
<< draft_tokenizer->vocab_size()
107-
<< ", target vocab_size: " << target_tokenizer->vocab_size();
108-
return false;
109-
}
110-
111-
const std::string test_text = "hello from xllm!";
112-
std::vector<int32_t> draft_token_ids;
113-
std::vector<int32_t> target_token_ids;
114-
if (!draft_tokenizer->encode(test_text, &draft_token_ids) ||
115-
!target_tokenizer->encode(test_text, &target_token_ids)) {
116-
if (draft_token_ids != target_token_ids) {
117-
LOG(ERROR) << "draft and target tokenizers are not compatible";
118-
return false;
119-
}
120-
}
100+
// Modern speculative algorithms (MTP / EAGLE / EAGLE3 / DFlash) drive the
101+
// draft from the target's hidden states and share the target vocabulary, so
102+
// there is no independent draft tokenizer to validate for compatibility.
121103

122104
// check if the max context length are the same
123105
const auto& draft_model_args = draft_engine_->model_args();

xllm/core/distributed_runtime/worker_server.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ limitations under the License.
5454
#include "util/net.h"
5555
#include "util/threadpool.h"
5656
#include "util/timer.h"
57+
#include "util/utils.h"
5758
#if defined(USE_NPU)
5859
#include "hccl/hccl.h"
5960
#include "xllm_atb_layers/core/include/atb_speed/base/external_comm_manager.h"
@@ -66,6 +67,13 @@ extern char** environ;
6667
namespace xllm {
6768
namespace {
6869
void handle_signal(int signum) { _exit(0); }
70+
71+
bool uses_aux_hidden_speculative_worker(const runtime::Options& options) {
72+
if (!options.enable_speculative_decode()) {
73+
return false;
74+
}
75+
return util::algorithm_captures_aux_hidden(options.speculative_algorithm());
76+
}
6977
} // namespace
7078

7179
void WorkerServer::create_server(const runtime::Options& options,
@@ -364,6 +372,11 @@ WorkerServer::WorkerServer(int32_t local_worker_idx,
364372
bool use_spawn_worker)
365373
: server_name_("DistributeWorkerServer") {
366374
server_name_.append(std::to_string(options.server_idx()));
375+
if (use_spawn_worker && uses_aux_hidden_speculative_worker(options)) {
376+
LOG(FATAL) << options.speculative_algorithm()
377+
<< " does not support spawned workers yet. Disable offline "
378+
"spawn workers or use --speculative_algorithm=MTP.";
379+
}
367380

368381
if (worker_type == WorkerType::LLM || worker_type == WorkerType::ELM ||
369382
worker_type == WorkerType::VLM || worker_type == WorkerType::EVLM ||

xllm/core/framework/config/speculative_config.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ DEFINE_int32(num_speculative_tokens, 0, "Number of speculative tokens.");
3333
DEFINE_string(speculative_algorithm,
3434
"MTP",
3535
"Speculative decoding algorithm. Supported options: MTP, Eagle3, "
36-
"Suffix. Default is MTP.");
36+
"Suffix, DFlash. Default is MTP.");
3737

3838
DEFINE_int32(speculative_suffix_cache_max_depth,
3939
64,

xllm/core/framework/model/model_input_params.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,10 @@ struct ModelEmbeddingInput {
713713
// input embedding
714714
mutable torch::Tensor input_embedding;
715715

716+
// Input is target hidden fed through the context-K/V materialization path
717+
// (the model reads input_embedding, not token_ids).
718+
bool write_context_kv = false;
719+
716720
// embedding ids of each sequence
717721
std::vector<int32_t> embedding_ids;
718722

@@ -739,6 +743,7 @@ struct ModelEmbeddingInput {
739743
ModelEmbeddingInput to(const torch::Device& device) const {
740744
ModelEmbeddingInput out;
741745
out.input_embedding = safe_to(input_embedding, device);
746+
out.write_context_kv = write_context_kv;
742747
out.embedding_ids = embedding_ids;
743748
out.linear_state_ids = linear_state_ids;
744749
out.linear_state_indices = safe_to(linear_state_indices, device, true);

xllm/core/runtime/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ cc_library(
3636
mtp_worker_impl.h
3737
suffix_worker_impl.h
3838
eagle3_worker_impl.h
39+
dflash_worker_impl.h
3940
spec_input_builder.h
4041
forward_shared_memory_manager.h
4142
worker_rendezvous.h
@@ -68,6 +69,7 @@ cc_library(
6869
mtp_worker_impl.cpp
6970
suffix_worker_impl.cpp
7071
eagle3_worker_impl.cpp
72+
dflash_worker_impl.cpp
7173
spec_input_builder.cpp
7274
forward_shared_memory_manager.cpp
7375
cp_input_partition.cpp

xllm/core/runtime/acl_graph_executor_impl.cpp

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ std::pair<torch::Tensor, torch::Tensor> find_attention_plan_kv_cache(
5959
return {torch::Tensor(), torch::Tensor()};
6060
}
6161

62+
bool is_write_context_kv_phase(const ModelInputParams& params) {
63+
return params.embedding.write_context_kv;
64+
}
65+
66+
bool is_chunked_prefill_graph(const ModelInputParams& params) {
67+
return params.meta.batch_forward_type.is_chunked_prefill() &&
68+
params.is_spec_verify && !is_write_context_kv_phase(params);
69+
}
70+
71+
uint32_t get_graph_sequence_width(const ModelInputParams& params,
72+
const runtime::Options& options) {
73+
if (is_chunked_prefill_graph(params)) {
74+
return static_cast<uint32_t>(
75+
std::max<int32_t>(params.meta.q_max_seq_len, 1));
76+
}
77+
return static_cast<uint32_t>(options.num_decoding_tokens());
78+
}
79+
6280
} // namespace
6381

6482
bool AclGraph::capture(CausalLM* model,
@@ -346,6 +364,14 @@ ModelOutput AclGraph::replay(CausalLM* model,
346364
// Get current NPU stream from libtorch NPU API
347365
aclrtStream stream = c10_npu::getCurrentNPUStream().stream();
348366

367+
// Ensure the non-blocking persistent-buffer updates above (tokens/positions/
368+
// block_tables/seq_lens) are visible before the graph replays. Without this
369+
// ordering edge the replay can read stale inputs. This is a full host-device
370+
// sync (a correctness requirement, not a tunable): a lighter device-side
371+
// event wait would be preferable but the persistent buffers are updated on
372+
// the host prepare stream, so the replay stream must observe them first.
373+
aclrtSynchronizeStream(stream);
374+
349375
graph_.replay();
350376
if (model->is_hybrid_linear_attention()) {
351377
CHECK(graph_params.has_value())
@@ -386,8 +412,8 @@ AclGraphExecutorImpl::AclGraphExecutorImpl(CausalLM* model,
386412
const torch::Device& device,
387413
const runtime::Options& options)
388414
: model_(model), args_(args), device_(device), options_(options) {
389-
const bool need_update_attn_mask = model->is_hybrid_linear_attention();
390415
const bool is_hybrid_linear_attn = model->is_hybrid_linear_attention();
416+
const bool need_update_attn_mask = is_hybrid_linear_attn;
391417
graph_slot_count_ =
392418
::xllm::ExecutionConfig::get_instance().enable_graph_double_buffer() ? 2
393419
: 1;
@@ -428,6 +454,12 @@ ModelOutput AclGraphExecutorImpl::run(const torch::Tensor& tokens,
428454
<< " in_spec_verify_phase: " << in_spec_verify_phase
429455
<< " q_max_seq_len: " << params_single.meta.q_max_seq_len
430456
<< " n_layers: " << args_.n_layers();
457+
if (is_write_context_kv_phase(params_single)) {
458+
VLOG(kGraphExecutorLogVerboseLevel)
459+
<< "AclGraphExecutorImpl::run() in eager mode for DFlash context K/V";
460+
COUNTER_INC(num_model_execution_total_eager);
461+
return model_->forward(tokens, positions, kv_caches, params);
462+
}
431463
if ((!in_decoding_phase && !in_spec_verify_phase) || args_.n_layers() == 1) {
432464
VLOG(kGraphExecutorLogVerboseLevel)
433465
<< "AclGraphExecutorImpl::run() in eager mode";
@@ -484,9 +516,21 @@ ModelOutput AclGraphExecutorImpl::run(const torch::Tensor& tokens,
484516
}
485517
// Keep actual n_tokens for replay output slicing.
486518
const uint32_t n_tokens = tokens_tensor.size(/*dim=*/0);
487-
const uint32_t local_batch_size = n_tokens / options_.num_decoding_tokens();
488-
const uint32_t global_batch_size =
489-
graph_num_tokens / options_.num_decoding_tokens();
519+
const uint32_t graph_sequence_width =
520+
get_graph_sequence_width(params_single, options_);
521+
CHECK_GT(graph_sequence_width, 0) << "graph sequence width must be positive";
522+
if (n_tokens % graph_sequence_width != 0 ||
523+
graph_num_tokens % graph_sequence_width != 0) {
524+
LOG_FIRST_N(WARNING, 1)
525+
<< "Falling back to eager mode because graph token count is not "
526+
"divisible by graph sequence width. n_tokens="
527+
<< n_tokens << ", graph_num_tokens=" << graph_num_tokens
528+
<< ", graph_sequence_width=" << graph_sequence_width;
529+
COUNTER_INC(num_model_execution_total_eager);
530+
return model_->forward(tokens, positions, kv_caches, params);
531+
}
532+
const uint32_t local_batch_size = n_tokens / graph_sequence_width;
533+
const uint32_t global_batch_size = graph_num_tokens / graph_sequence_width;
490534

491535
// Large decode batches create too many/too large ACL graphs and may OOM.
492536
// Fall back to eager mode when batch size exceeds the safety threshold.
@@ -498,7 +542,7 @@ ModelOutput AclGraphExecutorImpl::run(const torch::Tensor& tokens,
498542
.acl_graph_decode_batch_size_limit()));
499543
if (global_batch_size > decode_batch_size_limit) {
500544
LOG_FIRST_N(WARNING, 1)
501-
<< "Falling back to eager mode because decode batch_size (global="
545+
<< "Falling back to eager mode because graph batch_size (global="
502546
<< global_batch_size << ", local=" << local_batch_size << ") > "
503547
<< decode_batch_size_limit
504548
<< "; ACL graph is disabled for this request size to avoid OOM. "
@@ -508,7 +552,20 @@ ModelOutput AclGraphExecutorImpl::run(const torch::Tensor& tokens,
508552
return model_->forward(tokens, positions, kv_caches, params);
509553
}
510554

511-
const uint32_t bucket_num_tokens = get_bucket_num_tokens(graph_num_tokens);
555+
uint32_t bucket_num_tokens = get_bucket_num_tokens(graph_num_tokens);
556+
if (is_chunked_prefill_graph(params_single)) {
557+
const uint32_t bucket_batch_size = get_bucket_num_tokens(global_batch_size);
558+
bucket_num_tokens = bucket_batch_size * graph_sequence_width;
559+
}
560+
if (static_cast<int64_t>(bucket_num_tokens) >
561+
options_.max_tokens_per_batch()) {
562+
LOG_FIRST_N(WARNING, 1)
563+
<< "Falling back to eager mode because ACL graph bucket tokens ("
564+
<< bucket_num_tokens << ") exceed max_tokens_per_batch ("
565+
<< options_.max_tokens_per_batch() << ").";
566+
COUNTER_INC(num_model_execution_total_eager);
567+
return model_->forward(tokens, positions, kv_caches, params);
568+
}
512569

513570
// Check if conditions are suitable for graph execution (replay or capture)
514571
const auto max_seq_len = args_.max_position_embeddings();

0 commit comments

Comments
 (0)