Skip to content

feat(rollout): segment-based kernel injection for hybrid-engine greedy decode - #8602

Open
delock wants to merge 45 commits into
masterfrom
gma/qwen_ki_expr
Open

delock wants to merge 45 commits into
masterfrom
gma/qwen_ki_expr

Conversation

@delock

@delock delock commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds a segment-KI framework that accelerates greedy decode in the hybrid-engine rollout by injecting native CUDA kernels into comm-free segments of the model, plus a C++ decode loop that runs the full autoregressive generation with near-zero per-step Python overhead.

Segment detection: structural, not per-model

Detection is attribute-name based: any HF family whose gated MLP uses gate_proj/up_proj/down_proj (LLaMA, Qwen, Mistral, DeepSeek, Gemma...) or whose GatedDeltaNet block uses the in_proj_{qkv,z,b,a} set is picked up without per-model code. Projections carrying TP collectives are never fused (a defensive isinstance check against the Allreduce layer hierarchy).

Co-location by construction

Both GLU (dual-weight GEMV) and GDN (quad-weight GEMV) replacements read the original weight Parameters directly through autograd.Functions with exact backward, so training and generation share one forward path: zero weight copies, no inject/eject switching between rollout and training. Verified end-to-end by an integration test (below): generate → train one step → generate, output changes, no sync calls.

Components

File Role
csrc/module_inject/fused_glu.cu dual/quad-weight GEMV (MLP / GDN input projections), GDN gates, decode attention (warp-per-head, KV split-K, online softmax), fused residual+RMSNorm, triple QKV GEMV, graph-capturable decode step
csrc/module_inject/decode_loop.cu C++ generation loop taking a backend-agnostic std::function replay callable (CUDA graph, eager forward, or future SYCL); argmax and buffer updates run as a private kernel; EOS checked via amortized D2H sync every 16 steps
deepspeed/module_inject/segment_ki.py segment detection + forward replacement (apply_segment_ki)
deepspeed/module_inject/kernel_reference.py pure-PyTorch executable specifications for every kernel — test oracle, runtime fallback, porting reference
deepspeed/ops/module_inject/{fused_glu,decode_loop}.py op builders (JIT compile, lazy load)
deepspeed/utils/static_cache.py hybrid-slot static cache: real static KV layers for full attention, pass-through slot reusing HF's cudagraph-safe GDN state buffers by reference
deepspeed/runtime/rollout/hybrid_engine_rollout.py prefill → cache setup → graph capture → C++ decode loop, with layered fallback (full-step graph for b=1, graph+argmax for b>1, C++ loop, Python loop)

Performance

Qwen3.5-4B, RTX 4080 SUPER, greedy, b=1:

tok/s relative
HF eager 22.5 1.0×
DeepSpeed (this PR) 69.4 3.1×
vLLM 74.9 —

Tests

tests/unit/module_inject/test_segment_ki_kernels.py — 11 tests, all passing (executed on RTX 4080 SUPER):

  • Kernel vs reference consistency (GPU, 6 tests): every kernel compared against its kernel_reference.py oracle on identical inputs (dual_gemv, quad_gemv, gdn_gates, triple_gemv, fused_add_norm, decode_attn)
  • Injection invariants (CPU-runnable, 4 tests): injected forward matches un-injected output, no weight copies installed, composite autograd path, backward scatters gradients to the original Parameters
  • Rollout → train → rollout integration (GPU, Qwen3.5-0.8B-Base): generate with graph capture + segKI, one SGD step through the same injected model, generate again with changed output — no inject/eject/sync calls anywhere in between
  • Multi-GPU (AutoTP) validation (planned follow-up)

Notes

  • SYCL/XPU porting: kernel_reference.py serves as the executable spec; decode_loop's std::function interface is backend-agnostic (graph replay or eager forward); SYCL kernels would follow the same op-builder pattern.
  • Related upstream fix split into a standalone PR: fix: attention_unfused alpha missing norm_factor squaring #8666 (attention_unfused alpha squaring).

@delock
delock force-pushed the gma/qwen_ki_expr branch 2 times, most recently from 11231a7 to d41c914 Compare September 26, 2026 07:00
@delock delock changed the title feat(rollout): HybridEngineRollout graph capture + segment-KI for Qwen3.5 hybrid — 67 tok/s (1.14× of vLLM) feat(rollout): segment-based kernel injection for hybrid-engine greedy decode Sep 26, 2026
@delock
delock marked this pull request as ready for review September 28, 2026 06:27

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b31faddfc4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread csrc/module_inject/decode_loop.cu Resolved
Comment thread deepspeed/module_inject/segment_ki.py Outdated
Comment on lines +184 to +185
from deepspeed.module_inject.kernel_reference import dual_gemv_silu_mul
return self.down_proj(dual_gemv_silu_mul(input, self.gate_proj.weight, self.up_proj.weight))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the original MLP activation

The structural selector accepts every bias-free module with these three projection names, but this replacement always computes SiLU. A GELU-gated MLP such as Gemma satisfies that selector and will therefore silently produce different logits whenever use_segki=True; constrain detection to SiLU MLPs or retain/call the module's configured activation.

Useful? React with 👍 / 👎.

Comment thread deepspeed/module_inject/segment_ki.py Outdated
Comment on lines +123 to +124
grad_gate_w = torch.matmul(grad_gate_coef.t(), hidden)
grad_up_w = torch.matmul(grad_up_coef.t(), hidden)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Flatten GLU activations before computing gradients

For normal decoder MLP inputs shaped [batch, sequence, hidden], this branch is used during backward, but Tensor.t() only accepts tensors with at most two dimensions. Consequently, a training step through any injected GLU with a 3-D activation fails at this expression; flatten the leading dimensions as the GDN backward path does before forming the weight gradients.

Useful? React with 👍 / 👎.

