Neuron: prompt chunks through the decode region graphs (issue #26)

- PrepareArgs.prompt_as_decode: decode-layout metadata for a prompt chunk;
  input_ids are the prompt's own tokens instead of last token + MTP drafts.
- DEEPINFER_NEURON_PREFILL_BUCKET adds one graph size; the model runner pads
  a prompt chunk to it with the decode placeholders (every EP rank takes the
  same bucket, so the static collectives match). Sampling still sees a prompt
  step.
- nki_attention.paged_mqa_logits: one gather + one kernel call for a chunk
  (one sequence) instead of a T-way loop.
- bf16 linear for the LM head eagerly on a NeuronCore (b.float() was a
  2.6 GiB copy a call); DEEPINFER_NEURON_REGION_TIMING; --num_prompts.

Host mode, tiny V4.1, chunk 128: same tokens as eager prefill (short 9/9,
long 715 tokens in 6 chunks 8/9 against golden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
whn09 2026-10-23 03:19:56 +08:00
parent c24574bfd6
commit 3629d113fb
11 changed files with 116 additions and 13 deletions

View File

@ -25,6 +25,9 @@ struct PrepareArgs {
int32_t image_sentinel = -1;
bool is_prompt = true;
bool force_load_balance = false;
// decode-shaped metadata for a prompt chunk (Neuron prefill through the decode graphs): input_ids are the prompt's
// own tokens rather than the last token + MTP drafts; everything else follows the decode layout.
bool prompt_as_decode = false;
// prompt-only
int cp_size = 1;

View File

@ -97,12 +97,13 @@ static py::dict prepare_common_base(const PrepareArgs &args) {
auto input_ids = get_or_create_tensor(result, has_out, "input_ids", {total_q}, torch::kInt32);
auto p_input_ids = input_ids.mutable_data_ptr<int32_t>();
const bool prompt_ids = is_prompt || args.prompt_as_decode;
i_q = 0;
for (auto p_seq : seq_metadata_list) {
auto &token_ids = p_seq->seq_data->token_ids;
static_assert(std::is_same_v<decltype(*p_input_ids), decltype(*token_ids.data())>);
int seqlen_q_golden;
if (is_prompt) {
if (prompt_ids && p_seq->is_prompt) {
TORCH_CHECK(int(token_ids.size()) >= p_seq->seqlen_k);
seqlen_q_golden = p_seq->seqlen_q;
} else {
@ -110,7 +111,7 @@ static py::dict prepare_common_base(const PrepareArgs &args) {
seqlen_q_golden = 1;
}
memcpy(p_input_ids + i_q, token_ids.data() + p_seq->seqlen_k - p_seq->seqlen_q, seqlen_q_golden * sizeof(int32_t));
if (!is_prompt) {
if (seqlen_q_golden < p_seq->seqlen_q) {
int seqlen_q_draft = p_seq->seqlen_q - 1;
TORCH_CHECK(int(p_seq->mtp.draft_tokens.size()) >= seqlen_q_draft);
for (int j = 0; j < seqlen_q_draft; ++j) {

View File

@ -67,6 +67,7 @@ void pybind_engine_output(pybind11::module &engine) {
.def_readwrite("yoco_prefill_keep_tokens", &PrepareArgs::yoco_prefill_keep_tokens)
.def_readwrite("max_backtracking_tokens", &PrepareArgs::max_backtracking_tokens)
.def_readwrite("force_load_balance", &PrepareArgs::force_load_balance)
.def_readwrite("prompt_as_decode", &PrepareArgs::prompt_as_decode)
.def_readwrite("dense_compress_ratios", &PrepareArgs::dense_compress_ratios)
.def_readwrite("sparse_compress_ratios", &PrepareArgs::sparse_compress_ratios);

View File

@ -114,8 +114,14 @@ class envs:
DEEPINFER_NEURON_GRAPH_MODE = EnvVar.str(default="regions")
# Neuron only: cut the region graphs further at the stage boundaries marked by neuron_ops.regions.split().
DEEPINFER_NEURON_REGION_SPLIT = EnvVar.bool(default=True)
# Neuron only: synchronize around every region graph call and log per-kind times each decode step (profiling).
DEEPINFER_NEURON_REGION_TIMING = EnvVar.bool()
# Keep the token embedding (and the text model's unused vision tower) in host memory on Neuron.
DEEPINFER_NEURON_HOST_EMBED = EnvVar.bool(default=True)
# Neuron only, regions mode: run prompt chunks through the decode region graphs padded to this many tokens
# (issue #26, the vllm-neuron#71 layout). 0 = eager prefill. Needs max_num_batched_tokens <= bucket and
# DEEPINFER_MAX_BACKTRACKING_TOKENS >= bucket (the SWA / compressor rings must hold a whole chunk).
DEEPINFER_NEURON_PREFILL_BUCKET = EnvVar.int(default=0)
# Neuron only: compile the decode step with fullgraph=True (raise on the first graph break).
DEEPINFER_NEURON_FULLGRAPH = EnvVar.bool()

View File

@ -152,8 +152,10 @@ class ModelMetadata:
window_size: int,
swa_block_size: int,
metadata_zos: MetadataZOS | None = None,
prompt_as_decode: bool = False,
) -> dict:
is_prompt = seq_metadata_list[0].is_prompt
# prompt_as_decode: decode-layout metadata for a prompt chunk (Neuron prefill through the decode graphs).
is_prompt = seq_metadata_list[0].is_prompt and not prompt_as_decode
prepare_func = self.prepare_prompt if is_prompt else self.prepare_decode
@ -168,6 +170,7 @@ class ModelMetadata:
args.cp_size = PG.context_parallel_group().size()
else:
args.is_prompt = False
args.prompt_as_decode = prompt_as_decode
args.max_num_blocks = max_num_blocks
args.out = out_dict
args.window_size = window_size

View File

@ -29,6 +29,8 @@ import os
import torch
ENABLED = os.environ.get("DEEPINFER_NEURON_NKI_ATTN", "1") == "1"
#: DEEPINFER_NEURON_PREFILL_BUCKET (issue #26): a step of exactly this many query tokens is one prompt chunk.
PREFILL_BUCKET = int(os.environ.get("DEEPINFER_NEURON_PREFILL_BUCKET", "0"))
D_QK = 512
INDEX_DIM = 128
@ -320,6 +322,23 @@ def paged_mqa_logits(
slot = torch.clamp(pos // page_kv, max=width - 1)
lens = context_lens.reshape(num_q, 1).to(torch.int32)
valid = (pos.view(1, n) < lens) & (pos.view(1, n) < width * page_kv)
w = weights.to(torch.float32)
kernel = _kernel("index_score", "weighted_scores")
if num_q == PREFILL_BUCKET:
# A prompt chunk run as a decode step (issue #26): its tokens are one sequence's, so they share token 0's block
# table and differ only in their context length. Gather the keys once and score every token in one kernel call
# (padding rows past the chunk score garbage on the same keys; their results are never read).
page = block_table[0].to(torch.int32).index_select(0, slot)
page = torch.where(pos < lens[0], page, torch.zeros_like(page)).view(1, n)
offset = (pos % page_kv).view(1, n)
data = gather_rows(kv_cache, page, offset, data_bytes, data_bytes).to(torch.int32)
sf = gather_rows(kv_cache, page, offset, SF_BYTES, SF_BYTES, base=page_kv * data_bytes).to(torch.int32)
values = _unpack_e2m1(data) if is_fp4 else _e4m3(data) # [1, n, 128] fp32
keys = (values.view(n, SF_BYTES, 32) * _pow2(sf.view(n, SF_BYTES) - 127).unsqueeze(-1)).view(n, INDEX_DIM)
scores = kernel(q.reshape(num_q * heads, -1).contiguous(), keys.to(torch.bfloat16), w.transpose(0, 1).contiguous())
return _finish_logits(scores, valid, max_context_len, num_q, device)
rows = torch.arange(num_q, device=device, dtype=torch.int32).view(num_q, 1) * width + slot.view(1, n)
page = block_table.to(torch.int32).reshape(-1).index_select(0, rows.reshape(-1)).view(num_q, n)
page = torch.where(valid, page, torch.zeros_like(page)) # stale table slots past the context: read page 0
@ -331,12 +350,13 @@ def paged_mqa_logits(
keys = (values.view(num_q, n, SF_BYTES, 32) * _pow2(sf - 127).unsqueeze(-1)).view(num_q, n, INDEX_DIM)
keys = keys.to(torch.bfloat16)
w = weights.to(torch.float32)
kernel = _kernel("index_score", "weighted_scores")
# Each query token has its own context: one kernel call per token (the kernel shares k across its tokens).
scores = torch.cat([kernel(q[t].contiguous(), keys[t], w[t].reshape(heads, 1)) for t in range(num_q)], dim=0)
scores = torch.where(valid, scores, torch.full_like(scores, float("-inf")))[:, :max_context_len]
return _finish_logits(scores, valid, max_context_len, num_q, device)
def _finish_logits(scores: torch.Tensor, valid: torch.Tensor, max_context_len: int, num_q: int, device) -> torch.Tensor: # noqa: ANN001
scores = torch.where(valid, scores, torch.full_like(scores, float("-inf")))[:, :max_context_len]
stride = _ceil(max_context_len, 256) # deep_gemm's 512 B row alignment
logits = torch.empty((num_q, stride), dtype=torch.bfloat16, device=device)[:, :max_context_len]
logits.copy_(scores.to(torch.bfloat16))

View File

@ -8,6 +8,8 @@ fullgraph. Layers of the same kind trace to the same graph, so neuronx-cc's NEFF
from __future__ import annotations
import functools
import time
from collections import defaultdict
from collections.abc import Callable
from typing import Any
@ -35,6 +37,15 @@ def raise_dynamo_limits() -> None:
_ENTRIES: dict[tuple[type, str], Callable[..., Any]] = {}
#: DEEPINFER_NEURON_REGION_TIMING: seconds per region kind in the current step (each call synchronized).
_TIMES: dict[str, list[float]] = defaultdict(list)
def take_timings() -> dict[str, list[float]]:
"""The region timings recorded since the last call, then reset."""
out = dict(_TIMES)
_TIMES.clear()
return out
def entry(module: torch.nn.Module, name: str) -> Callable[..., Any]:
@ -55,9 +66,18 @@ def entry(module: torch.nn.Module, name: str) -> Callable[..., Any]:
getattr(type(module), name), backend=envs.DEEPINFER_NEURON_COMPILE_BACKEND, fullgraph=False, dynamic=False
)
label = f"{type(module).__name__}.{name}"
def fn(mod: torch.nn.Module, *args: Any, **kwargs: Any) -> Any:
with hide_cuda():
return compiled(mod, *args, **kwargs)
if not envs.DEEPINFER_NEURON_REGION_TIMING:
return compiled(mod, *args, **kwargs)
torch.neuron.synchronize()
started = time.perf_counter()
out = compiled(mod, *args, **kwargs)
torch.neuron.synchronize()
_TIMES[label].append(time.perf_counter() - started)
return out
_ENTRIES[key] = fn
return functools.partial(fn, module)

View File

@ -106,9 +106,10 @@ def _bf16_gemm(
if k == 0:
_write_output(torch.zeros((m, n), dtype=torch.float32, device=d.device), d, c, alpha)
return
if torch.compiler.is_compiling():
if torch.compiler.is_compiling() or b.device.type == "neuron":
# NEURON graphs: one bf16 dot with fp32 accumulation and no materialized fp32 transpose (vllm-neuron#71 runs
# these as bf16 linears; an fp32 transpose feeding a dot is a neuronx-cc InferNonlocalTensors trigger).
# Eagerly on a NeuronCore too: ``b.float()`` of the LM head is a 2.6 GiB copy a call.
_write_output(torch.nn.functional.linear(a, b).float(), d, c, alpha)
return
_write_output(a.float() @ b.float().transpose(0, 1), d, c, alpha)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import copy
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
@ -79,6 +80,13 @@ class ModelRunner:
max_batch_size=self.scheduler_config.max_num_seqs * (1 + self.nextn),
input_on_device=envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING,
)
bucket = getattr(self.cuda_graph_runner, "prefill_bucket", 0)
if bucket:
assert self.scheduler_config.max_num_batched_tokens <= bucket, (
f"max_num_batched_tokens ({self.scheduler_config.max_num_batched_tokens}) > DEEPINFER_NEURON_PREFILL_BUCKET"
)
# The chunk's tokens are all stored before any of them attends: the rings must hold a whole chunk.
assert envs.DEEPINFER_MAX_BACKTRACKING_TOKENS >= bucket, "set DEEPINFER_MAX_BACKTRACKING_TOKENS >= the prefill bucket"
self.dflash_use_cuda_graph = (
self.use_cuda_graph
and not envs.DEEPINFER_DFLASH_ENFORCE_EAGER
@ -124,6 +132,7 @@ class ModelRunner:
seq_metadata_list: list[SequenceMetadata],
out_dict: dict | None = None,
metadata_zos: MetadataZOS | None = None,
prompt_as_decode: bool = False,
) -> InputMetadata | None:
"""Build input metadata on CPU, or on device for ZOS decode.
@ -138,6 +147,7 @@ class ModelRunner:
window_size=self.window_size,
swa_block_size=self.swa_block_size,
metadata_zos=metadata_zos,
prompt_as_decode=prompt_as_decode,
)
if out_dict is not None:
return None
@ -181,16 +191,30 @@ class ModelRunner:
if total_q is None:
total_q = sum(seq.seqlen_q for seq in seq_metadata_list)
is_prompt = seq_metadata_list[0].is_prompt
run_cuda_graph = not is_prompt and self.use_cuda_graph and bool(self.cuda_graph_runner.graphs)
# Neuron (issue #26): a prompt chunk runs through the decode graphs as a decode-shaped step padded to the
# prefill bucket. Every EP rank takes the same bucket (idle ranks run a placeholder prompt), so the static
# collectives inside the graphs match.
prefill_bucket = getattr(self.cuda_graph_runner, "prefill_bucket", 0) if self.use_cuda_graph else 0
prompt_as_decode = is_prompt and prefill_bucket > 0
if prompt_as_decode:
assert total_q <= prefill_bucket, f"prompt chunk of {total_q} tokens exceeds DEEPINFER_NEURON_PREFILL_BUCKET"
# The indexer scores the chunk against one block table (nki_attention.paged_mqa_logits).
assert num_reqs == 1, "a Neuron prefill step through the decode graphs takes one sequence"
run_cuda_graph = (not is_prompt or prompt_as_decode) and self.use_cuda_graph and bool(self.cuda_graph_runner.graphs)
if run_cuda_graph:
graph_size, cuda_graph_used = self.cuda_graph_runner.select(total_q)
graph_size, cuda_graph_used = self.cuda_graph_runner.select(prefill_bucket if prompt_as_decode else total_q)
self._prepare_input_tensors(
seq_metadata_list + self.cuda_graph_runner.padding_seq_metadata_list[: graph_size - total_q],
out_dict=cuda_graph_used.input_metadata_source.to_dict(),
metadata_zos=metadata_zos,
prompt_as_decode=prompt_as_decode,
)
input_metadata = self._graph_input_metadata(cuda_graph_used, num_reqs)
if prompt_as_decode:
# The graph runs on its decode-mode metadata; sampling and post-processing see a prompt step.
input_metadata = copy.copy(input_metadata)
input_metadata.is_prompt = True
else:
graph_size, cuda_graph_used = 0, None
input_metadata = self._prepare_input_tensors(

View File

@ -10,6 +10,7 @@ from __future__ import annotations
import dataclasses
import math
import time
from collections.abc import Callable
from typing import TYPE_CHECKING
@ -21,6 +22,7 @@ from deepinfer.logger import init_logger
from deepinfer.model_executor.input_metadata import InputMetadata
from deepinfer.model_executor.model_output import CausalLMForwardOutput
from deepinfer.neuron_ops import neuron_compile # noqa: F401 (registers the "deepinfer_neuron" backend)
from deepinfer.neuron_ops import regions
from deepinfer.neuron_ops.transfer_to_neuron import hide_cuda
from deepinfer.sequence import SequenceMetadata
from deepinfer.worker.cache_engine import GPUCacheInterface, KVCache
@ -120,6 +122,11 @@ class _LazyKVCache:
class NeuronGraphRunner(CudaGraphRunner):
def __init__(self, max_batch_size: int, input_on_device: bool = False) -> None:
super().__init__(max_batch_size, input_on_device)
# Prompt chunks run as decode-shaped steps of this many tokens (issue #26): one more graph size.
self.prefill_bucket = envs.DEEPINFER_NEURON_PREFILL_BUCKET
if self.prefill_bucket > self.max_batch_size:
self.max_batch_size = self.prefill_bucket
self.batch_sizes = [*self.batch_sizes, self.prefill_bucket]
self.compiled: dict[int, Callable[..., CausalLMForwardOutput]] = {}
self.model: DeepseekForCausalLM | None = None
self.kv_caches: GPUCacheInterface | None = None
@ -179,8 +186,23 @@ class NeuronGraphRunner(CudaGraphRunner):
if not self.input_on_device:
input_metadata_device.recursive_copy_(cuda_graph_data.input_metadata_source)
batch_size = next(bs for bs, data in self.graphs.items() if data is cuda_graph_data)
if not envs.DEEPINFER_NEURON_REGION_TIMING:
with hide_cuda():
return self.compiled[batch_size](input_metadata_device)
torch.neuron.synchronize()
started = time.perf_counter()
with hide_cuda():
return self.compiled[batch_size](input_metadata_device)
out = self.compiled[batch_size](input_metadata_device)
torch.neuron.synchronize()
total = time.perf_counter() - started
timings = regions.take_timings()
in_regions = sum(sum(v) for v in timings.values())
if torch.distributed.is_initialized() and torch.distributed.get_rank() != 0:
return out
parts = ", ".join(f"{k} {len(v)}x {1e3 * sum(v) / len(v):.2f} ms" for k, v in sorted(timings.items()))
logger.info(f"Neuron step bs={batch_size}: {1e3 * total:.1f} ms, regions {1e3 * in_regions:.1f} ms ({parts}), "
f"outside regions {1e3 * (total - in_regions):.1f} ms")
return out
def destroy(self) -> None:
super().destroy()

View File

@ -37,6 +37,7 @@ parser = argparse.ArgumentParser(description="V4.1 offline inference on Neuron (
parser.add_argument("--model_name", default=DEFAULT_MODEL_NAME, help="HF model id or local HF checkpoint directory")
parser.add_argument("--ep", default=64, type=int)
parser.add_argument("--max_tokens", default=16, type=int)
parser.add_argument("--num_prompts", default=len(PROMPTS), type=int, help="run only the first N prompts (eager prefill compiles per prompt length)")
parser.add_argument("--num_kv_blocks", default=400, type=int, help="fixed KV pool per rank; the scheduler admits a request only if max_model_len tokens fit, per running sequence")
parser.add_argument("--max_model_len", default=16384, type=int, help="the candidate-block filter needs >= candidate_topk_blocks * candidate_block_size (2048 * 8)")
parser.add_argument("--max_num_batched_tokens", default=1024, type=int)
@ -86,8 +87,9 @@ def main(dp_size: int, dp_rank: int) -> None:
from deepinfer.sampling_params import SamplingParams
params = [SamplingParams(temperature=0.0, max_tokens=args.max_tokens) for _ in PROMPTS]
outputs = llm.generate(prompts=PROMPTS, sampling_params=params, use_tqdm=False)
prompts = PROMPTS[: args.num_prompts]
params = [SamplingParams(temperature=0.0, max_tokens=args.max_tokens) for _ in prompts]
outputs = llm.generate(prompts=prompts, sampling_params=params, use_tqdm=False)
for i, output in enumerate(outputs):
text = llm.llm_engine.tokenizer.decode([x.token_id for x in output])
logps = [x.token_logprobs for x in output]