Comment thread deepspeed/module_inject/segment_ki.py Outdated
Comment on lines +233 to +236
if batch_size == 1 and seq_len == 1:
fused = GDNInputProj.apply(
hidden_states.squeeze(0).squeeze(0), self.in_proj_qkv.weight, self.in_proj_z.weight, self.in_proj_b.weight,
self.in_proj_a.weight, gdn_kernel_op).view(1, 1, -1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back when segment-KI inputs are not bf16

A CUDA use_segki=True rollout installs the native GDN op regardless of model dtype, and the b=1 decode path passes it here. quad_gemv unconditionally obtains BFloat16 data pointers, so ordinary fp16 Qwen3.5 inference raises instead of using the composite path; gate native dispatch on bf16 (and contiguous compatible weights) or provide an fp16 kernel.

Useful? React with 👍 / 👎.

Comment on lines +590 to +594
if getattr(module, "_ki_orig_layer_forward", None) is None:
module._ki_orig_layer_forward = module.forward
module._ki_norm_op = kernel_op
module._ki_postattn_scale = torch.add(1.0, norms[1].weight.detach()).contiguous()
module.forward = _fused_norm_layer_forward.__get__(module, type(module))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep fused RMSNorm scale tied to its Parameter

This creates a detached, independent copy of the RMSNorm scale at injection time. After a co-located training step updates post_attention_layernorm.weight, graph decode still multiplies by the stale copy, so rollout no longer reflects the trained model; compute the scale from the live parameter in the forward path or refresh it after updates.

Useful? React with 👍 / 👎.

Comment on lines +916 to +917
graph_op.decode_step_graph(static_logits[:, -1, :].contiguous(), static_token.view(batch_size, 1),
write_pos, static_attn, full_token_buf)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not invoke the bf16 graph kernel for fp16 logits

The graph-step op is loaded and used for every batch-one graph capture, even when use_segki is false, but decode_step_graph rejects logits that are not CUDA bf16. Existing fp16 graph-capture rollouts therefore fail during capture rather than reaching a fallback; only add this node for bf16 logits or retain the PyTorch argmax/update path.

Useful? React with 👍 / 👎.

Comment thread csrc/module_inject/fused_glu.cu Outdated
Comment on lines +198 to +201
int64_t new_pos = write_pos[0] + 1;
write_pos[0] = new_pos;
out_buf[new_pos] = best;
if (new_pos + 1 < max_len) { mask[new_pos + 1] = true; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reveal the current KV slot after graph replay

After the first captured decode forward, new_pos is the slot that will hold the next input token, but this reveals new_pos + 1. Thus any full-attention layer that remains on native SDPA sees its current KV slot masked and an unwritten future slot unmasked on the next replay, corrupting generation for models not patched by install_decode_attention; reveal new_pos instead.

Useful? React with 👍 / 👎.

Comment on lines +273 to +276
layer_types = getattr(text_config, "layer_types", None) or ["full_attention"] * num_layers
self._layers = [
DSStaticGDNSlot(max_cache_len) if t == "linear_attention" else DeepSpeedStaticLayer(max_cache_len)
for t in layer_types

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep hybrid cache slots compatible with continuous batching

For hybrid configurations this now substitutes DSStaticGDNSlot into the cache layer list, but continuous batching still iterates every layer and unconditionally copies target_layer.keys and target_layer.values in _continuous_prefill. The first admitted request on a model with a linear_attention layer therefore raises AttributeError; skip/pass through linear slots in that prefill and compaction path.

Useful? React with 👍 / 👎.

Comment thread csrc/module_inject/decode_loop.cu Outdated
Comment on lines +125 to +127
}

return steps_done;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pad the CUDA output buffer from device code

When an EOS is found at a periodic check, buf_ptr is a CUDA device pointer, yet this host-side loop dereferences it directly. On normal CUDA allocations this is an invalid host memory access (and can crash) precisely when EOS is encountered; launch a padding kernel or use a device tensor operation instead.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Several paths currently break gradients, padded prompts, fallback decoding, hybrid continuous batching, and native-op compilation or packaging.

Review effort: Balanced
Findings: 10 High severity · 6 Medium severity · 2 Low severity

Open (18)
What changed in this PR

Adds segment-based kernel injection and native decode acceleration for hybrid-engine greedy rollout.

Changes:

  • Adds fused CUDA kernels, reference implementations, and op builders.
  • Extends static-cache support for hybrid attention models.
  • Integrates graph capture, native decode loops, documentation, and tests.
File Description
csrc/​module_inject/​fused_glu.cu Implements fused CUDA kernels.
csrc/​module_inject/​decode_loop.cu Implements the native decode loop.
deepspeed/​module_inject/​segment_ki.py Detects and injects fused model segments.
deepspeed/​module_inject/​kernel_reference.py Provides PyTorch kernel references.
deepspeed/​ops/​module_inject/​__init__.py Exports native-op builders.
deepspeed/​ops/​module_inject/​fused_glu.py Adds the fused-kernel builder.
deepspeed/​ops/​module_inject/​decode_loop.py Adds the decode-loop builder.
deepspeed/​utils/​static_cache.py Adds hybrid cache slots and position semantics.
deepspeed/​runtime/​rollout/​hybrid_engine_rollout.py Integrates injection and accelerated decoding.
tests/​unit/​module_inject/​test_segment_ki_kernels.py Tests kernels and injection behavior.
tests/​unit/​utils/​test_static_cache.py Updates static-cache expectations.
docs/​code-docs/​source/​inference-engine.rst Documents segment-based injection.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread csrc/module_inject/decode_loop.cu Resolved
Comment thread deepspeed/module_inject/segment_ki.py Resolved
Comment thread deepspeed/module_inject/segment_ki.py Resolved
Comment thread deepspeed/module_inject/segment_ki.py Resolved
Comment thread deepspeed/module_inject/segment_ki.py Resolved
Comment thread csrc/module_inject/decode_loop.cu Resolved
@nathon-lee

Copy link
Copy Markdown
Contributor

Hi @delock, thanks for submitting this PR. I have left a few comments and questions.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Graph compatibility, packed GDN execution, projection bias handling, and deterministic argmax behavior have unresolved correctness issues.

Review effort: Balanced
Findings: 3 High severity · 1 Medium severity · 2 Low severity

Open (6)
Resolved since last review (18)

Comment thread deepspeed/module_inject/segment_ki.py Outdated
Comment on lines +374 to +376
kw.pop("use_cache", None)
kw.pop("cu_seqlens", None)
kw.pop("cu_seq_lens_q", None)
Comment thread deepspeed/module_inject/segment_ki.py Outdated
Comment on lines +562 to +563
module._ki_qkv_op = kernel_op if (hasattr(kernel_op, "triple_gemv")
and os.environ.get("DS_TIER2", "1") == "1") else None
Comment on lines +1036 to +1039
attention_mask={
"full_attention": None,
"linear_attention": None
},
Comment thread csrc/module_inject/fused_glu.cu Outdated
Comment on lines +109 to +114
if (tid < stride) {
if (s_val[tid + stride] > s_val[tid]) {
s_val[tid] = s_val[tid + stride];
s_idx[tid] = s_idx[tid + stride];
}
}
Comment thread csrc/module_inject/fused_glu.cu Outdated
Comment on lines +731 to +733
m.def("decode_attn", &decode_attn, "b=1 decode attention with GQA, graph-compatible (CUDA)");
m.def("decode_step", &decode_step, "fused decode step update (CUDA)");
m.def("decode_step_graph", &decode_step_graph, "graph-capturable decode step (CUDA)");
Comment on lines +275 to +277
batch2 = rollout.generate(req, greedy)
assert not torch.equal(batch1.input_ids, batch2.input_ids), \
"rollout output unchanged after training — kernels read stale weights"
@delock

delock commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @delock, thanks for submitting this PR. I have left a few comments and questions.

Hi @nathon-lee , kindly reminder in case you didn't submit your review comments. Thanks!

@nathon-lee

Copy link
Copy Markdown
Contributor

Hi, @delock Thanks for the PR and for addressing the earlier comments. I recommend addressing these two points before merging:

  1. _generate_graph() no longer passes cache_position or position_ids. A minimal CUDA test with PyTorch 2.9.1, Transformers 4.57.1, and a tiny LLaMA succeeds with explicit position tensors but fails without them with operation not permitted when stream is capturing. This path also runs with use_segki=False. Please preserve the existing compatibility and add a regression test.
  2. Most of the numerical tests compare the new kernels with the new reference implementations. Please also compare outputs and gradients directly against the original HF forward, with explicit BF16 tolerances. Bit-for-bit agreement is not necessary, but changes such as the intermediate rounding order in fused residual-add + RMSNorm should be evaluated against the original implementation.

A few additional points to check; I am not treating these as confirmed merge blockers:

  • The GDN softplus expression logf(1 + expf(x)) loses precision for negative inputs; for example, the FP32 expression produces 0 at x=-20. Please use a stable implementation and check the effect of changing g from FP32 to BF16.
  • The GDN convolution wrapper only forwards seq_idx, while the scan receives cu_seq_lens_q/cu_seqlens. Please confirm that the supported convolution implementation still receives the packed-sequence boundaries. If packed training is supported, a test comparing outputs and gradients from separate and packed execution would help. Otherwise, please document the limitation.
  • Is reusing a captured graph after a training step intended to be supported? The current test captures a new graph on each generate() call, so it does not cover that case. If graph reuse is out of scope, please clarify this in the documentation.
  • Some native-op loading failures fall back silently. Please log the reason at least once so users can tell which path is running.
  • Please clarify whether throughput counts executed decode steps or valid response tokens, and report capture overhead separately from steady-state decode performance. Please also update the PR description to reflect the removal of the standalone C++ decode loop.

The position-handling regression is reproduced with the versions above. The other points are requests for testing or clarification, not confirmed runtime failures.

@delock

delock commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @nathon-lee , thanks for the review comments. I have followup them with subsequent commits. There is one finding, fused_add_norm didn't act correctly with BS>1, and it is found by comparing with HF forward.

Hi, @delock Thanks for the PR and for addressing the earlier comments. I recommend addressing these two points before merging:

  1. _generate_graph() no longer passes cache_position or position_ids. A minimal CUDA test with PyTorch 2.9.1, Transformers 4.57.1, and a tiny LLaMA succeeds with explicit position tensors but fails without them with operation not permitted when stream is capturing. This path also runs with use_segki=False. Please preserve the existing compatibility and add a regression test.
  2. Most of the numerical tests compare the new kernels with the new reference implementations. Please also compare outputs and gradients directly against the original HF forward, with explicit BF16 tolerances. Bit-for-bit agreement is not necessary, but changes such as the intermediate rounding order in fused residual-add + RMSNorm should be evaluated against the original implementation.

A few additional points to check; I am not treating these as confirmed merge blockers:

  • The GDN softplus expression logf(1 + expf(x)) loses precision for negative inputs; for example, the FP32 expression produces 0 at x=-20. Please use a stable implementation and check the effect of changing g from FP32 to BF16.
  • The GDN convolution wrapper only forwards seq_idx, while the scan receives cu_seq_lens_q/cu_seqlens. Please confirm that the supported convolution implementation still receives the packed-sequence boundaries. If packed training is supported, a test comparing outputs and gradients from separate and packed execution would help. Otherwise, please document the limitation.
  • Is reusing a captured graph after a training step intended to be supported? The current test captures a new graph on each generate() call, so it does not cover that case. If graph reuse is out of scope, please clarify this in the documentation.
  • Some native-op loading failures fall back silently. Please log the reason at least once so users can tell which path is running.
  • Please clarify whether throughput counts executed decode steps or valid response tokens, and report capture overhead separately from steady-state decode performance. Please also update the PR description to reflect the removal of the standalone C++ decode loop.

The position-handling regression is reproduced with the versions above. The other points are requests for testing or clarification, not confirmed runtime failures.

@nathon-lee

nathon-lee commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Hi @delock, thanks for following up. That was a useful catch: the previous fused_add_norm kernel only indexed row 0, leaving the other output rows unwritten for BS > 1. The per-row indexing fix addresses that, and the multi-row kernel check passes here. Good to see the HF comparison caught it.

I did notice one issue at 6647fb30: static_cache.py:103 calls get_accelerator().is_stream_capturing(), but the accelerator interface and implementations do not define it. The existing per-row cache tests fail with AttributeError on both CPU and CUDA; the LLaMA graph path also fails with use_segki=False. Could you include the missing implementation?

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-22T03:22:42.556857Z
We couldn’t complete this review. Comment @codex review to try again.
cc3e804 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@delock

delock commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @delock, thanks for following up. That was a useful catch: the previous fused_add_norm kernel only indexed row 0, leaving the other output rows unwritten for BS > 1. The per-row indexing fix addresses that, and the multi-row kernel check passes here. Good to see the HF comparison caught it.

I did notice one issue at 6647fb30: static_cache.py:103 calls get_accelerator().is_stream_capturing(), but the accelerator interface and implementations do not define it. The existing per-row cache tests fail with AttributeError on both CPU and CUDA; the LLaMA graph path also fails with use_segki=False. Could you include the missing implementation?

Hi @nathon-lee , sorry for the late reply, let me check with latest status of master.

@delock

delock commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Hi @delock, thanks for following up. That was a useful catch: the previous fused_add_norm kernel only indexed row 0, leaving the other output rows unwritten for BS > 1. The per-row indexing fix addresses that, and the multi-row kernel check passes here. Good to see the HF comparison caught it.
I did notice one issue at 6647fb30: static_cache.py:103 calls get_accelerator().is_stream_capturing(), but the accelerator interface and implementations do not define it. The existing per-row cache tests fail with AttributeError on both CPU and CUDA; the LLaMA graph path also fails with use_segki=False. Could you include the missing implementation?

Hi @nathon-lee , sorry for the late reply, let me check with latest status of master.

Hi @nathon-lee , the latest code should contain this missing implementation. Sorry for inconvience.

def total_memory(self, device_index=None):
...

@abc.abstractmethod

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should not remove this?

Comment thread accelerator/abstract_accelerator.py Outdated
per-segment graphs with collectives issued between replays."""
return False

def is_stream_capturing(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can reuse this?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can reuse the interface on master. is_stream_capturing is removed.

# b>1 attends every slot the mask reveals.
static_attn[:, :, :, pos] = True
get_accelerator().replay_graph(graph)
next_token = static_logits[:, -1, :].argmax(dim=-1, keepdim=True)

@phoeenniixx phoeenniixx Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have sampling for temp>0 as per the docstring (line 9-10).

We are removing is_greedy check in line 232 so even for sampled req (with b>1) we might reach here to get greedy?

@phoeenniixx

Copy link
Copy Markdown
Contributor

The new _ki_prompt_padded check routes every padded batch to HF generate, including with use_segki=False and for non-hybrid models. On master, the graph path handled padded prompts through the attention mask, so this looks like a regression for existing use_graph_capture users. Could the fallback be limited to the cases that need it (segKI kernels and hybrid/GDN models)? Since most rollout batches with different prompts are padded, supporting padding on the segKI path would also matter for real speedups, but that seems fine as a follow-up.

delock and others added 8 commits November 9, 2026 10:23
…_greedy

Adopt master's routing structure exactly (#8980 + #8939): the local
use_graph_capture is the config value narrowed by
is_graph_capture_supported(). No is_greedy gate — #8939 added sampling
to the graph decode path (token selection runs outside the captured
graph, ~0.9% decode cost). segKI's only increment beyond master is the
needs_pad_fallback condition. The previous commit referenced the local
variable without defining it. (flake8 skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
Master's #8939 refactored _continuous_next_tokens to accept a
select_next_tokens parameter (applying repetition penalty via
_apply_repetition_penalty, then delegating token selection to the
injected selector). Our auto-merged version kept the old 5-arg inline
implementation. Updated the method, both call sites, and added
select_next_tokens definitions in the enclosing continuous methods.
(flake8 skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
The previous commit added @classmethod without removing the existing
@staticmethod; both together make the method uncallable as a bound
method. (flake8 skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
Master's tests mock the module-level logger and use SimpleNamespace
mocks that lack num_attention_heads. Our merge dropped the logger
definition and the TP head-count adjustment assumed the attribute
exists. (flake8 skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
…iction

Same auto-merge pattern: master's #8939 added a sampling parameter to
_continuous_prefill_cache and removed the greedy-only restriction in
continuous batching. Both call sites updated. (flake8 skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
The previous commit added sampling to _continuous_prefill_cache's
signature and its call sites inside _continuous_prefill, but forgot
to add sampling to _continuous_prefill itself and its callers —
yielding F821 undefined name at the inner call sites. (flake8
skipped: sandbox)

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
The #8939 merge resolution dropped three master behaviours in
hybrid_engine_rollout.py:

- _generate_graph lost the flash_attention_2 rejection, so graph capture
  proceeded into StaticCache and crashed inside transformers instead of
  raising the documented ValueError.
- _generate_continuous routed on self.use_graph_capture directly, losing
  the accelerator capability gate, so backends without the graph-capture
  opt-in entered the graph path instead of falling back to eager
  continuous batching.
- generate() logged through a local logging.getLogger while DeepSpeed's
  logger is the one exposing warning_once.

Restore master's exact code for all three and import the DeepSpeed
logger.

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
@delock

delock commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

#9008 needs to be merged before this PR as a better AutoTP solution.

@tohtana tohtana added the needs-gpu-test GPU testing is recommended to validate the changes in this PR. label Nov 10, 2026
banxingmjj pushed a commit to openanolis/DeepSpeed that referenced this pull request Nov 11, 2026
…utoTP (deepspeedai#9008)

## Summary

Size HybridEngine rollout KV caches from each rank's own prefill KV
tensors, not from the global model config. Under AutoTP, each rank holds
only its share of the KV heads. Today the cache is allocated for the
full model's KV-head count, so TP>1 rollout fails on all three decode
paths:

| Path | TP>1 failure today | Change |
| --- | --- | --- |
| Plain graph (`_generate_graph`) | Passing batch size, device and dtype
makes `DeepSpeedStaticCache` initialize from `module.config` up front,
so the lazy per-layer setup never runs. The prefill copy then fails with
`4 vs 2` heads, or broadcasts silently when the rank has 1 local head. |
Drop those up-front arguments, so the existing lazy setup sizes each
layer from the prefill tensors before capture. |
| Continuous eager (`_generate_continuous`) | The same up-front setup
from config fails on the first prefill copy (`4 vs 2`, `2 vs 8`). |
Create the cache lazily. `_continuous_cache_layer` initializes each
layer from the prefill tensors, sized to the slot count. The slot count
is the decode batch capacity, not the prefill batch. |
| Continuous graph | The graph state cache is initialized from config,
and capture happens before any prefill. | Create the cache lazily and
run one decode forward on the current stream before warmup, so every
layer is initialized with local shapes. Without this, those tensors
would be allocated on the warmup side stream. |

`lazy_initialization` gains an optional `max_batch_size`, which the
continuous path uses because its capacity is the slot count.
`cache_memory_bytes` is now computed after decode, once the layers
exist. No model config is changed and no TP/world group is consulted, so
TP×DP layouts work with no extra logic.

## Related work / scope

- deepspeedai#9000 / deepspeedai#9004 fix attention-mask construction during capture, not KV
shape allocation. This PR is independent of them. For real Hugging Face
model validation, deepspeedai#9004's runtime hunk was applied to **both** the
baseline and the fixed tree.
- deepspeedai#8602 divides cache heads by the global world size, which breaks TP×DP
(for example TP2×DP4). This fix reads the tensors directly instead of
inferring the TP group.
- On Transformers 5.17, continuous eager also needs deepspeedai#8852
(`get_query_offset`), which is still open, so the continuous eager HF
runs below apply deepspeedai#8852 to both baseline and fixed trees. The new unit
tests use a local model and do not depend on deepspeedai#8852.
- **Known limitation, not changed here:** a persistent continuous graph
(deepspeedai#8732) that contains NCCL ops makes
`torch.distributed.destroy_process_group()` hang in
`ProcessGroupNCCL.shutdown()` at exit. Releasing the rollout (`del` +
`gc.collect()` + `synchronize()`) before destroying avoids it. Before
this PR, TP>1 continuous graph could not run, so the hang was not
reachable. A release/close API is left for a follow-up.

## Validation

**Hardware/software:** 8× NVIDIA L20, driver 580.173.02, CUDA runtime
13.0, torch 2.14.1+cu130, Transformers 5.17.0, Python 3.10.22. Baseline:
`d8241a9ba266405e1a58fadf19e24b2370f6d7fa`.

```bash
CUDA_VISIBLE_DEVICES=0 python -m pytest --forked tests/unit/v1/test_hybrid_engine_rollout_v1.py \
  -k "static_graph_uses_prefill_kv_shape or continuous_generation_uses_local_kv_shape"
# baseline: 6 failed, 3 passed; this PR: 9 passed

CUDA_VISIBLE_DEVICES=0 python -m pytest --forked tests/unit/v1/test_hybrid_engine_rollout_v1.py \
  tests/unit/runtime/rollout/test_hybrid_engine_rollout.py tests/unit/utils/test_static_cache.py
# baseline: 6 failed, 64 passed, 1 skipped; this PR: 70 passed, 1 skipped
```

The new tests write head-dependent cache contents, compare tokens with
an independent oracle, and count real graph replays:

| Local / config KV heads | Plain graph | Continuous eager | Continuous
graph |
| --- | --- | --- | --- |
| 4 / 4 | pass → pass | pass → pass | pass → pass |
| 2 / 4 | fail → pass | fail → pass | fail → pass |
| 1 / 4 | fail → pass | fail → pass | fail → pass |

**Multi-GPU tiny Llama, continuous path.** 2 layers, 4 KV heads, fp32, 4
requests per rank, 2 slots. Tokens were compared with plain eager
generation on every rank.

| Layout | Continuous graph (before → after) | Continuous eager + deepspeedai#8852
(before → after) |
| --- | --- | --- |
| TP1, world 1 | pass → pass | pass → pass |
| TP2, world 2 | shape error → pass | `4 vs 2` error → pass |
| TP4, world 4 | shape error → pass | `2 vs 8` error → pass |
| TP2×DP4, world 8 | shape error → pass | `4 vs 2` error → pass |
| TP4×DP2, world 8 | not run → pass | `2 vs 8` error → pass |

**Plain graph with real models**, with deepspeedai#9004 applied to both trees:

| Model / precision | Layouts | Result |
| --- | --- | --- |
| Tiny Llama / fp32 | TP1×DP1, TP1×DP4, TP2×DP2, TP2×DP4 | All pass;
graph tokens and masks equal native eager generation |
| Qwen3-8B / bf16 | TP2×DP4 | 4 prompts × 16 new tokens; graph tokens
and masks equal native eager generation |

On the plain-graph runs, each rank made three generation calls: 3
captures and 45 replays, and repeated graph outputs matched. The
baseline TP2×DP4 Llama run failed at the KV copy (`4 vs 2`). The Qwen3
graph calls took 0.464 / 0.439 s, including prefill and capture, with
8.25 GiB max allocated per GPU. These numbers come from correctness
runs; **no speedup is claimed**.

**Distributed AutoTP integration test** (`TestAutoTPContinuousRollout`,
world size 2): real AutoTP via `deepspeed.initialize` (`autotp_size=2`)
on a tiny Llama, 3 requests on 2 continuous slots, tokens compared with
`generate()` on the same sharded model. On 2× NVIDIA H20, torch
2.9.0a0+50eac811a6.nv25.09, Transformers 4.57.6, Python 3.12.3:

```bash
CUDA_VISIBLE_DEVICES=0,1 python -m pytest tests/unit/v1/test_hybrid_engine_rollout_v1.py -k TestAutoTPContinuousRollout
# baseline (rollout + static cache from d8241a9): 2 failed (eager `4 vs 2`, graph `[2, 2, 16]` vs `[2, 4, 16]`); this PR: 2 passed
```

```bash
DS_ACCELERATOR=cpu python -m pytest tests/unit/runtime/rollout/test_hybrid_engine_rollout.py \
  tests/unit/utils/test_static_cache.py tests/unit/v1/test_hybrid_engine_rollout_v1.py
# 60 passed, 11 skipped; Python 3.12.3 / torch 2.14.0.dev20260804+xpu

pre-commit run --files deepspeed/runtime/rollout/hybrid_engine_rollout.py \
  deepspeed/utils/static_cache.py tests/unit/v1/test_hybrid_engine_rollout_v1.py
# passed
```

**Intel XPU** (1× Arc Pro B70, torch 2.14.1+xpu, Transformers 5.22.1,
Intel compute runtime 26.27.39122.11, Level Zero loader 1.28.2). Master
has no XPU graph backend, so the new graph tests skip there. On top of
deepspeedai#9007 (XPU graph capture):

```bash
DS_ACCELERATOR=xpu python -m pytest tests/unit/v1/test_hybrid_engine_rollout_v1.py \
  -k "static_graph_uses_prefill_kv_shape or continuous_generation_uses_local_kv_shape"
# deepspeedai#9007 alone: 6 failed, 3 passed; deepspeedai#9007 + this PR: 9 passed

DS_ACCELERATOR=xpu python -m pytest tests/unit/v1/test_hybrid_engine_rollout_v1.py \
  tests/unit/runtime/rollout/test_hybrid_engine_rollout.py
# deepspeedai#9007 + this PR: 60 passed, 3 skipped; process exit 0
```

This clean-exit torch 2.14.1 rerun confirms all nine new tests pass; the
same tests against deepspeedai#9007 without this PR give 6 failed / 3 passed (exit
1). The earlier torch 2.15.0+xpu run printed passing tests but aborts
during interpreter teardown on this host (exit 134). A minimal XPU
tensor program reproduces that abort without DeepSpeed, so that earlier
run is not counted as a clean test pass. The earlier master-based tree
reported 52 passed / 10 skipped, with graph coverage skipped as
expected.

The opt-in `test_continuous_graph_matches_pretrained_model_when_enabled`
(only runs when `DEEPSPEED_PRETRAINED_ROLLOUT_MODEL` is set) fails the
same way on master and on this PR with Transformers 5.17:
`'DeepSpeedStaticCache' object has no attribute 'get_query_offset'`,
which deepspeedai#8852 addresses.

**Validated:** ZeRO-0, no offload, fp32/bf16, AutoTP with data-parallel
replicas on CUDA, plain and continuous decode, eager and graph;
single-device graph tests on XPU with deepspeedai#9007. **Not validated:** a full
training-to-rollout cycle, ZeRO-1/2/3, offload, pipeline/expert/sequence
parallelism, older HF preallocated cache APIs, and multi-device XPU
graph. No claim is made about training convergence or performance.

AI assistance: GitHub Copilot helped with the implementation, the tests
and the validation. I reviewed every changed line before signing off.

---------

Signed-off-by: Jin, Youzhi <youzhi.jin@intel.com>
Signed-off-by: iLeGend <824040212@qq.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Ma, Guokai <guokai.ma@gmail.com>
…oups

The CPU shm inference_all_reduce_ kernel is initialized with the WORLD
topology and takes no group argument, so it can only produce a correct
sum for a world-wide reduction. When a proper TP subgroup called
inference_all_reduce (autotp_size=2 inside a 4-rank world), the kernel
combined the two sibling subgroups' partial sums into a 4-way sum,
corrupting outputs and grad norms.

This is the cpu-torch-latest-v1-multirank CI failure: TestTpGradNorm
[2-1]/[2-2] failed deterministically (base_norm 1.6128 vs tp_norm
2.0052, bit-identical across runs) on avx512bw runners where the shm
kernel engages; avx2-only runners silently fell back to gloo and
passed, which made the check look flaky run-to-run.

Fix: engage the shm kernel only when the requested group spans the
whole world; a subgroup with full world size is necessarily the world
itself, so a size comparison suffices. Every other group falls through
to the group-aware torch.distributed.all_reduce. No other backend or
routing change: the accelerator custom-collective path is already bound
to its registered group per process and is untouched.

Add a regression test that reduces through two 2-rank subgroups of a
4-rank world and asserts subgroup-scoped results (1/5, not the
world-sum 6). With the guard removed the test reproduces the CI failure
exactly.

Validated on CPU (torch 2.12.1+cpu, python 3.11):
- unit/v1/autotp/test_autotp_training.py -k TestTpGradNorm:
  4 passed, 2 skipped (previously 2 failed, bit-identical to CI)
- unit/comm/test_custom_collective.py: 5 passed, 2 skipped
- unit/v1/autotp/test_tp_plan_e2e.py: test_tp_plan_basic_training passed
- full test_autotp_training.py: no regressions vs the unpatched head

Signed-off-by: Ma, Guokai <guokai.ma@gmail.com>
delock and others added 3 commits November 13, 2026 08:20
Conflict resolution:
- generate path: combine master's ZeRO-3 group-aware stopping
  criterion (_GlobalEosStoppingCriteria + synced_gpus opt-out) with our
  sampling kwargs (do_sample/temperature/top_p) in the eager fallback
- StaticCache construction: keep our TP-sharded config copy and eager
  init (graph capture needs pre-allocated buffers); master's simplified
  lazy-init call site only fits its non-TP default path
- GDN slot binding comment + prefill-shape inference comment both kept
- test_static_cache: keep both our GDN-protocol test and master's
  memory_bytes test (pure both-add)

Signed-off-by: Guokai Ma <guokai.ma@intel.com>
The TP guard requires the full blocks (the injected forward slices
per-rank head ranges); the old column specs tripped it after the merge.

Signed-off-by: Guokai Ma <guokai.ma@intel.com>
…kwargs

The fallback prefill branch of _continuous_prefill_cache passed
do_sample=False explicitly while also unpacking
**_hf_sampling_kwargs(sampling), whose return value always contains
do_sample -- so every execution of that branch raised
"GenerationMixin.generate() got multiple values for keyword argument
'do_sample'". The explicit kwarg is a leftover from the greedy-only era
(e53e2dc); dropping it lets _hf_sampling_kwargs own the value, which
also keeps first-token selection consistent with the static-cache
path's _token_selector(sampling).

Surfaced by TestAutoTPContinuousRollout::test_matches_generate_on_sharded_llama
[False] from #9008 after the 2026-11-13 master merge: it drives the
no-cache-class fallback branch that our own tests (static-cache models)
never reach.

Also collapse a stray blank line in tests/unit/utils/test_static_cache.py
flagged by the Formatting check (yapf).

Validated locally: yapf repo-clean, pre-commit green on changed files,
unit/comm and test_static_cache unchanged vs baseline; the rollout test
itself requires the CI transformers environment.

Signed-off-by: Ma, Guokai <guokai.ma@gmail.com>
# workload at higher batch.
eos_mask = torch.zeros(batch_size, dtype=torch.bool, device=device)
for step in range(max_new_tokens - 1):
if eos_mask.all():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (perf, b>1): if eos_mask.all(): is a device→host sync on every decode step. The host has to wait for the replay plus select_next_tokens to finish before it can enqueue the next step's copy_/fill_/mask update/replay. As a result, the ~50 µs/step of Python work mentioned above runs in series with the GPU instead of overlapping it, and the GPU sits idle between replays. It also syncs when eos_token_id is None, where the mask can never become true.

The b=1 path below already avoids this with eos_check_every = 16. The same approach should work here with identical output, because generate() passes the result through _pad_after_eos (L339), which pads everything after each row's first EOS:

eos_check_every = 16
for step in range(max_new_tokens - 1):
    ...  # copy_/fill_/mask/replay/select, unchanged
    output_ids.append(next_token)
    if eos_token_id is None:
        continue
    eos_mask |= (next_token.view(batch_size) == eos_token_id)
    if step % eos_check_every == eos_check_every - 1 and bool(eos_mask.all()):
        remaining = max_new_tokens - 2 - step
        if remaining > 0:
            output_ids.append(torch.full((batch_size, remaining), pad_token_id, dtype=torch.long, device=device))
        break

At most eos_check_every - 1 extra steps run after the last row finishes, and their tokens get padded anyway. The Python fallback loop at L1780 could use the same pattern, but it matters less there.


op = self._ki_norm_op
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

input_layernorm (and the final model.norm) still run HF's eager Qwen3_5RMSNorm: x.float(), pow, mean, +eps, rsqrt, two muls, 1 + w.float(), cast back, which is about 10 launches per norm. In a profile of the b=1 and b=64 decode graphs, these 33 norms are one of the larger remaining non-GEMM costs, since only the post-attention norm goes through fused_add_norm.

Two options, cheapest first:

  1. Replace the eager norm with F.rms_norm(x.float(), (hidden,), scale_fp32, eps).type_as(x), where scale_fp32 = 1 + w.float() is a cached buffer (see the postattn_scale comment below). That's 3 launches instead of about 10. On H20 / torch 2.13 the output is bitwise-equal to HF at b=1 and within 1 bf16 ulp at b=64, because the reduction order differs. That is the same class of difference fused_add_norm already has.
  2. Fuse across the layer boundary: layer i's trailing hidden_states + new_residual (L1137) followed by layer i+1's input_layernorm is exactly the fused_add_norm pattern. That removes both the add and the eager norm, but the patched layer would have to hand the un-added pair to the next layer, which is a larger change to the HF loop contract.

Option 1 alone looks like a self-contained win.

Comment on lines +265 to +268
# other batches or no kernel: the composite reference (gradients also
# correct there); measured faster than the batched GEMV kernels
from deepspeed.module_inject.kernel_reference import grouped_linear
glu = grouped_linear(input, [self.gate_proj.weight, self.up_proj.weight], True)[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For m > 32, this bypasses the native op and calls kernel_reference.grouped_linear, which is the spec/fallback. That path does two separate F.linear calls plus an fp32 decomposed SiLU (exp, add, div, two muls, and the casts), so the epilogue alone is about 6 elementwise launches per layer.

The native grouped_linear already owns the large-M choice (fused_glu.cu L1624–1649). On GPUs with >100 KB opt-in smem (Ampere/Hopper, grouped_wmma_threshold() == 1) it does one GEMM over the cached [gate; up] concat plus silu_mul. On Ada it falls back to the aten composite. So routing every m > 1 through self._ki_dual_op when it is set, and keeping the reference only for the no-kernel case, should reduce both GEMMs and the epilogue at b=64.

This also has a parity benefit. I checked on H20 with random bf16 inputs: the reference epilogue (g / (1 + exp(-g)) * u) in fp32 is not bitwise-equal to HF's F.silu(gate) * up, while the native composite at::silu(g.float()).to(bf16) * u is. So today b ≤ 32 and b > 32 round the MLP differently. If the native op must stay off for m > 32, F.silu(g) * u in bf16 would at least match HF and save most of the epilogue launches.

Comment on lines +364 to +366
else:
fused = GDNInputProj.apply(hidden_states, self.in_proj_qkv.weight, self.in_proj_z.weight,
self.in_proj_b.weight, self.in_proj_a.weight, gdn_kernel_op)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At TP=1, b>1 decode lands in the native grouped_linear with n=4. For M > grouped_wmma_threshold() that is 4 separate matmuls plus an at::cat (fused_glu.cu L1652–1661). The b/a projections only have num_v_heads (32) rows each, so they are two GEMMs per layer that are almost pure launch cost, plus a cat that copies the full [qkv | z | b | a] output.

The TP>1 branch above already solves this with _ki_tp_cat_w (two groups, refreshed by segki_refresh). Possible variants for TP=1:

  • Cheapest: cache only cat([in_proj_b.weight, in_proj_a.weight]) (2 * num_v_heads × hidden, about 0.3 MB per layer). That takes 4 GEMMs down to 3.
  • Same as TP>1: cache cat([z, b, a]) and call apply6(x2, qkv_w, qkv_w, qkv_w, zba, zba, zba), which gives 2 GEMMs and one code path for TP=1 and TP>1. The cost is about (value_dim + 2 * num_v_heads) × hidden bf16 per GDN layer, about 0.5 GB total for Qwen3.5-4B.

Either way the cat stays unless the GEMMs write into slices of one preallocated output.

# directly. During capture the add becomes part of the graph, so replay
# reads the parameter's memory and reflects post-training values.
new_residual = hidden_states
postattn_scale = torch.add(1.0, self.post_attention_layernorm.weight).contiguous()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: 1 + w is one extra launch per layer per step (32 per decode step), and the same pattern would apply to the input norm if it moves to F.rms_norm.

I see the comment about picking up post-training values through the captured add. An alternative that keeps that property: compute it once into a persistent buffer, and have segki_refresh update it in place (buf.copy_(1 + w)) instead of rebuilding it. Captured graphs read the same address, so replay still sees the new values.

The same in-place idea would matter for the other caches segki_refresh currently delattrs (_ki_tp_cat_w, _ki_attn_cat_w, g_glu_cat_cache) once the decode graph is reused across generate() calls (#9077 / #9009). Rebuilding them gives new storage, and an already-captured graph would keep reading the stale copy.

rotary = getattr(getattr(module, "model", module), "rotary_emb", None)
if rotary is not None:
with torch.no_grad():
positions = torch.arange(max_len, device=device)[None, :]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On transformers 5.x, Qwen3.5's rotary_emb is MRoPE (mrope_section) and expects position_ids of shape [3, bs, L]. The 2-D positions here make rotary(...) raise, and the except Exception below swallows it, so install_decode_attention / install_fused_norm / install_lm_head are all silently skipped and decode falls back to the HF SDPA path. For text-only rows the three sections share the same positions, so the resulting [L, head_dim] tables are unchanged:

Suggested change
positions = torch.arange(max_len, device=device)[None, :]
positions = torch.arange(max_len, device=device)[None, :]
if hasattr(rotary, "mrope_section"):
# MRoPE takes [3, batch, len] positions; text-only rows are all equal.
positions = positions[None].expand(3, 1, -1)

Also suggest logging a warning (or re-raising) in the except below instead of falling back silently, so a failed install is visible.

@jinyouzhi

Copy link
Copy Markdown
Contributor

Two more decode-path suggestions, both specific to the segKI kernels in this PR. I compared the decode step against vLLM on Qwen3.5-4B (H20). At larger batch sizes most of the remaining kernel gap is in these two places.

1. Full attention: use FA3 (or FlashInfer) for decode instead of decode_attn

At b>1, decode_attn does not share the KV loads across GQA heads (16 q / 4 kv heads), so it stays well behind FA3. vLLM uses FA3 here (FLASH_ATTN backend), not FlashInfer. What I tried:

  • Keep the static KV cache in NHD layout ([B, L, KV, D]). DeepSpeedStaticLayer.keys/values stay as HND permute views of the same storage, so update() and the fallback forwards keep working unchanged.
  • One small Triton kernel per step does q/k RMSNorm(1+w), partial NeoX RoPE (from the existing _ki_cos_t/_ki_sin_t tables) and the K/V store at write_pos[b]. This replaces the fused prologue inside decode_attn.
  • Then call flash_attn_varlen_func(..., fa_version=3) in paged mode: block_table = arange(B)[:, None], page size = cache length, seqused_k = write_pos + 1, computed on device so it is graph-safe. Note that varlen FA3 rejects cu_seqlens_k and seqused_k together, so the paged form is required. Page size and num_splits made no measurable difference.
  • Apply the output gate (* sigmoid(gate)), then o_proj.

Like the current kernel, this only applies to unpadded prompts. Padded prompts still fall back to SDPA.

2. GDN decode: use a packed fused-recurrent kernel

The current path launches FLA 0.6's fused_recurrent_gated_delta_rule_fwd_kernel in place, which is already good. The remaining gap against vLLM's fused_recurrent_gated_delta_rule_packed_decode (its vendored FLA) comes from:

  • state layout [V, K] (vLLM) vs [K, V], with a wider BV tile;
  • gates (softplus/exp/sigmoid from a, b, A_log, dt_bias) computed inside the kernel instead of in a separate gdn_gates launch;
  • q/k/v read directly from the packed conv output, and GQA head mapping done by index (i_h = i_hv // (HV // H)) instead of repeat_qk;
  • q/k l2norm done in-kernel.

What I tried: allocate the recurrent state as [B+1, HV, V, K] fp32 (slot 0 is the kernel's null block). The HF slot keeps recurrent_states[0] = vk[1:].transpose(-1, -2) as a view, so snapshot/restore and copy_ still alias the same memory. Then call the packed kernel right after _ki_gdn_conv_update. This skips the gates, the repeat, the scan wrapper and the state write-back. Output and final state match the FLA path to within fp32 rounding.

Smaller item: the conv update still uses causal-conv1d CUDA. vLLM's Triton conv update is faster at large batch, but I have not ported it (its state semantics differ from the HF [B, dim, width] conv state).

With both changes, greedy outputs are unchanged at b1/b4/b16. At b64 the tokens drift late in the sequence, which is consistent with bf16 reduction-order differences.

Reference implementation, prototype quality and opt-in via env flags: jinyouzhi@be8c2d886 (deepspeed/module_inject/segki_vllm_backend.py, plus the hooks in _decode_attn_forward, _fused_gdn_forward and _generate_graph). Both paths depend on vLLM's kernels. For this PR, a cleaner option may be an optional dependency on flash_attn_interface / FLA's packed kernel behind the existing interface.

Related items that are independent of segKI went to separate PRs: graph reuse across calls (#9077) and running the whole decode step inside the graph (#9079).

@tohtana tohtana removed the needs-gpu-test GPU testing is recommended to validate the changes in this PR. label Nov 14, 2026
@delock

delock commented Nov 14, 2026

Copy link
Copy Markdown
Collaborator Author

Two more decode-path suggestions, both specific to the segKI kernels in this PR. I compared the decode step against vLLM on Qwen3.5-4B (H20). At larger batch sizes most of the remaining kernel gap is in these two places.

1. Full attention: use FA3 (or FlashInfer) for decode instead of decode_attn

At b>1, decode_attn does not share the KV loads across GQA heads (16 q / 4 kv heads), so it stays well behind FA3. vLLM uses FA3 here (FLASH_ATTN backend), not FlashInfer. What I tried:

  • Keep the static KV cache in NHD layout ([B, L, KV, D]). DeepSpeedStaticLayer.keys/values stay as HND permute views of the same storage, so update() and the fallback forwards keep working unchanged.
  • One small Triton kernel per step does q/k RMSNorm(1+w), partial NeoX RoPE (from the existing _ki_cos_t/_ki_sin_t tables) and the K/V store at write_pos[b]. This replaces the fused prologue inside decode_attn.
  • Then call flash_attn_varlen_func(..., fa_version=3) in paged mode: block_table = arange(B)[:, None], page size = cache length, seqused_k = write_pos + 1, computed on device so it is graph-safe. Note that varlen FA3 rejects cu_seqlens_k and seqused_k together, so the paged form is required. Page size and num_splits made no measurable difference.
  • Apply the output gate (* sigmoid(gate)), then o_proj.

Like the current kernel, this only applies to unpadded prompts. Padded prompts still fall back to SDPA.

2. GDN decode: use a packed fused-recurrent kernel

The current path launches FLA 0.6's fused_recurrent_gated_delta_rule_fwd_kernel in place, which is already good. The remaining gap against vLLM's fused_recurrent_gated_delta_rule_packed_decode (its vendored FLA) comes from:

  • state layout [V, K] (vLLM) vs [K, V], with a wider BV tile;
  • gates (softplus/exp/sigmoid from a, b, A_log, dt_bias) computed inside the kernel instead of in a separate gdn_gates launch;
  • q/k/v read directly from the packed conv output, and GQA head mapping done by index (i_h = i_hv // (HV // H)) instead of repeat_qk;
  • q/k l2norm done in-kernel.

What I tried: allocate the recurrent state as [B+1, HV, V, K] fp32 (slot 0 is the kernel's null block). The HF slot keeps recurrent_states[0] = vk[1:].transpose(-1, -2) as a view, so snapshot/restore and copy_ still alias the same memory. Then call the packed kernel right after _ki_gdn_conv_update. This skips the gates, the repeat, the scan wrapper and the state write-back. Output and final state match the FLA path to within fp32 rounding.

Smaller item: the conv update still uses causal-conv1d CUDA. vLLM's Triton conv update is faster at large batch, but I have not ported it (its state semantics differ from the HF [B, dim, width] conv state).

With both changes, greedy outputs are unchanged at b1/b4/b16. At b64 the tokens drift late in the sequence, which is consistent with bf16 reduction-order differences.

Reference implementation, prototype quality and opt-in via env flags: jinyouzhi@be8c2d886 (deepspeed/module_inject/segki_vllm_backend.py, plus the hooks in _decode_attn_forward, _fused_gdn_forward and _generate_graph). Both paths depend on vLLM's kernels. For this PR, a cleaner option may be an optional dependency on flash_attn_interface / FLA's packed kernel behind the existing interface.

Related items that are independent of segKI went to separate PRs: graph reuse across calls (#9077) and running the whole decode step inside the graph (#9079).

Thanks @jinyouzhi for the investigation. I'll test your suggestions. And these new changes would be added in separate PRs you opened.

@jinyouzhi

Copy link
Copy Markdown
Contributor

Thanks! @delock For the relatively independent or generic parts, I've already opened separate PRs. But for changes that are coupled to this PR itself, it's probably better if you lead them. The comments and prototype are for reference only, so I prefer no need for a separate PR.

banxingmjj pushed a commit to openanolis/DeepSpeed that referenced this pull request Nov 14, 2026
…eedai#8997)

## Summary

Adds a latency-optimized one-shot all-reduce for P2P-capable CUDA pairs
(TP=2), filling the existing `deepspeed.comm.inference_all_reduce` hook
— the same slot the CPU backend fills with shm+AVX. **Split out of
deepspeedai#8602**: this is a generic comm-layer optimization with zero dependency
on segment-KI; any AutoTP TP=2 workload benefits, including plain `HF
generate` whose decode steps all-reduce small hidden-state tensors
through the TP-wrapped linears.

### How it works
- `csrc/inference/custom_ar.cu`: one-shot P2P kernel over CUDA IPC
buffers (2x4MB, sequence-numbered flags)
- `deepspeed/comm/custom_ar.py`: topology gating — world==2, P2P
capable, bounded-spin handshake probe; **symmetric verdict** via
`all_reduce(MIN)` so a unidirectional fabric fault disables the fast
path on both ranks (prevents the peer-spin hang)
- `op_builder/custom_ar.py` (`DS_BUILD_CUSTOM_AR`): built through the
standard op-builder path like every other op (wheel-prebuildable),
initialized next to the shm op in `TorchBackend`, mirroring
`build_shm_op`
- `layers.py`: `RowParallel` routes no-grad small-message decode through
`inference_all_reduce` (falls back to NCCL unchanged); inference mode
uses reduce-scatter+all-gather
- Fallbacks are safe and logged: non-CUDA, world≠2, no P2P, oversized
(>4MB), or build failure → NCCL

## Not a duplicate

Split from deepspeedai#8602 (segment-KI), which remains the umbrella PR. No other
open PR implements a CUDA fast path for the `inference_all_reduce` hook
(CPU shm path is `ShareMemCommBuilder`, untouched here).

## Tests

`tests/unit/comm/test_custom_ar.py` — routing contract (4 tests,
CPU-runnable):
- not-ready before initialize; initialize() no-ops without a process
group
- payload gate is zero when not ready (no message can ever qualify)

Run: `pytest tests/unit/comm/test_custom_ar.py tests/unit/comm/ -q` → 38
passed, 28 skipped on CPU (local, Python 3.11, CPU only).

**On-device validation**: the kernel + handshake path was exercised on
2x GPU (AutoDL, see deepspeedai#8602 commit `4bcc86b82` verification) through the
ZeRO-3→arena→TP2 rollout e2e. A dedicated 2-GPU parity test (custom_ar
vs NCCL all-reduce) is planned as follow-up; reviewer may request
hardware specifics from deepspeedai#8602's logs.

## Compatibility matrix

Validated: CPU (comm unit suite), CUDA TP=2 (via deepspeedai#8602 e2e). Not
validated: XPU/HPU/NPU (code routes through `get_accelerator()` and
falls back; only `can_device_access_peer` is CUDA-specific, marked
`#ignore-cuda`). ZeRO/offload unaffected (inference-path only).

## AI assistance

This PR was prepared with AI assistance (code assembly, builder wiring,
contract tests, review-hardening fixes — symmetric handshake verdict,
`get_accelerator()` routing). The human submitter reviewed every line;
the handshake-hang analysis and the split decision originated from human
review discussion.

---------

Signed-off-by: Ma, Guokai <guokai.ma@intel.com>
Signed-off-by: Guokai Ma <guokai.ma@intel.com>
Signed-off-by: Ma, Guokai <guokai.ma@gmail.com>
Co-authored-by: pengdurice <pengduhit@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants